From 4c108cc6b9e20cfee34bd21fddac27aad6156ce0 Mon Sep 17 00:00:00 2001 From: LoadingALIAS Date: Mon, 20 Jul 2026 22:07:21 -0400 Subject: [PATCH 1/2] ci: eliminate redundant merge validation Rely on the protected pull-request gate for ordinary changes and require Cargo graph assurance from the exact-commit Weekly evidence run before release. --- .github/workflows/ci.yaml | 12 ++- .github/workflows/release.yaml | 20 +---- CONTRIBUTING.md | 19 +++-- docs/release.md | 28 +++---- justfile | 2 - scripts/README.md | 2 - scripts/ci/check-ci-ownership-test.sh | 34 ++++---- scripts/ci/check-ci-ownership.sh | 23 +++--- scripts/ci/pre-push.sh | 2 +- scripts/ci/release-ci-check-test.sh | 70 ---------------- scripts/ci/release-ci-check.sh | 99 ----------------------- scripts/ci/release-evidence-check-test.sh | 10 +++ scripts/ci/release-evidence-check.sh | 1 + scripts/ci/release-preflight.sh | 4 +- scripts/ci/release-recipes-test.sh | 7 +- 15 files changed, 80 insertions(+), 253 deletions(-) delete mode 100755 scripts/ci/release-ci-check-test.sh delete mode 100755 scripts/ci/release-ci-check.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9cfbee1c..365813fc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,10 +1,8 @@ name: CI on: - push: - branches: - - main pull_request: + types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: concurrency: @@ -22,7 +20,7 @@ permissions: jobs: rail-plan: name: Build Plan - if: ${{ github.event_name != 'pull_request' || github.actor != 'dependabot[bot]' }} + 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' }} @@ -64,7 +62,7 @@ jobs: dependency-smoke: name: Dependency Smoke - if: ${{ github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' }} + if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft && github.actor == 'dependabot[bot]' }} uses: ./.github/workflows/_rust-job.yaml with: runner: ubuntu-latest @@ -81,7 +79,7 @@ jobs: suite: name: CI Suite needs: rail-plan - if: ${{ github.event_name != 'pull_request' || (github.actor != 'dependabot[bot]' && (needs.rail-plan.outputs.build == 'true' || needs.rail-plan.outputs.test == 'true' || needs.rail-plan.outputs.infra == 'true')) }} + 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')) }} uses: ./.github/workflows/_ci-suite.yaml with: cache_key_prefix: commit @@ -92,7 +90,7 @@ jobs: complete: name: Complete needs: [rail-plan, suite, dependency-smoke] - if: always() + if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} runs-on: ubuntu-latest steps: - name: Assert suite passed diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5247183a..1dc6f743 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -61,7 +61,7 @@ jobs: publish: name: Publish - needs: [preflight, ci-gate, evidence-gate] + needs: [preflight, evidence-gate] runs-on: ubuntu-latest timeout-minutes: 90 environment: crates-io @@ -414,21 +414,3 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: scripts/ci/release-evidence-check.sh --commit "$GITHUB_SHA" - - ci-gate: - name: CI Gate - runs-on: ubuntu-latest - timeout-minutes: 180 - permissions: - actions: read - contents: read - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Wait for CI on release commit - env: - GH_TOKEN: ${{ github.token }} - run: scripts/ci/release-ci-check.sh --commit "$GITHUB_SHA" --wait diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34d0ad64..c1374d33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,11 +11,11 @@ releasable history; it is not a working branch. | Push | Shares the branch after fast, change-aware local checks. | | Pull request | Declares merge intent and runs CI on the proposed change. | | Merge | Adds the reviewed change to protected `main`. | -| `main` CI | Records proof for the exact commit that can become a release. | -Pull-request and `main` CI may test the same source tree, but they authorize -different events. Pull-request CI decides whether a change may merge. `main` -CI proves the exact protected-branch commit used by the release gate. +The protected branch requires an up-to-date `Complete` result and has no bypass +actors, so merged commits do not repeat the pull-request suite. Release +candidates get a separate exact-commit Weekly run with the complete CI suite, +Cargo graph assurance, and release evidence. ## Daily branch workflow @@ -85,14 +85,17 @@ just push current branch. No rscrypto Git-hook installation is required. Use `just push-full` when the change is unusually broad or release-sensitive. -Open the pull request. A branch push alone does not start the normal PR suite: +Open a draft pull request while work is still changing; expensive jobs wait +until the pull request is ready for review. A branch push alone does not start +the normal PR suite: ```bash -gh pr create --base main --fill +gh pr create --base main --fill --draft ``` -Wait for the required `Complete` check. Because the repository currently has -one maintainer, no second approval is required; review the final diff yourself, +Mark the pull request ready only when its head is ready for CI, then wait for +the required `Complete` check. Because the repository currently has one +maintainer, no second approval is required; review the final diff yourself, resolve any open threads, and merge in the GitHub UI. After the merge: diff --git a/docs/release.md b/docs/release.md index ce6d172f..cc62acce 100644 --- a/docs/release.md +++ b/docs/release.md @@ -39,8 +39,9 @@ never run `cargo publish` locally. candidate=$(git rev-parse HEAD) ``` -5. Wait for `main` CI to pass, then dispatch the expensive release evidence on - that commit. Do this before another pull request merges into `main`. +5. Dispatch the expensive release evidence on that commit. Do this before + another pull request merges into `main`. Weekly reruns the complete CI suite, + including compiler-backed Cargo graph assurance, on the exact candidate. ```bash gh workflow run weekly.yaml --ref main @@ -51,15 +52,15 @@ never run `cargo publish` locally. dependencies, features, build inputs, or test policy change afterward, rerun both workflows. -6. After exact-commit CI and both evidence workflows are green, create and push - the signed tag: +6. After both exact-commit evidence workflows are green, create and push the + signed tag: ```bash test "$(git rev-parse HEAD)" = "$candidate" just release-tag ``` - `release-tag` rechecks live repository controls, exact-commit CI, and release + `release-tag` rechecks live repository controls and exact-commit release evidence before allowing the tag. It never publishes to crates.io locally. 7. The tag starts the `Release` workflow. Approve its `crates-io` environment @@ -74,17 +75,16 @@ never run `cargo publish` locally. | Gate | What it prevents | |---|---| | Release pull request | An unreviewed version or changelog mutation reaching protected `main`. | -| `main` CI | Tagging a commit that was never proven in its final protected-branch identity. | +| Exact-commit Weekly suite | Tagging a candidate without full tests and compiler-backed Cargo graph assurance. | | Weekly and RISC-V evidence | Publishing cryptographic claims without the required platform and timing evidence. | | Signed immutable tag | Moving a released version to different source later. | | Immutable, attested GitHub Release | Publishing artifacts that cannot be tied back to the tag and build. | | Environment approval | A tag or compromised workflow publishing to crates.io without a final human decision. | | Trusted Publishing | Long-lived crates.io credentials becoming a repository secret. | -The repeated pull-request and `main` checks are intentional. The first answers -"may this change merge?" The second proves "this exact protected-branch commit -may become a release." The expensive Weekly and RISC-V evidence is not repeated -on every pull request; it runs only for a release candidate. +Pull-request CI answers "may this change merge?" once. The exact-commit Weekly +suite and RISC-V evidence answer "may this protected-branch commit become a +release?" only for a release candidate. ## One-time setup @@ -139,8 +139,8 @@ release, `just release-check` validates configuration, dependency unification, pending intent, SemVer advice, and the generated release plan. Pull-request CI uses cargo-rail's planner to select checks from the actual -changed surfaces. Pushes to `main`, Weekly, and release preflight run the full -Cargo graph proof because those commits can become release inputs. +changed surfaces. Weekly runs the full Cargo graph proof for an exact release +candidate; release preflight consumes that result instead of recompiling it. `release-prepare` consumes the change files. After its pull request merges, `release-tag` deliberately does not rerun the pending-intent check. Instead, it @@ -164,8 +164,8 @@ receive anything, the workflow: 1. Verifies the annotated SSH signature, tag target, crate version, and changelog version. -2. Revalidates configuration, the unified Cargo graph, dependency policy, - audit results, SemVer, and successful CI for the exact commit. +2. Revalidates configuration, dependency policy, audit results, SemVer, and the + exact-commit Weekly Cargo graph result. 3. Requires complete Weekly CT/RSA and RISC-V native/CT evidence from that exact commit and crate version. 4. Builds the `.crate` once, reproduces the source archive from the tag, and diff --git a/justfile b/justfile index e8372560..153d7450 100644 --- a/justfile +++ b/justfile @@ -227,7 +227,6 @@ release-prepare: release-tag: just check-unify just check-repository-controls - scripts/ci/release-ci-check.sh --commit "$(git rev-parse HEAD)" scripts/ci/release-evidence-check.sh --commit "$(git rev-parse HEAD)" cargo rail release finalize rscrypto --yes --skip-publish @@ -251,7 +250,6 @@ check-actions: @scripts/ci/check-ci-ownership-test.sh @scripts/ci/emit-manual-matrix-test.sh @scripts/ci/pre-push-test.sh - @scripts/ci/release-ci-check-test.sh @scripts/ci/release-evidence-check-test.sh @scripts/ci/repository-controls-evidence-test.sh @scripts/ci/release-identity-test.sh diff --git a/scripts/README.md b/scripts/README.md index ce614c49..2391878b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -25,8 +25,6 @@ its caller. | `ci/check-ci-ownership.sh` | `just check-actions`, `ci/check-ci-ownership-test.sh` | | `ci/check-ci-ownership-test.sh`| `just check-actions` | | `ci/pre-push-test.sh` | `just check-actions` | -| `ci/release-ci-check.sh` | `just release-tag`, `release.yaml`, `ci/release-ci-check-test.sh` | -| `ci/release-ci-check-test.sh` | `just check-actions` | | `ci/release-evidence-check.sh` | `just release-tag`, `release.yaml`, `ci/release-evidence-check-test.sh` | | `ci/release-evidence-check-test.sh` | `just check-actions` | | `ci/repository-controls-evidence.sh` | `just check-repository-controls`, `release.yaml`, `ci/repository-controls-evidence-test.sh` | diff --git a/scripts/ci/check-ci-ownership-test.sh b/scripts/ci/check-ci-ownership-test.sh index afba16de..eb18100b 100755 --- a/scripts/ci/check-ci-ownership-test.sh +++ b/scripts/ci/check-ci-ownership-test.sh @@ -33,6 +33,22 @@ baseline="$TMP_ROOT/baseline" make_fixture "$baseline" "$CHECKER" --root "$baseline" >/dev/null +push_ci="$TMP_ROOT/push-ci" +make_fixture "$push_ci" +yq eval '.on.push.branches = ["main"]' -i "$push_ci/.github/workflows/ci.yaml" +expect_failure "$push_ci" "duplicated post-merge CI" + +missing_ready_event="$TMP_ROOT/missing-ready-event" +make_fixture "$missing_ready_event" +yq eval 'del(.on.pull_request.types[] | select(. == "ready_for_review"))' -i \ + "$missing_ready_event/.github/workflows/ci.yaml" +expect_failure "$missing_ready_event" "draft PR cannot start CI when marked ready" + +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" +expect_failure "$draft_runs_suite" "draft PR can run the expensive suite" + duplicate_matrix="$TMP_ROOT/duplicate-matrix" make_fixture "$duplicate_matrix" printf '\n# duplicate owner\n run: just test-feature-matrix\n' >>"$duplicate_matrix/.github/workflows/weekly.yaml" @@ -70,23 +86,11 @@ expect_failure "$duplicate_release_graph" "duplicate release Cargo graph assuran missing_release_graph_gate="$TMP_ROOT/missing-release-graph-gate" make_fixture "$missing_release_graph_gate" -sed -i.bak '/CI Suite \/ Cargo Graph Assurance \/ run/d' \ - "$missing_release_graph_gate/scripts/ci/release-ci-check.sh" -rm -f "$missing_release_graph_gate/scripts/ci/release-ci-check.sh.bak" +sed -i.bak '/CI Suite (weekly) \/ Cargo Graph Assurance \/ run/d' \ + "$missing_release_graph_gate/scripts/ci/release-evidence-check.sh" +rm -f "$missing_release_graph_gate/scripts/ci/release-evidence-check.sh.bak" expect_failure "$missing_release_graph_gate" "missing release Cargo graph assurance gate" -missing_release_ci_gate="$TMP_ROOT/missing-release-ci-gate" -make_fixture "$missing_release_ci_gate" -sed -i.bak '/release-ci-check\.sh/d' "$missing_release_ci_gate/.github/workflows/release.yaml" -rm -f "$missing_release_ci_gate/.github/workflows/release.yaml.bak" -expect_failure "$missing_release_ci_gate" "missing shared exact-commit release CI gate" - -missing_release_ci_checkout="$TMP_ROOT/missing-release-ci-checkout" -make_fixture "$missing_release_ci_checkout" -yq eval 'del(.jobs."ci-gate".steps[] | select(.uses | test("^actions/checkout@")))' -i \ - "$missing_release_ci_checkout/.github/workflows/release.yaml" -expect_failure "$missing_release_ci_checkout" "release CI gate without checkout" - colliding_rsa_concurrency="$TMP_ROOT/colliding-rsa-concurrency" make_fixture "$colliding_rsa_concurrency" sed -i.bak 's/group: rsa-/group: /' "$colliding_rsa_concurrency/.github/workflows/rsa.yaml" diff --git a/scripts/ci/check-ci-ownership.sh b/scripts/ci/check-ci-ownership.sh index e0114e51..ace6213e 100755 --- a/scripts/ci/check-ci-ownership.sh +++ b/scripts/ci/check-ci-ownership.sh @@ -17,6 +17,7 @@ if [[ -z "$ROOT" ]]; then fi WORKFLOWS="$ROOT/.github/workflows" +CI="$WORKFLOWS/ci.yaml" SUITE="$WORKFLOWS/_ci-suite.yaml" WEEKLY="$WORKFLOWS/weekly.yaml" RISCV="$WORKFLOWS/riscv.yaml" @@ -29,7 +30,6 @@ 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" RELEASE_PREFLIGHT="$ROOT/scripts/ci/release-preflight.sh" -RELEASE_CI="$ROOT/scripts/ci/release-ci-check.sh" RELEASE_EVIDENCE="$ROOT/scripts/ci/release-evidence-check.sh" RELEASE_SOURCE="$ROOT/scripts/ci/package-release-source.sh" RELEASE_MANIFEST="$ROOT/scripts/ci/write-release-manifest.sh" @@ -84,6 +84,7 @@ require_unique_feature_sets() { [[ -z "$duplicate" ]] || fail "duplicate feature profile in $1: $duplicate" } +require_file "$CI" require_file "$SUITE" require_file "$WEEKLY" require_file "$RISCV" @@ -96,7 +97,6 @@ require_file "$EXECUTABLE_MATRIX" require_file "$CHECK_ALL" require_file "$CI_CHECK" require_file "$RELEASE_PREFLIGHT" -require_file "$RELEASE_CI" require_file "$RELEASE_EVIDENCE" require_file "$RELEASE_SOURCE" require_file "$RELEASE_MANIFEST" @@ -122,6 +122,15 @@ if grep -En 'group-by:[[:space:]]*dependency-name' "$DEPENDABOT" >/dev/null; the fail "Dependabot must not use the upstream-broken cross-directory dependency-name grouping" fi +[[ $(yq eval '.on.push // "missing"' "$CI") == "missing" ]] \ + || fail "ordinary CI must not repeat the PR suite after merge" +[[ $(yq eval '[.on.pull_request.types[]] | sort | join(",")' "$CI") \ + == "opened,ready_for_review,reopened,synchronize" ]] \ + || fail "CI must run when a ready pull request is opened, updated, reopened, or leaves draft" +[[ $(yq eval '[.jobs[] | select((.if // "") | contains("pull_request.draft"))] | length' "$CI") \ + == $(yq eval '.jobs | length' "$CI") ]] \ + || fail "every CI job must defer draft pull requests" + [[ $(count_feature_sets "$COMPILE_MATRIX") -eq 29 ]] \ || fail "compile feature matrix must retain all 29 profiles" [[ $(count_feature_sets "$EXECUTABLE_MATRIX") -eq 38 ]] \ @@ -175,14 +184,10 @@ fi [[ $(count_matches 'cargo rail unify --check' "$SUITE") -eq 1 ]] \ || fail "the reusable suite must have exactly one Cargo graph assurance owner" if grep -En 'cargo rail unify --check' "$RELEASE_PREFLIGHT" >/dev/null; then - fail "tag preflight must consume exact-commit CI graph assurance instead of repeating it" + fail "tag preflight must consume exact-commit Weekly graph assurance instead of repeating it" fi -[[ $(yq eval '[.jobs."ci-gate".steps[] | select(.uses | test("^actions/checkout@[0-9a-f]{40}$"))] | length' "$RELEASE") -eq 1 ]] \ - || fail "release CI Gate must check out the shared exact-commit checker" -grep -Fq 'scripts/ci/release-ci-check.sh --commit "$GITHUB_SHA" --wait' "$RELEASE" \ - || fail "release CI Gate must use the shared exact-commit checker" -grep -Fq 'CI Suite / Cargo Graph Assurance / run' "$RELEASE_CI" \ - || fail "release CI Gate must verify exact-commit Cargo Graph Assurance" +grep -Fq 'CI Suite (weekly) / Cargo Graph Assurance / run' "$RELEASE_EVIDENCE" \ + || fail "release evidence must require exact-commit Weekly Cargo Graph Assurance" [[ $(yq eval '.concurrency.group' "$RSA") == 'rsa-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}' ]] \ || fail "reusable RSA workflow concurrency must not collide with its caller" [[ $(yq eval '.jobs.ct.with.upload_raw_artifacts' "$WEEKLY") == "true" ]] \ diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh index db119495..3fc6e818 100755 --- a/scripts/ci/pre-push.sh +++ b/scripts/ci/pre-push.sh @@ -181,7 +181,7 @@ needs_actions_check() { return 0 fi - changed_file_matches '^\.config/target-matrix\.json$|^\.github/actions-lock\.yaml$|^\.github/(workflows|actions)/.*\.ya?ml$|^\.github/(rulesets|repository-settings)/.*\.json$|^scripts/ci/(pin-actions|check-ci-ownership|check-ci-ownership-test|release-ci-check|release-ci-check-test|release-evidence-check|release-evidence-check-test|repository-controls-evidence|repository-controls-evidence-test|package-release-source|write-release-manifest|release-identity-test|publish-immutable-release|publish-immutable-release-test|release-recipes-test)\.sh$' + changed_file_matches '^\.config/target-matrix\.json$|^\.github/actions-lock\.yaml$|^\.github/(workflows|actions)/.*\.ya?ml$|^\.github/(rulesets|repository-settings)/.*\.json$|^scripts/ci/(pin-actions|check-ci-ownership|check-ci-ownership-test|release-evidence-check|release-evidence-check-test|repository-controls-evidence|repository-controls-evidence-test|package-release-source|write-release-manifest|release-identity-test|publish-immutable-release|publish-immutable-release-test|release-recipes-test)\.sh$' } needs_host_checks() { diff --git a/scripts/ci/release-ci-check-test.sh b/scripts/ci/release-ci-check-test.sh deleted file mode 100755 index dade3286..00000000 --- a/scripts/ci/release-ci-check-test.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CHECKER="$SCRIPT_DIR/release-ci-check.sh" -TMP_ROOT=$(mktemp -d) -trap 'rm -rf "$TMP_ROOT"' EXIT - -mkdir -p "$TMP_ROOT/bin" -cat >"$TMP_ROOT/bin/gh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$1 $2" == "run list" ]]; then - case "${FAKE_GH_MODE:-success}" in - missing) - echo '[]' - ;; - pending) - echo '[{"databaseId":4242,"status":"in_progress","conclusion":"","url":"https://example.invalid/runs/4242","createdAt":"2026-07-15T00:00:00Z"}]' - ;; - failed) - echo '[{"databaseId":4242,"status":"completed","conclusion":"failure","url":"https://example.invalid/runs/4242","createdAt":"2026-07-15T00:00:00Z"}]' - ;; - prior-success) - echo '[ - {"databaseId":4242,"status":"completed","conclusion":"success","url":"https://example.invalid/runs/4242","createdAt":"2026-07-15T00:00:00Z"}, - {"databaseId":4243,"status":"completed","conclusion":"cancelled","url":"https://example.invalid/runs/4243","createdAt":"2026-07-15T01:00:00Z"} - ]' - ;; - *) - echo '[{"databaseId":4242,"status":"completed","conclusion":"success","url":"https://example.invalid/runs/4242","createdAt":"2026-07-15T00:00:00Z"}]' - ;; - esac - exit 0 -fi - -if [[ "$1 $2" == "run view" ]]; then - graph_conclusion=success - if [[ ${FAKE_GH_MODE:-success} == graph-failed ]]; then - graph_conclusion=failure - fi - cat <&2 -exit 2 -EOF -chmod +x "$TMP_ROOT/bin/gh" - -export PATH="$TMP_ROOT/bin:$PATH" -commit=0123456789abcdef0123456789abcdef01234567 - -output=$($CHECKER --commit "$commit" --repo loadingalias/rscrypto) -grep -Fq 'Exact-commit CI passed: https://example.invalid/runs/4242' <<<"$output" - -output=$(FAKE_GH_MODE=prior-success "$CHECKER" --commit "$commit" --repo loadingalias/rscrypto) -grep -Fq 'Exact-commit CI passed: https://example.invalid/runs/4242' <<<"$output" - -for mode in missing pending failed graph-failed; do - if FAKE_GH_MODE=$mode "$CHECKER" --commit "$commit" --repo loadingalias/rscrypto >/dev/null 2>&1; then - echo "release CI check accepted invalid mode: $mode" >&2 - exit 1 - fi -done - -echo "Release CI regression tests passed" diff --git a/scripts/ci/release-ci-check.sh b/scripts/ci/release-ci-check.sh deleted file mode 100755 index ff61139c..00000000 --- a/scripts/ci/release-ci-check.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - echo "usage: release-ci-check.sh --commit SHA [--repo OWNER/REPO] [--wait]" >&2 - exit 2 -} - -commit="" -repo="${GITHUB_REPOSITORY:-loadingalias/rscrypto}" -wait=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --commit) - commit=${2:-} - shift 2 - ;; - --repo) - repo=${2:-} - shift 2 - ;; - --wait) - wait=true - shift - ;; - *) usage ;; - esac -done - -[[ "$commit" =~ ^[0-9a-fA-F]{40}$ ]] || usage -[[ "$repo" == */* ]] || usage - -attempts=1 -if [[ "$wait" == true ]]; then - attempts=360 -fi - -for ((attempt = 1; attempt <= attempts; attempt++)); do - runs=$(gh run list \ - --repo "$repo" \ - --workflow CI \ - --branch main \ - --commit "$commit" \ - --event push \ - --json databaseId,status,conclusion,url,createdAt \ - --limit 10) - - selected=$(jq -c ' - map(select(.status == "completed" and .conclusion == "success")) - | sort_by(.createdAt) - | last // empty - ' <<<"$runs") - - if [[ -n "$selected" ]]; then - run_id=$(jq -r '.databaseId' <<<"$selected") - run_url=$(jq -r '.url' <<<"$selected") - jobs=$(gh run view "$run_id" --repo "$repo" --json jobs) - graph_result=$(jq -r ' - [.jobs[] | select(.name == "CI Suite / Cargo Graph Assurance / run")] - | if length == 1 then .[0].conclusion else "missing" end - ' <<<"$jobs") - - if [[ "$graph_result" != "success" ]]; then - echo "Cargo Graph Assurance result for $commit: $graph_result" >&2 - exit 1 - fi - - echo "Exact-commit CI passed: $run_url" - exit 0 - fi - - latest=$(jq -c 'sort_by(.createdAt) | last // empty' <<<"$runs") - if [[ -n "$latest" ]]; then - run_status=$(jq -r '.status' <<<"$latest") - run_conclusion=$(jq -r '.conclusion // ""' <<<"$latest") - run_url=$(jq -r '.url' <<<"$latest") - echo "CI for $commit: status=$run_status conclusion=${run_conclusion:-none}" - echo "$run_url" - - if [[ "$run_status" == "completed" ]]; then - echo "CI did not pass for $commit: $run_conclusion" >&2 - exit 1 - fi - else - echo "No push CI run for $commit on main." - fi - - if [[ "$wait" != true ]]; then - echo "Wait for exact-commit CI to pass before creating the release tag." >&2 - exit 1 - fi - - echo "Retry $attempt/$attempts in 30 seconds." - sleep 30 -done - -echo "Timed out waiting for CI to complete for $commit" >&2 -exit 1 diff --git a/scripts/ci/release-evidence-check-test.sh b/scripts/ci/release-evidence-check-test.sh index fd274e8f..6e2e83f0 100755 --- a/scripts/ci/release-evidence-check-test.sh +++ b/scripts/ci/release-evidence-check-test.sh @@ -47,13 +47,18 @@ if [[ "$1 $2" == "run view" ]]; then case "$3" in 4242) rsa_conclusion=success + graph_conclusion=success if [[ ${FAKE_GH_MODE:-success} == failed-rsa ]]; then rsa_conclusion=failure fi + if [[ ${FAKE_GH_MODE:-success} == failed-cargo-graph ]]; then + graph_conclusion=failure + fi cat </dev/null 2>&1; then + echo "release evidence check accepted failed exact-commit Cargo Graph Assurance" >&2 + exit 1 +fi + if FAKE_GH_MODE=failed-riscv-native "$CHECKER" --root "$fixture" --commit "$evidence_sha" --repo loadingalias/rscrypto >/dev/null 2>&1; then echo "release evidence check accepted failed RISC-V native evidence" >&2 exit 1 diff --git a/scripts/ci/release-evidence-check.sh b/scripts/ci/release-evidence-check.sh index c75a0e12..bd84f6c8 100755 --- a/scripts/ci/release-evidence-check.sh +++ b/scripts/ci/release-evidence-check.sh @@ -130,6 +130,7 @@ require_job() { require_job Weekly "$weekly_run_id" "$weekly_jobs" "Constant-Time Evidence (weekly) / Complete (CT)" require_job Weekly "$weekly_run_id" "$weekly_jobs" "RSA Evidence (weekly) / Complete (RSA)" +require_job Weekly "$weekly_run_id" "$weekly_jobs" "CI Suite (weekly) / Cargo Graph Assurance / run" require_job Weekly "$weekly_run_id" "$weekly_jobs" "Complete (weekly)" require_job RISC-V "$riscv_run_id" "$riscv_jobs" "Native CI / run" require_job RISC-V "$riscv_run_id" "$riscv_jobs" "Constant-Time Evidence (RISC-V) / CT Full (RISE RISC-V riscv64) / run" diff --git a/scripts/ci/release-preflight.sh b/scripts/ci/release-preflight.sh index d259e3e9..aeff271f 100755 --- a/scripts/ci/release-preflight.sh +++ b/scripts/ci/release-preflight.sh @@ -108,8 +108,8 @@ fi cargo rail config validate --strict cargo rail config migrate --check -# Exact-commit CI owns exhaustive compiler-backed Cargo graph assurance. The -# release CI Gate verifies that named job before publication can proceed. +# Exact-commit Weekly owns exhaustive compiler-backed Cargo graph assurance. +# The release evidence gate verifies that named job before publication. cargo deny check all # RustCrypto `rsa` is used only as a dev/test/bench oracle. Production RSA # verification is implemented in `src/auth/rsa.rs`; keep this scoped to the diff --git a/scripts/ci/release-recipes-test.sh b/scripts/ci/release-recipes-test.sh index 96ef3531..a4b1a04a 100755 --- a/scripts/ci/release-recipes-test.sh +++ b/scripts/ci/release-recipes-test.sh @@ -15,8 +15,6 @@ grep -Fq "cargo rail release run rscrypto --bump auto --yes --pr" <<<"$prepare_r grep -Fq "git push" <<<"$prepare_recipe" grep -Fq "cargo rail release finalize rscrypto --yes --skip-publish" <<<"$tag_recipe" # shellcheck disable=SC2016 # Match the literal command rendered by just. -grep -Fq 'scripts/ci/release-ci-check.sh --commit "$(git rev-parse HEAD)"' <<<"$tag_recipe" -# shellcheck disable=SC2016 # Match the literal command rendered by just. grep -Fq 'scripts/ci/release-evidence-check.sh --commit "$(git rev-parse HEAD)"' <<<"$tag_recipe" grep -Fq 'just check-repository-controls' <<<"$tag_recipe" grep -Fq 'scripts/ci/repository-controls-evidence.sh' <<<"$controls_recipe" @@ -26,11 +24,10 @@ if grep -Fq -- '--allow-redacted-bypass' <<<"$controls_recipe"; then fi controls_line=$(grep -nF 'just check-repository-controls' <<<"$tag_recipe" | cut -d: -f1) -ci_line=$(grep -nF 'scripts/ci/release-ci-check.sh' <<<"$tag_recipe" | cut -d: -f1) evidence_line=$(grep -nF 'scripts/ci/release-evidence-check.sh' <<<"$tag_recipe" | cut -d: -f1) finalize_line=$(grep -nF 'cargo rail release finalize' <<<"$tag_recipe" | cut -d: -f1) -if (( controls_line >= ci_line || ci_line >= evidence_line || evidence_line >= finalize_line )); then - echo "release-tag must validate repository controls, exact-commit CI, and evidence before creating the tag" >&2 +if (( controls_line >= evidence_line || evidence_line >= finalize_line )); then + echo "release-tag must validate repository controls and exact-commit evidence before creating the tag" >&2 exit 1 fi From 57647cd61414925b42b6fd89968f002777360807 Mon Sep 17 00:00:00 2001 From: LoadingALIAS Date: Mon, 20 Jul 2026 22:16:56 -0400 Subject: [PATCH 2/2] ci: let Dependabot own action pins Validate immutable workflow pins directly against their upstream tags and supported runtimes, removing the duplicate action lock and updater. --- .github/actions-lock.yaml | 127 --------- .github/dependabot.yaml | 22 +- justfile | 11 +- scripts/README.md | 6 +- scripts/ci/check-action-pins-test.sh | 120 ++++++++ scripts/ci/check-action-pins.sh | 186 +++++++++++++ scripts/ci/check-ci-ownership-test.sh | 6 + scripts/ci/check-ci-ownership.sh | 6 + scripts/ci/ci-check.sh | 2 +- scripts/ci/dependabot-smoke-test.sh | 2 +- scripts/ci/dependabot-smoke.sh | 6 +- scripts/ci/pin-actions-test.sh | 95 ------- scripts/ci/pin-actions.sh | 383 -------------------------- scripts/ci/pre-push.sh | 2 +- scripts/ci/upgrade-actions-test.sh | 51 ---- scripts/ci/upgrade-actions.sh | 306 -------------------- scripts/update/update-all.sh | 12 - 17 files changed, 332 insertions(+), 1011 deletions(-) delete mode 100644 .github/actions-lock.yaml create mode 100755 scripts/ci/check-action-pins-test.sh create mode 100755 scripts/ci/check-action-pins.sh delete mode 100755 scripts/ci/pin-actions-test.sh delete mode 100755 scripts/ci/pin-actions.sh delete mode 100755 scripts/ci/upgrade-actions-test.sh delete mode 100755 scripts/ci/upgrade-actions.sh diff --git a/.github/actions-lock.yaml b/.github/actions-lock.yaml deleted file mode 100644 index ade1c772..00000000 --- a/.github/actions-lock.yaml +++ /dev/null @@ -1,127 +0,0 @@ -# GitHub Actions Version Lock File -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Single source of truth for all external GitHub Actions used in workflows. -# Managed by scripts/ci/upgrade-actions.sh and scripts/ci/pin-actions.sh. -# -# Usage: -# 1. Add new external actions here first with desired semantic version -# 2. Run: just update (upgrades refs, resolves SHAs, rewrites workflows) -# 3. Run: just pin-actions when only refreshing SHAs for existing refs -# -# Format: -# : -# ref: # What we want (Dependabot monitors this) -# sha: # Immutable pin (generated) -# updated: # Last verification timestamp -# -# Security Note: -# Workflows use SHA-pinned format: uses: actions/checkout@abc123... # v4.1.7 -# Dependabot reads the comment (# v4.1.7) and can open dependency-update PRs. -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Official GitHub Actions -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -actions/checkout: - ref: v7.0.0 - sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" - updated: "2026-07-13T17:52:44Z" - notes: "Repository checkout - used in every workflow" -actions/upload-artifact: - ref: v7.0.1 - sha: "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" - updated: "2026-07-13T17:52:45Z" - notes: "Upload build artifacts and test results" -actions/download-artifact: - ref: v8.0.1 - sha: "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" - updated: "2026-07-13T17:52:46Z" - notes: "Download build artifacts" -actions/cache: - ref: v6.1.0 - sha: "55cc8345863c7cc4c66a329aec7e433d2d1c52a9" - updated: "2026-07-13T17:52:47Z" - notes: "Cache dependencies and build artifacts" -actions/attest-build-provenance: - ref: v4.1.1 - sha: "0f67c3f4856b2e3261c31976d6725780e5e4c373" - updated: "2026-07-13T17:52:47Z" - notes: "SLSA build provenance attestations for release crate artifacts" -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Testing & Reporting -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -mikepenz/action-junit-report: - ref: v6.4.2 - sha: "d9f48fc87bc235f7e214acf696ca5abc0a986f16" - updated: "2026-07-13T17:52:48Z" - notes: "Publish JUnit test reports as GitHub Checks" -codecov/codecov-action: - ref: v7.0.0 - sha: "fb8b3582c8e4def4969c97caa2f19720cb33a72f" - updated: "2026-07-13T17:52:49Z" - notes: "Upload coverage reports to Codecov" -ossf/scorecard-action: - ref: v2.4.3 - sha: "4eaacf0543bb3f2c246792bd56e8cdeffafb205a" - updated: "2026-07-13T17:52:49Z" - notes: "OpenSSF Scorecard security posture checks" -github/codeql-action/upload-sarif: - ref: v4.37.0 - sha: "99df26d4f13ea111d4ec1a7dddef6063f76b97e9" - updated: "2026-07-13T17:52:50Z" - notes: "Upload Scorecard SARIF results to GitHub code scanning" -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Rust Toolchain & Ecosystem -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -dtolnay/rust-toolchain: - ref: v1 - sha: "e97e2d8cc328f1b50210efc529dca0028893a2d9" - updated: "2026-07-13T17:52:51Z" - notes: "Rust toolchain installation - always tracks latest nightly" -taiki-e/install-action: - ref: v2.83.2 - sha: "43aecc8d72668fbcfe75c31400bc4f890f1c5853" - updated: "2026-07-13T17:52:52Z" - notes: "Install cargo tools (nextest, just, deny, audit)" -Swatinem/rust-cache: - ref: v2.9.1 - sha: "c19371144df3bb44fab255c43d04cbc2ab54d1c4" - updated: "2026-07-13T17:52:52Z" - notes: "GitHub runner Rust cache" -loadingalias/cargo-rail-action: - ref: v5.1.0 - sha: "90f7f739028887508c6e8d8b7cc510dc612eb653" - updated: "2026-07-13T17:52:53Z" - notes: "cargo-rail scope v2 outputs and release-intent base-ref for CI" -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# RunsOn (AWS EC2 Runners) -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -runs-on/action: - ref: v2.2.0 - sha: "4e5f72399b6b17f2e79c511c1b38a315a64d22dc" - updated: "2026-07-13T17:52:54Z" - notes: "RunsOn MagicCache - S3-backed unlimited cache for AWS EC2 runners" -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Release Workflow Actions -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -taiki-e/create-gh-release-action: - ref: v1.11.0 - sha: "eba8ea96c86cca8a37f1b56e94b4d13301fba651" - updated: "2026-07-13T17:52:54Z" - notes: "Create GitHub releases from CHANGELOG.md" -taiki-e/setup-cross-toolchain-action: - ref: v1.41.0 - sha: "3d9770ce98eb7dbcf378563182a5e8031165f75b" - updated: "2026-07-13T17:52:55Z" - notes: "Cross-compilation toolchain setup for multi-target builds" -taiki-e/upload-rust-binary-action: - ref: v1.30.2 - sha: "f0d45ae91ee7b8ee928de7a9d04d893a08bcbec6" - updated: "2026-07-13T17:52:56Z" - notes: "Build and upload pre-built binaries to GitHub Releases" -rust-lang/crates-io-auth-action: - ref: v1.0.5 - sha: "c6f97d42243bad5fab37ca0427f495c86d5b1a18" - updated: "2026-07-13T17:52:56Z" - notes: "crates.io Trusted Publishing short-lived token exchange" diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index a9c93e42..ef533b9a 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,9 +1,5 @@ version: 2 updates: - # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - # Rust Dependencies (Cargo) - # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - # One non-overlapping scan covers every Cargo manifest. Keep updates # independent: cross-directory `group-by` PRs cannot currently refresh. - package-ecosystem: "cargo" @@ -24,18 +20,8 @@ updates: open-pull-requests-limit: 0 rebase-strategy: "auto" - # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - # GitHub Actions (Security-Critical: SHA-Pinned) - # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - # Dependabot understands SHA-pinned actions when formatted as: - # uses: actions/checkout@abc123... # v4.1.7 - # - # It will: - # 1. Monitor the semantic version in the comment - # 2. Update the SHA when new versions release - # 3. Preserve the SHA-pinned format for security - # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - + # Workflow pins are authoritative. Dependabot updates the immutable SHA and + # same-line semantic ref; dependency smoke validates both against upstream. - package-ecosystem: "github-actions" directory: "/" schedule: @@ -46,11 +32,9 @@ updates: open-pull-requests-limit: 1 rebase-strategy: "auto" - # Group all GitHub Actions updates to reduce PR noise groups: github-actions: patterns: ["*"] update-types: ["minor", "patch"] - # Note: Major version updates are NOT grouped - # These require manual review due to potential breaking changes + # Major updates stay separate for explicit review. diff --git a/justfile b/justfile index 153d7450..0a78a373 100644 --- a/justfile +++ b/justfile @@ -230,21 +230,16 @@ release-tag: scripts/ci/release-evidence-check.sh --commit "$(git rev-parse HEAD)" cargo rail release finalize rscrypto --yes --skip-publish -# Update Root/Fuzz Manifests & GHA Pins +# Update coordinated Cargo manifests update: @scripts/update/update-all.sh update-check: @scripts/update/update-all.sh --check -# Refresh .github/actions-lock.yaml SHAs -pin-actions: - @scripts/ci/pin-actions.sh --update-lock - check-actions: - @scripts/ci/pin-actions.sh --verify-only - @scripts/ci/pin-actions-test.sh - @scripts/ci/upgrade-actions-test.sh + @scripts/ci/check-action-pins.sh + @scripts/ci/check-action-pins-test.sh @scripts/ci/dependabot-smoke-test.sh @scripts/ci/check-ci-ownership.sh @scripts/ci/check-ci-ownership-test.sh diff --git a/scripts/README.md b/scripts/README.md index 2391878b..c9814515 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -19,8 +19,8 @@ its caller. | `test/test-fuzz.sh` | `just test-fuzz`, `weekly.yaml` | | `test/test-coverage.sh` | `just test-coverage`, `just test-fuzz-coverage`, `weekly.yaml` | | `bench/bench.sh` | `just bench`, `just bench-quick` | -| `ci/pin-actions.sh` | `just pin-actions`, `just check-actions`, `scripts/update/update-all.sh` | -| `ci/pin-actions-test.sh` | `just check-actions`, `ci/dependabot-smoke.sh` | +| `ci/check-action-pins.sh` | `just check-actions`, `ci/ci-check.sh`, `ci/dependabot-smoke.sh` | +| `ci/check-action-pins-test.sh` | `just check-actions`, `ci/dependabot-smoke.sh` | | `ci/dependabot-smoke-test.sh` | `just check-actions` | | `ci/check-ci-ownership.sh` | `just check-actions`, `ci/check-ci-ownership-test.sh` | | `ci/check-ci-ownership-test.sh`| `just check-actions` | @@ -35,8 +35,6 @@ its caller. | `ci/publish-immutable-release.sh` | `release.yaml`, `ci/publish-immutable-release-test.sh` | | `ci/publish-immutable-release-test.sh` | `just check-actions` | | `ci/release-recipes-test.sh` | `just check-actions` | -| `ci/upgrade-actions.sh` | `scripts/update/update-all.sh` | -| `ci/upgrade-actions-test.sh` | `just check-actions` | | `ci/pre-push.sh` | `just push`, `just push-full` | | `update/update-all.sh` | `just update`, `just update-check` | diff --git a/scripts/ci/check-action-pins-test.sh b/scripts/ci/check-action-pins-test.sh new file mode 100755 index 00000000..75ddc32c --- /dev/null +++ b/scripts/ci/check-action-pins-test.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHECKER="$SCRIPT_DIR/check-action-pins.sh" +TMP_ROOT=$(mktemp -d) +trap 'rm -rf "$TMP_ROOT"' EXIT + +expected_sha=7188fc363630916deb702c7fdcf4e481b751f97a +fake_bin="$TMP_ROOT/bin" +mkdir -p "$fake_bin" + +cat >"$fake_bin/git" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == "ls-remote" ]] +ref="" +for argument in "$@"; do + case "$argument" in + refs/tags/*) + [[ "$argument" == *'^{}' ]] || ref=${argument#refs/tags/} + ;; + esac +done +printf '%s\trefs/tags/%s\n' \ + "${FAKE_RESOLVED_SHA:-7188fc363630916deb702c7fdcf4e481b751f97a}" "$ref" +SH + +cat >"$fake_bin/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +output="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + output=$2 + shift 2 + ;; + *) shift ;; + esac +done +[[ -n "$output" ]] +[[ ${FAKE_MISSING_DEFINITION:-0} != 1 ]] || exit 22 +printf 'runs:\n using: %s\n' "${FAKE_RUNTIME:-node24}" >"$output" +SH +chmod +x "$fake_bin/git" "$fake_bin/curl" + +make_fixture() { + local fixture=$1 + mkdir -p "$fixture/.github/workflows" "$fixture/.github/actions" + cat >"$fixture/.github/workflows/scorecard.yaml" </dev/null 2>&1; then + echo "expected action-pin failure: $description" >&2 + exit 1 + fi +} + +baseline="$TMP_ROOT/baseline" +make_fixture "$baseline" +run_checker "$baseline" >/dev/null + +unpinned="$TMP_ROOT/unpinned" +make_fixture "$unpinned" +sed -i.bak "s/@$expected_sha/@v4/" "$unpinned/.github/workflows/scorecard.yaml" +rm -f "$unpinned/.github/workflows/scorecard.yaml.bak" +expect_failure "$unpinned" "action is not SHA-pinned" + +missing_ref="$TMP_ROOT/missing-ref" +make_fixture "$missing_ref" +sed -i.bak 's/ # v4.37.1//' "$missing_ref/.github/workflows/scorecard.yaml" +rm -f "$missing_ref/.github/workflows/scorecard.yaml.bak" +expect_failure "$missing_ref" "semantic ref comment is missing" + +inconsistent="$TMP_ROOT/inconsistent" +make_fixture "$inconsistent" +cat >"$inconsistent/.github/actions/nested.yaml" <<'YAML' +runs: + using: composite + steps: + - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 +YAML +expect_failure "$inconsistent" "one action has inconsistent pins" + +wrong_tag="$TMP_ROOT/wrong-tag" +make_fixture "$wrong_tag" +if FAKE_RESOLVED_SHA=0000000000000000000000000000000000000000 \ + run_checker "$wrong_tag" >/dev/null 2>&1; then + echo "expected action-pin failure: semantic ref resolves to another SHA" >&2 + exit 1 +fi + +unsupported_runtime="$TMP_ROOT/unsupported-runtime" +make_fixture "$unsupported_runtime" +if FAKE_RUNTIME=node20 run_checker "$unsupported_runtime" >/dev/null 2>&1; then + echo "expected action-pin failure: unsupported action runtime" >&2 + exit 1 +fi + +missing_definition="$TMP_ROOT/missing-definition" +make_fixture "$missing_definition" +if FAKE_MISSING_DEFINITION=1 run_checker "$missing_definition" >/dev/null 2>&1; then + echo "expected action-pin failure: missing action definition" >&2 + exit 1 +fi + +echo "Action pin regression tests passed" diff --git a/scripts/ci/check-action-pins.sh b/scripts/ci/check-action-pins.sh new file mode 100755 index 00000000..76308d0d --- /dev/null +++ b/scripts/ci/check-action-pins.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "usage: check-action-pins.sh [--root PATH]" >&2 + exit 2 +} + +root="" +while [[ $# -gt 0 ]]; do + case "$1" in + --root) + root=${2:?missing path after --root} + shift 2 + ;; + *) usage ;; + esac +done + +if [[ -z "$root" ]]; then + root=$(git rev-parse --show-toplevel) +fi +root=$(cd "$root" && pwd) + +for dependency in curl git sed sort yq; do + command -v "$dependency" >/dev/null 2>&1 || { + echo "action pin error: missing dependency: $dependency" >&2 + exit 1 + } +done + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +inventory="$tmp/inventory.tsv" +: >"$inventory" +status=0 + +fail() { + echo "action pin error: $*" >&2 + status=1 +} + +workflow_files() { + local roots=() + [[ -d "$root/.github/workflows" ]] && roots+=("$root/.github/workflows") + [[ -d "$root/.github/actions" ]] && roots+=("$root/.github/actions") + [[ ${#roots[@]} -gt 0 ]] || return 0 + find "${roots[@]}" \( -name '*.yaml' -o -name '*.yml' \) -type f | sort +} + +while IFS= read -r file; do + line_number=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + use=$(sed -nE 's/^[[:space:]-]*uses:[[:space:]]*([^[:space:]#]+).*/\1/p' <<<"$line") + [[ -n "$use" ]] || continue + + case "$use" in + ./* | docker://*) continue ;; + esac + + parsed=$(sed -nE \ + 's/^[[:space:]-]*uses:[[:space:]]*([^@[:space:]#]+)@([0-9a-f]{40})[[:space:]]*#[[:space:]]*(v?[0-9]+(\.[0-9]+){0,2})[[:space:]]*$/\1\ \2\ \3/p' \ + <<<"$line") + if [[ -z "$parsed" ]]; then + fail "external action must use a lowercase 40-character SHA and same-line semantic ref: ${file#"$root"/}:$line_number" + continue + fi + + action=${parsed%%$'\t'*} + remainder=${parsed#*$'\t'} + sha=${remainder%%$'\t'*} + ref=${remainder#*$'\t'} + if [[ ! "$action" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$ ]]; then + fail "invalid external action name '$action': ${file#"$root"/}:$line_number" + continue + fi + + printf '%s\t%s\t%s\t%s:%s\n' \ + "$action" "$sha" "$ref" "${file#"$root"/}" "$line_number" >>"$inventory" + done <"$file" +done < <(workflow_files) + +if [[ ! -s "$inventory" ]]; then + fail "no external GitHub Actions were found" +fi + +sorted="$tmp/inventory.sorted.tsv" +sort -t $'\t' -k1,1 -k2,2 -k3,3 "$inventory" >"$sorted" +unique="$tmp/actions.tsv" +: >"$unique" +previous_action="" +previous_sha="" +previous_ref="" +previous_location="" +while IFS=$'\t' read -r action sha ref location; do + if [[ "$action" == "$previous_action" ]]; then + if [[ "$sha" != "$previous_sha" || "$ref" != "$previous_ref" ]]; then + fail "$action has inconsistent pins at $previous_location and $location" + fi + continue + fi + printf '%s\t%s\t%s\n' "$action" "$sha" "$ref" >>"$unique" + previous_action=$action + previous_sha=$sha + previous_ref=$ref + previous_location=$location +done <"$sorted" + +resolve_ref() { + local repository=$1 + local ref=$2 + local refs + refs=$(git ls-remote \ + "https://github.com/${repository}.git" \ + "refs/tags/$ref" \ + "refs/tags/$ref^{}") || return 1 + awk ' + $2 ~ /\^\{\}$/ { peeled = $1 } + $2 !~ /\^\{\}$/ { direct = $1 } + END { print (peeled != "" ? peeled : direct) } + ' <<<"$refs" +} + +fetch_definition() { + local repository=$1 + local sha=$2 + local path=$3 + local output=$4 + curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \ + --output "$output" \ + "https://raw.githubusercontent.com/$repository/$sha/$path" +} + +while IFS=$'\t' read -r action sha ref; do + owner=${action%%/*} + remainder=${action#*/} + repository_name=${remainder%%/*} + repository="$owner/$repository_name" + resolved=$(resolve_ref "$repository" "$ref" || true) + if [[ -z "$resolved" ]]; then + fail "$action@$ref does not resolve to a commit" + continue + fi + if [[ "$resolved" != "$sha" ]]; then + fail "$action@$ref resolves to $resolved, not pinned SHA $sha" + continue + fi + + if [[ "$action" == "$repository" ]]; then + path_prefix="" + else + path_prefix="${action#"$repository"/}/" + fi + + case "$path_prefix" in + .github/workflows/*.yml/ | .github/workflows/*.yaml/) + workflow_path=${path_prefix%/} + if ! fetch_definition "$repository" "$sha" "$workflow_path" "$tmp/definition"; then + fail "$action@$sha does not contain $workflow_path" + fi + ;; + *) + runtime="" + for filename in action.yml action.yaml; do + if fetch_definition "$repository" "$sha" "${path_prefix}${filename}" "$tmp/definition" 2>/dev/null; then + runtime=$(yq eval -r '.runs.using // ""' "$tmp/definition" 2>/dev/null || true) + [[ -n "$runtime" ]] && break + fi + done + case "$runtime" in + composite | docker | node24) ;; + "") fail "$action@$sha has no readable action definition" ;; + *) fail "$action@$sha uses unsupported runtime '$runtime'" ;; + esac + ;; + esac +done <"$unique" + +if [[ "$status" -ne 0 ]]; then + exit "$status" +fi + +action_count=$(wc -l <"$unique" | tr -d ' ') +use_count=$(wc -l <"$inventory" | tr -d ' ') +echo "Action pins verified: $use_count use(s), $action_count action(s)" diff --git a/scripts/ci/check-ci-ownership-test.sh b/scripts/ci/check-ci-ownership-test.sh index eb18100b..12ae03e0 100755 --- a/scripts/ci/check-ci-ownership-test.sh +++ b/scripts/ci/check-ci-ownership-test.sh @@ -33,6 +33,12 @@ baseline="$TMP_ROOT/baseline" make_fixture "$baseline" "$CHECKER" --root "$baseline" >/dev/null +missing_action_updates="$TMP_ROOT/missing-action-updates" +make_fixture "$missing_action_updates" +yq eval 'del(.updates[] | select(."package-ecosystem" == "github-actions"))' -i \ + "$missing_action_updates/.github/dependabot.yaml" +expect_failure "$missing_action_updates" "GitHub Actions updates are disabled" + push_ci="$TMP_ROOT/push-ci" make_fixture "$push_ci" yq eval '.on.push.branches = ["main"]' -i "$push_ci/.github/workflows/ci.yaml" diff --git a/scripts/ci/check-ci-ownership.sh b/scripts/ci/check-ci-ownership.sh index ace6213e..3001aaff 100755 --- a/scripts/ci/check-ci-ownership.sh +++ b/scripts/ci/check-ci-ownership.sh @@ -121,6 +121,12 @@ require_file "$DEPENDABOT" if grep -En 'group-by:[[:space:]]*dependency-name' "$DEPENDABOT" >/dev/null; then fail "Dependabot must not use the upstream-broken cross-directory dependency-name grouping" fi +[[ $(yq eval '[.updates[] | select(."package-ecosystem" == "github-actions")] | length' "$DEPENDABOT") == "1" ]] \ + || fail "Dependabot must have exactly one GitHub Actions update entry" +[[ $(yq eval '.updates[] | select(."package-ecosystem" == "github-actions") | .directory' "$DEPENDABOT") == "/" ]] \ + || fail "Dependabot must scan all repository workflows" +[[ $(yq eval '.updates[] | select(."package-ecosystem" == "github-actions") | ."open-pull-requests-limit"' "$DEPENDABOT") == "1" ]] \ + || fail "Dependabot must limit GitHub Actions updates to one open pull request" [[ $(yq eval '.on.push // "missing"' "$CI") == "missing" ]] \ || fail "ordinary CI must not repeat the PR suite after merge" diff --git a/scripts/ci/ci-check.sh b/scripts/ci/ci-check.sh index aba7e267..0f76f049 100755 --- a/scripts/ci/ci-check.sh +++ b/scripts/ci/ci-check.sh @@ -17,7 +17,7 @@ echo "" echo "🔗 Checking workflow ownership and action pins..." "$SCRIPT_DIR/check-ci-ownership.sh" "$SCRIPT_DIR/check-ci-ownership-test.sh" -"$SCRIPT_DIR/pin-actions.sh" --verify-only +"$SCRIPT_DIR/check-action-pins.sh" export RSCRYPTO_SKIP_CHECK_SUPPLY_CHAIN=1 "$SCRIPT_DIR/../check/check.sh" --all diff --git a/scripts/ci/dependabot-smoke-test.sh b/scripts/ci/dependabot-smoke-test.sh index 0416c5fd..5ea41b3c 100755 --- a/scripts/ci/dependabot-smoke-test.sh +++ b/scripts/ci/dependabot-smoke-test.sh @@ -25,7 +25,7 @@ assert_plan false false 1 fuzz/Cargo.toml fuzz/Cargo.lock assert_plan false false 1 fuzz-packages/fast-rapidhash/Cargo.toml fuzz-packages/fast-rapidhash/Cargo.lock assert_plan false false 1 tools/ct-dudect/Cargo.toml tools/ct-dudect/Cargo.lock assert_plan false true 0 .github/workflows/ci.yaml -assert_plan true true 0 Cargo.lock .github/workflows/ci.yaml .github/actions-lock.yaml +assert_plan true true 0 Cargo.lock .github/workflows/ci.yaml .github/dependabot.yaml CHANGED=(src/lib.rs) classify_changed_files diff --git a/scripts/ci/dependabot-smoke.sh b/scripts/ci/dependabot-smoke.sh index d3b86041..14ec2b8e 100755 --- a/scripts/ci/dependabot-smoke.sh +++ b/scripts/ci/dependabot-smoke.sh @@ -58,8 +58,8 @@ run_manifest_smoke() { run_automation_smoke() { echo "GitHub Actions dependency smoke" - scripts/ci/pin-actions.sh --verify-only - scripts/ci/pin-actions-test.sh + scripts/ci/check-action-pins.sh + scripts/ci/check-action-pins-test.sh scripts/ci/check-ci-ownership.sh scripts/ci/check-ci-ownership-test.sh actionlint @@ -87,7 +87,7 @@ classify_changed_files() { tools/*/Cargo.toml | tools/*/Cargo.lock) add_manifest "${path%/*}/Cargo.toml" ;; - .github/actions-lock.yaml | .github/dependabot.yaml | .github/workflows/* | .github/actions/*) + .github/dependabot.yaml | .github/workflows/* | .github/actions/*) RUN_AUTOMATION=true ;; *) diff --git a/scripts/ci/pin-actions-test.sh b/scripts/ci/pin-actions-test.sh deleted file mode 100755 index 00b14d24..00000000 --- a/scripts/ci/pin-actions-test.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CHECKER="$SCRIPT_DIR/pin-actions.sh" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -TMP_ROOT=$(mktemp -d) -trap 'rm -rf "$TMP_ROOT"' EXIT - -make_fixture() { - local fixture=$1 - mkdir -p "$fixture/.github/workflows" "$fixture/.github/actions" - cp "$REPO_ROOT/.github/actions-lock.yaml" "$fixture/.github/actions-lock.yaml" - cp "$REPO_ROOT/.github/workflows/scorecard.yaml" "$fixture/.github/workflows/scorecard.yaml" -} - -expect_failure() { - local fixture=$1 - local description=$2 - if "$CHECKER" --verify-only --root "$fixture" >/dev/null 2>&1; then - echo "expected action-pin failure: $description" >&2 - exit 1 - fi -} - -baseline="$TMP_ROOT/baseline" -make_fixture "$baseline" -"$CHECKER" --verify-only --root "$baseline" >/dev/null - -sha_mismatch="$TMP_ROOT/sha-mismatch" -make_fixture "$sha_mismatch" -sed -i.bak -E \ - 's|github/codeql-action/upload-sarif@[0-9a-f]{40}|github/codeql-action/upload-sarif@0000000000000000000000000000000000000000|' \ - "$sha_mismatch/.github/workflows/scorecard.yaml" -rm -f "$sha_mismatch/.github/workflows/scorecard.yaml.bak" -expect_failure "$sha_mismatch" "workflow SHA differs from lock" - -ref_mismatch="$TMP_ROOT/ref-mismatch" -make_fixture "$ref_mismatch" -sed -i.bak -E 's/# v[0-9][^[:space:]]*/# v0.0.0/' "$ref_mismatch/.github/workflows/scorecard.yaml" -rm -f "$ref_mismatch/.github/workflows/scorecard.yaml.bak" -expect_failure "$ref_mismatch" "workflow ref differs from lock" - -missing_lock="$TMP_ROOT/missing-lock" -make_fixture "$missing_lock" -yq eval -i 'del(."github/codeql-action/upload-sarif")' "$missing_lock/.github/actions-lock.yaml" -expect_failure "$missing_lock" "workflow action missing from lock" - -unpinned="$TMP_ROOT/unpinned" -make_fixture "$unpinned" -sed -i.bak -E \ - 's|github/codeql-action/upload-sarif@[0-9a-f]{40}|github/codeql-action/upload-sarif@v4|' \ - "$unpinned/.github/workflows/scorecard.yaml" -rm -f "$unpinned/.github/workflows/scorecard.yaml.bak" -expect_failure "$unpinned" "workflow action is not SHA-pinned" - -nested_action="$TMP_ROOT/nested-action" -fake_bin="$TMP_ROOT/bin" -mkdir -p "$nested_action/.github/workflows" "$nested_action/.github/actions" "$fake_bin" -cat >"$nested_action/.github/actions-lock.yaml" <<'YAML' -github/codeql-action/upload-sarif: - ref: v4.37.0 - sha: "0000000000000000000000000000000000000000" -YAML -cat >"$nested_action/.github/workflows/scorecard.yaml" <<'YAML' -jobs: - scorecard: - steps: - - uses: github/codeql-action/upload-sarif@0000000000000000000000000000000000000000 # v4.37.0 -YAML -cat >"$fake_bin/gh" <<'SH' -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$1" == "auth" && "$2" == "status" ]]; then - exit 0 -fi - -[[ "$1" == "api" ]] -[[ "$2" == "repos/github/codeql-action/commits/v4.37.0" ]] -printf '%s\n' 99df26d4f13ea111d4ec1a7dddef6063f76b97e9 -SH -chmod +x "$fake_bin/gh" - -PATH="$fake_bin:$PATH" "$CHECKER" --update-lock --root "$nested_action" >/dev/null -[[ $(yq eval -r '."github/codeql-action/upload-sarif".sha' "$nested_action/.github/actions-lock.yaml") \ - == "99df26d4f13ea111d4ec1a7dddef6063f76b97e9" ]] -grep -Fq \ - 'uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0' \ - "$nested_action/.github/workflows/scorecard.yaml" -yq eval -i '."github/codeql-action/upload-sarif".updated = "unchanged"' "$nested_action/.github/actions-lock.yaml" -PATH="$fake_bin:$PATH" "$CHECKER" --update-lock --root "$nested_action" >/dev/null -[[ $(yq eval -r '."github/codeql-action/upload-sarif".updated' "$nested_action/.github/actions-lock.yaml") == "unchanged" ]] - -echo "Action pin regression tests passed" diff --git a/scripts/ci/pin-actions.sh b/scripts/ci/pin-actions.sh deleted file mode 100755 index 2113cc13..00000000 --- a/scripts/ci/pin-actions.sh +++ /dev/null @@ -1,383 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# GitHub Actions SHA Pinning Script -# -# Purpose: -# Resolves semantic versions from .github/actions-lock.yaml to commit SHAs, -# then rewrites all workflow files to use SHA-pinned references. -# -# Usage: -# ./scripts/ci/pin-actions.sh [--verify-only] [--update-lock] [--root PATH] -# -# Options: -# --verify-only Check if workflows match lock file (CI mode) -# --update-lock Resolve the refs already in the lock file to SHAs -# --root PATH Operate on another repository root (validation fixtures) -# -# Requirements: -# - yq (YAML processor): brew install yq OR apt-get install yq -# - jq (JSON processor) -# - gh (GitHub CLI) OR curl -# - -VERIFY_ONLY=false -UPDATE_LOCK=false -ROOT_OVERRIDE="" - -# Parse arguments. -while [[ $# -gt 0 ]]; do - case $1 in - --verify-only) - VERIFY_ONLY=true - shift - ;; - --update-lock) - UPDATE_LOCK=true - shift - ;; - --root) - ROOT_OVERRIDE=${2:?missing path after --root} - shift 2 - ;; - *) - echo "Unknown option: $1" - echo "Usage: $0 [--verify-only] [--update-lock] [--root PATH]" - exit 1 - ;; - esac -done - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ -n "$ROOT_OVERRIDE" ]]; then - REPO_ROOT="$(cd "$ROOT_OVERRIDE" && pwd)" -else - REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -fi -LOCK_FILE="$REPO_ROOT/.github/actions-lock.yaml" -WORKFLOWS_DIR="$REPO_ROOT/.github/workflows" -ACTIONS_DIR="$REPO_ROOT/.github/actions" - -# Dependency Checks - -check_dependencies() { - local missing=() - - if ! command -v yq &> /dev/null; then - missing+=("yq (install: brew install yq)") - fi - - if ! command -v jq &> /dev/null; then - missing+=("jq (install: brew install jq)") - fi - - if [ ${#missing[@]} -gt 0 ]; then - echo "ERROR: Missing required dependencies:" - printf ' - %s\n' "${missing[@]}" - exit 1 - fi -} - -workflow_files() { - find "$WORKFLOWS_DIR" "$ACTIONS_DIR" \( -name "*.yaml" -o -name "*.yml" \) -type f 2>/dev/null | sort -} - -# GitHub API - Resolve ref to SHA - -action_repository() { - local action="$1" - local owner=${action%%/*} - local remainder=${action#*/} - local repository=${remainder%%/*} - - if [[ -z "$owner" || -z "$repository" || "$remainder" == "$action" ]]; then - echo "ERROR: invalid GitHub Action name '$action'" >&2 - return 1 - fi - - printf '%s/%s\n' "$owner" "$repository" -} - -resolve_ref_to_sha() { - local action="$1" # e.g., "actions/checkout" - local ref="$2" # e.g., "v4" or "master" - local repository - repository=$(action_repository "$action") - - echo " Resolving $action@$ref..." >&2 - - # Try gh CLI first (respects auth, higher rate limits) - if command -v gh &> /dev/null && gh auth status &> /dev/null; then - local sha - sha=$(gh api "repos/$repository/commits/$ref" --jq '.sha' 2>/dev/null || echo "") - if [ -n "$sha" ]; then - echo "$sha" - return 0 - fi - fi - - # Fallback to curl (unauthenticated, 60 req/hour limit) - local sha - sha=$(curl -sSL "https://api.github.com/repos/$repository/commits/$ref" 2>/dev/null | jq -r '.sha // empty') - - if [ -z "$sha" ]; then - echo "ERROR: Failed to resolve $action@$ref" >&2 - return 1 - fi - - echo "$sha" -} - -# Update Lock File with SHAs for Locked Refs - -update_lock_file() { - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Updating actions-lock.yaml with SHAs for locked refs" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - local actions - actions=$(yq eval 'keys | .[]' "$LOCK_FILE" | grep -v '^#') - - local temp_lock - temp_lock=$(mktemp) - cp "$LOCK_FILE" "$temp_lock" - - for action in $actions; do - echo "Processing: $action" - - local ref - ref=$(yq eval ".\"$action\".ref" "$LOCK_FILE") - - if [ "$ref" = "null" ] || [ -z "$ref" ]; then - echo " ⚠️ Skipping: No ref defined" - continue - fi - - local sha current_sha - if ! sha=$(resolve_ref_to_sha "$action" "$ref"); then - echo " ❌ Failed to resolve SHA" - continue - fi - - current_sha=$(yq eval -r ".\"$action\".sha // \"\"" "$LOCK_FILE") - if [[ "$sha" == "$current_sha" ]]; then - echo " ✅ $ref already resolves to $sha" - echo "" - continue - fi - - local timestamp - timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - # Update YAML in-place - yq eval -i ".\"$action\".sha = \"$sha\"" "$temp_lock" - yq eval -i ".\"$action\".updated = \"$timestamp\"" "$temp_lock" - - echo " ✅ $ref → $sha" - echo "" - done - - mv "$temp_lock" "$LOCK_FILE" - echo "✅ Lock file updated successfully" - echo "" -} - -# Rewrite Workflow Files to Use SHA Pins - -rewrite_workflows() { - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Rewriting workflows to use SHA-pinned actions" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - local files - files=$(workflow_files) - - for file in $files; do - echo "Processing: $(basename "$file")" - - # Read all actions from lock file - local actions - actions=$(yq eval 'keys | .[]' "$LOCK_FILE" | grep -v '^#') - - local modified=false - local temp_file - temp_file=$(mktemp) - cp "$file" "$temp_file" - - for action in $actions; do - local ref sha - ref=$(yq eval ".\"$action\".ref" "$LOCK_FILE") - sha=$(yq eval ".\"$action\".sha" "$LOCK_FILE") - - if [ "$sha" = "null" ] || [ -z "$sha" ]; then - echo " ⚠️ Skipping $action: No SHA in lock file" - continue - fi - - # Pattern: uses: actions/checkout@v4 - # Replace with: uses: actions/checkout@abc123... # v4 - - # Check if action exists in file - if ! grep -q "uses: $action@" "$temp_file"; then - continue - fi - - # Perform replacement using sed - # Match: uses: actions/checkout@ - # Replace: uses: actions/checkout@ # - sed -i.bak -E "s|(uses: $action)@[a-zA-Z0-9._-]+( *#.*)?$|\1@$sha # $ref|g" "$temp_file" - - modified=true - echo " ✅ Pinned $action@$ref → $sha" - done - - if [ "$modified" = true ]; then - mv "$temp_file" "$file" - rm -f "$file.bak" - echo " 💾 Saved changes" - else - rm -f "$temp_file" "$temp_file.bak" - echo " ℹ️ No changes needed" - fi - - echo "" - done - - echo "✅ All workflows updated" - echo "" -} - -# Verify Mode - Check if workflows are properly pinned - -verify_workflows() { - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Verifying workflows are properly pinned" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - local files - files=$(workflow_files) - - local unpinned=() - local unlocked=() - local mismatched=() - - for file in $files; do - # Find lines with "uses:" that are NOT pinned to a SHA - # SHA pattern: 40 hex characters - # Exclude local actions: composite actions (./.github/actions/) and reusable workflows (./.github/workflows/) - local issues - issues=$(grep -n "uses:" "$file" | grep -v "@[0-9a-f]\{40\}" | grep -v "^#" | grep -v "uses: \\./\\.github/actions/" | grep -v "uses: \\./\\.github/workflows/" || true) - - if [ -n "$issues" ]; then - unpinned+=("$file") - echo "❌ UNPINNED: $(basename "$file")" - while IFS= read -r issue; do - echo " $issue" - done <<< "$issues" - echo "" - fi - - local external_uses - external_uses=$(grep -n "uses:" "$file" \ - | grep -v "uses: \\./\\.github/actions/" \ - | grep -v "uses: \\./\\.github/workflows/" \ - | grep -v '^docker://' \ - | grep -v '^$' \ - || true) - - local line use_ref action workflow_sha workflow_ref lock_sha lock_ref - while IFS= read -r line; do - [ -z "$line" ] && continue - use_ref=$(sed -E 's/^[0-9]+:[[:space:]-]*uses:[[:space:]]*([^[:space:]#]+).*/\1/' <<<"$line") - action="${use_ref%@*}" - if [ "$action" = "$use_ref" ]; then - continue - fi - if ! yq eval -e ".\"$action\"" "$LOCK_FILE" >/dev/null 2>&1; then - unlocked+=("$file:$action") - continue - fi - - workflow_sha="${use_ref##*@}" - workflow_ref=$(sed -nE 's/.*#[[:space:]]*([^[:space:]]+)[[:space:]]*$/\1/p' <<<"$line") - lock_sha=$(yq eval -r ".\"$action\".sha // \"\"" "$LOCK_FILE") - lock_ref=$(yq eval -r ".\"$action\".ref // \"\"" "$LOCK_FILE") - - if [[ "$workflow_sha" != "$lock_sha" ]]; then - mismatched+=("$file:$action SHA $workflow_sha != $lock_sha") - fi - if [[ -z "$workflow_ref" || "$workflow_ref" != "$lock_ref" ]]; then - mismatched+=("$file:$action ref ${workflow_ref:-} != $lock_ref") - fi - done <<< "$external_uses" - done - - if [ ${#unlocked[@]} -gt 0 ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "❌ LOCK COVERAGE FAILED" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Found external actions missing from .github/actions-lock.yaml:" - printf ' - %s\n' "${unlocked[@]}" - echo "" - fi - - if [ ${#mismatched[@]} -gt 0 ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "❌ LOCK CONSISTENCY FAILED" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Found workflow pins that differ from .github/actions-lock.yaml:" - printf ' - %s\n' "${mismatched[@]}" - echo "" - fi - - if [ ${#unpinned[@]} -gt 0 ] || [ ${#unlocked[@]} -gt 0 ] || [ ${#mismatched[@]} -gt 0 ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "❌ VERIFICATION FAILED" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Found ${#unpinned[@]} unpinned file(s), ${#unlocked[@]} unlocked action(s), and ${#mismatched[@]} lock mismatch(es)." - echo "" - echo "To fix, run:" - echo " 1. Add missing external actions to .github/actions-lock.yaml" - echo " 2. just pin-actions" - echo "" - exit 1 - else - echo "✅ All workflows properly pinned" - echo "" - fi -} - -# Main Execution - -main() { - check_dependencies - - if [ "$VERIFY_ONLY" = true ]; then - verify_workflows - exit 0 - fi - - if [ "$UPDATE_LOCK" = true ]; then - update_lock_file - fi - - rewrite_workflows - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "✅ GitHub Actions pinning complete" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Next steps:" - echo " 1. Review changes: git diff" - echo " 2. Test workflows locally if possible" - echo " 3. Commit changes: git add -A && git commit -m 'ci: pin GitHub Actions to commit SHAs'" - echo "" -} - -main diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh index 3fc6e818..719376b5 100755 --- a/scripts/ci/pre-push.sh +++ b/scripts/ci/pre-push.sh @@ -181,7 +181,7 @@ needs_actions_check() { return 0 fi - changed_file_matches '^\.config/target-matrix\.json$|^\.github/actions-lock\.yaml$|^\.github/(workflows|actions)/.*\.ya?ml$|^\.github/(rulesets|repository-settings)/.*\.json$|^scripts/ci/(pin-actions|check-ci-ownership|check-ci-ownership-test|release-evidence-check|release-evidence-check-test|repository-controls-evidence|repository-controls-evidence-test|package-release-source|write-release-manifest|release-identity-test|publish-immutable-release|publish-immutable-release-test|release-recipes-test)\.sh$' + changed_file_matches '^\.config/target-matrix\.json$|^\.github/(workflows|actions)/.*\.ya?ml$|^\.github/(rulesets|repository-settings)/.*\.json$|^scripts/ci/(check-action-pins|check-action-pins-test|check-ci-ownership|check-ci-ownership-test|release-evidence-check|release-evidence-check-test|repository-controls-evidence|repository-controls-evidence-test|package-release-source|write-release-manifest|release-identity-test|publish-immutable-release|publish-immutable-release-test|release-recipes-test)\.sh$' } needs_host_checks() { diff --git a/scripts/ci/upgrade-actions-test.sh b/scripts/ci/upgrade-actions-test.sh deleted file mode 100755 index 75bb7d50..00000000 --- a/scripts/ci/upgrade-actions-test.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CHECKER="$SCRIPT_DIR/upgrade-actions.sh" -TMP_ROOT=$(mktemp -d) -trap 'rm -rf "$TMP_ROOT"' EXIT - -fixture="$TMP_ROOT/repository" -fake_bin="$TMP_ROOT/bin" -mkdir -p "$fixture/.github" "$fake_bin" - -cat >"$fixture/.github/actions-lock.yaml" <<'YAML' -github/codeql-action/upload-sarif: - ref: v4.37.0 - sha: "99df26d4f13ea111d4ec1a7dddef6063f76b97e9" -YAML - -cat >"$fake_bin/gh" <<'SH' -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$1" == "auth" && "$2" == "status" ]]; then - exit 0 -fi - -[[ "$1" == "api" ]] -[[ "$2" == "--paginate" ]] -[[ "$3" == "repos/github/codeql-action/tags?per_page=100" ]] -printf '%s\n' v4.37.0 -SH -chmod +x "$fake_bin/gh" - -cat >"$fake_bin/curl" <<'SH' -#!/usr/bin/env bash -set -euo pipefail - -url=${!#} -[[ "$url" == "https://raw.githubusercontent.com/github/codeql-action/v4.37.0/upload-sarif/action.yml" ]] -cat <<'YAML' -runs: - using: node24 -YAML -SH -chmod +x "$fake_bin/curl" - -output=$(PATH="$fake_bin:$PATH" "$CHECKER" --check --root "$fixture") -grep -Fq "Up to date: v4.37.0" <<<"$output" -grep -Fq "All GitHub Action refs are current under the Node 24 policy." <<<"$output" - -echo "Action upgrade regression tests passed" diff --git a/scripts/ci/upgrade-actions.sh b/scripts/ci/upgrade-actions.sh deleted file mode 100755 index 2f3f4180..00000000 --- a/scripts/ci/upgrade-actions.sh +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Upgrade .github/actions-lock.yaml refs to the latest stable tags that are safe -# for current GitHub-hosted runners, then let pin-actions.sh resolve SHAs. - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -LOCK_FILE="$REPO_ROOT/.github/actions-lock.yaml" - -CHECK_ONLY=false - -usage() { - cat <<'USAGE' -Usage: scripts/ci/upgrade-actions.sh [--check] [--root PATH] - -Options: - --check Print the refs that would change without editing the lock file. - --root Operate on another repository root (validation fixtures). -USAGE -} - -ROOT_OVERRIDE="" -while [[ $# -gt 0 ]]; do - case "$1" in - --check) - CHECK_ONLY=true - shift - ;; - --root) - ROOT_OVERRIDE=${2:?missing path after --root} - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown option: $1" - usage - exit 1 - ;; - esac -done - -if [[ -n "$ROOT_OVERRIDE" ]]; then - REPO_ROOT="$(cd "$ROOT_OVERRIDE" && pwd)" - LOCK_FILE="$REPO_ROOT/.github/actions-lock.yaml" -fi - -check_dependencies() { - local missing=() - - if ! command -v yq >/dev/null 2>&1; then - missing+=("yq (install: brew install yq)") - fi - - if ! command -v jq >/dev/null 2>&1; then - missing+=("jq (install: brew install jq)") - fi - - if ! command -v curl >/dev/null 2>&1; then - missing+=("curl") - fi - - if [[ ${#missing[@]} -gt 0 ]]; then - echo "ERROR: Missing required dependencies:" - printf ' - %s\n' "${missing[@]}" - exit 1 - fi -} - -github_api() { - local url="$1" - local headers=( - -H "Accept: application/vnd.github+json" - -H "X-GitHub-Api-Version: 2022-11-28" - -H "User-Agent: rscrypto-action-upgrader" - ) - - if [[ -n "${GITHUB_TOKEN:-}" ]]; then - headers+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") - fi - - curl -fsSL "${headers[@]}" "$url" -} - -raw_github() { - local url="$1" - local headers=(-H "User-Agent: rscrypto-action-upgrader") - - if [[ -n "${GITHUB_TOKEN:-}" ]]; then - headers+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") - fi - - curl -fsSL "${headers[@]}" "$url" -} - -action_repository() { - local action="$1" - local owner=${action%%/*} - local remainder=${action#*/} - local repository=${remainder%%/*} - - if [[ -z "$owner" || -z "$repository" || "$remainder" == "$action" ]]; then - echo "ERROR: invalid GitHub Action name '$action'" >&2 - return 1 - fi - - printf '%s/%s\n' "$owner" "$repository" -} - -action_definition_prefix() { - local action="$1" - local repository="$2" - - if [[ "$action" == "$repository" ]]; then - return 0 - fi - - printf '%s/' "${action#"$repository"/}" -} - -list_tags() { - local action="$1" - local repository - repository=$(action_repository "$action") - - if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then - if gh api --paginate "repos/$repository/tags?per_page=100" --jq '.[].name' 2>/dev/null; then - return 0 - fi - fi - - local page=1 - while true; do - local response names - response=$(github_api "https://api.github.com/repos/$repository/tags?per_page=100&page=$page") - names=$(jq -r '.[].name' <<<"$response") - - if [[ -z "$names" ]]; then - break - fi - - printf '%s\n' "$names" - page=$((page + 1)) - done -} - -stable_semver_tags() { - jq -R -s -r ' - split("\n") - | map(select(length > 0)) - | map(select(test("^v?[0-9]+(\\.[0-9]+){0,2}$"))) - | map( - capture("^(?v?)(?[0-9]+)(\\.(?[0-9]+))?(\\.(?[0-9]+))?$") as $v - | { - tag: ., - major: ($v.major | tonumber), - minor: (($v.minor // "0") | tonumber), - patch: (($v.patch // "0") | tonumber), - specificity: ((if $v.minor then 1 else 0 end) + (if $v.patch then 1 else 0 end)) - } - ) - | sort_by(.major, .minor, .patch, .specificity) - | reverse - | .[].tag - ' -} - -action_runtime() { - local action="$1" - local ref="$2" - local repository definition_prefix tmp - repository=$(action_repository "$action") - definition_prefix=$(action_definition_prefix "$action" "$repository") - tmp=$(mktemp) - - for filename in action.yml action.yaml; do - if raw_github "https://raw.githubusercontent.com/$repository/$ref/${definition_prefix}${filename}" >"$tmp" 2>/dev/null; then - yq eval '.runs.using // ""' "$tmp" 2>/dev/null || true - rm -f "$tmp" - return 0 - fi - done - - rm -f "$tmp" - return 1 -} - -runtime_is_supported() { - local using="$1" - - case "$using" in - composite|docker|node24) - return 0 - ;; - *) - return 1 - ;; - esac -} - -select_latest_supported_ref() { - local action="$1" - local current_ref="$2" - local tags semver_tags - - tags=$(list_tags "$action") - semver_tags=$(stable_semver_tags <<<"$tags") - - if [[ -z "$semver_tags" ]]; then - local current_runtime - current_runtime=$(action_runtime "$action" "$current_ref" || true) - if runtime_is_supported "$current_runtime"; then - printf '%s\n' "$current_ref" - return 0 - fi - - echo "ERROR: $action has no stable semver tags and $current_ref uses unsupported runtime '$current_runtime'" >&2 - return 1 - fi - - local tag runtime - while IFS= read -r tag; do - [[ -z "$tag" ]] && continue - - runtime=$(action_runtime "$action" "$tag" || true) - if runtime_is_supported "$runtime"; then - printf '%s\n' "$tag" - return 0 - fi - - echo " Skipping $action@$tag (runs.using=${runtime:-unknown})" >&2 - done <<<"$semver_tags" - - echo "ERROR: no stable Node 24-compatible/composite/docker tag found for $action" >&2 - return 1 -} - -main() { - check_dependencies - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - if [[ "$CHECK_ONLY" == true ]]; then - echo "Checking GitHub Action refs for supported latest stable tags" - else - echo "Upgrading GitHub Action refs to supported latest stable tags" - fi - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - local temp_lock - temp_lock=$(mktemp) - cp "$LOCK_FILE" "$temp_lock" - - local changed=false - local action - while IFS= read -r action; do - [[ -z "$action" ]] && continue - - local current_ref next_ref - current_ref=$(yq eval ".\"$action\".ref" "$LOCK_FILE") - - if [[ "$current_ref" == "null" || -z "$current_ref" ]]; then - echo "Processing: $action" - echo " Skipping: no ref defined" - echo "" - continue - fi - - echo "Processing: $action@$current_ref" - next_ref=$(select_latest_supported_ref "$action" "$current_ref") - - if [[ "$next_ref" == "$current_ref" ]]; then - echo " Up to date: $current_ref" - else - changed=true - echo " Upgrade: $current_ref -> $next_ref" - if [[ "$CHECK_ONLY" == false ]]; then - yq eval -i ".\"$action\".ref = \"$next_ref\"" "$temp_lock" - fi - fi - echo "" - done < <(yq eval 'keys | .[]' "$LOCK_FILE") - - if [[ "$CHECK_ONLY" == true ]]; then - rm -f "$temp_lock" - if [[ "$changed" == true ]]; then - echo "GitHub Action ref updates are available." - else - echo "All GitHub Action refs are current under the Node 24 policy." - fi - return 0 - fi - - if [[ "$changed" == true ]]; then - mv "$temp_lock" "$LOCK_FILE" - echo "Action refs upgraded. Run scripts/ci/pin-actions.sh --update-lock to refresh SHAs." - else - rm -f "$temp_lock" - echo "No action ref upgrades needed." - fi -} - -main diff --git a/scripts/update/update-all.sh b/scripts/update/update-all.sh index df6b0c93..052e5518 100755 --- a/scripts/update/update-all.sh +++ b/scripts/update/update-all.sh @@ -102,18 +102,6 @@ while IFS= read -r tool_dir; do TOOL_COUNT=$((TOOL_COUNT + 1)) done < <(discover_tool_workspaces) -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "GitHub actions upgrade/pinning" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -if [[ "$CHECK_ONLY" == true ]]; then - "$REPO_ROOT/scripts/ci/upgrade-actions.sh" --check - "$REPO_ROOT/scripts/ci/pin-actions.sh" --verify-only -else - "$REPO_ROOT/scripts/ci/upgrade-actions.sh" - "$REPO_ROOT/scripts/ci/pin-actions.sh" --update-lock -fi - echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Summary"