diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4db0dc77..ffd9f48f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,9 @@ updates: interval: "daily" cooldown: default-days: 7 + # Lockfile-only dependencies are updated too. + allow: + - dependency-type: "all" labels: - "dependencies" - "rust" @@ -35,6 +38,23 @@ updates: - "github-actions" commit-message: prefix: "chore(ci)" + groups: + # The CodeQL actions share one bundle version and fail when only some of + # them move, so they always update together. + codeql-action: + patterns: + - "github/codeql-action*" + github-actions-safe-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + github-actions-major-dependencies: + patterns: + - "*" + update-types: + - "major" - package-ecosystem: "cargo" directory: "/fuzz" diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 7677b9ae..517ec187 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -3,6 +3,13 @@ name: Security & Dependency Audit on: branch_protection_rule: workflow_dispatch: + inputs: + dependency_review_base: + description: >- + Base commit for Dependency Review when validating a repaired + dependency update branch. Empty for a mainline run. + required: false + type: string schedule: - cron: '0 0 * * *' # Run daily at midnight push: @@ -154,7 +161,12 @@ jobs: dependency_review: name: Dependency Review runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + # A repaired dependency update is validated by dispatch on its branch, + # because a branch update made with the Actions token leaves pull request + # workflows waiting for approval. The dispatch names the base commit. + if: >- + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.dependency_review_base != '') steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -162,6 +174,9 @@ jobs: - name: Dependency Review uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + base-ref: ${{ inputs.dependency_review_base || github.event.pull_request.base.sha }} + head-ref: ${{ github.event.pull_request.head.sha || github.sha }} codeql: name: CodeQL (${{ matrix.language }}) @@ -208,7 +223,7 @@ jobs: ossf_scorecard: name: OSSF Scorecard - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && inputs.dependency_review_base == '' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed2aeb9a..b082daf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ name: CI branches: [main] pull_request: branches: [main] + # Raised for a main commit that the Actions token merged, because such a + # merge starts no push workflow. + workflow_dispatch: permissions: contents: read @@ -78,6 +81,11 @@ jobs: (cd /tmp/guard-release-test/verify && sha256sum --check SHA256SUMS) rm -rf /tmp/guard-release-test + - name: Test dependency automation policy + if: runner.os == 'Linux' + shell: bash + run: ./scripts/test-dependabot-automerge.sh + - name: Exercise packaged systemd argument expansion if: runner.os == 'Linux' shell: bash diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 357c6ed0..c92e60e9 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -1,140 +1,66 @@ name: Dependabot review gate -# Gate Dependabot PRs that need human review while staying quiet for updates -# that normal CI can cover: +# Reports how the dependency automation classifies a Dependabot pull request. +# The classification policy is scripts/dependabot-automerge.sh, read from the +# default branch so the pull request cannot change it. The job reads GitHub API +# metadata only; it does not execute pull request code, post comments, approve +# reviews, or merge changes. # -# - patch and minor cargo updates -# - all GitHub Actions updates (we already SHA-pin them, so the bump -# is functionally a vetted-tag → vetted-tag move) +# The job succeeds for a routine update, and for any update that a repository +# owner, member, or collaborator approved at its exact head commit. It fails +# for a major or unclassified update without that review, so the required +# check blocks the merge. A submitted or dismissed review runs the job again. # -# Major and unclassified version bumps fail closed for human review. The gate -# does not execute pull request code, use external model credentials, post -# comments, approve reviews, or merge changes. Major security updates follow -# the same fail-closed policy because alert lookup requires a separate -# privileged token. +# Merging is the separate, privileged dependabot-reconcile.yml workflow, which +# repeats the classification itself. That workflow dispatches this one on a +# branch that it repaired or refreshed, because a branch update made with the +# Actions token leaves pull request workflows waiting for approval. -on: +"on": pull_request: types: - opened - reopened - synchronize + pull_request_review: + types: + - submitted + - dismissed workflow_dispatch: inputs: - pr_number: - description: "Dependabot pull request number to process" + pull_request: + description: Number of the Dependabot pull request whose head is the dispatched commit. required: true - type: number + type: string -permissions: - contents: read +permissions: {} concurrency: - group: dependabot-review-${{ github.event.pull_request.number || inputs.pr_number }} + group: dependabot-review-${{ github.event.pull_request.number || inputs.pull_request }} cancel-in-progress: true jobs: review-gate: name: Classify dependency update - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.user.login == 'dependabot[bot]' + if: >- + github.event_name == 'workflow_dispatch' || + github.event.pull_request.user.login == 'dependabot[bot]' runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read - pull-requests: read # Reads Dependabot PR metadata and changed-file names. + pull-requests: read # Reads pull request, commit, review, and changed-file metadata. steps: - - name: Resolve pull request - id: pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} - DISPATCH_PR_NUMBER: ${{ inputs.pr_number }} - run: | - set -euo pipefail - - pr_number="${DISPATCH_PR_NUMBER:-${EVENT_PR_NUMBER:-}}" - if [ -z "$pr_number" ]; then - echo "No pull request number was provided." - exit 1 - fi - - author="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.user.login')" - if [ "$author" != "dependabot[bot]" ]; then - echo "PR #${pr_number} was opened by ${author}, not dependabot[bot]." - exit 1 - fi - - { - echo "number=${pr_number}" - echo "url=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.html_url')" - echo "head_sha=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.head.sha')" - echo "head_ref=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.head.ref')" - } >> "$GITHUB_OUTPUT" - - - name: Resolve update metadata - id: fetch_meta - if: github.event_name == 'pull_request' - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 - - - name: Resolve dispatched update metadata - id: meta - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ steps.pr.outputs.number }} - FETCH_UPDATE_TYPE: ${{ steps.fetch_meta.outputs.update-type }} - FETCH_PACKAGE_ECOSYSTEM: ${{ steps.fetch_meta.outputs.package-ecosystem }} - HEAD_REF: ${{ steps.pr.outputs.head_ref }} - run: | - set -euo pipefail - - update_type="$FETCH_UPDATE_TYPE" - package_ecosystem="$FETCH_PACKAGE_ECOSYSTEM" - - if [ -z "$update_type" ]; then - body="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.body // ""')" - update_type="$(printf '%s\n' "$body" | sed -n 's/^[[:space:]]*-[[:space:]]*update-type:[[:space:]]*//p' | head -1)" - fi - if [ -z "$update_type" ]; then - case "$HEAD_REF" in - *major*) update_type="version-update:semver-major" ;; - *minor*) update_type="version-update:semver-minor" ;; - *patch*) update_type="version-update:semver-patch" ;; - esac - fi - - if [ -z "$package_ecosystem" ]; then - files="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename')" - if printf '%s\n' "$files" | grep -Eq '^\.github/workflows/'; then - package_ecosystem="github_actions" - elif printf '%s\n' "$files" | grep -Eq '(^|/)Cargo\.(toml|lock)$'; then - package_ecosystem="cargo" - else - package_ecosystem="unknown" - fi - fi - - { - echo "update-type=${update_type}" - echo "package-ecosystem=${package_ecosystem}" - } >> "$GITHUB_OUTPUT" - - - name: Enforce review policy + - name: Check out the default-branch policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: scripts + + - name: Classify pull request env: - UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} - PACKAGE_ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }} - run: | - set -euo pipefail - - case "$UPDATE_TYPE" in - version-update:semver-patch|version-update:semver-minor) - echo "Routine update; normal CI is sufficient." - exit 0 - ;; - esac - - if [ "$PACKAGE_ECOSYSTEM" = "github_actions" ]; then - echo "SHA-pinned GitHub Actions update; normal CI is sufficient." - exit 0 - fi - - echo "Major or unclassified dependency update requires human review." - exit 1 + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request }} + EXPECTED_HEAD: ${{ github.event_name == 'workflow_dispatch' && github.sha || '' }} + run: scripts/dependabot-automerge.sh gate "$PR_NUMBER" diff --git a/.github/workflows/dependabot-reconcile.yml b/.github/workflows/dependabot-reconcile.yml new file mode 100644 index 00000000..88dc2b1d --- /dev/null +++ b/.github/workflows/dependabot-reconcile.yml @@ -0,0 +1,327 @@ +name: Dependabot reconcile + +# Merges eligible Dependabot pull requests, refreshes branches that lack the +# main commit, repairs Cargo updates whose required checks fail or whose branch +# conflicts with main, and keeps workflow runs present and rerun for the +# commits this automation creates. +# +# The workflow has no pull request trigger and runs only the default-branch +# copies of its scripts. Three boundaries keep the model credential, dependency +# code, and repository write access apart: +# +# - merge, plan, publish, and runs hold repository tokens. They decide from +# GitHub API metadata and never check out or execute pull request content. +# - generate holds the model credential and no write token. The model reads +# and edits files; it cannot run commands, so no dependency code runs +# beside the credential. +# - apply runs Cargo on the repaired tree. It holds neither the model +# credential nor a write token. +# +# publish commits only files that pass the path and content policy, through +# the Git database API, which GitHub signs. A repaired or refreshed head gets +# its required checks by dispatch, and merges like any other update once they +# pass. +# +# A repair that was published, or that cannot proceed, is recorded as an +# artifact named after its plan. The planner skips a plan whose record exists. +# An operational failure leaves no record and is tried again on the next run. +# +# The schedule covers every open Dependabot pull request, including ones that +# were opened before this workflow existed. + +"on": + schedule: + - cron: "23 * * * *" + workflow_dispatch: + +permissions: {} + +concurrency: + group: dependabot-reconcile + cancel-in-progress: false + +env: + # OpenCode release used for the repair, pinned by digest. + OPENCODE_VERSION: "1.18.33" + OPENCODE_SHA256: e546123213ae47909a4268692aa4b94950d011afe9cac9938753a2194f1c16d5 + +jobs: + merge: + name: Merge eligible dependency updates + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + checks: read # Reads required check results for pull request head commits. + contents: write # Merges eligible pull requests and refreshes their branches. + pull-requests: write # Merges eligible pull requests. + steps: + - name: Check out the default-branch policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: scripts + + - name: Merge eligible pull requests + env: + GH_TOKEN: ${{ github.token }} + run: scripts/dependabot-automerge.sh reconcile + + plan: + name: Plan a dependency repair + needs: merge + if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read # Reads the logs of failed required checks. + checks: read # Reads required check results for pull request head commits. + contents: read + pull-requests: read + outputs: + repair: ${{ steps.plan.outputs.repair }} + head_sha: ${{ steps.plan.outputs.head_sha }} + attempt: ${{ steps.plan.outputs.attempt }} + steps: + - name: Check out the default-branch policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: scripts + + - name: Select a pull request to repair + id: plan + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + scripts/dependabot-automerge.sh repair-plan "$RUNNER_TEMP/plan" + { + echo "repair=$(jq -r '.repair' "$RUNNER_TEMP/plan/plan.json")" + echo "head_sha=$(jq -r '.head_sha // "" | select(test("^[0-9a-f]{40}$"))' "$RUNNER_TEMP/plan/plan.json")" + echo "attempt=$(jq -r '.attempt // "" | select(test("^repair-attempt-[0-9]+-[0-9a-f]{64}$"))' "$RUNNER_TEMP/plan/plan.json")" + } >> "$GITHUB_OUTPUT" + + - name: Upload the plan + if: steps.plan.outputs.repair == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: repair-plan + path: ${{ runner.temp }}/plan + retention-days: 7 + + generate: + name: Generate a dependency repair + needs: plan + if: needs.plan.outputs.repair == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Check out the default-branch repair engine + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: scripts + path: trusted + + - name: Fetch the pull request head as data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.plan.outputs.head_sha }} + fetch-depth: 0 + persist-credentials: false + path: work + + - name: Download the plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: repair-plan + path: ${{ runner.temp }}/plan + + - name: Install the pinned OpenCode release + run: | + set -euo pipefail + archive="$RUNNER_TEMP/opencode.tar.gz" + curl --fail --silent --show-error --location --output "$archive" \ + "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" + echo "${OPENCODE_SHA256} ${archive}" | sha256sum --check + mkdir "$RUNNER_TEMP/opencode" + tar -xzf "$archive" -C "$RUNNER_TEMP/opencode" + echo "$RUNNER_TEMP/opencode" >> "$GITHUB_PATH" + + - name: Prepare the repair tree + id: prepare + run: | + status=0 + trusted/scripts/dependency-repair.sh prepare work "$RUNNER_TEMP/plan/plan.json" || status=$? + if [ "$status" -eq 3 ]; then + echo "record=true" >> "$GITHUB_OUTPUT" + fi + exit "$status" + + # The only step that holds the model credential. Nothing from the pull + # request has been executed in this job, and nothing is executed here: + # the model has read and edit tools only. The script takes the + # credential out of its environment and hands it to the model process + # alone. + - name: Generate the repair + id: generate + env: + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + run: | + status=0 + trusted/scripts/dependency-repair.sh generate work \ + "$RUNNER_TEMP/plan/plan.json" "$RUNNER_TEMP/plan/context.md" \ + "$RUNNER_TEMP/generated" || status=$? + if [ "$status" -eq 3 ]; then + echo "record=true" >> "$GITHUB_OUTPUT" + fi + exit "$status" + + - name: Record a repair that cannot proceed + if: ${{ !cancelled() && (steps.prepare.outputs.record == 'true' || steps.generate.outputs.record == 'true') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ needs.plan.outputs.attempt }} + path: ${{ runner.temp }}/plan/plan.json + + - name: Upload the generated repair + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: repair-generated + path: ${{ runner.temp }}/generated + retention-days: 7 + + apply: + name: Apply a dependency repair + needs: [plan, generate] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Check out the default-branch repair engine + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: scripts + path: trusted + + - name: Fetch the pull request head as data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.plan.outputs.head_sha }} + fetch-depth: 0 + persist-credentials: false + path: work + + - name: Download the plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: repair-plan + path: ${{ runner.temp }}/plan + + - name: Download the generated repair + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: repair-generated + path: ${{ runner.temp }}/generated + + - name: Apply the repair without credentials + id: apply + run: | + status=0 + trusted/scripts/dependency-repair.sh prepare work "$RUNNER_TEMP/plan/plan.json" \ + && trusted/scripts/dependency-repair.sh apply work "$RUNNER_TEMP/plan/plan.json" \ + "$RUNNER_TEMP/generated" "$RUNNER_TEMP/candidate" || status=$? + if [ "$status" -eq 3 ]; then + echo "record=true" >> "$GITHUB_OUTPUT" + fi + exit "$status" + + - name: Record a repair that cannot proceed + if: ${{ !cancelled() && steps.apply.outputs.record == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ needs.plan.outputs.attempt }} + path: ${{ runner.temp }}/plan/plan.json + + - name: Upload the repair candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: repair-candidate + path: ${{ runner.temp }}/candidate + retention-days: 7 + + publish: + name: Publish a dependency repair + needs: [plan, apply] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # Commits the validated repair to the pull request branch. + pull-requests: read + steps: + - name: Check out the default-branch policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: scripts + + - name: Download the repair candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: repair-candidate + path: ${{ runner.temp }}/candidate + + - name: Publish the validated repair + id: publish + env: + GH_TOKEN: ${{ github.token }} + run: | + status=0 + scripts/dependabot-automerge.sh publish-repair "$RUNNER_TEMP/candidate" || status=$? + # Status 4: the pull request changed and the candidate was dropped. + case "$status" in + 0|3) echo "record=true" >> "$GITHUB_OUTPUT" ;; + 4) status=0 ;; + esac + exit "$status" + + - name: Record a repair that was published or made no progress + if: ${{ !cancelled() && steps.publish.outputs.record == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ needs.plan.outputs.attempt }} + path: ${{ runner.temp }}/candidate/manifest.json + + runs: + name: Ensure workflow runs + needs: [merge, publish] + if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write # Lists workflow runs, dispatches missing runs, and reruns failed runs. + contents: read + pull-requests: read + steps: + - name: Check out the default-branch policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: scripts + + - name: Dispatch and rerun validation for pull requests + env: + GH_TOKEN: ${{ github.token }} + run: scripts/dependabot-automerge.sh ensure-validation-runs + + - name: Dispatch and rerun mainline runs + if: ${{ !cancelled() }} + env: + GH_TOKEN: ${{ github.token }} + run: scripts/dependabot-automerge.sh ensure-mainline-runs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e831a9ad..41a9d04f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,9 +62,11 @@ jobs: echo "release commit signature is not verified" exit 1 } - ci_run_id=$(gh run list --workflow CI --commit "$tag_commit" --event push --limit 10 \ - --json databaseId,headSha \ - --jq "map(select(.headSha == \"${tag_commit}\")) | first | .databaseId // empty") + # A commit merged with the Actions token starts no push workflow; its + # mainline run is dispatched on main instead. + ci_run_id=$(gh run list --workflow CI --commit "$tag_commit" --branch main --limit 20 \ + --json databaseId,headSha,headBranch,event \ + --jq "map(select(.headSha == \"${tag_commit}\" and .headBranch == \"main\" and (.event == \"push\" or .event == \"workflow_dispatch\"))) | first | .databaseId // empty") test -n "$ci_run_id" || { echo "mainline CI run was not found for the release commit" exit 1 diff --git a/Cargo.lock b/Cargo.lock index 418e7cbb..980800cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,9 +131,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -141,9 +141,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -468,7 +468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1379,7 +1379,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1656,14 +1656,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -1715,7 +1715,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1726,9 +1726,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -2034,10 +2034,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2514,7 +2514,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9cbf7dd1..71ae2269 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -11,11 +11,64 @@ focused on one coherent change, add behavioral tests for changed behavior, and update the existing documentation when a public contract changes. Run the pre-commit gate below over the complete diff before requesting review. A maintainer review and passing required checks are required before merge. +Routine Dependabot updates are the exception described under +[Dependency updates](#dependency-updates). Contributions are accepted under the repository's MIT license. By submitting a contribution, the contributor confirms they have the right to provide it under that license. +## Dependency updates + +`scripts/dependabot-automerge.sh` holds the policy for Dependabot pull requests, +and the scheduled `Dependabot reconcile` workflow applies it from `main`. It +never checks out or runs pull request content with a repository token. + +- A Cargo patch or minor update, or a GitHub Actions update pinned to a full + commit SHA, merges without a maintainer review once every required check + succeeded for its exact head commit and every review thread is resolved. +- A major Cargo update, an unclassified update, or any other ecosystem fails + the required `Classify dependency update` check until a repository owner, + member, or collaborator approves the exact head commit. +- An update merges only when its head commit contains the current `main` + commit, so its checks ran on the tree that the merge produces. One update + merges per run, and one branch that lacks the `main` commit is refreshed per + run by merging `main` into it. The merge is made on a scratch branch under + `dependency-refresh/`, and the pull request branch advances to it only as a + fast-forward, after the merge commit is verified. A conflicting workflow + update waits for the rebase that Dependabot performs on a conflict. +- A workflow run that was cancelled, or that holds a failure no rerun + reproduced, is rerun. A failure counts as a defect of the update once the + same job failed in two consecutive attempts. + +A Cargo update whose required checks fail, or whose branch conflicts with +`main`, is repaired by `scripts/dependency-repair.sh`. A language model reads +the failure logs and edits files without the ability to run commands; Cargo +then applies the requested lockfile updates in a job that holds neither the +model credential nor a repository token. A separate job commits the result to +the pull request branch as a signed commit, limited to the Cargo manifests, +lockfiles, and Rust source of that update. The repaired update merges under +the same conditions as any other: a patch or minor update stays routine, and a +major update still needs its approving review. A repair that was published, +or that cannot proceed, is not attempted again for the same Dependabot commit, +`main` commit, and set of failing checks. An operational failure, such as a +model run that reaches its time limit, is tried again on the next run. +Workflow updates are not repaired. + +The same repair runs locally against the current checkout, including a linked +worktree, and leaves its result uncommitted. The context file holds the +failing check output: + +```bash +scripts/dependency-repair.sh candidate context.md +``` + +The model credential is read from `OPENROUTER_API_KEY`, removed from the +script's environment, and handed to the model process alone, which otherwise +inherits only `PATH`, `HOME`, and `TMPDIR`. The model and +the OpenCode release are fixed in the script, and `opencode` on `PATH` must be +that release. + ## Building ```bash diff --git a/scripts/dependabot-automerge.sh b/scripts/dependabot-automerge.sh new file mode 100755 index 00000000..2d5eaec3 --- /dev/null +++ b/scripts/dependabot-automerge.sh @@ -0,0 +1,1290 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Classifies, repairs, validates, and merges Dependabot pull requests, and keeps +# mainline workflow evidence present for commits that the Actions token merged. +# +# Usage: +# scripts/dependabot-automerge.sh classify +# scripts/dependabot-automerge.sh gate +# scripts/dependabot-automerge.sh reconcile +# scripts/dependabot-automerge.sh repair-plan +# scripts/dependabot-automerge.sh publish-repair +# scripts/dependabot-automerge.sh ensure-validation-runs +# scripts/dependabot-automerge.sh ensure-mainline-runs +# +# Environment: +# GITHUB_REPOSITORY owner/name of the repository to act on. +# GH_TOKEN token for the gh CLI. +# EXPECTED_HEAD gate only: the commit a dispatched gate reports on. +# +# Every decision is derived from GitHub API metadata. The script never checks +# out, builds, or executes pull request content, so it is safe to run from the +# default branch with a write token. Repair content is produced elsewhere, by +# scripts/dependency-repair.sh in jobs that hold no write token, and reaches +# publish-repair only as files that are validated before they are committed. +# +# classify prints one JSON object. Its decision is one of: +# eligible the update merges automatically once its checks pass; +# review a genuine Dependabot update that needs an approving review; +# manual the pull request is not a Dependabot update this automation +# recognizes, and it is never merged automatically. +# +# A pull request is recognized only when it is open, not a draft, authored by +# dependabot[bot], targets main, and its head branch lives in this repository +# under dependabot/. Its first commit is authored by dependabot[bot], committed +# by GitHub, and verified. Any later commit is a repair: authored by +# github-actions[bot], verified, and directly on top of the previous commit. A +# repair with two parents merges a commit that main contains. +# +# A recognized update is eligible when every changed file is a modification +# inside one manifest set that matches the head branch ecosystem and: +# - Cargo (root or fuzz manifests): every listed update is a patch or minor +# version update, including 0.x versions; +# - GitHub Actions (workflow files): every added line pins an action to a +# full commit SHA, whatever the update type. +# Major Cargo updates, unclassified updates, and other ecosystems need review. +# +# A repaired update stays eligible when its repair is confined to the same +# manifest set and that crate's Rust source. Its required checks, the resolved +# review threads, and the integration evidence below are what admit it. +# +# An approving review counts when a repository owner, member, or collaborator +# approved the exact head commit and no such reviewer requests changes. +# +# gate succeeds for an eligible update and for any update with an approving +# review. It fails otherwise, so the required check blocks an unreviewed major +# update. +# +# reconcile merges at most one pull request per run. It merges only when the +# effective branch rules match the declared required checks, the head commit +# contains the current main commit, every required check succeeded for that +# exact head commit, every review thread is resolved, and the classification +# is unchanged immediately before the merge. A head that contains main has the +# tree that the squash merge produces, so its checks are the integration +# evidence. reconcile refreshes at most one branch per run that lacks the main +# commit and does not conflict with it, through the branch merge API. The merge +# is made on a scratch branch under dependency-refresh/ that the run creates at +# the pull request head and removes afterwards; the pull request branch +# advances only after the merge commit meets everything classify requires of a +# repair, as a fast-forward. A refresh that is refused, unverified, or +# overtaken leaves the pull request branch unchanged and the next stale branch +# is tried. The refresh commit carries the marker that lets Dependabot keep +# rebasing the branch. A conflicting Cargo update is left to repair-plan; a +# conflicting workflow update is left to the rebase that Dependabot performs +# on a conflict. +# +# repair-plan selects at most one Cargo update whose required checks failed or +# whose branch conflicts with main, and writes plan.json and context.md. A +# failed check counts once a rerun reproduced it: the same job failed in two +# consecutive attempts of its workflow run. Each plan has a record name +# derived from the Dependabot commit, the main commit, and the failing checks. +# The workflow stores a record under that name when a repair was published or +# cannot proceed, and repair-plan skips a plan whose record exists, so the +# model runs again only after one of the three changed. An operational failure, +# such as a model run that timed out, leaves no record and is tried again on +# the next run. +# +# publish-repair commits validated repair files to the pull request branch +# through the Git database API. GitHub signs a commit created this way without +# custom author information, and the branch advances only after the commit +# meets everything classify requires of a repair, as a fast-forward. It exits +# with status 3 when the candidate made no progress: it changes nothing, +# violates the policy, or was refused. It exits with status 4 when the pull +# request changed and the candidate was dropped. +# +# ensure-validation-runs dispatches the required workflows on a head that this +# automation committed. A branch update made with the Actions token leaves +# pull request workflows waiting for approval, and workflow_dispatch is the +# supported event that the same token can raise. Dispatch is refused unless +# the branch workflows equal the main workflows, or differ from them only by +# an eligible workflow update on a head that contains main. It also reruns a +# workflow run that was cancelled, or that holds a failure that no rerun +# reproduced yet. +# +# ensure-mainline-runs dispatches the push-triggered mainline workflows for the +# current main commit when no run exists for it, and reruns them the same way. + +BOT_LOGIN="dependabot[bot]" +REPAIR_LOGIN="github-actions[bot]" +BRANCH="main" +# GitHub Actions. Only check runs reported by this app satisfy a required +# context. +APP_ID=15368 +MAINLINE_WORKFLOWS=(ci.yml audit.yml) +VALIDATION_WORKFLOWS=(ci.yml audit.yml dependabot-automerge.yml) +GATE_CONTEXT="Classify dependency update" +# The bracketed marker lets Dependabot force push over the commit. +REFRESH_MESSAGE=$'chore(deps): merge main into dependency update\n\nDependency-Repair: refresh\n[dependabot skip]' +LOG_TAIL_LINES=120 +DISPATCH_POLLS="${DISPATCH_POLLS:-6}" +DISPATCH_POLL_SECONDS="${DISPATCH_POLL_SECONDS:-5}" + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +usage() { + cat >&2 < + $0 gate + $0 reconcile + $0 repair-plan + $0 publish-repair + $0 ensure-validation-runs + $0 ensure-mainline-runs +EOF + exit 64 +} + +REPO="${GITHUB_REPOSITORY:-}" +if ! [[ "$REPO" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + echo "error: GITHUB_REPOSITORY must be owner/name" >&2 + exit 2 +fi + +work_dir=$(mktemp -d) +# The scratch branch that this invocation created and has not removed, without +# its dependency-refresh/ prefix. +scratch_name="" +trap 'remove_scratch_branch || true; rm -rf "$work_dir"' EXIT + +api() { + gh api --method GET "$@" +} + +api_pages() { + gh api --method GET --paginate "$@" +} + +# Sends the JSON document on standard input. +api_write() { + gh api --method "$1" "$2" --input - +} + +report() { + echo "$1" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo "- $1" >> "$GITHUB_STEP_SUMMARY" + fi +} + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ ]] +} + +# Succeeds when the first commit is the second commit or one of its ancestors. +is_ancestor() { + api "repos/${REPO}/compare/${1}...${2}" \ + | jq -e '.status == "ahead" or .status == "identical"' > /dev/null +} + +# Prints the classification object. Returns nonzero only when metadata is +# unreadable. +classify() { + local number="$1" dir parent merges_ok=true repairs fingerprint + [[ "$number" =~ ^[1-9][0-9]*$ ]] || { + echo "error: pull request number is malformed" >&2 + return 2 + } + dir=$(mktemp -d "$work_dir/classify.XXXXXX") + + api "repos/${REPO}/pulls/${number}" > "$dir/pull.json" || return 2 + api_pages "repos/${REPO}/pulls/${number}/commits?per_page=100" \ + | jq -s '[.[][] | { + sha, + author: .author.login, + committer: .committer.login, + verified: .commit.verification.verified, + message: .commit.message, + parents: [.parents[]?.sha] + }]' > "$dir/commits.json" || return 2 + api_pages "repos/${REPO}/pulls/${number}/files?per_page=100" \ + | jq -s '[.[][] | {filename, status, patch: (.patch // null)}]' > "$dir/files.json" || return 2 + api_pages "repos/${REPO}/pulls/${number}/reviews?per_page=100" \ + | jq -s '[.[][] | { + login: .user.login, + type: .user.type, + association: .author_association, + state, + commit_id, + submitted_at: (.submitted_at // "") + }]' > "$dir/reviews.json" || return 2 + + # Every commit that a repair merged must be part of main. + repairs=$(jq '[.[1:][]] | length' "$dir/commits.json") + if [ "$repairs" -gt 0 ]; then + while IFS= read -r parent; do + is_sha "$parent" || return 2 + is_ancestor "$parent" "$BRANCH" || merges_ok=false + done < <(jq -r '.[1:][] | select((.parents | length) == 2) | .parents[1]' "$dir/commits.json") + fi + + fingerprint=$(jq -cS -n \ + --slurpfile pull "$dir/pull.json" \ + --slurpfile commits "$dir/commits.json" \ + --slurpfile files "$dir/files.json" '{ + pull: ($pull[0] | {state, draft, user: .user.login, base: .base.ref, + base_repo: .base.repo.full_name, head: .head.sha, head_ref: .head.ref, + head_repo: .head.repo.full_name, commits, changed_files}), + commits: $commits[0], + files: $files[0] + }' | sha256sum | cut -d ' ' -f 1) || return 2 + + jq -n \ + --argjson number "$number" \ + --arg repo "$REPO" \ + --arg bot "$BOT_LOGIN" \ + --arg repair_bot "$REPAIR_LOGIN" \ + --arg branch "$BRANCH" \ + --arg fingerprint "$fingerprint" \ + --argjson merges_ok "$merges_ok" \ + --slurpfile pull "$dir/pull.json" \ + --slurpfile commits "$dir/commits.json" \ + --slurpfile files "$dir/files.json" \ + --slurpfile reviews "$dir/reviews.json" ' + def metadata: + split("\n") as $lines + | [range(0; $lines | length) + | select($lines[.] == "---" and $lines[. + 1] == "updated-dependencies:")] as $starts + | if ($starts | length) != 1 then null else + ($starts[0] + 2) as $from + | ([range($from; $lines | length) | select($lines[.] == "...")] | first) as $to + | if $to == null then null else + reduce $lines[$from:$to][] as $line ({ok: true, items: []}; + if .ok | not then . + elif $line | test("^- dependency-name: \\S") then + .items += [{"dependency-name": ($line | sub("^- dependency-name: "; ""))}] + elif ((.items | length) > 0) and ($line | test("^ [a-z-]+: \\S")) then + ($line | capture("^ (?[a-z-]+): (?.*)$")) as $pair + | .items[-1][$pair.key] = $pair.value + else .ok = false + end) + | if .ok and (.items | length) > 0 then .items else null end + end + end; + + def level: + (."update-type" // "") as $type + | (."dependency-version" // "") as $version + | if $version | test("^[0-9]+(\\.[0-9]+){0,2}(\\+[0-9A-Za-z.-]+)?$") | not then "unclassified" + elif $type == "version-update:semver-patch" or $type == "version-update:semver-minor" + then "compatible" + elif $type == "version-update:semver-major" then "major" + else "unclassified" + end; + + def file_class: + if test("^Cargo\\.(toml|lock)$") then "cargo-root" + elif test("^fuzz/Cargo\\.(toml|lock)$") then "cargo-fuzz" + elif test("^\\.github/workflows/[^/]+\\.ya?ml$") then "github-actions" + else "other" + end; + + def is_source($class): + if $class == "cargo-root" + then test("^(src|tests|benches)/([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\\.rs$") + elif $class == "cargo-fuzz" + then test("^fuzz/fuzz_targets/([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\\.rs$") + else false + end; + + def branch_prefix: + if . == "github-actions" then "dependabot/github_actions/" else "dependabot/cargo/" end; + + def changed_lines: + if . == null then null else [split("\n")[] | select(test("^[+-]"))] end; + + # Every added line is an action reference pinned to a full commit SHA. + def pinned_actions: + all(.[]; .patch != null + and (.patch | changed_lines | map(select(startswith("+"))) | length > 0) + and all(.patch | changed_lines[] | select(startswith("+")); + test("^\\+\\s*(- )?uses: [^\\s@]+@[0-9a-f]{40}(\\s+#.*)?$"))); + + def trust: + $pull[0] as $p + | $commits[0] as $c + | if $p.number != $number or $p.state != "open" or $p.draft != false then + "the pull request is not open and ready" + elif $p.user.login != $bot or $p.user.type != "Bot" then + "the pull request is not a Dependabot update" + elif $p.base.ref != $branch or $p.base.repo.full_name != $repo then + "the pull request does not target the protected branch" + elif $p.head.repo.full_name != $repo or (($p.head.ref // "") | startswith("dependabot/") | not) then + "the head branch is not a Dependabot branch in this repository" + elif ($c | length) == 0 or $p.commits != ($c | length) or $c[-1].sha != $p.head.sha then + "the commit list is empty, incomplete, or does not end at the head" + elif $c[0].author != $bot or $c[0].committer != "web-flow" or $c[0].verified != true then + "the first commit is not a verified Dependabot commit" + elif any(range(1; $c | length); . as $i + | $c[$i].author != $repair_bot + or $c[$i].verified != true + or (($c[$i].committer == "web-flow" or $c[$i].committer == $repair_bot) | not) + or (($c[$i].parents | length) < 1 or ($c[$i].parents | length) > 2) + or $c[$i].parents[0] != $c[$i - 1].sha) then + "a later commit is not a verified repair on top of the Dependabot commit" + elif $merges_ok | not then + "a repair merges a commit that main does not contain" + else null + end; + + def policy: + $pull[0] as $p + | $commits[0] as $c + | $files[0] as $f + | (($c | length) > 1) as $repaired + | if ($f | length) == 0 or ($f | length) != $p.changed_files then + {class: "other", reason: "the changed file list is empty or incomplete"} + elif any($f[]; .status != "modified") then + {class: "other", reason: "the update adds, removes, or renames files"} + else + ($f | map(.filename | file_class) | map(select(. != "other")) | unique) as $classes + | (if ($classes | length) == 1 then $classes[0] else "other" end) as $class + | ($f | map(select((.filename | file_class) != $class))) as $outside + | if $class == "other" then + {class: "other", reason: "the changed files are outside one eligible manifest set"} + elif $p.head.ref | startswith($class | branch_prefix) | not then + {class: "other", reason: "the changed files do not match the head branch ecosystem"} + elif any($outside[]; .filename | is_source($class) | not) then + {class: "other", reason: "the changed files are outside one eligible manifest set"} + elif ($repaired | not) and ($outside | length) > 0 then + {class: $class, reason: "the update changes files outside its manifest set"} + else + (($c[0].message // "") | metadata) as $items + | if $items == null then + {class: $class, reason: "the update metadata is missing or malformed"} + elif $class == "github-actions" and ($f | pinned_actions | not) then + {class: $class, reason: "an added line is not an action pinned to a full commit SHA"} + elif $class != "github-actions" and any($items[] | level; . == "unclassified") then + {class: $class, reason: "an update has no recognized type or version"} + elif $class != "github-actions" and any($items[] | level; . == "major") then + {class: $class, reason: "the update includes a major version"} + else + {class: $class, reason: null} + end + end + end; + + def approved: + $pull[0].head.sha as $head + | [$reviews[0][] + | select(.type == "User" + and (.association == "OWNER" or .association == "MEMBER" or .association == "COLLABORATOR") + and (.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED"))] + | group_by(.login) + | map(sort_by(.submitted_at) | last) + | any(.[]; .state == "APPROVED" and .commit_id == $head) + and all(.[]; .state != "CHANGES_REQUESTED"); + + trust as $distrust + | (if $distrust == null then policy else {class: "other", reason: $distrust} end) as $policy + | $commits[0] as $c + | { + decision: (if $distrust != null then "manual" + elif $policy.reason == null then "eligible" + else "review" end), + reason: ($policy.reason // (if ($c | length) > 1 + then "the update is routine and its repair stays inside its manifest set and Rust source" + else "the update is routine" end)), + approved: approved, + number: $number, + ecosystem: $policy.class, + head_sha: ($pull[0].head.sha // ""), + head_ref: ($pull[0].head.ref // ""), + update_sha: ($c[0].sha // ""), + mergeable_state: ($pull[0].mergeable_state // "unknown"), + repairs: ([$c[1:][]] | length), + updates: (if $distrust != null then [] else + [(($c[0].message // "") | metadata // [])[] + | {name: ."dependency-name", version: (."dependency-version" // "")}] end), + fingerprint: $fingerprint + } + | if (.decision != "manual") and (.head_sha | test("^[0-9a-f]{40}$") | not) + then error("head commit is malformed") + else . + end + ' || return 2 +} + +gate() { + local result decision approved + result=$(classify "$1") || return 2 + decision=$(jq -r '.decision' <<< "$result") + approved=$(jq -r '.approved' <<< "$result") + report "Decision: ${decision}. Reason: $(jq -r '.reason' <<< "$result"). Approving review: ${approved}." + # A dispatched gate reports on the dispatched commit, so it must be the + # pull request head. + if [ -n "${EXPECTED_HEAD:-}" ] && [ "$(jq -r '.head_sha' <<< "$result")" != "$EXPECTED_HEAD" ]; then + report "The dispatched commit is not the pull request head." + return 1 + fi + [ "$decision" = "eligible" ] || [ "$approved" = "true" ] +} + +declared_contexts() { + "$script_dir/sync-branch-protection.sh" --list +} + +# Succeeds when the effective rules for main are the ones this script verifies +# on its own: the declared required checks from GitHub Actions, and pull +# request rules that an unreviewed squash merge satisfies. Prints the reason +# otherwise. A rule of any other type fails closed. +effective_rules_verified() { + local approved="$1" declared + declared=$(declared_contexts | jq -R . | jq -s .) || return 2 + api_pages "repos/${REPO}/rules/branches/${BRANCH}?per_page=100" \ + | jq -s 'add' > "$work_dir/rules.json" || return 2 + jq -r --argjson declared "$declared" --argjson app_id "$APP_ID" --argjson approved "$approved" ' + ["deletion", "non_fast_forward", "pull_request", "required_status_checks", + "required_linear_history", "required_signatures"] as $known + | [.[] | select(.type == "required_status_checks")] as $status + | [.[] | select(.type == "pull_request") | .parameters] as $pull + | if type != "array" then "the effective rules are unreadable" + elif any(.[]; .type as $type | $known | index($type) | not) then + "an effective rule is not verified by this automation: " + + ([.[] | .type | select(. as $type | $known | index($type) | not)] | unique | join(", ")) + elif ($status | length) != 1 then + "the effective rules do not hold exactly one required status check rule" + elif any($status[0].parameters.required_status_checks[]; .integration_id != $app_id) then + "a required status check is not bound to GitHub Actions" + elif ([$status[0].parameters.required_status_checks[].context] | sort) != ($declared | sort) then + "the effective required status checks differ from the declared list" + elif any($pull[]; .require_code_owner_review == true or .require_last_push_approval == true + or ((.required_reviewers // []) | length) > 0) then + "the effective rules require a review this automation cannot verify" + elif any($pull[]; (.required_approving_review_count // 0) > (if $approved then 1 else 0 end)) then + "the effective rules require more approving reviews than the pull request has" + elif any($pull[]; (.allowed_merge_methods // ["squash"]) | index("squash") | not) then + "the effective rules do not allow a squash merge" + else empty + end + ' "$work_dir/rules.json" +} + +# Writes the newest GitHub Actions check run per context for a commit. +read_check_runs() { + api_pages "repos/${REPO}/commits/${1}/check-runs?filter=latest&per_page=100" \ + | jq -s --arg sha "$1" --argjson app_id "$APP_ID" ' + [.[].check_runs[] | select(.head_sha == $sha and .app.id == $app_id)] + | group_by(.name) + | map(sort_by(.started_at // "", .id) | last) + ' > "$2" +} + +# Prints "" for the given contexts, where state is one of +# success, failed, pending, or missing. +context_states() { + local file="$1" + shift + printf '%s\n' "$@" | jq -R . | jq -s -r --slurpfile runs "$file" ' + .[] as $context + | ($runs[0] | map(select(.name == $context)) | first) as $run + | (if $run == null then "missing" + elif $run.status != "completed" then "pending" + elif $run.conclusion == "success" then "success" + else "failed" end) + "\t" + $context' +} + +required_contexts() { + local -a contexts + mapfile -t contexts < <(declared_contexts) + [ "${#contexts[@]}" -gt 0 ] || return 2 + printf '%s\n' "${contexts[@]}" +} + +main_head() { + api "repos/${REPO}/git/ref/heads/${BRANCH}" \ + | jq -er '.object.sha | strings | select(test("^[0-9a-f]{40}$"))' +} + +# Succeeds when every review thread is resolved and the thread list was read in +# full for the given head commit. +review_threads_resolved() { + local number="$1" sha="$2" + # shellcheck disable=SC2016 # GraphQL variables, not shell expansions. + gh api graphql --paginate \ + -f owner="${REPO%%/*}" -f name="${REPO##*/}" -F number="$number" \ + -f query=' + query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + headRefOid + reviewThreads(first: 100, after: $endCursor) { + nodes { isResolved } + pageInfo { hasNextPage endCursor } + } + } + } + }' \ + | jq -s -e --arg sha "$sha" ' + map(.data.repository.pullRequest) as $pages + | ($pages | length) > 0 + and all($pages[]; . != null and .headRefOid == $sha) + and ($pages[-1].reviewThreads.pageInfo.hasNextPage == false) + and all($pages[].reviewThreads.nodes[]; .isResolved == true) + ' > /dev/null +} + +open_dependabot_pulls() { + api_pages "repos/${REPO}/pulls?state=open&base=${BRANCH}&per_page=100" \ + | jq -s -r --arg bot "$BOT_LOGIN" '.[][] | select(.user.login == $bot) | .number' +} + +# Succeeds when the commit meets what classify requires of a repair commit +# with the given parents. Reads the commit JSON on standard input. +repair_commit_verified() { + jq -e --arg bot "$REPAIR_LOGIN" --argjson parents "$1" ' + .commit.verification.verified == true + and .author.login == $bot + and (.committer.login == "web-flow" or .committer.login == $bot) + and ([.parents[]?.sha] == $parents)' > /dev/null +} + +# Removes the scratch branch that this invocation created, if one remains. A +# branch that cannot be removed is reported by name and left in place. +remove_scratch_branch() { + local name="$scratch_name" + [ -n "$name" ] || return 0 + scratch_name="" + if gh api --method DELETE "repos/${REPO}/git/refs/heads/dependency-refresh/${name}" > /dev/null; then + return 0 + fi + report "error, the scratch branch dependency-refresh/${name} could not be removed and is left in place" + return 1 +} + +# Merges the main commit into a pull request branch. GitHub computes the merge +# and creates the commit; nothing is checked out. The merge is made on a +# scratch branch that this invocation creates at the pull request head, so the +# pull request branch advances only to a commit that already met everything +# classify requires of a repair, and only as a fast-forward from that head. +# +# Returns 0 when the branch was refreshed, 1 when it was not and the run +# failed, 2 when it was not and nothing failed, and 3 when it was refreshed +# and the scratch branch could not be removed. +refresh_branch() { + local number="$1" ref="$2" sha="$3" main="$4" scratch response commit + if ! [[ "$ref" =~ ^dependabot/[A-Za-z0-9._/+-]+$ ]]; then + report "#${number}: error, the head branch name is malformed" + return 1 + fi + scratch=$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n') + if ! [[ "$scratch" =~ ^[0-9a-f]{16}$ ]] || ! [[ "${GITHUB_RUN_ID:-0}" =~ ^[0-9]+$ ]]; then + report "#${number}: error, no scratch branch name could be made" + return 1 + fi + scratch="${number}-${GITHUB_RUN_ID:-0}-${scratch}" + + # Creation fails when the name is taken, so an existing branch is never + # reused, moved, or removed. + if ! response=$(jq -n --arg ref "refs/heads/dependency-refresh/${scratch}" --arg sha "$sha" \ + '{ref: $ref, sha: $sha}' | api_write POST "repos/${REPO}/git/refs"); then + report "#${number}: error, the scratch branch for the refresh of ${ref} could not be created; the branch is unchanged" + return 1 + fi + scratch_name="$scratch" + if ! jq -e --arg ref "refs/heads/dependency-refresh/${scratch}" --arg sha "$sha" \ + '.ref == $ref and .object.sha == $sha' <<< "$response" > /dev/null; then + report "#${number}: error, the scratch branch for the refresh of ${ref} is not at ${sha}; the branch is unchanged" + remove_scratch_branch || true + return 1 + fi + + if ! response=$(jq -n --arg base "dependency-refresh/${scratch}" --arg head "$main" \ + --arg message "$REFRESH_MESSAGE" '{base: $base, head: $head, commit_message: $message}' \ + | api_write POST "repos/${REPO}/merges"); then + report "#${number}: error, the merge of ${main} into ${sha} was refused; the branch is unchanged" + remove_scratch_branch || true + return 1 + fi + commit=$(jq -r '.sha // ""' <<< "$response") + if ! is_sha "$commit"; then + report "#${number}: nothing to refresh, ${sha} already contains ${main}" + remove_scratch_branch || return 1 + return 2 + fi + if ! api "repos/${REPO}/commits/${commit}" \ + | repair_commit_verified "$(jq -n --arg head "$sha" --arg main "$main" '[$head, $main]')"; then + report "#${number}: error, the refresh commit ${commit} is not a verified ${REPAIR_LOGIN} merge of ${main} into ${sha}; the branch is unchanged" + remove_scratch_branch || true + return 1 + fi + + if ! response=$(api "repos/${REPO}/pulls/${number}"); then + report "#${number}: error, the pull request is unreadable after the merge; the branch is unchanged" + remove_scratch_branch || true + return 1 + fi + if ! jq -e --arg ref "$ref" --arg sha "$sha" --arg repo "$REPO" ' + .state == "open" and .head.ref == $ref and .head.sha == $sha + and .head.repo.full_name == $repo' <<< "$response" > /dev/null; then + report "#${number}: waiting, the pull request changed during the refresh; the branch is unchanged" + remove_scratch_branch || return 1 + return 2 + fi + + # A fast-forward only: the update fails if the branch moved. + if ! jq -n --arg sha "$commit" '{sha: $sha, force: false}' \ + | api_write PATCH "repos/${REPO}/git/refs/heads/${ref}" > /dev/null; then + report "#${number}: error, the branch refused the refresh commit ${commit}" + remove_scratch_branch || true + return 1 + fi + report "#${number}: refreshed with ${main} as ${commit}; it merges once its checks pass" + remove_scratch_branch || return 3 +} + +reconcile() { + local numbers number result decision approved reason sha state states unmet main ecosystem + local problem again outcome refreshed=0 failures=0 + + main=$(main_head) || { + report "error, the ${BRANCH} head commit is unreadable" + return 1 + } + numbers=$(open_dependabot_pulls) || { + report "error, open pull requests are unreadable" + return 1 + } + + for number in $numbers; do + if ! result=$(classify "$number"); then + report "#${number}: error, pull request metadata is unreadable" + failures=$((failures + 1)) + continue + fi + decision=$(jq -r '.decision' <<< "$result") + approved=$(jq -r '.approved' <<< "$result") + reason=$(jq -r '.reason' <<< "$result") + sha=$(jq -r '.head_sha' <<< "$result") + state=$(jq -r '.mergeable_state' <<< "$result") + ecosystem=$(jq -r '.ecosystem' <<< "$result") + + if [ "$decision" = "manual" ]; then + report "#${number}: manual, ${reason}" + continue + fi + if [ "$decision" = "review" ] && [ "$approved" != "true" ]; then + report "#${number}: needs an approving review, ${reason}" + continue + fi + + if ! problem=$(effective_rules_verified "$approved"); then + report "#${number}: error, the effective branch rules are unreadable" + failures=$((failures + 1)) + continue + fi + if [ -n "$problem" ]; then + report "#${number}: error, ${problem}" + failures=$((failures + 1)) + continue + fi + + # Checks on a head without the main commit say nothing about the tree + # that a merge would produce. + if ! is_ancestor "$main" "$sha"; then + if [ "$state" = "dirty" ]; then + case "$ecosystem" in + cargo-*) report "#${number}: waiting, the branch conflicts with ${BRANCH}; a repair resolves it" ;; + *) report "#${number}: waiting, the branch conflicts with ${BRANCH}; Dependabot rebases it" ;; + esac + elif [ "$refreshed" -eq 1 ]; then + report "#${number}: waiting, the branch lacks ${main}; one branch is refreshed per run" + else + # Only a refresh that advanced the branch uses the one refresh of this + # run; after any other outcome the next stale branch is tried. + outcome=0 + refresh_branch "$number" "$(jq -r '.head_ref' <<< "$result")" "$sha" "$main" || outcome=$? + case "$outcome" in + 0) refreshed=1 ;; + 2) ;; + 3) + refreshed=1 + failures=$((failures + 1)) + ;; + *) failures=$((failures + 1)) ;; + esac + fi + continue + fi + + mapfile -t contexts < <(required_contexts) + if ! read_check_runs "$sha" "$work_dir/runs.json" \ + || ! states=$(context_states "$work_dir/runs.json" "${contexts[@]}"); then + report "#${number}: error, required checks are unreadable" + failures=$((failures + 1)) + continue + fi + unmet=$(grep -v '^success'$'\t' <<< "$states" | head -n 1 | cut -f 2- || true) + if [ -n "$unmet" ]; then + report "#${number}: waiting, required check has not succeeded: ${unmet}" + continue + fi + + case "$state" in + clean|unstable|has_hooks) ;; + *) + report "#${number}: waiting, mergeable state is ${state}" + continue + ;; + esac + + if ! review_threads_resolved "$number" "$sha"; then + report "#${number}: waiting, a review thread is unresolved or unreadable" + continue + fi + + # The decision is bound to the metadata it was made from. A retargeted + # base, a changed file list, or a new commit changes the fingerprint. + if ! again=$(classify "$number") \ + || [ "$(jq -cS 'del(.mergeable_state)' <<< "$again")" != "$(jq -cS 'del(.mergeable_state)' <<< "$result")" ] \ + || [ "$(main_head)" != "$main" ]; then + report "#${number}: waiting, the pull request or ${BRANCH} changed during evaluation" + continue + fi + + if gh pr merge "$number" --repo "$REPO" --squash --match-head-commit "$sha"; then + report "#${number}: merged at ${sha}" + report "every other update now lacks the new ${BRANCH} commit and is refreshed first" + break + else + report "#${number}: error, merge was refused at ${sha}" + failures=$((failures + 1)) + fi + done + + [ "$failures" -eq 0 ] +} + +# Prints the tail of a failed job log. Log text is untrusted and is only ever +# handed to the repair model as data. +failed_log_tail() { + api "repos/${REPO}/actions/jobs/${1}/logs" 2> /dev/null \ + | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //; s/\x1b\[[0-9;]*[A-Za-z]//g' \ + | tail -n "$LOG_TAIL_LINES" \ + | cut -c 1-400 || true +} + +workflow_tree() { + api "repos/${REPO}/contents/.github?ref=${1}" \ + | jq -er '.[] | select(.name == "workflows" and .type == "dir") | .sha' +} + +# Resolves a check run to its matching Actions job before using the job ID. +verified_job_for_context() { + local context="$1" check job id + check=$(jq -ce --arg context "$context" \ + 'map(select(.name == $context)) | first | select(. != null)' "$work_dir/runs.json") || return 1 + id=$(jq -er '.id | numbers | select(. > 0)' <<< "$check") || return 1 + job=$(api "repos/${REPO}/actions/jobs/${id}") || return 1 + jq -ce --argjson check "$check" --arg repo "$REPO" ' + select(.id == $check.id + and (.check_run_url | strings | endswith("/repos/" + $repo + "/check-runs/" + ($check.id | tostring))) + and .head_sha == $check.head_sha and .name == $check.name + and .status == "completed" and .conclusion == $check.conclusion + and (.run_id | type == "number" and . > 0)) + ' <<< "$job" +} + +# attempt_jobs : writes the jobs of one attempt of a +# workflow run as one JSON array. +attempt_jobs() { + api_pages "repos/${REPO}/actions/runs/${1}/attempts/${2}/jobs?per_page=100" \ + | jq -se ' + if length > 0 and all(.[]; type == "object" + and (.total_count | type == "number" and . >= 0 and floor == .) + and (.jobs | type == "array") + and all(.jobs[]; type == "object")) + then [.[].jobs[]] + else error("invalid attempt jobs response") + end + ' > "$3" +} + +# Succeeds when a job of the given name failed or timed out in the attempt +# before the given one. A rerun reproduced a failure only then: an attempt that +# follows a cancelled one, or a job that fails for the first time on a rerun, +# has failed once. Returns 1 when no such job exists and 2 when the earlier +# attempt is unreadable. +failed_in_previous_attempt() { + local run_id="$1" attempt="$2" name="$3" + [ "$attempt" -ge 2 ] || return 1 + attempt_jobs "$run_id" "$((attempt - 1))" "$work_dir/previous-jobs.json" || return 2 + jq -e --arg name "$name" 'any(.[]; .name == $name + and (.conclusion == "failure" or .conclusion == "timed_out"))' \ + "$work_dir/previous-jobs.json" > /dev/null || return 1 +} + +# Succeeds when every failed check belongs to the latest attempt of its run, +# at least one job failed or timed out, and each such job also failed or timed +# out in the attempt before. +failures_reproduced() { + local context job run run_id attempt own=1 + for context in "$@"; do + job=$(verified_job_for_context "$context") || return 1 + run_id=$(jq -er '.run_id' <<< "$job") || return 1 + run=$(api "repos/${REPO}/actions/runs/${run_id}") || return 1 + attempt=$(jq -er --argjson job "$job" ' + select(.id == $job.run_id and .head_sha == $job.head_sha + and .status == "completed") + | .run_attempt | numbers | select(. >= 2 and floor == .) + ' <<< "$run") || return 1 + attempt_jobs "$run_id" "$attempt" "$work_dir/jobs.json" || return 1 + jq -e --argjson job "$job" ' + [.[] | select(.id == $job.id and .run_id == $job.run_id + and .head_sha == $job.head_sha and .conclusion == $job.conclusion)] + | length == 1 + ' "$work_dir/jobs.json" > /dev/null || return 1 + if jq -e '.conclusion == "failure" or .conclusion == "timed_out"' <<< "$job" > /dev/null; then + failed_in_previous_attempt "$run_id" "$attempt" "$(jq -r '.name' <<< "$job")" || return 1 + own=0 + fi + done + return "$own" +} + +# Selects one pull request to repair and writes plan.json and context.md. +repair_plan() { + local out="$1" numbers number result decision sha state states failed main merge + local ecosystem manifest job context attempt + local -a failed_contexts + mkdir -p "$out" + echo '{"repair": false}' > "$out/plan.json" + : > "$out/context.md" + + main=$(main_head) || return 2 + numbers=$(open_dependabot_pulls) || return 2 + mapfile -t contexts < <(required_contexts) + + for number in $numbers; do + result=$(classify "$number") || { + report "#${number}: error, pull request metadata is unreadable" + continue + } + decision=$(jq -r '.decision' <<< "$result") + ecosystem=$(jq -r '.ecosystem' <<< "$result") + sha=$(jq -r '.head_sha' <<< "$result") + state=$(jq -r '.mergeable_state' <<< "$result") + [ "$decision" != "manual" ] || continue + case "$ecosystem" in + cargo-root) manifest="Cargo.toml" ;; + cargo-fuzz) manifest="fuzz/Cargo.toml" ;; + *) + # The Actions token cannot write workflow files, so a workflow update + # is left to Dependabot. + continue + ;; + esac + + read_check_runs "$sha" "$work_dir/runs.json" || continue + states=$(context_states "$work_dir/runs.json" "${contexts[@]}") || continue + # The gate fails for an update that only needs a review; that is not a + # defect a repair can fix. + states=$(grep -vF $'\t'"$GATE_CONTEXT" <<< "$states" || true) + failed=$(grep '^failed'$'\t' <<< "$states" | cut -f 2- || true) + + merge=false + if [ "$state" = "dirty" ]; then + merge=true + elif grep -q '^pending'$'\t' <<< "$states" || [ -z "$failed" ]; then + continue + else + mapfile -t failed_contexts <<< "$failed" + if ! failures_reproduced "${failed_contexts[@]}"; then + report "#${number}: waiting, the failed checks at ${sha} have not been reproduced by a rerun" + continue + fi + is_ancestor "$main" "$sha" || merge=true + fi + + # The workflow stores a record under this name once a repair for this + # plan was published or cannot proceed. + attempt="repair-attempt-${number}-$(printf '%s\n' "$(jq -r '.update_sha' <<< "$result")" \ + "$main" "$merge" "$failed" | sha256sum | cut -d ' ' -f 1)" + if ! api "repos/${REPO}/actions/artifacts?name=${attempt}&per_page=100" \ + | jq -e '[.artifacts[]? | select(.expired == false)] | length == 0' > /dev/null; then + report "#${number}: a repair for the same update, ${BRANCH} commit, and failing checks was already made, or its record is unreadable" + continue + fi + + jq -n --argjson result "$result" --arg main "$main" --arg manifest "$manifest" \ + --argjson merge "$merge" --arg attempt "$attempt" '{ + repair: true, + attempt: $attempt, + number: $result.number, + head_sha: $result.head_sha, + head_ref: $result.head_ref, + main_sha: $main, + merge: $merge, + ecosystem: $result.ecosystem, + manifest: $manifest, + updates: $result.updates + }' > "$out/plan.json" + + { + echo "# Failing required checks" + echo + if [ -z "$failed" ]; then + echo "None reported. The branch conflicts with ${BRANCH}." + fi + while IFS= read -r context; do + [ -n "$context" ] || continue + job=$(verified_job_for_context "$context" | jq -r '.id') || job="" + echo "## ${context}" + echo + echo '```text' + [[ "$job" =~ ^[0-9]+$ ]] && failed_log_tail "$job" + echo '```' + echo + done <<< "$failed" + } > "$out/context.md" + + report "#${number}: repair planned at ${sha} (merge ${BRANCH}: ${merge}; failing: $(tr '\n' ';' <<< "$failed"))" + return 0 + done + + report "no pull request needs a repair" +} + +# Commits a validated repair candidate to its pull request branch. +publish_repair() { + local dir="$1" manifest="$1/manifest.json" number head main merge result ref ecosystem + local base_commit base_tree head_tree parents message path blob tree commit entries + + jq -e 'type == "object"' "$manifest" > /dev/null || { + report "error, the repair manifest is unreadable" + return 1 + } + number=$(jq -r '.number' "$manifest") + head=$(jq -r '.head_sha' "$manifest") + main=$(jq -r '.main_sha' "$manifest") + merge=$(jq -r '.merge' "$manifest") + if ! [[ "$number" =~ ^[1-9][0-9]*$ ]] || ! is_sha "$head" || ! is_sha "$main" \ + || ! [[ "$merge" =~ ^(true|false)$ ]]; then + report "error, the repair manifest is malformed" + return 1 + fi + + result=$(classify "$number") || { + report "#${number}: error, pull request metadata is unreadable" + return 1 + } + ref=$(jq -r '.head_ref' <<< "$result") + ecosystem=$(jq -r '.ecosystem' <<< "$result") + if [ "$(jq -r '.decision' <<< "$result")" = "manual" ] \ + || [ "$(jq -r '.head_sha' <<< "$result")" != "$head" ]; then + report "#${number}: repair dropped, the pull request changed or is no longer repairable" + return 4 + fi + case "$ecosystem" in + cargo-root|cargo-fuzz) ;; + *) + report "#${number}: repair dropped, the ecosystem is not repairable" + return 4 + ;; + esac + + if ! "$script_dir/dependency-repair.sh" check-policy "$dir" "$ecosystem" final; then + report "#${number}: error, the repair candidate violates the path or content policy" + return 3 + fi + if [ -z "$(find "$dir/files" -type f -print -quit)" ] && [ "$merge" = "false" ]; then + report "#${number}: the repair produced no change" + return 3 + fi + + head_tree=$(api "repos/${REPO}/git/commits/${head}" | jq -er '.tree.sha') || return 1 + if [ "$merge" = "true" ]; then + is_ancestor "$main" "$BRANCH" || { + report "#${number}: error, the merged commit is not part of ${BRANCH}" + return 1 + } + base_commit="$main" + parents=$(jq -n --arg head "$head" --arg main "$main" '[$head, $main]') + message=$'chore(deps): merge main and repair dependency update\n\nDependency-Repair: merge' + else + base_commit="$head" + parents=$(jq -n --arg head "$head" '[$head]') + message=$'chore(deps): repair dependency update\n\nDependency-Repair: update' + fi + base_tree=$(api "repos/${REPO}/git/commits/${base_commit}" | jq -er '.tree.sha') || return 1 + + # Every repaired path already exists as a regular file. The repair cannot + # add, remove, or change the mode of a file. + api "repos/${REPO}/git/trees/${base_tree}?recursive=1" > "$work_dir/tree.json" || return 1 + jq -e '.truncated == false' "$work_dir/tree.json" > /dev/null || { + report "#${number}: error, the base tree listing is incomplete" + return 1 + } + entries='[]' + while IFS= read -r -d '' path; do + path=${path#"$dir/files/"} + jq -e --arg path "$path" \ + 'any(.tree[]; .path == $path and .type == "blob" and .mode == "100644")' \ + "$work_dir/tree.json" > /dev/null || { + report "#${number}: error, a repaired path is not an existing regular file: ${path}" + return 3 + } + blob=$(base64 -w 0 "$dir/files/$path" \ + | jq -R -s '{content: ., encoding: "base64"}' \ + | api_write POST "repos/${REPO}/git/blobs" | jq -er '.sha') || { + report "#${number}: error, a blob could not be created" + return 1 + } + entries=$(jq --arg path "$path" --arg sha "$blob" \ + '. + [{path: $path, mode: "100644", type: "blob", sha: $sha}]' <<< "$entries") + done < <(find "$dir/files" -type f -print0 | LC_ALL=C sort -z) + + tree=$(jq -n --arg base "$base_tree" --argjson entries "$entries" \ + '{base_tree: $base, tree: $entries}' \ + | api_write POST "repos/${REPO}/git/trees" | jq -er '.sha') || { + report "#${number}: error, the tree could not be created" + return 1 + } + if [ "$tree" = "$head_tree" ]; then + report "#${number}: the repair produced no change" + return 3 + fi + + # No author or committer is sent: GitHub then signs the commit itself. + commit=$(jq -n --arg message "$message" --arg tree "$tree" --argjson parents "$parents" \ + '{message: $message, tree: $tree, parents: $parents}' \ + | api_write POST "repos/${REPO}/git/commits" | jq -er '.sha') || { + report "#${number}: error, the commit could not be created" + return 1 + } + if ! is_sha "$commit" || ! api "repos/${REPO}/commits/${commit}" | repair_commit_verified "$parents"; then + report "#${number}: error, the repair commit is not a verified ${REPAIR_LOGIN} commit; the branch is unchanged" + return 3 + fi + + # A fast-forward only: the update fails if the branch moved. + if jq -n --arg sha "$commit" '{sha: $sha, force: false}' \ + | api_write PATCH "repos/${REPO}/git/refs/heads/${ref}" > /dev/null; then + report "#${number}: repair published as ${commit} on top of ${head}" + else + report "#${number}: error, the branch refused the repair commit ${commit}" + return 3 + fi +} + +# Prints " " for the newest run of a workflow for a commit on a +# branch, among runs raised by the given events (a JSON array). The state is: +# missing no such run exists; +# active the run has not completed; +# passed the run succeeded; +# failed the run failed, and every job that failed or timed out in its +# latest attempt did so in the attempt before, which makes it a +# finding; +# retry the run was cancelled, or holds a failure that no rerun +# reproduced yet. +# Fails when the runs, or the jobs of a failed run that was rerun, are +# unreadable. +run_state() { + local run state id attempt name status + run=$(api "repos/${REPO}/actions/workflows/${1}/runs?head_sha=${2}&per_page=100" \ + | jq -ce --arg head "$2" --arg branch "$3" --argjson events "$4" ' + [.workflow_runs[] | select(.head_sha == $head and .head_branch == $branch + and (.event as $event | $events | index($event) != null))] + | sort_by(.run_started_at // "", .id) | last + | if . == null then {state: "missing", id: 0} + else {id, attempt: (.run_attempt // 1), state: ( + if .status != "completed" then "active" + elif .conclusion == "success" then "passed" + elif .conclusion == "failure" and (.run_attempt // 1) >= 2 then "failed" + else "retry" + end)} + end') || return 1 + state=$(jq -r '.state' <<< "$run") + id=$(jq -r '.id' <<< "$run") + if [ "$state" = "failed" ]; then + attempt=$(jq -er '.attempt | numbers | select(floor == .)' <<< "$run") || return 1 + [[ "$id" =~ ^[1-9][0-9]*$ ]] || return 1 + attempt_jobs "$id" "$attempt" "$work_dir/run-jobs.json" || return 1 + if ! jq -e 'any(.[]; .conclusion == "failure" or .conclusion == "timed_out")' \ + "$work_dir/run-jobs.json" > /dev/null; then + state="retry" + fi + while IFS= read -r name; do + status=0 + failed_in_previous_attempt "$id" "$attempt" "$name" || status=$? + case "$status" in + 0) ;; + 1) + state="retry" + break + ;; + *) return 1 ;; + esac + done < <(jq -r '.[] | select(.conclusion == "failure" or .conclusion == "timed_out") | .name' \ + "$work_dir/run-jobs.json") + fi + echo "${state} ${id}" +} + +# Reruns the jobs of a run that did not succeed. +rerun() { + local workflow="$1" run="$2" sha="$3" + [[ "$run" =~ ^[1-9][0-9]*$ ]] || return 1 + # A cancelled run may hold no failed job, which the first form refuses. + if gh run rerun "$run" --repo "$REPO" --failed || gh run rerun "$run" --repo "$REPO"; then + report "${workflow}: rerun requested for run ${run} at ${sha}" + else + report "${workflow}: error, the rerun of run ${run} at ${sha} was refused" + return 1 + fi +} + +# Dispatches a workflow and confirms that a run exists for the intended commit. +dispatch_and_confirm() { + local workflow="$1" ref="$2" sha="$3" state attempt + shift 3 + if ! gh workflow run "$workflow" --repo "$REPO" --ref "$ref" "$@"; then + report "${workflow}: error, dispatch failed for ${sha}" + return 1 + fi + for ((attempt = 0; attempt < DISPATCH_POLLS; attempt++)); do + if state=$(run_state "$workflow" "$sha" "$ref" '["push", "workflow_dispatch"]') \ + && [ "${state%% *}" != "missing" ]; then + report "${workflow}: dispatched for ${sha} on ${ref}" + return 0 + fi + sleep "$DISPATCH_POLL_SECONDS" + done + report "${workflow}: error, no run appeared for ${sha} on ${ref} after dispatch" + return 1 +} + +ensure_validation_runs() { + local numbers number result sha ref main workflow state events dispatchable failures=0 + local -a inputs + + main=$(main_head) || return 2 + numbers=$(open_dependabot_pulls) || return 2 + for number in $numbers; do + result=$(classify "$number") || continue + [ "$(jq -r '.decision' <<< "$result")" != "manual" ] || continue + sha=$(jq -r '.head_sha' <<< "$result") + ref=$(jq -r '.head_ref' <<< "$result") + [[ "$ref" =~ ^dependabot/[A-Za-z0-9._/+-]+$ ]] || continue + + # Dependabot's own push starts pull request runs. A head that this + # automation committed has none that count, and is validated by dispatch. + if [ "$(jq -r '.repairs' <<< "$result")" -gt 0 ]; then + events='["push", "workflow_dispatch"]' + else + events='["pull_request", "pull_request_review", "push", "workflow_dispatch"]' + fi + dispatchable="" + + for workflow in "${VALIDATION_WORKFLOWS[@]}"; do + if ! state=$(run_state "$workflow" "$sha" "$ref" "$events"); then + report "#${number}: error, ${workflow} runs are unreadable for ${sha}" + failures=$((failures + 1)) + continue + fi + case "${state%% *}" in + retry) + rerun "$workflow" "${state##* }" "$sha" || failures=$((failures + 1)) + continue + ;; + missing) [ "$(jq -r '.repairs' <<< "$result")" -gt 0 ] || continue ;; + *) continue ;; + esac + + # The dispatched run executes the branch copy of each workflow. That + # copy is the main copy, or the main copy with an eligible update whose + # added lines are all actions pinned to a commit. + if [ -z "$dispatchable" ]; then + dispatchable=false + if [ "$(workflow_tree "$sha")" = "$(workflow_tree "$main")" ] \ + || { [ "$(jq -r '.ecosystem + " " + .decision' <<< "$result")" = "github-actions eligible" ] \ + && is_ancestor "$main" "$sha"; }; then + dispatchable=true + fi + fi + if [ "$dispatchable" = "false" ]; then + report "#${number}: validation not dispatched, the branch workflows differ from ${BRANCH}" + break + fi + if [ "$(api "repos/${REPO}/pulls/${number}" | jq -r '.head.sha')" != "$sha" ]; then + report "#${number}: the head moved past ${sha}; the next run covers the new head" + break + fi + case "$workflow" in + audit.yml) inputs=(-f "dependency_review_base=${main}") ;; + dependabot-automerge.yml) inputs=(-f "pull_request=${number}") ;; + *) inputs=() ;; + esac + dispatch_and_confirm "$workflow" "$ref" "$sha" "${inputs[@]}" || failures=$((failures + 1)) + done + done + + [ "$failures" -eq 0 ] +} + +ensure_mainline_runs() { + local head workflow state failures=0 + + head=$(main_head) || { + echo "error: the ${BRANCH} head commit is unreadable" >&2 + return 2 + } + + for workflow in "${MAINLINE_WORKFLOWS[@]}"; do + if ! state=$(run_state "$workflow" "$head" "$BRANCH" '["push", "workflow_dispatch"]'); then + report "${workflow}: error, runs are unreadable for ${head}" + failures=$((failures + 1)) + continue + fi + case "${state%% *}" in + retry) + rerun "$workflow" "${state##* }" "$head" || failures=$((failures + 1)) + continue + ;; + missing) ;; + *) + report "${workflow}: a run exists for ${head}" + continue + ;; + esac + if [ "$(main_head)" != "$head" ]; then + report "${workflow}: ${BRANCH} moved past ${head}; the next reconcile covers the new head" + continue + fi + dispatch_and_confirm "$workflow" "$BRANCH" "$head" || failures=$((failures + 1)) + done + + [ "$failures" -eq 0 ] +} + +case "${1:-}" in + classify) + [ "$#" -eq 2 ] || usage + classify "$2" + ;; + gate) + [ "$#" -eq 2 ] || usage + gate "$2" + ;; + reconcile) + [ "$#" -eq 1 ] || usage + reconcile + ;; + repair-plan) + [ "$#" -eq 2 ] || usage + repair_plan "$2" + ;; + publish-repair) + [ "$#" -eq 2 ] || usage + publish_repair "$2" + ;; + ensure-validation-runs) + [ "$#" -eq 1 ] || usage + ensure_validation_runs + ;; + ensure-mainline-runs) + [ "$#" -eq 1 ] || usage + ensure_mainline_runs + ;; + *) usage ;; +esac diff --git a/scripts/dependency-repair.sh b/scripts/dependency-repair.sh new file mode 100755 index 00000000..a7f5fccc --- /dev/null +++ b/scripts/dependency-repair.sh @@ -0,0 +1,646 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Repairs a Cargo dependency update with a language model, in stages that keep +# the model credential, dependency code, and repository write access apart. +# +# Usage: +# scripts/dependency-repair.sh prepare +# scripts/dependency-repair.sh generate +# scripts/dependency-repair.sh apply +# scripts/dependency-repair.sh check-policy generated|final +# scripts/dependency-repair.sh candidate [Cargo.toml|fuzz/Cargo.toml] +# +# Stages: +# prepare checks out the planned head commit and, when the plan asks for +# it, merges the planned main commit without committing. A lockfile +# conflict takes the main lockfile; apply then restores the planned +# updates. Only Git runs. +# generate runs OpenCode with the model credential. The model can read the +# repository and edit Cargo manifests and Rust source. It cannot +# run commands, reach the network, start a language server, or load +# repository-provided agent configuration, so no dependency code +# runs while the credential is present. It requests lockfile +# changes as lines in .dependency-repair/cargo-update: +# [@] [] +# OpenCode is isolated with its own switches: --pure loads no +# external plugin; OPENCODE_DISABLE_PROJECT_CONFIG skips project +# configuration, .opencode directories, and instruction files; +# the XDG base directories point its global configuration, data, +# cache, and state at a directory made for the run. It has no +# switch for the .opencode directory in the home directory, so +# generate refuses to run when that directory exists. The model +# and the OpenCode release are fixed: the isolation is verified +# for that pair only. +# apply runs without the model credential and refuses to start when it is +# set. It overlays the generated files, runs the requested +# `cargo update` operations, verifies that the lockfile resolves +# and that no newly locked version was published within the last +# hour, and writes the candidate: manifest.json and files/. As a +# separate command it needs a checkout that no model run touched. +# candidate runs generate and then apply in the current checkout and leaves +# the result uncommitted, for a reviewed local repair. +# +# The model credential is taken out of the environment when the script starts +# and is exported only to the OpenCode process. Cargo and the crates.io index +# lookup run with a reduced environment. +# +# The tree is recorded outside the checkout before the model runs. Afterwards +# the script compares files against that record and runs no Git command on +# the checkout, because a model edit could have reached Git metadata. Any +# change besides a modified, repairable file fails the stage. +# +# No stage holds a GitHub write token. A candidate is published separately by +# scripts/dependabot-automerge.sh publish-repair, which repeats check-policy. +# +# Exit status 3 means that the repair itself cannot proceed for this plan: the +# model result violates the policy or does not resolve. The workflow records +# that outcome so the same plan is not generated again. Any other failure is +# operational and is tried again on the next run. +# +# Environment: +# OPENROUTER_API_KEY model credential; generate and candidate only. +# DEPENDENCY_REPAIR_TIMEOUT seconds allowed for the model run (default 900). +# DEPENDENCY_REPAIR_AUDIT_LOG JSON Lines evidence log (default: audit.jsonl +# in the output directory). +# AGENT_AUDIT_LOG_CLI optional command that records the invocation +# in a shared audit log. It is called once, on +# exit, as: dependency-repair +# -- . + +MODEL_KEY="${OPENROUTER_API_KEY:-}" +unset OPENROUTER_API_KEY +# A caller that exports a variable of this name does not make it inherited. +declare +x MODEL_KEY + +MODEL="openrouter/anthropic/claude-sonnet-5.5" +OPENCODE_VERSION="1.18.33" +TIMEOUT="${DEPENDENCY_REPAIR_TIMEOUT:-900}" +REQUESTS=".dependency-repair/cargo-update" +REGISTRY='source = "registry+https://github.com/rust-lang/crates.io-index"' +INDEX="https://index.crates.io" +MINIMUM_PUBLISH_AGE=3600 +AUDIT_LOG="${DEPENDENCY_REPAIR_AUDIT_LOG:-}" + +usage() { + sed -n '8,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,3\}//' >&2 + exit 64 +} + +# die [status] +die() { + echo "error: $1" >&2 + audit "${stage:-unknown}" failed "$1" + exit "${2:-1}" +} + +# The repair cannot proceed for this plan; see exit status 3 above. +refuse() { + die "$1" 3 +} + +# Removes the tree record of a stage, then records the invocation in the +# shared audit log when one is configured. A failure to record never changes +# the outcome. +finish() { + local status=$? write=1 + [ -z "$scratch" ] || rm -rf "$scratch" + [ -n "${AGENT_AUDIT_LOG_CLI:-}" ] || return 0 + [ "${stage:-}" != "check-policy" ] || write=0 + "$AGENT_AUDIT_LOG_CLI" dependency-repair "${stage:-unknown}" "$write" "$status" \ + -- "${invocation[@]}" > /dev/null 2>&1 || true +} + +# Appends one evidence record. The log never holds a credential or file content. +audit() { + [ -n "$AUDIT_LOG" ] || return 0 + jq -cn --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg action "$1" \ + --arg outcome "$2" --arg detail "$3" \ + '{time: $time, tool: "dependency-repair", action: $action, outcome: $outcome, detail: $detail}' \ + >> "$AUDIT_LOG" +} + +# Removes every environment variable except the named ones. +keep_environment() { + local name kept + while IFS= read -r name; do + for kept in "$@"; do + [ "$name" != "$kept" ] || continue 2 + done + unset "$name" 2> /dev/null || true + done < <(compgen -e) +} + +plan_field() { + jq -er --arg key "$2" '.[$key] | if type == "boolean" then tostring else strings end' "$1" +} + +manifest_ecosystem() { + case "$1" in + Cargo.toml) echo cargo-root ;; + fuzz/Cargo.toml) echo cargo-fuzz ;; + *) return 1 ;; + esac +} + +# Succeeds when the ecosystem's repair may change the path at the given stage. +path_allowed() { + local ecosystem="$1" path="$2" stage_name="$3" part='([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\.rs' + case "$ecosystem" in + cargo-root) + [ "$path" = "Cargo.toml" ] && return 0 + [ "$path" = "Cargo.lock" ] && [ "$stage_name" = "final" ] && return 0 + [[ "$path" =~ ^(src|tests|benches)/${part}$ ]] + ;; + cargo-fuzz) + [ "$path" = "fuzz/Cargo.toml" ] && return 0 + [ "$path" = "fuzz/Cargo.lock" ] && [ "$stage_name" = "final" ] && return 0 + [[ "$path" =~ ^fuzz/fuzz_targets/${part}$ ]] + ;; + *) return 1 ;; + esac +} + +valid_request() { + [[ "$1" =~ ^[A-Za-z0-9_-]+(@[0-9][0-9A-Za-z.+-]*)?(\ [0-9][0-9A-Za-z.+-]*)?$ ]] +} + +check_policy() { + local dir="$1" ecosystem="$2" stage_name="$3" path line + [ "$stage_name" = "generated" ] || [ "$stage_name" = "final" ] || usage + [ -d "$dir" ] || { echo "policy: the directory is missing" >&2; return 1; } + if [ -n "$(find "$dir" ! -type f ! -type d -print -quit)" ]; then + echo "policy: the candidate holds something other than regular files" >&2 + return 1 + fi + if [ -d "$dir/files" ]; then + while IFS= read -r -d '' path; do + path=${path#"$dir/files/"} + if ! path_allowed "$ecosystem" "$path" "$stage_name"; then + echo "policy: path is not repairable: ${path}" >&2 + return 1 + fi + if grep -qE '^(<<<<<<<|>>>>>>>)( |$)' "$dir/files/$path"; then + echo "policy: file holds a conflict marker: ${path}" >&2 + return 1 + fi + case "$path" in + *Cargo.lock) + if grep '^source = ' "$dir/files/$path" | grep -qvxF "$REGISTRY"; then + echo "policy: lockfile names a source other than crates.io: ${path}" >&2 + return 1 + fi + ;; + esac + done < <(find "$dir/files" -type f -print0) + fi + if [ -f "$dir/cargo-update" ]; then + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + if ! valid_request "$line"; then + echo "policy: malformed lockfile request" >&2 + return 1 + fi + done < "$dir/cargo-update" + fi +} + +# Refuses agent configuration that could load plugins or tools into the model +# run: in the repository, as a second line behind the project configuration +# switch, and in the home directory, which no switch covers. +refuse_agent_configuration() { + local name + for name in opencode.json opencode.jsonc .opencode; do + [ ! -e "$1/$name" ] || refuse "the repository carries agent configuration: ${name}" + done + [ ! -e "${HOME:-/nonexistent}/.opencode" ] || die "the home directory carries agent configuration: .opencode" +} + +# Prints the directory for state that prepare hands to the later stages. It +# belongs to the checkout's own Git directory, which for a linked worktree is +# not /.git. +state_directory() { + git -C "$1" rev-parse --path-format=absolute --git-path dependency-repair +} + +# Prints one line per path of the tree. A file line carries its size and +# modification time, so two listings differ exactly where the tree changed. +tree_listing() { + (cd "$1" && find . -path ./.dependency-repair -prune -o \ + \( -type d -printf '%P\t%y\t%m\n' -o -printf '%P\t%y\t%m\t%s\t%T@\n' \) | LC_ALL=C sort) +} + +# Records the tree in : files/ holds the given commit, or the +# working files when no commit is given; paths lists the files Git tracks or +# would track; listing describes every path. Runs Git, so it runs before the +# model does. +snapshot() { + local repo="$1" commit="$2" dir="$3" index + mkdir -p "$dir/files" + [ -z "$(cd "$repo" && find . -name $'*[\t\n]*' -print -quit)" ] \ + || die "a path holds a tab or a line break" + git -C "$repo" ls-files -z --cached --others --exclude-standard \ + | tr '\0' '\n' | LC_ALL=C sort -u > "$dir/paths" + if [ -n "$commit" ]; then + index=$(mktemp -u) + GIT_INDEX_FILE="$index" git -C "$repo" read-tree "$commit" + GIT_INDEX_FILE="$index" git -C "$repo" checkout-index --all --prefix="$dir/files/" + rm -f "$index" + else + tar -C "$repo" -c --verbatim-files-from -T "$dir/paths" | tar -x -C "$dir/files" + fi + tree_listing "$repo" > "$dir/listing" +} + +# Copies every file that differs from the snapshot into /files, without +# running Git. Fails when anything changed besides the content of a file that +# the policy lets the repair modify. +collect_changes() { + local repo="$1" base="$2" out="$3" ecosystem="$4" stage_name="$5" path before after + mkdir -p "$out/files" + while IFS= read -r path; do + before="$base/files/$path" + after="$repo/$path" + if [ -L "$before" ] || [ -L "$after" ] || [ ! -f "$before" ] || [ ! -f "$after" ]; then + if [ -L "$before" ] && [ -L "$after" ] && [ "$(readlink "$before")" = "$(readlink "$after")" ]; then + continue + fi + refuse "the repair adds, removes, or retypes a file: ${path}" + fi + cmp -s "$before" "$after" && continue + path_allowed "$ecosystem" "$path" "$stage_name" || refuse "the repair changes a path outside the policy: ${path}" + mkdir -p "$out/files/$(dirname "$path")" + cp "$after" "$out/files/$path" + done < <({ cat "$base/paths"; (cd "$base/files" && find . ! -type d -printf '%P\n'); } | LC_ALL=C sort -u) + + tree_listing "$repo" > "$out/listing" + while IFS= read -r path; do + # OpenCode stores its project identifier there; Git does not read it. + [ "$path" != ".git/opencode" ] || continue + before=$(grep -F "${path}"$'\t' "$base/listing" | awk -F '\t' -v path="$path" '$1 == path { print $2 "\t" $3 }') + after=$(awk -F '\t' -v path="$path" '$1 == path { print $2 "\t" $3 }' "$out/listing") + if [ "$before" != "$after" ] || [ "${after%%$'\t'*}" != "f" ] || ! grep -qxF "$path" "$base/paths"; then + rm -f "$out/listing" + refuse "the run changed something other than the content of a tracked file: ${path}" + fi + done < <(LC_ALL=C comm -3 "$base/listing" "$out/listing" | sed 's/^\t//' | cut -f 1 | LC_ALL=C sort -u) + rm -f "$out/listing" +} + +prepare() { + local repo="$1" plan="$2" head main merge state path + head=$(plan_field "$plan" head_sha) + main=$(plan_field "$plan" main_sha) + merge=$(plan_field "$plan" merge) + if ! [[ "$head" =~ ^[0-9a-f]{40}$ ]] || ! [[ "$main" =~ ^[0-9a-f]{40}$ ]]; then + die "the plan names a malformed commit" + fi + + [ -z "$(git -C "$repo" status --porcelain)" ] || die "prepare needs a checkout without local changes" + git -C "$repo" checkout --quiet --detach "$head" + [ "$(git -C "$repo" rev-parse HEAD)" = "$head" ] || die "the planned head commit is not checked out" + refuse_agent_configuration "$repo" + state=$(state_directory "$repo") + rm -rf "$state" + mkdir -p "$state" + : > "$state/lock-conflicts" + : > "$state/conflicts" + + if [ "$merge" = "true" ]; then + git -C "$repo" -c user.name=dependency-repair -c user.email=dependency-repair@invalid \ + merge --quiet --no-commit --no-ff "$main" > /dev/null 2>&1 || true + if [ "$(git -C "$repo" rev-parse --quiet --verify MERGE_HEAD || true)" != "$main" ] \ + && ! git -C "$repo" merge-base --is-ancestor "$main" HEAD; then + refuse "the planned main commit could not be merged" + fi + while IFS= read -r path; do + case "$path" in + Cargo.lock|fuzz/Cargo.lock) + git -C "$repo" checkout --quiet "$main" -- "$path" + echo "$path" >> "$state/lock-conflicts" + ;; + *) echo "$path" >> "$state/conflicts" ;; + esac + done < <(git -C "$repo" diff --name-only --diff-filter=U) + refuse_agent_configuration "$repo" + fi + audit prepare ok "head ${head}; merge ${merge}; conflicts $(tr '\n' ' ' < "$state/conflicts")" +} + +agent_configuration() { + local ecosystem="$1" + jq -cn --arg ecosystem "$ecosystem" --arg requests "$REQUESTS" ' + (if $ecosystem == "cargo-root" + then ["Cargo.toml", "src/*", "tests/*", "benches/*"] + else ["fuzz/Cargo.toml", "fuzz/fuzz_targets/*"] end) as $editable + | { + "$schema": "https://opencode.ai/config.json", + autoupdate: false, + share: "disabled", + snapshot: false, + lsp: false, + formatter: false, + mcp: {}, + plugin: [], + instructions: [], + enabled_providers: ["openrouter"], + permission: { + "*": "deny", + read: {"*": "allow", "*.env": "deny", "*.env.*": "deny"}, + glob: "allow", + grep: "allow", + edit: ({"*": "deny"} + ([$editable[], $requests] | map({(.): "allow"}) | add)), + bash: "deny", + task: "deny", + skill: "deny", + lsp: "deny", + question: "deny", + webfetch: "deny", + websearch: "deny", + external_directory: "deny", + doom_loop: "deny" + } + }' +} + +agent_prompt() { + local manifest="$1" lockfile="${1%Cargo.toml}Cargo.lock" + cat < update to the newest compatible version + @ move that locked version to an exact one + A security advisory is fixed this way, using the patched version it names. +- You may edit ${manifest} and Rust source files only when a lockfile request + cannot fix the failure, for example a compile error from a changed API or a + merge conflict marker. Remove every conflict marker you find. +- Do not edit any other file, weaken a test or lint, or add a dependency. +- If nothing can be fixed safely, change nothing. + +Finish with a short summary of what you changed and why. +EOF +} + +# Runs the model. The credential reaches only this process, through its +# environment, with every other variable but PATH, HOME, and TMPDIR removed. +run_agent() { + local repo="$1" ecosystem="$2" manifest="$3" agent_state config prompt version status=0 + [ -n "$MODEL_KEY" ] || die "OPENROUTER_API_KEY is not set" + [[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || die "the timeout is malformed" + version=$(opencode --version 2> /dev/null || true) + [ "$version" = "$OPENCODE_VERSION" ] \ + || die "OpenCode ${OPENCODE_VERSION} is required on PATH, found ${version:-none}" + refuse_agent_configuration "$repo" + + agent_state=$(mktemp -d) + config=$(agent_configuration "$ecosystem") + prompt=$(agent_prompt "$manifest") + ( + keep_environment PATH HOME TMPDIR + export XDG_CONFIG_HOME="$agent_state/config" XDG_DATA_HOME="$agent_state/data" \ + XDG_CACHE_HOME="$agent_state/cache" XDG_STATE_HOME="$agent_state/state" TERM=dumb \ + OPENCODE_CONFIG_CONTENT="$config" OPENCODE_DISABLE_PROJECT_CONFIG=1 \ + OPENCODE_DISABLE_CLAUDE_CODE=1 OPENCODE_DISABLE_EXTERNAL_SKILLS=1 \ + OPENCODE_DISABLE_LSP_DOWNLOAD=1 OPENCODE_DISABLE_AUTOUPDATE=1 \ + OPENROUTER_API_KEY="$MODEL_KEY" + cd "$repo" + exec timeout "$TIMEOUT" opencode run --pure --model "$MODEL" \ + --title dependency-repair "$prompt" < /dev/null + ) || status=$? + rm -rf "$agent_state" + # A run that timed out says nothing about the plan: an unreachable provider + # ends the same way. It is operational, so the plan is tried again. + [ "$status" -ne 124 ] || die "the model run did not finish in ${TIMEOUT} seconds" + [ "$status" -eq 0 ] || die "the model run failed with status ${status}" +} + +# generate +generate() { + local repo="$1" plan="$2" context="$3" out="$4" state="$5" base="$6" manifest ecosystem + manifest=$(plan_field "$plan" manifest) + ecosystem=$(manifest_ecosystem "$manifest") || die "the plan names an unsupported manifest" + mkdir -p "$out" + [ -n "$AUDIT_LOG" ] || AUDIT_LOG="$out/audit.jsonl" + + mkdir -p "$repo/.dependency-repair" + { + cat "$context" + if [ -s "$state/conflicts" ]; then + printf '\n# Files with merge conflict markers\n\n' + sed 's/^/- /' "$state/conflicts" + fi + if [ -s "$state/lock-conflicts" ]; then + printf '\n# Lockfiles reset to main\n\n' + echo "The planned updates are applied again after your changes; do not request them." + fi + } > "$repo/.dependency-repair/context.md" + : > "$repo/$REQUESTS" + + audit generate started "model ${MODEL}; manifest ${manifest}" + run_agent "$repo" "$ecosystem" "$manifest" + + # From here on the checkout is model-edited: no Git command runs on it. + if [ ! -f "$repo/$REQUESTS" ] || [ -L "$repo/$REQUESTS" ] || [ -L "$repo/.dependency-repair" ]; then + refuse "the lockfile request file was replaced" + fi + cp "$repo/$REQUESTS" "$out/cargo-update" + rm -rf "$repo/.dependency-repair" + collect_changes "$repo" "$base" "$out" "$ecosystem" generated + jq '{number, head_sha, main_sha, merge}' "$plan" > "$out/manifest.json" + check_policy "$out" "$ecosystem" generated || refuse "the generated repair violates the policy" + + # The credential must never leave through the repair itself. + if grep -rqF -f <(printf '%s\n' "$MODEL_KEY") "$out"; then + rm -rf "${out:?}/files" "$out/cargo-update" + refuse "the generated repair contains the model credential" + fi + audit generate ok "files $(find "$out/files" -type f | wc -l); requests $(grep -c . "$out/cargo-update" || true)" +} + +cargo_update() { + local repo="$1" manifest="$2" + shift 2 + ( + keep_environment PATH HOME CARGO_HOME RUSTUP_HOME RUSTUP_TOOLCHAIN TMPDIR + cd "$repo" + cargo update --quiet --manifest-path "$manifest" "$@" + ) +} + +locked_versions() { + awk -F '"' '/^name = / { name = $2 } /^version = / { print name " " $2 }' "$1" | LC_ALL=C sort -u +} + +# Fails when a version that the repair newly locked was published on crates.io +# less than an hour ago. Dependabot applies its own cooldown to the versions it +# selects; this covers the versions that the repair selects. +check_publish_age() { + local before="$1" after="$2" name version lower path published now + now=$(date -u +%s) + while read -r name version; do + [[ "$name" =~ ^[A-Za-z0-9_-]+$ ]] || die "a locked package name is malformed" + lower=${name,,} + case "${#lower}" in + 1) path="1/${lower}" ;; + 2) path="2/${lower}" ;; + 3) path="3/${lower:0:1}/${lower}" ;; + *) path="${lower:0:2}/${lower:2:2}/${lower}" ;; + esac + published=$( + keep_environment PATH HOME + curl --fail --silent --show-error --location --max-time 60 "${INDEX}/${path}" \ + | jq -r --arg version "$version" 'select(.vers == $version) | .pubtime // empty' | tail -n 1 + ) || die "the crates.io index is unreadable for ${name}" + # Index entries from before the field existed carry no publication time. + [ -n "$published" ] || continue + published=$(date -u -d "$published" +%s) || die "the publication time of ${name} ${version} is malformed" + if [ $((now - published)) -lt "$MINIMUM_PUBLISH_AGE" ]; then + die "${name} ${version} was published less than an hour ago" + fi + done < <(LC_ALL=C comm -13 <(locked_versions "$before") <(locked_versions "$after")) +} + +# Runs the lockfile operations and writes the candidate. With an overlay the +# generated files are copied in first. +# apply +apply() { + local repo="$1" plan="$2" generated="$3" out="$4" overlay="$5" state="$6" base="$7" + local manifest lockfile ecosystem spec precise line name version path + [ -z "$MODEL_KEY" ] || die "apply refuses to run with the model credential present" + manifest=$(plan_field "$plan" manifest) + lockfile="${manifest%Cargo.toml}Cargo.lock" + ecosystem=$(manifest_ecosystem "$manifest") || die "the plan names an unsupported manifest" + mkdir -p "$out" + [ -n "$AUDIT_LOG" ] || AUDIT_LOG="$out/audit.jsonl" + check_policy "$generated" "$ecosystem" generated || refuse "the generated repair violates the policy" + + if [ "$overlay" = "true" ] && [ -d "$generated/files" ]; then + while IFS= read -r -d '' path; do + path=${path#"$generated/files/"} + if [ ! -f "$repo/$path" ] || [ -L "$repo/$path" ]; then + refuse "a generated path is not an existing regular file: ${path}" + fi + cp "$generated/files/$path" "$repo/$path" + done < <(find "$generated/files" -type f -print0) + fi + if grep -qE '^(<<<<<<<|>>>>>>>)( |$)' "$repo/$manifest"; then + refuse "the manifest still holds a conflict marker" + fi + + # Bring the lockfile in line with the manifest, changing as little as + # possible, then restore the updates that a lockfile conflict discarded. + cargo_update "$repo" "$manifest" --workspace || refuse "the lockfile does not resolve" + if [ -s "$state/lock-conflicts" ]; then + while IFS=$'\t' read -r name version; do + if ! [[ "$name" =~ ^[A-Za-z0-9_-]+$ ]] || ! [[ "$version" =~ ^[0-9][0-9A-Za-z.+-]*$ ]]; then + continue + fi + if cargo_update "$repo" "$manifest" --package "$name" --precise "$version"; then + audit apply ok "restored ${name} ${version}" + else + audit apply skipped "could not restore ${name} ${version}" + fi + done < <(jq -r '.updates[]? | [.name, .version] | @tsv' "$plan") + fi + + if [ -f "$generated/cargo-update" ]; then + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + spec=${line%% *} + precise="" + [ "$spec" = "$line" ] || precise=${line#* } + if [ -n "$precise" ]; then + cargo_update "$repo" "$manifest" --package "$spec" --precise "$precise" \ + || refuse "the requested lockfile update failed: ${line}" + else + cargo_update "$repo" "$manifest" --package "$spec" \ + || refuse "the requested lockfile update failed: ${line}" + fi + audit apply ok "cargo update ${line}" + done < "$generated/cargo-update" + fi + + ( + keep_environment PATH HOME CARGO_HOME RUSTUP_HOME RUSTUP_TOOLCHAIN TMPDIR + cd "$repo" + cargo metadata --quiet --locked --manifest-path "$manifest" --format-version 1 > /dev/null + ) || refuse "the repaired lockfile does not resolve" + check_publish_age "$base/files/$lockfile" "$repo/$lockfile" + + collect_changes "$repo" "$base" "$out" "$ecosystem" final + jq '{number, head_sha, main_sha, merge}' "$plan" > "$out/manifest.json" + check_policy "$out" "$ecosystem" final || refuse "the candidate violates the policy" + audit apply ok "candidate files: $(cd "$out/files" && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')" +} + +candidate() { + local context="$1" manifest="${2:-Cargo.toml}" repo head work + manifest_ecosystem "$manifest" > /dev/null || usage + [ -f "$context" ] || die "the context file is missing" + repo=$(git rev-parse --show-toplevel) + [ -z "$(git -C "$repo" status --porcelain)" ] || die "the checkout has uncommitted changes" + head=$(git -C "$repo" rev-parse HEAD) + work=$(mktemp -d "${TMPDIR:-/tmp}/dependency-repair.XXXXXX") + [ -n "$AUDIT_LOG" ] || AUDIT_LOG="$work/audit.jsonl" + jq -n --arg head "$head" --arg manifest "$manifest" \ + '{number: 0, head_sha: $head, main_sha: $head, merge: false, manifest: $manifest, updates: []}' \ + > "$work/plan.json" + mkdir "$work/state" + snapshot "$repo" "$head" "$work/base" + + stage=generate + generate "$repo" "$work/plan.json" "$context" "$work/generated" "$work/state" "$work/base" + MODEL_KEY="" + stage=apply + apply "$repo" "$work/plan.json" "$work/generated" "$work/candidate" false "$work/state" "$work/base" + + echo "Candidate files (uncommitted in ${repo}):" + (cd "$work/candidate/files" && find . -type f | sed 's|^\./| |' | LC_ALL=C sort) + echo "Lockfile requests: $(tr '\n' ';' < "$work/generated/cargo-update")" + echo "Evidence: ${work}" +} + +stage="${1:-}" +invocation=("$@") +scratch="" +trap finish EXIT +case "$stage" in + prepare) + [ "$#" -eq 3 ] || usage + prepare "$2" "$3" + ;; + generate) + [ "$#" -eq 5 ] || usage + scratch=$(mktemp -d) + snapshot "$2" "" "$scratch" + generate "$2" "$3" "$4" "$5" "$(state_directory "$2")" "$scratch" + ;; + apply) + [ "$#" -eq 5 ] || usage + scratch=$(mktemp -d) + if [ "$(plan_field "$3" merge)" = "true" ]; then + snapshot "$2" "$(plan_field "$3" main_sha)" "$scratch" + else + snapshot "$2" "$(plan_field "$3" head_sha)" "$scratch" + fi + apply "$2" "$3" "$4" "$5" true "$(state_directory "$2")" "$scratch" + ;; + check-policy) + [ "$#" -eq 4 ] || usage + check_policy "$2" "$3" "$4" + ;; + candidate) + if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then usage; fi + candidate "$2" "${3:-Cargo.toml}" + ;; + *) usage ;; +esac diff --git a/scripts/sync-branch-protection.sh b/scripts/sync-branch-protection.sh index aca77460..93152be3 100755 --- a/scripts/sync-branch-protection.sh +++ b/scripts/sync-branch-protection.sh @@ -12,6 +12,9 @@ set -euo pipefail # exit 2 when rules are unreadable. # scripts/sync-branch-protection.sh --apply Print the diff, then apply the # intended list via the API. +# scripts/sync-branch-protection.sh --list Print the declared contexts, one +# per line, without reading live +# repository state. # # Environment: # REPO owner/name (default: derived from the current directory's remote) @@ -36,7 +39,6 @@ set -euo pipefail # would have no status and could never merge. BRANCH="${BRANCH:-main}" -REPO="${REPO:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}" # app_id 15368 is GitHub Actions; pinning it prevents other apps from # satisfying these contexts. @@ -63,12 +65,18 @@ case "${1:-}" in "") ;; --check) mode="check" ;; --apply) mode="apply" ;; + --list) + printf '%s\n' "${REQUIRED_CONTEXTS[@]}" + exit 0 + ;; *) - echo "usage: $0 [--check|--apply]" >&2 + echo "usage: $0 [--check|--apply|--list]" >&2 exit 64 ;; esac +REPO="${REPO:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}" + current_contexts() { local rules rule rules=$(gh api "repos/${REPO}/rules/branches/${BRANCH}") || return 2 diff --git a/scripts/test-dependabot-automerge.sh b/scripts/test-dependabot-automerge.sh new file mode 100755 index 00000000..cf578354 --- /dev/null +++ b/scripts/test-dependabot-automerge.sh @@ -0,0 +1,2742 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Exercises scripts/dependabot-automerge.sh and scripts/dependency-repair.sh +# against stub gh, opencode, and cargo commands, and checks the trust +# boundaries of the workflows that run them. No network access, token, or model +# credential is used: the gh stub serves fixtures for read requests and records +# every write request so the tests can assert exactly which ones were made. + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +subject="$root/scripts/dependabot-automerge.sh" +engine="$root/scripts/dependency-repair.sh" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +export GITHUB_REPOSITORY="example/guard" +export DISPATCH_POLLS=2 DISPATCH_POLL_SECONDS=0 +unset GITHUB_STEP_SUMMARY GH_TOKEN GITHUB_TOKEN EXPECTED_HEAD OPENROUTER_API_KEY \ + DEPENDENCY_REPAIR_AUDIT_LOG AGENT_AUDIT_LOG_CLI || true +bot="dependabot[bot]" +repair_bot="github-actions[bot]" +failures=0 +tests=0 + +mkdir "$tmp/bin" +cat > "$tmp/bin/gh" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +key() { + printf '%s' "$1" | tr -c 'A-Za-z0-9\n' '_' +} +if [ "$1" != "api" ]; then + printf '%s\n' "$*" >> "$FIXTURES/writes" + [ ! -e "$FIXTURES/refuse-writes" ] + exit +fi +shift +if [ "$1" = "graphql" ]; then + number="" + while [ "$#" -gt 0 ]; do + case "$1" in number=*) number="${1#number=}" ;; esac + shift + done + cat "$FIXTURES/graphql_$number" + exit +fi +path="" +method="GET" +input=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --method) method="$2"; shift ;; + --paginate) ;; + --input) input=1; shift ;; + -*) echo "stub gh: unsupported option $1" >&2; exit 1 ;; + *) path="$1" ;; + esac + shift +done +if [ "$method" != "GET" ]; then + body="" + [ "$input" -eq 0 ] || body=$(jq -c .) + # A scratch branch has a name made per invocation. Its request is recorded + # under one name, and the name itself beside it. + scratch=$(printf '%s' "$path" | sed -n 's#.*/git/refs/heads/dependency-refresh/\(.*\)$#\1#p') + if [ -n "$scratch" ]; then + printf '%s\n' "$scratch" >> "$FIXTURES/scratch-$method" + path="${path%"$scratch"}SCRATCH" + fi + printf 'api %s %s\n' "$method" "$path" >> "$FIXTURES/writes" + [ -z "$body" ] || printf '%s\n' "$body" >> "$FIXTURES/bodies" + [ ! -e "$FIXTURES/refuse-writes" ] || exit 1 + file="$FIXTURES/$(key "$method $path")" + [ -f "$file" ] || exit 1 + # A created ref is reported as requested unless the fixture says otherwise. + if [ "$method" = "POST" ] && [ "${path##*/git/}" = "refs" ]; then + jq --argjson body "$body" '.ref //= $body.ref | .object.sha //= $body.sha' "$file" + else + cat "$file" + fi + exit +fi +file="$FIXTURES/$(key "$path")" +# ".dispatched" replaces a fixture once its workflow was dispatched, and +# ".again" replaces it from the second read onward. +workflow=$(printf '%s' "$path" | sed -n 's#.*/actions/workflows/\([^/]*\)/runs.*#\1#p') +if [ -f "$file.dispatched" ] && grep -q "^workflow run $workflow " "$FIXTURES/writes"; then + file="$file.dispatched" +elif [ -f "$file.again" ] && [ -e "$file.read" ]; then + file="$file.again" +fi +if [ ! -f "$file" ]; then + echo "stub gh: no fixture for $path" >&2 + exit 1 +fi +: > "${file%.again}.read" +cat "$file" +STUB +chmod +x "$tmp/bin/gh" +export PATH="$tmp/bin:$PATH" + +fixture_path() { + printf '%s/%s' "$FIXTURES" "$(printf '%s' "$1" | tr -c 'A-Za-z0-9\n' '_')" +} + +fixture() { + cat > "$(fixture_path "$1")" +} + +sha_for() { + printf '%s' "$1" | sha1sum | cut -d ' ' -f 1 +} + +main_sha=$(sha_for main) +api="repos/$GITHUB_REPOSITORY" + +required_contexts() { + "$root/scripts/sync-branch-protection.sh" --list +} + +# rules [jq filter applied to the default effective rules] +rules() { + required_contexts | jq -R '{context: ., integration_id: 15368}' | jq -s '[ + {type: "deletion"}, + {type: "non_fast_forward"}, + {type: "pull_request", parameters: { + allowed_merge_methods: ["merge", "squash", "rebase"], + require_code_owner_review: false, require_last_push_approval: false, + required_approving_review_count: 0, required_review_thread_resolution: true, + required_reviewers: []}}, + {type: "required_status_checks", parameters: { + strict_required_status_checks_policy: false, required_status_checks: .}} + ]' | jq "${1:-.}" | fixture "$api/rules/branches/main?per_page=100" +} + +# check_runs [name=conclusion]... ("name=" removes the run, "name=pending" +# leaves it in progress, "name=foreign" reports success from another app). +# CHECKS_STARTED sets the start time of every run. +check_runs() { + local sha="$1" context override conclusion + CHECK_SHA="$sha" + shift + required_contexts | while IFS= read -r context; do + conclusion="success" + for override in "$@"; do + [ "${override%%=*}" = "$context" ] && conclusion="${override#*=}" + done + [ -n "$conclusion" ] || continue + jq -n --arg name "$context" --arg sha "$sha" --arg conclusion "$conclusion" \ + --arg started "${CHECKS_STARTED:-2026-01-01T00:00:00Z}" '{ + name: $name, head_sha: $sha, started_at: $started, + app: {id: (if $conclusion == "foreign" then 1 else 15368 end)}, + status: (if $conclusion == "pending" then "in_progress" else "completed" end), + conclusion: (if $conclusion == "pending" then null + elif $conclusion == "foreign" then "success" + else $conclusion end) + }' + done | jq -s '{check_runs: [to_entries[] | .value + {id: (77 + .key)}]}' \ + | fixture "$api/commits/$sha/check-runs?filter=latest&per_page=100" + job_attempt "$JOB_ATTEMPT" "$JOB_CONCLUSION" +} + +checks() { + local number="$1" + shift + check_runs "$(sha_for "$number")" "$@" +} + +main_ref() { + jq -n --arg sha "$1" '{object: {sha: $sha}}' | fixture "$api/git/ref/heads/main" +} + +# Starts a case with the declared branch rules and failed jobs that a rerun +# reproduced. +new_case() { + FIXTURES=$(mktemp -d "$tmp/case.XXXXXX") + export FIXTURES + : > "$FIXTURES/writes" + : > "$FIXTURES/bodies" + main_ref "$main_sha" + rules + jq -n '[{name: "workflows", type: "dir", sha: "workflows-tree"}]' \ + | fixture "$api/contents/.github?ref=$main_sha" + unset CHECK_SHA + job_attempt 2 failure +} + +# job_attempt [previous]: Actions job and +# workflow-run fixtures. From the second attempt on, the attempt before holds a +# job of the same name under its own ID with the previous conclusion (default +# "failure"); "absent" leaves that job out and "unreadable" omits the attempt. +job_attempt() { + local check id run_id job previous="${3:-failure}" + JOB_ATTEMPT="$1" + JOB_CONCLUSION="$2" + [ -n "${CHECK_SHA:-}" ] || return 0 + while IFS= read -r check; do + id=$(jq -r '.id' <<< "$check") + run_id=$((500 + id)) + job=$(jq -n --argjson check "$check" --argjson run_id "$run_id" \ + --arg conclusion "$2" --arg repo "$GITHUB_REPOSITORY" '{ + id: $check.id, run_id: $run_id, + check_run_url: ("https://api.github.com/repos/" + $repo + "/check-runs/" + ($check.id | tostring)), + head_sha: $check.head_sha, name: $check.name, status: "completed", + conclusion: $conclusion + }') + fixture "$api/actions/jobs/$id" <<< "$job" + jq -n --argjson run_id "$run_id" --arg sha "$CHECK_SHA" \ + --argjson attempt "$1" '{id: $run_id, head_sha: $sha, + status: "completed", run_attempt: $attempt}' \ + | fixture "$api/actions/runs/$run_id" + jq -n --argjson job "$job" '{total_count: 1, jobs: [$job]}' \ + | fixture "$api/actions/runs/$run_id/attempts/$1/jobs?per_page=100" + [ "$1" -ge 2 ] || continue + rm -f "$(fixture_path "$api/actions/runs/$run_id/attempts/$(($1 - 1))/jobs?per_page=100")" + [ "$previous" != "unreadable" ] || continue + jq -n --argjson job "$job" --arg previous "$previous" --argjson id "$((1000 + id))" \ + --arg repo "$GITHUB_REPOSITORY" '{jobs: (if $previous == "absent" then [] else [$job + { + id: $id, conclusion: $previous, + check_run_url: ("https://api.github.com/repos/" + $repo + "/check-runs/" + ($id | tostring)) + }] end)} | .total_count = (.jobs | length)' \ + | fixture "$api/actions/runs/$run_id/attempts/$(($1 - 1))/jobs?per_page=100" + done < <(jq -c '.check_runs[] | select(.status == "completed")' \ + "$(fixture_path "$api/commits/$CHECK_SHA/check-runs?filter=latest&per_page=100")") +} + +check_id_for() { + jq -r --arg name "$1" '.check_runs[] | select(.name == $name) | .id' \ + "$(fixture_path "$api/commits/$CHECK_SHA/check-runs?filter=latest&per_page=100")" +} + +# message ... +message() { + local entry name version type + printf 'chore(deps): bump dependencies\n\nBumps dependencies.\n\n---\nupdated-dependencies:\n' + for entry in "$@"; do + IFS=: read -r name version type <<< "$entry" + printf -- '- dependency-name: %s\n' "$name" + [ -z "$version" ] || printf ' dependency-version: %s\n' "$version" + printf ' dependency-type: direct:production\n' + [ -z "$type" ] || printf ' update-type: version-update:semver-%s\n' "$type" + done + printf '...\n\nSigned-off-by: %s \n' "$bot" +} + +pin() { + printf '@@ -1 +1 @@\n- uses: %s@%s # v1\n+ uses: %s@%s # v2' \ + "$1" "$(sha_for old)" "$1" "${2:-$(sha_for new)}" +} + +default_patch() { + case "$1" in + .github/workflows/*) pin actions/checkout ;; + *) printf '@@ -1 +1 @@\n-old %s\n+new %s' "$1" "$1" ;; + esac +} + +# files_json ... +files_json() { + local override="$1" file + shift + for file in "$@"; do + jq -n --arg file "$file" --arg status "${P_FILE_STATUS:-modified}" \ + --arg patch "${override:-$(default_patch "$file")}" \ + '{filename: $file, status: $status} + (if $patch == "none" then {} else {patch: $patch} end)' + done | jq -s . +} + +# Writes fixtures for one pull request. Arguments: number, head ref, commit +# message, then changed files. Overrides come from the P_* variables: +# P_REPAIRS later commits, each "update" or "merge"; +# P_PATCH patch of every changed file ("none" omits it); +# P_CONTAINS comparison of main with the head (default "ahead": the head +# contains main). +pull() { + local number="$1" ref="$2" text="$3" sha first previous index=0 kind commits + local -a repairs + shift 3 + sha=$(sha_for "$number") + read -r -a repairs <<< "${P_REPAIRS:-}" + first="$sha" + [ "${#repairs[@]}" -eq 0 ] || first=$(sha_for "$number-first") + + jq -n \ + --argjson number "$number" --arg ref "$ref" --arg sha "$sha" \ + --arg repo "${P_HEAD_REPO:-$GITHUB_REPOSITORY}" --arg base_repo "$GITHUB_REPOSITORY" \ + --arg user "${P_USER:-$bot}" --arg type "${P_TYPE:-Bot}" \ + --arg state "${P_STATE:-open}" --argjson draft "${P_DRAFT:-false}" \ + --arg base "${P_BASE:-main}" --arg mergeable "${P_MERGEABLE:-clean}" \ + --argjson commits "${P_COMMITS:-$((1 + ${#repairs[@]}))}" --argjson changed "${P_CHANGED:-$#}" '{ + number: $number, state: $state, draft: $draft, + user: {login: $user, type: $type}, + base: {ref: $base, repo: {full_name: $base_repo}}, + head: {ref: $ref, sha: $sha, repo: {full_name: $repo}}, + commits: $commits, changed_files: $changed, mergeable_state: $mergeable, + auto_merge: null + }' | fixture "$api/pulls/$number" + + commits=$(jq -n \ + --arg sha "${P_COMMIT_SHA:-$first}" --arg author "${P_AUTHOR:-$bot}" \ + --arg committer "${P_COMMITTER:-web-flow}" --argjson verified "${P_VERIFIED:-true}" \ + --arg message "$text" --argjson count "${P_COMMITS:-1}" ' + [range(0; if $count > 1 then $count else 1 end) | { + sha: $sha, author: {login: $author}, committer: {login: $committer}, + commit: {message: $message, verification: {verified: $verified}}, + parents: [{sha: "base"}] + }]') + previous="$first" + for kind in "${repairs[@]}"; do + index=$((index + 1)) + [ "$index" -eq "${#repairs[@]}" ] && next="$sha" || next=$(sha_for "$number-repair-$index") + commits=$(jq \ + --arg sha "$next" --arg previous "${P_REPAIR_PARENT:-$previous}" --arg kind "$kind" \ + --arg merged "$(sha_for merged)" --arg author "${P_REPAIR_AUTHOR:-$repair_bot}" \ + --argjson verified "${P_REPAIR_VERIFIED:-true}" '. + [{ + sha: $sha, author: {login: $author}, committer: {login: "web-flow"}, + commit: {message: "chore(deps): repair dependency update", verification: {verified: $verified}}, + parents: ([{sha: $previous}] + (if $kind == "merge" then [{sha: $merged}] else [] end)) + }]' <<< "$commits") + previous="$next" + done + fixture "$api/pulls/$number/commits?per_page=100" <<< "$commits" + jq -n --arg status "${P_MERGED_STATUS:-ahead}" '{status: $status}' \ + | fixture "$api/compare/$(sha_for merged)...main" + + jq -n --arg status "${P_CONTAINS:-ahead}" '{status: $status}' \ + | fixture "$api/compare/$main_sha...$sha" + + files_json "${P_PATCH:-}" "$@" | fixture "$api/pulls/$number/files?per_page=100" + + echo '[]' | fixture "$api/pulls/$number/reviews?per_page=100" + threads "$number" true +} + +# review [commit] +review() { + local path + path=$(fixture_path "$api/pulls/$1/reviews?per_page=100") + jq --arg login "$2" --arg state "$3" --arg association "$4" \ + --arg commit "${5:-$(sha_for "$1")}" --arg at "2026-03-0$(jq length "$path")T00:00:00Z" \ + '. + [{user: {login: $login, type: "User"}, state: $state, + author_association: $association, commit_id: $commit, submitted_at: $at}]' \ + "$path" > "$path.next" + mv "$path.next" "$path" +} + +# threads ... (one page; THREADS_HEAD and THREADS_MORE override) +threads() { + local number="$1" + shift + printf '%s\n' "$@" | jq -R 'select(length > 0) | {isResolved: (. == "true")}' | jq -s \ + --arg sha "${THREADS_HEAD:-$(sha_for "$number")}" --argjson more "${THREADS_MORE:-false}" '{ + data: {repository: {pullRequest: { + headRefOid: $sha, + reviewThreads: {nodes: ., pageInfo: {hasNextPage: $more, endCursor: null}} + }}} + }' > "$FIXTURES/graphql_$number" +} + +open_pulls() { + local number + for number in "$@"; do + cat "$(fixture_path "$api/pulls/$number")" + done | jq -s . | fixture "$api/pulls?state=open&base=main&per_page=100" +} + +pass() { + tests=$((tests + 1)) +} + +fail() { + tests=$((tests + 1)) + failures=$((failures + 1)) + echo "FAIL: $1" >&2 +} + +expect_decision() { + local name="$1" expected="$2" number="$3" actual + if ! actual=$("$subject" classify "$number" | jq -r '.decision'); then + fail "$name: classify failed" + elif [ "$actual" != "$expected" ]; then + fail "$name: expected $expected, got $actual" + elif [ -s "$FIXTURES/writes" ]; then + fail "$name: classify made a write request" + else + pass + fi +} + +expect_writes() { + local name="$1" expected="$2" actual + actual=$(cat "$FIXTURES/writes") + if [ "$actual" = "$expected" ]; then + pass + else + fail "$name: expected writes [$expected], got [$actual]" + fi +} + +# expect_status ... +expect_status() { + local name="$1" expected="$2" status=ok + shift 2 + "$@" > "$tmp/output" 2>&1 || status=fail + if [ "$status" = "$expected" ]; then + pass + else + fail "$name: expected $expected, got $status: $(tail -n 3 "$tmp/output")" + fi +} + +cargo_ref="dependabot/cargo/rust-safe-dependencies-0000000000" +fuzz_ref="dependabot/cargo/fuzz/serde_json-1.0.151" +actions_ref="dependabot/github_actions/codeql-action-0000000000" + +# --- Classifier policy ------------------------------------------------------- + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch tokio:1.50.0:minor rand:0.10.3:patch toml:0.9.12+spec-1.1.0:patch)" Cargo.toml Cargo.lock +expect_decision "root cargo patch and minor group" eligible 1 + +new_case +pull 1 "$fuzz_ref" "$(message serde_json:1.0.151:patch)" fuzz/Cargo.lock +expect_decision "fuzz cargo patch" eligible 1 + +new_case +pull 1 "$actions_ref" "$(message github/codeql-action/init:4.37.3:patch github/codeql-action/analyze:4.37.3:patch)" .github/workflows/audit.yml +expect_decision "grouped actions patch" eligible 1 + +new_case +pull 1 "$cargo_ref" "$(message base64:0.23.1:minor)" Cargo.toml Cargo.lock +expect_decision "0.x minor update" eligible 1 + +new_case +pull 1 "$cargo_ref" "$(message tiny:0.0.4:patch)" Cargo.lock +expect_decision "0.0.x patch update" eligible 1 + +new_case +pull 1 "$actions_ref" "$(message actions/checkout:8:major)" .github/workflows/ci.yml +expect_decision "SHA-pinned major action update" eligible 1 + +new_case +P_PATCH=$(pin actions/checkout v8) pull 1 "$actions_ref" "$(message actions/checkout:8:major)" .github/workflows/ci.yml +expect_decision "action update pinned to a tag" review 1 + +new_case +P_PATCH=$'@@ -1 +1 @@\n- run: old\n+ run: new' pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml +expect_decision "workflow change that is not an action pin" review 1 + +new_case +P_PATCH=none pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml +expect_decision "workflow change without a readable patch" review 1 + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +expect_decision "major update" review 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch dirs:7.0.0:major)" Cargo.toml Cargo.lock +expect_decision "group containing a major update" review 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:)" Cargo.lock +expect_decision "missing update type" review 1 + +new_case +pull 1 "$cargo_ref" "$(message clap::patch)" Cargo.lock +expect_decision "missing version" review 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:5.0.0-rc.1:minor)" Cargo.lock +expect_decision "prerelease version" review 1 + +new_case +pull 1 "$cargo_ref" "chore(deps): bump clap" Cargo.lock +expect_decision "missing metadata" review 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch | sed 's/^ dependency-type:.*/ nested:\n - value/')" Cargo.lock +expect_decision "malformed metadata" review 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch; message dirs:7.0.0:major)" Cargo.lock +expect_decision "duplicate metadata blocks" review 1 + +new_case +pull 1 "dependabot/docker/.clusterfuzzlite/base-1.2.3" "$(message base:1.2.3:patch)" .clusterfuzzlite/Dockerfile +expect_decision "docker update" review 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock src/main.rs +expect_decision "source file change without a repair" review 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock fuzz/Cargo.lock +expect_decision "mixed manifest sets" review 1 + +new_case +pull 1 "$cargo_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml +expect_decision "files outside the branch ecosystem" review 1 + +new_case +pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/actions/setup/action.yml +expect_decision "non-workflow file" review 1 + +new_case +P_FILE_STATUS=added pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/new.yml +expect_decision "added workflow file" review 1 + +new_case +P_CHANGED=3 pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "incomplete file list" review 1 + +# --- Classifier trust boundaries --------------------------------------------- + +new_case +P_USER=contributor P_TYPE=User pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "pull request by another author" manual 1 + +new_case +P_TYPE=User pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "author is not a bot account" manual 1 + +new_case +P_HEAD_REPO=fork/guard pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "head branch in another repository" manual 1 + +new_case +pull 1 "feature/cargo" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "head branch outside dependabot/" manual 1 + +new_case +P_BASE=release pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "base branch is not main" manual 1 + +new_case +P_DRAFT=true pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "draft pull request" manual 1 + +new_case +P_STATE=closed pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "closed pull request" manual 1 + +new_case +P_COMMITS=2 pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "additional commit that is not a repair" manual 1 + +new_case +P_AUTHOR=contributor pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "commit by another author" manual 1 + +new_case +P_COMMITTER=contributor pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "commit signed by another committer" manual 1 + +new_case +P_VERIFIED=false pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "unverified commit" manual 1 + +new_case +P_COMMIT_SHA=$(sha_for other) pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "commit differs from the head" manual 1 + +new_case +if "$subject" classify 1 > /dev/null 2>&1; then + fail "unreadable metadata: classify succeeded" +else + pass +fi + +new_case +if "$subject" classify "1; echo injected" > /dev/null 2>&1; then + fail "malformed number: classify succeeded" +else + pass +fi + +# --- Repair provenance ------------------------------------------------------- + +new_case +P_REPAIRS=update pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock +expect_decision "lockfile repair on top of the Dependabot commit" eligible 1 + +new_case +P_REPAIRS="merge update" pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock +expect_decision "merge of main followed by a lockfile repair" eligible 1 + +new_case +P_REPAIRS=merge pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml +expect_decision "merge of main into a workflow update" eligible 1 + +new_case +P_REPAIRS=merge P_MERGED_STATUS=diverged pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "merge of a commit that main does not contain" manual 1 + +new_case +P_REPAIRS=update P_REPAIR_AUTHOR=contributor pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "later commit by another author" manual 1 + +new_case +P_REPAIRS=update P_REPAIR_VERIFIED=false pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "unverified repair commit" manual 1 + +new_case +P_REPAIRS=update P_REPAIR_PARENT=$(sha_for elsewhere) pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "repair that is not on top of the Dependabot commit" manual 1 + +new_case +P_REPAIRS=update pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock src/server/execute.rs tests/cli_output.rs +expect_decision "repair that changes the manifest and Rust source" eligible 1 + +new_case +P_REPAIRS=update pull 1 "$fuzz_ref" "$(message serde_json:1.0.151:patch)" fuzz/Cargo.lock fuzz/fuzz_targets/parse.rs +expect_decision "fuzz repair that changes a fuzz target" eligible 1 + +new_case +P_REPAIRS=update pull 1 "$fuzz_ref" "$(message serde_json:1.0.151:patch)" fuzz/Cargo.lock src/main.rs +expect_decision "fuzz repair that changes root source" review 1 + +new_case +P_REPAIRS=update pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock build.rs +expect_decision "repair that changes a build script" review 1 + +new_case +P_REPAIRS=update pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock scripts/dependabot-automerge.sh +expect_decision "repair that changes the automation" review 1 + +new_case +P_REPAIRS=update pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml src/main.rs +expect_decision "workflow update that carries Rust source" review 1 + +new_case +P_REPAIRS="update update update update update update update update update" \ + pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock src/main.rs +expect_decision "the number of repairs does not end automation" eligible 1 + +new_case +P_REPAIRS=update pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +expect_decision "repaired major update" review 1 + +# --- Review gate ------------------------------------------------------------- + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_status "gate passes a routine update" ok "$subject" gate 1 + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +expect_status "gate blocks an unreviewed major update" fail "$subject" gate 1 + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +review 1 owner APPROVED OWNER +expect_status "gate passes a major update approved at its head" ok "$subject" gate 1 + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +review 1 owner APPROVED OWNER "$(sha_for earlier)" +expect_status "gate ignores an approval of an earlier commit" fail "$subject" gate 1 + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +review 1 visitor APPROVED NONE +review 1 helper APPROVED CONTRIBUTOR +expect_status "gate ignores approvals from accounts without repository access" fail "$subject" gate 1 + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +review 1 owner APPROVED OWNER +review 1 owner CHANGES_REQUESTED OWNER +expect_status "gate honors a later change request by the same reviewer" fail "$subject" gate 1 + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +review 1 owner APPROVED OWNER +review 1 member CHANGES_REQUESTED MEMBER +expect_status "gate honors a change request by another reviewer" fail "$subject" gate 1 + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +review 1 member CHANGES_REQUESTED MEMBER +review 1 member APPROVED MEMBER +review 1 member COMMENTED MEMBER +expect_status "gate uses each reviewer's latest decision" ok "$subject" gate 1 + +new_case +P_USER=contributor P_TYPE=User pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_status "gate blocks a pull request it does not recognize" fail "$subject" gate 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +EXPECTED_HEAD=$(sha_for 1) expect_status "dispatched gate accepts the pull request head" ok "$subject" gate 1 +EXPECTED_HEAD=$(sha_for other) expect_status "dispatched gate rejects another commit" fail "$subject" gate 1 + +# --- Reconcile --------------------------------------------------------------- + +eligible_pull() { + pull "$1" "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +} + +merge_call() { + printf 'pr merge %s --repo %s --squash --match-head-commit %s' \ + "$1" "$GITHUB_REPOSITORY" "$(sha_for "$1")" +} + +new_case +eligible_pull 1 +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "eligible and green: reconcile failed" +expect_writes "eligible and green merges at the exact head" "$(merge_call 1)" + +new_case +pull 2 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +checks 2 +P_USER=contributor P_TYPE=User pull 3 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +checks 3 +pull 4 "$fuzz_ref" "$(message serde_json:1.0.151:patch)" fuzz/Cargo.lock +checks 4 +eligible_pull 5 +checks 5 +open_pulls 2 3 4 5 +"$subject" reconcile > /dev/null || fail "backfill: reconcile failed" +expect_writes "one eligible pull request merges per run" "$(merge_call 4)" + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +review 1 owner APPROVED OWNER +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "approved major: reconcile failed" +expect_writes "an approved major update merges" "$(merge_call 1)" + +new_case +P_REPAIRS="merge update" eligible_pull 1 +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "repaired: reconcile failed" +expect_writes "a lockfile-repaired update merges" "$(merge_call 1)" + +for override in "Test & Lint=failure" "Security Audit (CVEs)=failure" \ + "CodeQL (rust)=pending" "Classify dependency update=" \ + "GitHub Actions Security=foreign" "Dependency Review=skipped" \ + "Build (aarch64-apple-darwin)=cancelled"; do + new_case + eligible_pull 1 + checks 1 "$override" + open_pulls 1 + "$subject" reconcile > /dev/null || fail "$override: reconcile failed" + expect_writes "no merge when $override" "" +done + +for state in blocked behind dirty unknown draft; do + new_case + P_MERGEABLE=$state eligible_pull 1 + checks 1 + open_pulls 1 + "$subject" reconcile > /dev/null || fail "$state: reconcile failed" + expect_writes "no merge when mergeable state is $state" "" +done + +new_case +P_MERGEABLE=unstable eligible_pull 1 +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "unstable: reconcile failed" +expect_writes "optional check failures do not block" "$(merge_call 1)" + +new_case +eligible_pull 1 +open_pulls 1 +if "$subject" reconcile > /dev/null 2>&1; then + fail "unreadable checks: reconcile succeeded" +fi +expect_writes "no merge when check runs are unreadable" "" + +new_case +eligible_pull 1 +checks 1 +open_pulls 1 +touch "$FIXTURES/refuse-writes" +expect_status "a refused merge fails the run" fail "$subject" reconcile + +new_case +open_pulls +"$subject" reconcile > /dev/null || fail "no pull requests: reconcile failed" +expect_writes "no pull requests" "" + +# Review threads. + +new_case +eligible_pull 1 +threads 1 true false true +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "unresolved thread: reconcile failed" +expect_writes "no merge with an unresolved review thread" "" + +new_case +eligible_pull 1 +rm "$FIXTURES/graphql_1" +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null 2>&1 || fail "unreadable threads: reconcile failed" +expect_writes "no merge when review threads are unreadable" "" + +new_case +eligible_pull 1 +THREADS_MORE=true threads 1 true +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "partial threads: reconcile failed" +expect_writes "no merge when the thread list is incomplete" "" + +new_case +eligible_pull 1 +THREADS_HEAD=$(sha_for moved) threads 1 true +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "thread head: reconcile failed" +expect_writes "no merge when threads were read for another head" "" + +# Effective branch rules. + +for change in \ + '(.[] | select(.type == "required_status_checks") | .parameters.required_status_checks) += [{context: "New Required Check", integration_id: 15368}]' \ + '(.[] | select(.type == "required_status_checks") | .parameters.required_status_checks) |= .[1:]' \ + '(.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[0].integration_id) = 1' \ + '(.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[0].integration_id) = null' \ + 'map(select(.type != "required_status_checks"))' \ + '. + [{type: "required_deployments", parameters: {}}]' \ + '(.[] | select(.type == "pull_request") | .parameters.required_approving_review_count) = 1' \ + '(.[] | select(.type == "pull_request") | .parameters.require_code_owner_review) = true' \ + '(.[] | select(.type == "pull_request") | .parameters.require_last_push_approval) = true' \ + '(.[] | select(.type == "pull_request") | .parameters.allowed_merge_methods) = ["merge"]' \ + '{message: "Not Found"}'; do + new_case + rules "$change" + eligible_pull 1 + checks 1 + open_pulls 1 + if "$subject" reconcile > /dev/null 2>&1; then + fail "rules [$change]: reconcile succeeded" + fi + expect_writes "no merge when the effective rules are [$change]" "" +done + +new_case +rules '(.[] | select(.type == "pull_request") | .parameters.required_approving_review_count) = 1' +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +review 1 owner APPROVED OWNER +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "one required approval: reconcile failed" +expect_writes "one required approval is met by an approving review" "$(merge_call 1)" + +# Metadata that changes while the head stays fixed. + +new_case +eligible_pull 1 +checks 1 +open_pulls 1 +jq '.base.ref = "release"' "$(fixture_path "$api/pulls/1")" > "$(fixture_path "$api/pulls/1").again" +"$subject" reconcile > /dev/null || fail "retargeted base: reconcile failed" +expect_writes "no merge when the base is retargeted during evaluation" "" + +new_case +eligible_pull 1 +checks 1 +open_pulls 1 +files_json "" Cargo.lock | jq '.[0].patch = "@@ -1 +1 @@\n-old\n+other"' \ + > "$(fixture_path "$api/pulls/1/files?per_page=100").again" +"$subject" reconcile > /dev/null || fail "changed files: reconcile failed" +expect_writes "no merge when the changed files move during evaluation" "" + +new_case +eligible_pull 1 +checks 1 +open_pulls 1 +main_ref "$(sha_for main-moved)" +mv "$(fixture_path "$api/git/ref/heads/main")" "$(fixture_path "$api/git/ref/heads/main").again" +main_ref "$main_sha" +"$subject" reconcile > /dev/null || fail "moved main: reconcile failed" +expect_writes "no merge when main moves during evaluation" "" + +# Integration evidence: the head must contain the current main commit. + +refresh_message=$'chore(deps): merge main into dependency update\n\nDependency-Repair: refresh\n[dependabot skip]' + +# refresh_result [verified] [author] [committer] [first parent] [branch] +refresh_result() { + echo '{}' | fixture "POST $api/git/refs" + jq -n --arg sha "$(sha_for refreshed)" '{sha: $sha}' | fixture "POST $api/merges" + jq -n --argjson verified "${2:-true}" --arg author "${3:-$repair_bot}" \ + --arg committer "${4:-web-flow}" --arg head "${5:-$(sha_for "$1")}" --arg main "$main_sha" '{ + author: {login: $author}, committer: {login: $committer}, + commit: {verification: {verified: $verified}}, + parents: [{sha: $head}, {sha: $main}] + }' | fixture "$api/commits/$(sha_for refreshed)" + echo '{}' | fixture "PATCH $api/git/refs/heads/${6:-$cargo_ref}" + : | fixture "DELETE $api/git/refs/heads/dependency-refresh/SCRATCH" +} + +scratch_created="api POST $api/git/refs" +scratch_merged="api POST $api/merges" +scratch_removed="api DELETE $api/git/refs/heads/dependency-refresh/SCRATCH" + +# refresh_calls [branch]: the write requests of one completed refresh. +refresh_calls() { + printf '%s\n%s\napi PATCH %s/git/refs/heads/%s\n%s' \ + "$scratch_created" "$scratch_merged" "$api" "${1:-$cargo_ref}" "$scratch_removed" +} + +# The write requests of a refresh that stopped after the merge. +refresh_abandoned=$(printf '%s\n%s\n%s' "$scratch_created" "$scratch_merged" "$scratch_removed") + +# Succeeds when no request advanced or removed a pull request branch. +branches_unchanged() { + ! grep -qE '^api (PATCH|PUT|DELETE) .*/git/refs/heads/dependabot/' "$FIXTURES/writes" +} + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +refresh_result 1 +open_pulls 1 +GITHUB_RUN_ID=4242 "$subject" reconcile > /dev/null || fail "stale head: reconcile failed" +expect_writes "a green head without the main commit is refreshed, not merged" "$(refresh_calls)" +if jq -s -e --arg head "$(sha_for 1)" --arg main "$main_sha" --arg message "$refresh_message" \ + --arg commit "$(sha_for refreshed)" ' + (.[0].ref | ltrimstr("refs/heads/")) as $scratch + | ($scratch | test("^dependency-refresh/1-4242-[0-9a-f]{16}$")) + and . == [ + {ref: ("refs/heads/" + $scratch), sha: $head}, + {base: $scratch, head: $main, commit_message: $message}, + {sha: $commit, force: false} + ]' "$FIXTURES/bodies" > /dev/null; then + pass +else + fail "the refresh merges the exact main commit on a scratch branch at the head, then fast-forwards" +fi +if [ "dependency-refresh/$(cat "$FIXTURES/scratch-DELETE")" = "$(jq -r '.ref | ltrimstr("refs/heads/")' "$FIXTURES/bodies" | head -n 1)" ]; then + pass +else + fail "the refresh removes exactly the scratch branch that it created" +fi + +new_case +P_CONTAINS=behind pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml +checks 1 +refresh_result 1 true "$repair_bot" web-flow "$(sha_for 1)" "$actions_ref" +open_pulls 1 +"$subject" reconcile > /dev/null || fail "stale workflow update: reconcile failed" +expect_writes "a stale workflow update is refreshed" "$(refresh_calls "$actions_ref")" + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 "Test & Lint=failure" +refresh_result 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "stale failing head: reconcile failed" +expect_writes "a failing head without the main commit is refreshed before anything else" "$(refresh_calls)" + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +P_CONTAINS=diverged eligible_pull 2 +checks 2 +refresh_result 1 +open_pulls 1 2 +"$subject" reconcile > /dev/null || fail "two stale heads: reconcile failed" +expect_writes "one branch is refreshed per run" "$(refresh_calls)" + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +eligible_pull 2 +checks 2 +refresh_result 1 +open_pulls 1 2 +"$subject" reconcile > /dev/null || fail "stale and current heads: reconcile failed" +expect_writes "a current green head still merges after a refresh" \ + "$(printf '%s\n%s' "$(refresh_calls)" "$(merge_call 2)")" + +for ref in "$cargo_ref" "$actions_ref"; do + new_case + if [ "$ref" = "$cargo_ref" ]; then + P_CONTAINS=diverged P_MERGEABLE=dirty eligible_pull 1 + else + P_CONTAINS=diverged P_MERGEABLE=dirty pull 1 "$ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml + fi + checks 1 + open_pulls 1 + "$subject" reconcile > /dev/null || fail "conflicting head: reconcile failed" + expect_writes "a conflicting branch on $ref is neither refreshed nor merged" "" +done + +new_case +P_CONTAINS=diverged pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "stale unapproved major: reconcile failed" +expect_writes "an update that awaits review is not refreshed" "" + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +open_pulls 1 +touch "$FIXTURES/refuse-writes" +expect_status "a refused refresh fails the run" fail "$subject" reconcile +expect_writes "a refresh whose scratch branch was refused writes nothing else" "$scratch_created" + +# A name that is taken refuses the creation. The existing branch is neither +# merged into nor removed. +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +refresh_result 1 +rm "$(fixture_path "POST $api/git/refs")" +open_pulls 1 +expect_status "a scratch branch name that is taken fails the run" fail "$subject" reconcile +expect_writes "a scratch branch that this run did not create is never merged into or removed" "$scratch_created" + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +refresh_result 1 +jq -n '{ref: "refs/heads/dependency-refresh/another"}' | fixture "POST $api/git/refs" +open_pulls 1 +expect_status "a scratch branch reported under another name fails the run" fail "$subject" reconcile +expect_writes "a scratch branch reported under another name is not merged into" \ + "$(printf '%s\n%s' "$scratch_created" "$scratch_removed")" +if [ "refs/heads/dependency-refresh/$(cat "$FIXTURES/scratch-DELETE")" = "$(jq -r '.ref' "$FIXTURES/bodies")" ]; then + pass +else + fail "only the requested scratch branch name is removed" +fi + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +refresh_result 1 +rm "$(fixture_path "POST $api/merges")" +open_pulls 1 +expect_status "a refused merge on the scratch branch fails the run" fail "$subject" reconcile +expect_writes "a refused merge leaves the pull request branch unchanged" "$refresh_abandoned" + +for fault in "false $repair_bot web-flow" "true contributor web-flow" "true $repair_bot contributor" \ + "true $repair_bot web-flow $(sha_for elsewhere)"; do + new_case + P_CONTAINS=diverged eligible_pull 1 + checks 1 + read -r -a fields <<< "$fault" + refresh_result 1 "${fields[@]}" + open_pulls 1 + expect_status "a refresh commit that is not a verified repair [$fault] fails the run" fail "$subject" reconcile + expect_writes "a refresh commit that is not a verified repair [$fault] never reaches the pull request branch" \ + "$refresh_abandoned" + if branches_unchanged; then + pass + else + fail "no write to the pull request branch when the refresh commit fails the predicate [$fault]" + fi +done + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +refresh_result 1 +rm "$(fixture_path "$api/commits/$(sha_for refreshed)")" +open_pulls 1 +expect_status "an unreadable refresh commit fails the run" fail "$subject" reconcile +expect_writes "an unreadable refresh commit never reaches the pull request branch" "$refresh_abandoned" + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +echo '' | fixture "POST $api/merges" +echo '{}' | fixture "POST $api/git/refs" +: | fixture "DELETE $api/git/refs/heads/dependency-refresh/SCRATCH" +open_pulls 1 +expect_status "a refresh that finds nothing to merge is not an error" ok "$subject" reconcile +expect_writes "a refresh that finds nothing to merge leaves the pull request branch unchanged" "$refresh_abandoned" + +# The pull request moves while the merge is computed. +for moved in '.head.sha = "0000000000000000000000000000000000000000"' '.state = "closed"' \ + '.head.ref = "dependabot/cargo/another"'; do + new_case + P_CONTAINS=diverged eligible_pull 1 + checks 1 + refresh_result 1 + jq "$moved" "$(fixture_path "$api/pulls/1")" > "$(fixture_path "$api/pulls/1").again" + open_pulls 1 + expect_status "a pull request that changed during the refresh is not an error [$moved]" ok "$subject" reconcile + expect_writes "a pull request that changed during the refresh keeps its branch [$moved]" "$refresh_abandoned" +done + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +refresh_result 1 +rm "$(fixture_path "PATCH $api/git/refs/heads/$cargo_ref")" +open_pulls 1 +expect_status "a branch that refuses the fast-forward fails the run" fail "$subject" reconcile +expect_writes "a refused fast-forward is not forced and the scratch branch is removed" "$(refresh_calls)" +if jq -s -e 'all(.[]; .force != true)' "$FIXTURES/bodies" > /dev/null; then + pass +else + fail "no refresh request forces a ref" +fi + +# An outcome that did not advance a branch does not use the one refresh of the +# run: the next stale branch is tried. +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +P_CONTAINS=diverged pull 2 "$fuzz_ref" "$(message serde_json:1.0.151:patch)" fuzz/Cargo.lock +checks 2 +refresh_result 2 true "$repair_bot" web-flow "$(sha_for 2)" "$fuzz_ref" +open_pulls 1 2 +expect_status "a refresh that failed its predicate still fails the run when a later one succeeds" fail "$subject" reconcile +expect_writes "the next stale branch is refreshed after the first refresh failed its predicate" \ + "$(printf '%s\n%s' "$refresh_abandoned" "$(refresh_calls "$fuzz_ref")")" +if [ "$(sort -u "$FIXTURES/scratch-DELETE" | wc -l)" -eq 2 ] \ + && [ "$(jq -r 'select(.ref) | .ref | ltrimstr("refs/heads/dependency-refresh/")' "$FIXTURES/bodies")" = "$(cat "$FIXTURES/scratch-DELETE")" ] \ + && [ "$(jq -c 'select(.force != null)' "$FIXTURES/bodies")" = "{\"sha\":\"$(sha_for refreshed)\",\"force\":false}" ]; then + pass +else + fail "each refresh uses and removes its own scratch branch and only the verified commit is published" +fi + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +P_CONTAINS=diverged pull 2 "$fuzz_ref" "$(message serde_json:1.0.151:patch)" fuzz/Cargo.lock +checks 2 +refresh_result 2 true "$repair_bot" web-flow "$(sha_for 2)" "$fuzz_ref" +jq '.head.sha = "0000000000000000000000000000000000000000"' "$(fixture_path "$api/pulls/1")" \ + > "$(fixture_path "$api/pulls/1").again" +jq -n --arg head "$(sha_for 1)" --arg main "$main_sha" --arg bot "$repair_bot" '{ + author: {login: $bot}, committer: {login: "web-flow"}, + commit: {verification: {verified: true}}, parents: [{sha: $head}, {sha: $main}] + }' | fixture "$api/commits/$(sha_for refreshed)" +jq -n --arg head "$(sha_for 2)" --arg main "$main_sha" --arg bot "$repair_bot" '{ + author: {login: $bot}, committer: {login: "web-flow"}, + commit: {verification: {verified: true}}, parents: [{sha: $head}, {sha: $main}] + }' > "$(fixture_path "$api/commits/$(sha_for refreshed)").again" +open_pulls 1 2 +expect_status "a pull request that moved does not use the refresh of the run" ok "$subject" reconcile +expect_writes "the next stale branch is refreshed after the first pull request moved" \ + "$(printf '%s\n%s' "$refresh_abandoned" "$(refresh_calls "$fuzz_ref")")" + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +P_CONTAINS=diverged pull 2 "$fuzz_ref" "$(message serde_json:1.0.151:patch)" fuzz/Cargo.lock +checks 2 +refresh_result 1 +open_pulls 1 2 +rm "$(fixture_path "POST $api/git/refs")" +expect_status "refused scratch branches fail the run" fail "$subject" reconcile +expect_writes "every stale branch is tried when no refresh advanced one" \ + "$(printf '%s\n%s' "$scratch_created" "$scratch_created")" + +# A scratch branch that cannot be removed is reported by name and left alone. +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +P_CONTAINS=diverged pull 2 "$fuzz_ref" "$(message serde_json:1.0.151:patch)" fuzz/Cargo.lock +checks 2 +refresh_result 1 +rm "$(fixture_path "DELETE $api/git/refs/heads/dependency-refresh/SCRATCH")" +open_pulls 1 2 +expect_status "a scratch branch that cannot be removed fails the run" fail "$subject" reconcile +expect_writes "a completed refresh uses the refresh of the run even when its scratch branch remains" "$(refresh_calls)" +if grep -qF "error, the scratch branch dependency-refresh/$(cat "$FIXTURES/scratch-DELETE") could not be removed and is left in place" "$tmp/output" \ + && [ "$(wc -l < "$FIXTURES/scratch-DELETE")" -eq 1 ]; then + pass +else + fail "a scratch branch that cannot be removed is reported by name and its removal is tried once" +fi + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +refresh_result 1 false +rm "$(fixture_path "DELETE $api/git/refs/heads/dependency-refresh/SCRATCH")" +open_pulls 1 +expect_status "a failed predicate with a scratch branch that cannot be removed fails the run" fail "$subject" reconcile +if grep -qF "the branch is unchanged" "$tmp/output" \ + && grep -qF "dependency-refresh/$(cat "$FIXTURES/scratch-DELETE") could not be removed and is left in place" "$tmp/output" \ + && branches_unchanged && [ "$(wc -l < "$FIXTURES/scratch-DELETE")" -eq 1 ]; then + pass +else + fail "a remaining scratch branch of a failed refresh is reported and the pull request branch is unchanged" +fi + +# Two runs at once each use a branch of their own. +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +refresh_result 1 +open_pulls 1 +"$subject" reconcile > /dev/null 2>&1 & +first=$! +"$subject" reconcile > /dev/null 2>&1 & +second=$! +wait "$first" || fail "concurrent refresh: the first run failed" +wait "$second" || fail "concurrent refresh: the second run failed" +if [ "$(sort -u "$FIXTURES/scratch-DELETE" | wc -l)" -eq 2 ] \ + && [ "$(sort "$FIXTURES/scratch-DELETE")" = "$(jq -r 'select(.ref) | .ref | ltrimstr("refs/heads/dependency-refresh/")' "$FIXTURES/bodies" | sort)" ]; then + pass +else + fail "concurrent runs create and remove distinct scratch branches" +fi + +new_case +eligible_pull 1 +checks 1 +rm "$(fixture_path "$api/compare/$main_sha...$(sha_for 1)")" +open_pulls 1 +"$subject" reconcile > /dev/null 2>&1 || true +if grep -q '^pr merge' "$FIXTURES/writes"; then + fail "an unreadable comparison with main allowed a merge" +else + pass +fi + +new_case +main_ref "not-a-commit" +eligible_pull 1 +checks 1 +open_pulls 1 +if "$subject" reconcile > /dev/null 2>&1; then + fail "unreadable main: reconcile succeeded" +fi +expect_writes "no merge when the main head is unreadable" "" + +# --- Repair planning --------------------------------------------------------- + +plan() { + "$subject" repair-plan "$FIXTURES/plan" > /dev/null 2>&1 || true + jq -c "${1:-.}" "$FIXTURES/plan/plan.json" 2> /dev/null || echo unreadable +} + +expect_plan() { + local name="$1" filter="$2" expected="$3" actual + actual=$(plan "$filter") + if [ "$actual" != "$expected" ]; then + fail "$name: expected $expected, got $actual" + elif [ -s "$FIXTURES/writes" ]; then + fail "$name: planning made a write request" + else + pass + fi +} + +# record_name [failed context]...: the record name of the plan +# for a pull request whose Dependabot commit is its head. +record_name() { + local number="$1" merge="$2" failed="" + shift 2 + [ "$#" -eq 0 ] || failed=$(required_contexts | grep -xFf <(printf '%s\n' "$@")) + printf 'repair-attempt-%s-%s' "$number" \ + "$(printf '%s\n' "$(sha_for "$number")" "$main_sha" "$merge" "$failed" | sha256sum | cut -d ' ' -f 1)" +} + +# contains [merge] [failed context]...: whether the head +# contains the main commit, and the plan whose record is looked up (default: +# no merge, "Test & Lint" failed; "-" for no failed check). No record exists +# unless ATTEMPTS says otherwise. +contains() { + local number="$1" status="$2" merge="${3:-false}" + shift 2 + [ "$#" -eq 0 ] || shift + [ "$#" -gt 0 ] || set -- "Test & Lint" + [ "$1" != "-" ] || set -- + jq -n --arg status "$status" '{status: $status}' \ + | fixture "$api/compare/$main_sha...$(sha_for "$number")" + jq -n "{artifacts: ${ATTEMPTS:-[]}}" \ + | fixture "$api/actions/artifacts?name=$(record_name "$number" "$merge" "$@")&per_page=100" +} + +new_case +eligible_pull 1 +checks 1 "Security Audit (CVEs)=failure" "License & Dependency Policy=failure" +contains 1 ahead false "Security Audit (CVEs)" "License & Dependency Policy" +open_pulls 1 +printf '2026-10-08T00:00:00.0000000Z error: 1 vulnerability found!\n' \ + | fixture "$api/actions/jobs/$(check_id_for "Security Audit (CVEs)")/logs" +expect_plan "failing checks on a current branch plan a repair without a merge" \ + '[.repair, .number, .merge, .manifest, .updates[0].name, .updates[0].version, .attempt]' \ + "[true,1,false,\"Cargo.toml\",\"clap\",\"4.6.7\",\"$(record_name 1 false "Security Audit (CVEs)" "License & Dependency Policy")\"]" +if grep -q '^## Security Audit (CVEs)$' "$FIXTURES/plan/context.md" \ + && grep -q '^error: 1 vulnerability found!$' "$FIXTURES/plan/context.md"; then + pass +else + fail "the repair context names the failing check and the end of its log" +fi + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +ATTEMPTS='[{expired: false}]' contains 1 ahead +open_pulls 1 +expect_plan "a recorded repair is not generated again for the same update, main commit, and failing checks" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" "Security Audit (CVEs)=failure" +ATTEMPTS='[{expired: false}]' contains 1 ahead +contains 1 ahead false "Test & Lint" "Security Audit (CVEs)" +open_pulls 1 +expect_plan "a different set of failing checks is repaired again" '.repair' true + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +job_attempt 1 failure +open_pulls 1 +expect_plan "a failure that no rerun reproduced is not repaired" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=cancelled" +contains 1 ahead +job_attempt 2 cancelled +open_pulls 1 +expect_plan "cancelled checks alone are not repaired" '.repair' false + +# A failure is reproduced when the same job failed in the attempt before. +for previous in failure timed_out; do + new_case + eligible_pull 1 + checks 1 "Test & Lint=failure" + contains 1 ahead + job_attempt 2 failure "$previous" + open_pulls 1 + expect_plan "a job that ended as [$previous] and then failed again is repaired" '.repair' true +done + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +job_attempt 3 failure +open_pulls 1 +expect_plan "a failure reproduced on a later attempt is repaired" '.repair' true + +for previous in cancelled success skipped absent unreadable; do + new_case + eligible_pull 1 + checks 1 "Test & Lint=failure" + contains 1 ahead + job_attempt 2 failure "$previous" + open_pulls 1 + expect_plan "a first failure on a rerun is not repaired when the attempt before was [$previous]" '.repair' false +done + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +job_id=$(check_id_for "Test & Lint") +jobs_fixture=$(jq '.jobs[0].name = "Another job"' \ + "$(fixture_path "$api/actions/runs/$((500 + job_id))/attempts/1/jobs?per_page=100")") +fixture "$api/actions/runs/$((500 + job_id))/attempts/1/jobs?per_page=100" <<< "$jobs_fixture" +open_pulls 1 +expect_plan "a job that fails for the first time beside an earlier failure of another job is not repaired" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +rm "$(fixture_path "$api/actions/jobs/$(check_id_for "Test & Lint")")" +open_pulls 1 +expect_plan "an unreadable job is not repaired" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +job_id=$(check_id_for "Test & Lint") +job_fixture=$(jq '.check_run_url = "https://api.github.com/repos/example/guard/check-runs/999"' \ + "$(fixture_path "$api/actions/jobs/$job_id")") +fixture "$api/actions/jobs/$job_id" <<< "$job_fixture" +open_pulls 1 +expect_plan "a check run with no matching Actions job is not repaired" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +job_id=$(check_id_for "Test & Lint") +rm "$(fixture_path "$api/actions/runs/$((500 + job_id))")" +open_pulls 1 +expect_plan "an unreadable workflow run is not repaired" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +job_id=$(check_id_for "Test & Lint") +run_fixture=$(jq 'del(.run_attempt)' "$(fixture_path "$api/actions/runs/$((500 + job_id))")") +fixture "$api/actions/runs/$((500 + job_id))" <<< "$run_fixture" +open_pulls 1 +expect_plan "a missing workflow-run attempt is not repaired" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +job_id=$(check_id_for "Test & Lint") +jobs_fixture=$(jq '.jobs[0].id = 999' \ + "$(fixture_path "$api/actions/runs/$((500 + job_id))/attempts/2/jobs?per_page=100")") +fixture "$api/actions/runs/$((500 + job_id))/attempts/2/jobs?per_page=100" <<< "$jobs_fixture" +open_pulls 1 +expect_plan "a job mismatched to the rerun attempt is not repaired" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +ATTEMPTS='[{expired: true}]' contains 1 ahead +open_pulls 1 +expect_plan "an expired record allows the repair again" '.repair' true + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +rm "$(fixture_path "$api/actions/artifacts?name=$(record_name 1 false "Test & Lint")&per_page=100")" +open_pulls 1 +expect_plan "unreadable attempt records stop the repair" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 diverged true +open_pulls 1 +expect_plan "failing checks on a stale branch plan a merge" '[.repair, .merge, .main_sha]' \ + "[true,true,\"$main_sha\"]" + +new_case +P_MERGEABLE=dirty eligible_pull 1 +checks 1 +contains 1 diverged true - +open_pulls 1 +expect_plan "a conflicting branch plans a merge" '[.repair, .merge]' '[true,true]' + +new_case +pull 1 "$fuzz_ref" "$(message serde_json:1.0.151:patch)" fuzz/Cargo.lock +checks 1 "Test & Lint=failure" +contains 1 ahead +open_pulls 1 +expect_plan "a fuzz update is repaired through its own manifest" '[.repair, .manifest]' \ + '[true,"fuzz/Cargo.toml"]' + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +checks 1 "Test & Lint=failure" "Classify dependency update=failure" +contains 1 ahead +open_pulls 1 +expect_plan "a failing major update is repaired but still needs review" '.repair' true + +new_case +pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock +checks 1 "Classify dependency update=failure" +contains 1 ahead +open_pulls 1 +expect_plan "a failing gate alone is not repaired" '.repair' false + +new_case +eligible_pull 1 +checks 1 +contains 1 diverged +open_pulls 1 +expect_plan "a green update is not repaired" '.repair' false + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" "CodeQL (rust)=pending" +contains 1 ahead +open_pulls 1 +expect_plan "an update with running checks is not repaired" '.repair' false + +new_case +pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml +checks 1 "CodeQL (actions)=failure" +contains 1 diverged +open_pulls 1 +expect_plan "a workflow update is not repaired" '.repair' false + +new_case +P_USER=contributor P_TYPE=User pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +checks 1 "Test & Lint=failure" +contains 1 ahead +open_pulls 1 +expect_plan "an unrecognized pull request is not repaired" '.repair' false + +new_case +P_REPAIRS="update update update update" eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +jq -n '{artifacts: []}' | fixture "$api/actions/artifacts?name=repair-attempt-1-$(printf '%s\n' \ + "$(sha_for 1-first)" "$main_sha" false "Test & Lint" | sha256sum | cut -d ' ' -f 1)&per_page=100" +open_pulls 1 +expect_plan "earlier repairs do not stop a repair for a new failure record" '.repair' true + +# --- Repair publication ------------------------------------------------------ + +# candidate ... +candidate() { + local number="$1" merge="$2" file + shift 2 + CANDIDATE="$FIXTURES/candidate" + mkdir -p "$CANDIDATE/files" + jq -n --argjson number "$number" --arg head "$(sha_for "$number")" --arg main "$main_sha" \ + --argjson merge "$merge" '{number: $number, head_sha: $head, main_sha: $main, merge: $merge}' \ + > "$CANDIDATE/manifest.json" + for file in "$@"; do + mkdir -p "$CANDIDATE/files/$(dirname "$file")" + printf 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' > "$CANDIDATE/files/$file" + done +} + +# Fixtures for the Git database requests of one publication. +git_database() { + local number="$1" verified="${2:-true}" author="${3:-$repair_bot}" committer="${4:-web-flow}" + jq -n '{tree: {sha: "head-tree"}}' | fixture "$api/git/commits/$(sha_for "$number")" + jq -n '{tree: {sha: "main-tree"}}' | fixture "$api/git/commits/$main_sha" + for tree in head-tree main-tree; do + jq -n '{truncated: false, tree: [ + {path: "Cargo.lock", type: "blob", mode: "100644"}, + {path: "Cargo.toml", type: "blob", mode: "100644"}, + {path: "src/main.rs", type: "blob", mode: "100644"}, + {path: "src/run.rs", type: "blob", mode: "100755"} + ]}' | fixture "$api/git/trees/$tree?recursive=1" + done + jq -n '{status: "identical"}' | fixture "$api/compare/$main_sha...main" + jq -n '{sha: "new-blob"}' | fixture "POST $api/git/blobs" + jq -n '{sha: "new-tree"}' | fixture "POST $api/git/trees" + jq -n --arg sha "$(sha_for repaired)" '{sha: $sha}' | fixture "POST $api/git/commits" + jq -n --arg author "$author" --argjson verified "$verified" --arg committer "$committer" \ + --arg head "${REPAIR_PARENT:-$(sha_for "$number")}" --arg main "$main_sha" --argjson merge "${REPAIR_MERGE:-false}" '{ + author: {login: $author}, committer: {login: $committer}, + commit: {verification: {verified: $verified}}, + parents: ([{sha: $head}] + (if $merge then [{sha: $main}] else [] end)) + }' | fixture "$api/commits/$(sha_for repaired)" + echo '{}' | fixture "PATCH $api/git/refs/heads/$cargo_ref" +} + +publication="api POST $api/git/blobs +api POST $api/git/trees +api POST $api/git/commits +api PATCH $api/git/refs/heads/$cargo_ref" + +new_case +eligible_pull 1 +git_database 1 +candidate 1 false Cargo.lock +expect_status "a lockfile repair is published" ok "$subject" publish-repair "$CANDIDATE" +expect_writes "publication creates a blob, a tree, a commit, then advances the branch" "$publication" +if jq -s -e --arg head "$(sha_for 1)" --arg commit "$(sha_for repaired)" ' + (.[0] | .encoding == "base64" and (.content | @base64d | startswith("source = "))) + and (.[1] == {base_tree: "head-tree", tree: [{path: "Cargo.lock", mode: "100644", type: "blob", sha: "new-blob"}]}) + and (.[2] | .tree == "new-tree" and .parents == [$head] + and (has("author") or has("committer") or has("signature") | not)) + and (.[3] == {sha: $commit, force: false})' "$FIXTURES/bodies" > /dev/null; then + pass +else + fail "publication sends the repaired file, no author, and a fast-forward" +fi + +new_case +eligible_pull 1 +REPAIR_MERGE=true git_database 1 +candidate 1 true Cargo.lock +expect_status "a merge repair is published" ok "$subject" publish-repair "$CANDIDATE" +if jq -s -e --arg head "$(sha_for 1)" --arg main "$main_sha" \ + '.[1].base_tree == "main-tree" and .[2].parents == [$head, $main]' "$FIXTURES/bodies" > /dev/null; then + pass +else + fail "a merge repair builds on the main tree with the head as first parent" +fi + +new_case +eligible_pull 1 +git_database 1 +jq -n '{status: "diverged"}' | fixture "$api/compare/$main_sha...main" +candidate 1 true Cargo.lock +status=0 +"$subject" publish-repair "$CANDIDATE" > /dev/null 2>&1 || status=$? +if [ "$status" -eq 1 ]; then + pass +else + fail "a refused merge is an error, not a repair without progress: status $status" +fi +expect_writes "no write for a merge of a commit outside main" "" + +new_case +eligible_pull 1 +git_database 1 false +candidate 1 false Cargo.lock +expect_status "an unsigned repair commit fails publication" fail "$subject" publish-repair "$CANDIDATE" +expect_writes "an unsigned repair commit never reaches the branch" \ + "$(head -n 3 <<< "$publication")" + +new_case +eligible_pull 1 +git_database 1 true contributor +candidate 1 false Cargo.lock +expect_status "a repair commit by another author fails publication" fail "$subject" publish-repair "$CANDIDATE" +expect_writes "a repair commit by another author never reaches the branch" \ + "$(head -n 3 <<< "$publication")" + +for fault in "committer:1 true $repair_bot contributor" "parent:1 true $repair_bot web-flow"; do + new_case + eligible_pull 1 + read -r -a fields <<< "${fault#*:}" + if [ "${fault%%:*}" = "parent" ]; then + REPAIR_PARENT=$(sha_for elsewhere) git_database "${fields[@]}" + else + git_database "${fields[@]}" + fi + candidate 1 false Cargo.lock + expect_status "a repair commit with another ${fault%%:*} fails publication" fail "$subject" publish-repair "$CANDIDATE" + expect_writes "a repair commit with another ${fault%%:*} never reaches the branch" \ + "$(head -n 3 <<< "$publication")" +done + +# expect_dropped : the candidate is dropped with status 4 and no write. +expect_dropped() { + local status=0 + "$subject" publish-repair "$CANDIDATE" > /dev/null 2>&1 || status=$? + if [ "$status" -eq 4 ]; then + pass + else + fail "$1: expected status 4, got $status" + fi + expect_writes "no write when $1" "" +} + +new_case +eligible_pull 1 +git_database 1 +candidate 1 false Cargo.lock +jq --arg head "$(sha_for stale)" '.head_sha = $head' "$CANDIDATE/manifest.json" > "$CANDIDATE/m" \ + && mv "$CANDIDATE/m" "$CANDIDATE/manifest.json" +expect_dropped "a repair is for a head that moved" + +new_case +P_USER=contributor P_TYPE=User pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +git_database 1 +candidate 1 false Cargo.lock +expect_dropped "a repair is for an unrecognized pull request" + +new_case +pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml +git_database 1 +candidate 1 false Cargo.lock +expect_dropped "a repair is for a workflow update" + +for file in README.md build.rs .github/workflows/ci.yml scripts/dependabot-automerge.sh \ + fuzz/Cargo.lock src/main.txt .cargo/config.toml deny.toml; do + new_case + eligible_pull 1 + git_database 1 + candidate 1 false "$file" + expect_status "a repair of $file is refused" fail "$subject" publish-repair "$CANDIDATE" + expect_writes "no write for a repair of $file" "" +done + +new_case +eligible_pull 1 +git_database 1 +candidate 1 false Cargo.lock src/new.rs +expect_status "a repair that adds a file is refused" fail "$subject" publish-repair "$CANDIDATE" +expect_writes "no branch update for a repair that adds a file" "api POST $api/git/blobs" + +new_case +eligible_pull 1 +git_database 1 +candidate 1 false src/run.rs +expect_status "a repair of an executable file is refused" fail "$subject" publish-repair "$CANDIDATE" +expect_writes "no write for a repair of an executable file" "" + +new_case +eligible_pull 1 +git_database 1 +candidate 1 false Cargo.lock +ln -s /etc/hostname "$CANDIDATE/files/Cargo.toml" +expect_status "a candidate with a symbolic link is refused" fail "$subject" publish-repair "$CANDIDATE" +expect_writes "no write for a candidate with a symbolic link" "" + +new_case +eligible_pull 1 +git_database 1 +candidate 1 false Cargo.lock +printf 'source = "git+https://example.invalid/crate"\n' >> "$CANDIDATE/files/Cargo.lock" +expect_status "a lockfile with a source outside crates.io is refused" fail "$subject" publish-repair "$CANDIDATE" +expect_writes "no write for a lockfile with a source outside crates.io" "" + +new_case +eligible_pull 1 +git_database 1 +candidate 1 false Cargo.toml +printf '<<<<<<< HEAD\n' >> "$CANDIDATE/files/Cargo.toml" +expect_status "a file with a conflict marker is refused" fail "$subject" publish-repair "$CANDIDATE" +expect_writes "no write for a file with a conflict marker" "" + +new_case +eligible_pull 1 +git_database 1 +candidate 1 false Cargo.lock +jq -n '{sha: "head-tree"}' | fixture "POST $api/git/trees" +status=0 +"$subject" publish-repair "$CANDIDATE" > /dev/null 2>&1 || status=$? +if [ "$status" -eq 3 ]; then + pass +else + fail "a repair without a change reports no progress: status $status" +fi +expect_writes "no commit for a repair without a change" "$(head -n 2 <<< "$publication")" + +new_case +P_REPAIRS="update update update update" eligible_pull 1 +git_database 1 +candidate 1 false Cargo.lock src/main.rs +expect_status "a further source repair on a repaired update is published" ok "$subject" publish-repair "$CANDIDATE" + +# --- Workflow runs ----------------------------------------------------------- + +# runs [fixture suffix]. RUN_EVENT, +# RUN_STATUS, RUN_CONCLUSION, and RUN_ATTEMPT describe every run. +runs() { + jq -n --arg sha "$2" --arg branch "$3" --argjson count "$4" --arg event "${RUN_EVENT:-push}" \ + --arg status "${RUN_STATUS:-completed}" --arg conclusion "${RUN_CONCLUSION:-success}" \ + --argjson attempt "${RUN_ATTEMPT:-1}" '{ + workflow_runs: [range(0; $count) | { + id: (500 + .), head_sha: $sha, head_branch: $branch, event: $event, status: $status, + conclusion: (if $status == "completed" then $conclusion else null end), + run_attempt: $attempt, run_started_at: "2026-01-01T00:00:0\(.)Z"}] + }' > "$(fixture_path "$api/actions/workflows/$1/runs?head_sha=$2&per_page=100")${5:-}" +} + +repaired_pull() { + P_REPAIRS=update eligible_pull 1 + jq -n --arg tree "${1:-workflows-tree}" '[{name: "workflows", type: "dir", sha: $tree}]' \ + | fixture "$api/contents/.github?ref=$(sha_for 1)" + open_pulls 1 +} + +new_case +repaired_pull +for workflow in ci.yml audit.yml dependabot-automerge.yml; do + runs "$workflow" "$(sha_for 1)" "$cargo_ref" 0 + RUN_EVENT=workflow_dispatch runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 .dispatched +done +expect_status "validation is dispatched for a repaired head" ok "$subject" ensure-validation-runs +expect_writes "a repaired head gets every required workflow by dispatch" \ + "workflow run ci.yml --repo $GITHUB_REPOSITORY --ref $cargo_ref +workflow run audit.yml --repo $GITHUB_REPOSITORY --ref $cargo_ref -f dependency_review_base=$main_sha +workflow run dependabot-automerge.yml --repo $GITHUB_REPOSITORY --ref $cargo_ref -f pull_request=1" + +new_case +repaired_pull +for workflow in ci.yml audit.yml dependabot-automerge.yml; do + runs "$workflow" "$(sha_for 1)" "$cargo_ref" 0 +done +expect_status "a dispatch without a run for the head fails" fail "$subject" ensure-validation-runs + +new_case +repaired_pull +for workflow in ci.yml audit.yml dependabot-automerge.yml; do + RUN_EVENT=workflow_dispatch runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 +done +"$subject" ensure-validation-runs > /dev/null || fail "existing validation: command failed" +expect_writes "existing validation runs are not repeated" "" + +new_case +repaired_pull +for workflow in ci.yml audit.yml dependabot-automerge.yml; do + RUN_EVENT=pull_request runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 + RUN_EVENT=workflow_dispatch runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 .dispatched +done +"$subject" ensure-validation-runs > /dev/null || fail "approval-gated runs: command failed" +if [ "$(grep -c '^workflow run ' "$FIXTURES/writes")" -eq 3 ]; then + pass +else + fail "pull request runs that wait for approval do not count as validation" +fi + +new_case +repaired_pull branch-workflows-tree +for workflow in ci.yml audit.yml dependabot-automerge.yml; do + runs "$workflow" "$(sha_for 1)" "$cargo_ref" 0 +done +"$subject" ensure-validation-runs > /dev/null || fail "differing workflows: command failed" +expect_writes "no dispatch when the branch workflows differ from main" "" + +# A refreshed workflow update differs from main by its own pinned actions. +refreshed_actions_pull() { + P_REPAIRS=merge pull 1 "$actions_ref" "$1" .github/workflows/ci.yml + jq -n '[{name: "workflows", type: "dir", sha: "branch-workflows-tree"}]' \ + | fixture "$api/contents/.github?ref=$(sha_for 1)" + open_pulls 1 + for workflow in ci.yml audit.yml dependabot-automerge.yml; do + runs "$workflow" "$(sha_for 1)" "$actions_ref" 0 + RUN_EVENT=workflow_dispatch runs "$workflow" "$(sha_for 1)" "$actions_ref" 1 .dispatched + done +} + +new_case +refreshed_actions_pull "$(message actions/checkout:8:major)" +expect_status "validation is dispatched for a refreshed workflow update" ok "$subject" ensure-validation-runs +if [ "$(grep -c '^workflow run ' "$FIXTURES/writes")" -eq 3 ]; then + pass +else + fail "a refreshed, eligible workflow update gets every required workflow by dispatch" +fi + +new_case +P_PATCH=$'@@ -1 +1 @@\n- run: old\n+ run: new' refreshed_actions_pull "$(message actions/checkout:7.0.2:patch)" +"$subject" ensure-validation-runs > /dev/null || fail "ineligible workflow change: command failed" +expect_writes "no dispatch for a workflow change that is not a pinned action" "" + +new_case +P_CONTAINS=diverged refreshed_actions_pull "$(message actions/checkout:8:major)" +"$subject" ensure-validation-runs > /dev/null || fail "stale workflow update: command failed" +expect_writes "no dispatch for a workflow update that lacks the main commit" "" + +new_case +eligible_pull 1 +open_pulls 1 +for workflow in ci.yml audit.yml dependabot-automerge.yml; do + runs "$workflow" "$(sha_for 1)" "$cargo_ref" 0 +done +"$subject" ensure-validation-runs > /dev/null || fail "unrepaired: command failed" +expect_writes "no dispatch for a head that Dependabot pushed" "" + +# Recovery of runs that did not succeed. + +rerun_call() { + printf 'run rerun 500 --repo %s --failed' "$GITHUB_REPOSITORY" +} + +for outcome in "failure 1" "cancelled 1" "cancelled 3" "timed_out 2" "startup_failure 1"; do + new_case + eligible_pull 1 + open_pulls 1 + for workflow in ci.yml audit.yml dependabot-automerge.yml; do + RUN_EVENT=pull_request runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 + done + RUN_EVENT=pull_request RUN_CONCLUSION=${outcome% *} RUN_ATTEMPT=${outcome#* } \ + runs ci.yml "$(sha_for 1)" "$cargo_ref" 1 + "$subject" ensure-validation-runs > /dev/null || fail "$outcome: command failed" + expect_writes "a pull request run that ended as [$outcome] is rerun" "$(rerun_call)" +done + +# run_jobs ...: the jobs of one attempt of run 500. +# Every attempt gives a job a new ID. +run_jobs() { + local attempt="$1" + shift + printf '%s\n' "$@" | jq -R 'select(length > 0) | split("=") + | {name: .[0], conclusion: .[1], status: "completed", run_id: 500}' \ + | jq -s --argjson attempt "$attempt" \ + '{jobs: [to_entries[] | .value + {id: (9000 + $attempt * 100 + .key)}]} | .total_count = (.jobs | length)' \ + | fixture "$api/actions/runs/500/attempts/$attempt/jobs?per_page=100" +} + +# failed_runs : every validation workflow run failed on that attempt. +failed_runs() { + eligible_pull 1 + open_pulls 1 + for workflow in ci.yml audit.yml dependabot-automerge.yml; do + RUN_EVENT=pull_request RUN_CONCLUSION=failure RUN_ATTEMPT="$1" \ + runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 + done +} + +for sequence in "failure" "timed_out"; do + new_case + failed_runs 2 + run_jobs 1 "Test & Lint=$sequence" "Build=success" + run_jobs 2 "Test & Lint=failure" "Build=success" + "$subject" ensure-validation-runs > /dev/null || fail "$sequence then failure: command failed" + expect_writes "a job that ended as [$sequence] and failed again on the rerun is a finding" "" +done + +for latest in empty nonfailing; do + new_case + failed_runs 2 + run_jobs 1 "Test & Lint=failure" + if [ "$latest" = empty ]; then + run_jobs 2 + else + run_jobs 2 "Test & Lint=success" "Build=skipped" "Cleanup=cancelled" + fi + "$subject" ensure-validation-runs > /dev/null || fail "$latest latest attempt: command failed" + expected_reruns=$(printf '%s\n%s\n%s' "$(rerun_call)" "$(rerun_call)" "$(rerun_call)") + expect_writes "a failed run with a [$latest] latest attempt is rerun without a repair write" "$expected_reruns" +done + +for response in empty malformed; do + new_case + failed_runs 2 + run_jobs 1 "Test & Lint=failure" + if [ "$response" = empty ]; then + : > "$(fixture_path "$api/actions/runs/500/attempts/2/jobs?per_page=100")" + else + printf '%s\n' '{"total_count": 0}' \ + | fixture "$api/actions/runs/500/attempts/2/jobs?per_page=100" + fi + expect_status "$response current-attempt response fails the run" fail "$subject" ensure-validation-runs + expect_writes "$response current-attempt response requests no API write" "" +done + +new_case +failed_runs 3 +run_jobs 2 "Test & Lint=failure" +run_jobs 3 "Test & Lint=timed_out" +"$subject" ensure-validation-runs > /dev/null || fail "reproduced on a later attempt: command failed" +expect_writes "a failure reproduced on a later attempt is a finding" "" + +new_case +failed_runs 2 +run_jobs 1 "Test & Lint=cancelled" "Build=cancelled" +run_jobs 2 "Test & Lint=failure" "Build=success" +"$subject" ensure-validation-runs > /dev/null || fail "cancelled then failure: command failed" +if [ "$(grep -c '^run rerun 500 ' "$FIXTURES/writes")" -eq 3 ]; then + pass +else + fail "a first failure after a cancelled attempt is rerun" +fi + +new_case +failed_runs 2 +run_jobs 1 "Test & Lint=failure" "Build=success" +run_jobs 2 "Test & Lint=failure" "Build=failure" +"$subject" ensure-validation-runs > /dev/null || fail "newly failing job: command failed" +if [ "$(grep -c '^run rerun 500 ' "$FIXTURES/writes")" -eq 3 ]; then + pass +else + fail "a job that fails for the first time on a rerun is rerun" +fi + +new_case +failed_runs 2 +run_jobs 1 +run_jobs 2 "Test & Lint=failure" +"$subject" ensure-validation-runs > /dev/null || fail "job absent before: command failed" +if [ "$(grep -c '^run rerun 500 ' "$FIXTURES/writes")" -eq 3 ]; then + pass +else + fail "a failed job that the attempt before did not hold is rerun" +fi + +for missing in 1 2; do + new_case + failed_runs 2 + run_jobs "$((3 - missing))" "Test & Lint=failure" + expect_status "unreadable jobs of attempt $missing fail the run" fail "$subject" ensure-validation-runs + expect_writes "unreadable jobs of attempt $missing request no rerun" "" +done + +for outcome in "failure 2" "success 1"; do + new_case + eligible_pull 1 + open_pulls 1 + run_jobs 1 "Test & Lint=failure" + run_jobs 2 "Test & Lint=failure" + for workflow in ci.yml audit.yml dependabot-automerge.yml; do + RUN_EVENT=pull_request RUN_CONCLUSION=${outcome% *} RUN_ATTEMPT=${outcome#* } \ + runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 + done + "$subject" ensure-validation-runs > /dev/null || fail "$outcome: command failed" + expect_writes "a run that ended as [$outcome] is left alone" "" +done + +new_case +eligible_pull 1 +open_pulls 1 +for workflow in ci.yml audit.yml dependabot-automerge.yml; do + RUN_EVENT=pull_request RUN_STATUS=in_progress runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 +done +"$subject" ensure-validation-runs > /dev/null || fail "running: command failed" +expect_writes "a run in progress is left alone" "" + +new_case +repaired_pull +for workflow in ci.yml audit.yml dependabot-automerge.yml; do + RUN_EVENT=workflow_dispatch RUN_CONCLUSION=cancelled runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 +done +"$subject" ensure-validation-runs > /dev/null || fail "cancelled dispatch: command failed" +if [ "$(grep -c '^run rerun 500 ' "$FIXTURES/writes")" -eq 3 ] && ! grep -q '^workflow run ' "$FIXTURES/writes"; then + pass +else + fail "a cancelled dispatched run is rerun, not counted as validation and not dispatched twice" +fi + +new_case +eligible_pull 1 +open_pulls 1 +for workflow in ci.yml audit.yml dependabot-automerge.yml; do + RUN_EVENT=pull_request RUN_CONCLUSION=failure runs "$workflow" "$(sha_for 1)" "$cargo_ref" 1 +done +touch "$FIXTURES/refuse-writes" +expect_status "a refused rerun fails the run" fail "$subject" ensure-validation-runs + +new_case +RUN_CONCLUSION=cancelled runs ci.yml "$main_sha" main 1 +RUN_CONCLUSION=failure RUN_ATTEMPT=2 runs audit.yml "$main_sha" main 1 +run_jobs 1 "Security Audit (CVEs)=failure" +run_jobs 2 "Security Audit (CVEs)=failure" +"$subject" ensure-mainline-runs > /dev/null || fail "cancelled mainline: command failed" +expect_writes "a cancelled mainline run is rerun and a reproduced failure is left alone" "$(rerun_call)" + +new_case +runs ci.yml "$main_sha" main 1 +RUN_CONCLUSION=failure RUN_ATTEMPT=2 runs audit.yml "$main_sha" main 1 +run_jobs 1 "Security Audit (CVEs)=cancelled" +run_jobs 2 "Security Audit (CVEs)=failure" +"$subject" ensure-mainline-runs > /dev/null || fail "first mainline failure on a rerun: command failed" +expect_writes "a first mainline failure after a cancelled attempt is rerun" "$(rerun_call)" + +new_case +runs ci.yml "$main_sha" main 0 +runs audit.yml "$main_sha" main 0 +RUN_EVENT=workflow_dispatch runs ci.yml "$main_sha" main 1 .dispatched +RUN_EVENT=workflow_dispatch runs audit.yml "$main_sha" main 1 .dispatched +"$subject" ensure-mainline-runs > /dev/null || fail "missing runs: command failed" +expect_writes "missing mainline runs are dispatched on main" \ + "$(printf 'workflow run ci.yml --repo %s --ref main\nworkflow run audit.yml --repo %s --ref main' \ + "$GITHUB_REPOSITORY" "$GITHUB_REPOSITORY")" + +new_case +runs ci.yml "$main_sha" main 0 +runs audit.yml "$main_sha" main 1 +expect_status "a mainline dispatch without a run for the commit fails" fail "$subject" ensure-mainline-runs + +new_case +runs ci.yml "$main_sha" main 1 +runs audit.yml "$main_sha" main 2 +"$subject" ensure-mainline-runs > /dev/null || fail "existing runs: command failed" +expect_writes "existing mainline runs are not repeated" "" + +new_case +runs ci.yml "$main_sha" feature 1 +RUN_EVENT=workflow_dispatch runs ci.yml "$main_sha" main 1 .dispatched +runs audit.yml "$main_sha" main 1 +"$subject" ensure-mainline-runs > /dev/null || fail "branch run: command failed" +expect_writes "a run on another branch is not mainline evidence" \ + "workflow run ci.yml --repo $GITHUB_REPOSITORY --ref main" + +new_case +main_ref "not-a-commit" +if "$subject" ensure-mainline-runs > /dev/null 2>&1; then + fail "malformed head: command succeeded" +fi +expect_writes "no dispatch when the main head is unreadable" "" + +# --- Repair engine ----------------------------------------------------------- + +# The credential is generated here so that no credential-shaped literal exists +# in the repository. +credential=$(head -c 24 /dev/urandom | base64 | tr -d '/+=') + +# Fixture commits are signed with a key made for this run. +ssh-keygen -q -t ed25519 -N '' -C dependency-automation-test -f "$tmp/signing-key" +printf 'test@example.invalid %s\n' "$(cat "$tmp/signing-key.pub")" > "$tmp/allowed-signers" +pinned_opencode=$(sed -n 's/^OPENCODE_VERSION="\(.*\)"$/\1/p' "$engine") + +cat > "$tmp/bin/opencode" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +# Records what the model run could see, then performs the scripted edits. The +# run has no inherited environment, so the record is found beside the stub. +AGENT_RECORD="$(dirname "$0")/../agent-record" +[ -z "${MODEL_KEY+set}" ] || touch "$AGENT_RECORD/internal-variable-inherited" +if [ "${1:-}" = "--version" ]; then + [ -z "${OPENROUTER_API_KEY:-}" ] || touch "$AGENT_RECORD/credential-seen-early" + cat "$AGENT_RECORD/version" + exit +fi +compgen -e | LC_ALL=C sort > "$AGENT_RECORD/environment" +printf '%s' "$HOME" > "$AGENT_RECORD/home" +printf '%s' "${TMPDIR:-}" > "$AGENT_RECORD/temporary" +printf '%s' "$XDG_CONFIG_HOME" > "$AGENT_RECORD/state" +printf '%s\n' "$@" > "$AGENT_RECORD/arguments" +printf '%s' "$OPENCODE_CONFIG_CONTENT" > "$AGENT_RECORD/configuration" +[ "${OPENROUTER_API_KEY:-}" = "$(cat "$AGENT_RECORD/credential")" ] && touch "$AGENT_RECORD/credential-seen" +cp .dependency-repair/context.md "$AGENT_RECORD/context" +# shellcheck disable=SC1091 +. "$AGENT_RECORD/script" +STUB +cat > "$tmp/bin/cargo" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +# Cargo runs with a reduced environment, so the record is found beside the stub. +CARGO_RECORD="$(dirname "$0")/../cargo-record" +printf '%s\n' "$*" >> "$CARGO_RECORD/calls" +compgen -e | LC_ALL=C sort > "$CARGO_RECORD/environment" +[ -z "${OPENROUTER_API_KEY:-}" ] || touch "$CARGO_RECORD/credential-seen" +case "$*" in + *"--package"*) + printf '# %s\n' "$*" >> Cargo.lock + [ ! -e "$CARGO_RECORD/lock-entry" ] || cat "$CARGO_RECORD/lock-entry" >> Cargo.lock + ;; +esac +[ ! -e "$CARGO_RECORD/fail" ] +STUB +cat > "$tmp/bin/curl" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +# Serves the crates.io index entry that the test wrote for the requested path. +CARGO_RECORD="$(dirname "$0")/../cargo-record" +for argument in "$@"; do url="$argument"; done +printf '%s\n' "$url" >> "$CARGO_RECORD/index-requests" +cat "$CARGO_RECORD/index/$(basename "$url")" +STUB +chmod +x "$tmp/bin/opencode" "$tmp/bin/cargo" "$tmp/bin/curl" + +registry='source = "registry+https://github.com/rust-lang/crates.io-index"' + +# Creates a repository with a main commit, an update commit, and a later main +# commit that conflicts with the update in the lockfile. +new_repository() { + REPO=$(mktemp -d "$tmp/repo.XXXXXX") + AGENT_RECORD=$(mktemp -d "$tmp/agent.XXXXXX") + CARGO_RECORD=$(mktemp -d "$tmp/cargo.XXXXXX") + OUT=$(mktemp -d "$tmp/out.XXXXXX") + ln -sfn "$AGENT_RECORD" "$tmp/agent-record" + ln -sfn "$CARGO_RECORD" "$tmp/cargo-record" + printf '%s' "$credential" > "$AGENT_RECORD/credential" + printf '%s\n' "$pinned_opencode" > "$AGENT_RECORD/version" + : > "$CARGO_RECORD/calls" + echo ':' > "$AGENT_RECORD/script" + ( + cd "$REPO" + git init --quiet --initial-branch main + git config user.name test + git config user.email test@example.invalid + git config gpg.format ssh + git config user.signingkey "$tmp/signing-key.pub" + git config gpg.ssh.allowedSignersFile "$tmp/allowed-signers" + git config commit.gpgsign true + mkdir -p src fuzz/fuzz_targets .github/workflows + printf '[package]\nname = "guard"\n' > Cargo.toml + printf 'name = "clap"\nversion = "4.6.6"\n%s\n' "$registry" > Cargo.lock + printf 'fn main() {}\n' > src/main.rs + printf '# guard\n' > README.md + printf 'name: CI\n' > .github/workflows/ci.yml + git add -A + git commit --quiet -m base + git checkout --quiet -b update + printf 'name = "clap"\nversion = "4.6.7"\n%s\n' "$registry" > Cargo.lock + git commit --quiet -am update + git checkout --quiet main + printf 'name = "clap"\nversion = "4.6.5"\n%s\n' "$registry" > Cargo.lock + printf 'fn main() { run() }\n' > src/main.rs + git commit --quiet -am main + ) + REPO_HEAD=$(git -C "$REPO" rev-parse update) + REPO_MAIN=$(git -C "$REPO" rev-parse main) + repair_plan false +} + +# expect_exit ... +expect_exit() { + local name="$1" expected="$2" status=0 + shift 2 + "$@" > "$tmp/output" 2>&1 || status=$? + if [ "$status" -eq "$expected" ]; then + pass + else + fail "$name: expected status $expected, got $status: $(tail -n 3 "$tmp/output")" + fi +} + +repair_plan() { + jq -n --arg head "$REPO_HEAD" --arg main "$REPO_MAIN" --argjson merge "$1" \ + --arg manifest "${2:-Cargo.toml}" '{ + repair: true, number: 7, head_sha: $head, main_sha: $main, merge: $merge, + manifest: $manifest, updates: [{name: "clap", version: "4.6.7"}, {name: "bad name", version: "1"}] + }' > "$OUT/plan.json" + printf '# Failing required checks\n\n## Security Audit (CVEs)\n' > "$OUT/context.md" +} + +agent() { + cat > "$AGENT_RECORD/script" +} + +generate() { + OPENROUTER_API_KEY="$credential" "$engine" generate "$REPO" "$OUT/plan.json" "$OUT/context.md" "$OUT/generated" +} + +apply() { + "$engine" apply "$REPO" "$OUT/plan.json" "$OUT/generated" "$OUT/candidate" +} + +new_repository +if git -C "$REPO" verify-commit "$REPO_HEAD" > /dev/null 2>&1 && git -C "$REPO" verify-commit "$REPO_MAIN" > /dev/null 2>&1 \ + && [ "$(git -C "$REPO" config --get commit.gpgsign)" = "true" ]; then + pass +else + fail "fixture commits are signed and verify against the key made for this run" +fi +"$engine" prepare "$REPO" "$OUT/plan.json" +agent <<'EOF' +echo 'rustls@0.23.42 0.23.45' > .dependency-repair/cargo-update +echo 'fn main() { repaired() }' > src/main.rs +EOF +expect_status "generate accepts a lockfile request and a source edit" ok generate +if [ "$(cat "$OUT/generated/cargo-update")" = "rustls@0.23.42 0.23.45" ] \ + && [ "$(find "$OUT/generated/files" -type f | sed "s|$OUT/generated/files/||")" = "src/main.rs" ] \ + && [ ! -e "$REPO/.dependency-repair" ]; then + pass +else + fail "generate collects the request and exactly the edited file" +fi +if [ "$(grep -vxE 'PWD|SHLVL|OLDPWD|_|TMPDIR' "$AGENT_RECORD/environment" | tr '\n' ' ')" = "HOME OPENCODE_CONFIG_CONTENT OPENCODE_DISABLE_AUTOUPDATE OPENCODE_DISABLE_CLAUDE_CODE OPENCODE_DISABLE_EXTERNAL_SKILLS OPENCODE_DISABLE_LSP_DOWNLOAD OPENCODE_DISABLE_PROJECT_CONFIG OPENROUTER_API_KEY PATH TERM XDG_CACHE_HOME XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME " ] \ + && [ "$(cat "$AGENT_RECORD/home")" = "$HOME" ] && [ "$(cat "$AGENT_RECORD/state")" != "$HOME/.config" ]; then + pass +else + fail "the model run sees only its own environment: $(tr '\n' ' ' < "$AGENT_RECORD/environment")" +fi +if [ -e "$AGENT_RECORD/credential-seen" ] && [ ! -e "$AGENT_RECORD/credential-seen-early" ] \ + && ! grep -qF "$credential" "$AGENT_RECORD/arguments" \ + && grep -qx -- '--pure' "$AGENT_RECORD/arguments" \ + && grep -qx -- 'openrouter/anthropic/claude-sonnet-5.5' "$AGENT_RECORD/arguments"; then + pass +else + fail "the credential reaches the model run through the environment only" +fi +if jq -e ' + .permission["*"] == "deny" and .permission.bash == "deny" and .permission.webfetch == "deny" + and .permission.task == "deny" and .permission.external_directory == "deny" + and .permission.edit["*"] == "deny" and .permission.edit["Cargo.toml"] == "allow" + and (.permission.edit | has("Cargo.lock") | not) + and .lsp == false and .formatter == false and .mcp == {} and .plugin == [] + and .share == "disabled" and .enabled_providers == ["openrouter"] + ' "$AGENT_RECORD/configuration" > /dev/null; then + pass +else + fail "the model configuration denies commands, network tools, servers, and plugins" +fi +if grep -q 'Security Audit (CVEs)' "$AGENT_RECORD/context"; then + pass +else + fail "the model run receives the failure context" +fi +if jq -s -e 'map(.action) == ["prepare", "generate", "generate"] and all(.[]; .tool == "dependency-repair")' \ + "$OUT/generated/audit.jsonl" > /dev/null 2>&1 \ + || jq -s -e 'map(.action) == ["generate", "generate"] and all(.[]; .tool == "dependency-repair")' \ + "$OUT/generated/audit.jsonl" > /dev/null 2>&1; then + pass +else + fail "generate writes an audit record for each action" +fi +if grep -rqF "$credential" "$OUT"; then + fail "the credential is written to the output or the audit log" +else + pass +fi + +git -C "$REPO" reset --quiet --hard +echo changed >> "$REPO/README.md" +expect_status "prepare refuses a checkout with local changes" fail "$engine" prepare "$REPO" "$OUT/plan.json" +git -C "$REPO" reset --quiet --hard +expect_status "apply runs the requested lockfile update" ok apply +if [ "$(cat "$CARGO_RECORD/calls")" = "update --quiet --manifest-path Cargo.toml --workspace +update --quiet --manifest-path Cargo.toml --package rustls@0.23.42 --precise 0.23.45 +metadata --quiet --locked --manifest-path Cargo.toml --format-version 1" ]; then + pass +else + fail "apply runs exactly the validated Cargo operations: $(cat "$CARGO_RECORD/calls")" +fi +if [ "$(cd "$OUT/candidate/files" && find . -type f | LC_ALL=C sort | tr '\n' ' ')" = "./Cargo.lock ./src/main.rs " ] \ + && jq -e --arg head "$REPO_HEAD" '.number == 7 and .head_sha == $head and .merge == false' \ + "$OUT/candidate/manifest.json" > /dev/null; then + pass +else + fail "apply writes the candidate files and manifest" +fi +if [ -e "$CARGO_RECORD/credential-seen" ]; then + fail "Cargo ran with the model credential" +else + pass +fi +OPENROUTER_API_KEY="$credential" expect_exit "apply refuses to run beside the model credential" 1 apply +git -C "$REPO" reset --quiet --hard +: > "$CARGO_RECORD/calls" +DEPLOY_PROBE="$credential" GH_PROBE="$credential" expect_status "apply runs beside unrelated variables" ok apply +if [ "$(grep -vxE 'PWD|SHLVL|OLDPWD|_|TMPDIR' "$CARGO_RECORD/environment" | tr '\n' ' ')" = "HOME PATH " ]; then + pass +else + fail "Cargo sees a reduced environment: $(tr '\n' ' ' < "$CARGO_RECORD/environment")" +fi + +# Publication age of versions that the repair locks. + +# age_case : a repair that locks fresh 1.2.3. +age_case() { + new_repository + "$engine" prepare "$REPO" "$OUT/plan.json" + agent <<< "echo 'fresh' > .dependency-repair/cargo-update" + generate > /dev/null 2>&1 + git -C "$REPO" reset --quiet --hard + printf 'name = "fresh"\nversion = "1.2.3"\n%s\n' "$registry" > "$CARGO_RECORD/lock-entry" + mkdir "$CARGO_RECORD/index" + jq -cn --arg time "$1" '{name: "fresh", vers: "1.2.2", pubtime: "2020-01-01T00:00:00Z"}, + ({name: "fresh", vers: "1.2.3"} + (if $time == "" then {} else {pubtime: $time} end))' \ + > "$CARGO_RECORD/index/fresh" +} + +age_case "$(date -u -d '10 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" +expect_exit "apply refuses a version published within the hour, as an operational failure" 1 apply +if [ "$(cat "$CARGO_RECORD/index-requests")" = "https://index.crates.io/fr/es/fresh" ]; then + pass +else + fail "apply reads the index entry of exactly the newly locked package" +fi +age_case "$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ)" +expect_status "apply accepts a version published more than an hour ago" ok apply +age_case "" +expect_status "apply accepts an index entry from before publication times were recorded" ok apply +age_case "$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ)" +rm "$CARGO_RECORD/index/fresh" +expect_exit "apply fails when the index is unreadable" 1 apply + +for edit in \ + "echo changed >> README.md" \ + "echo changed >> Cargo.lock" \ + "echo changed >> .github/workflows/ci.yml" \ + "echo 'fn extra() {}' > src/extra.rs" \ + "rm src/main.rs" \ + "ln -sf /etc/hostname src/main.rs" \ + "echo 'rustls; curl example.invalid' > .dependency-repair/cargo-update" \ + "echo 'rustls --config build.rustc-wrapper=x' > .dependency-repair/cargo-update" \ + "printf '[core]\n\tfsmonitor = touch %s/executed\n' \"\$(dirname \"\$0\")\" >> .git/config" \ + "mkdir -p .git/hooks && echo planted > .git/hooks/post-index-change" \ + "echo planted > .gitattributes" \ + "chmod +x src/main.rs" \ + "rm .dependency-repair/cargo-update && ln -s ../Cargo.toml .dependency-repair/cargo-update" \ + "printf '# %s\n' \"\$OPENROUTER_API_KEY\" >> Cargo.toml" \ + "exit 3"; do + new_repository + "$engine" prepare "$REPO" "$OUT/plan.json" + agent <<< "$edit" + expect_status "generate refuses [$edit]" fail generate + if [ -e "$tmp/bin/executed" ]; then + fail "a command planted in Git metadata ran after [$edit]" + fi +done + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +agent <<< "echo changed >> README.md" +expect_exit "a policy violation is a repair that cannot proceed" 3 generate + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +agent <<< "exit 124" +expect_exit "a model run that timed out is an operational failure, not a repair that cannot proceed" 1 generate +if grep -q 'did not finish' "$tmp/output" && [ ! -e "$OUT/generated/files" ]; then + pass +else + fail "a model run that timed out reports the timeout and yields no candidate" +fi + +# A provider that never answers ends at the time limit, like the retries that +# OpenCode makes against an unreachable or failing endpoint. +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +agent <<< "exec sleep 30" +DEPENDENCY_REPAIR_TIMEOUT=1 expect_exit "a model run that reaches the time limit is an operational failure" 1 generate + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +agent <<< "echo '// touched' >> src/main.rs" +mkdir "$OUT/temporary" +TMPDIR="$OUT/temporary" MODEL_KEY=inherited expect_status "generate runs with a temporary directory and an exported internal name" ok generate +if [ "$(cat "$AGENT_RECORD/temporary")" = "$OUT/temporary" ] && [ -z "$(find "$OUT/temporary" -mindepth 1 -print -quit)" ]; then + pass +else + fail "the model run keeps the temporary directory of the caller, and the engine leaves nothing in it" +fi +if [ ! -e "$AGENT_RECORD/internal-variable-inherited" ] && [ -e "$AGENT_RECORD/credential-seen" ]; then + pass +else + fail "a caller that exports the internal credential variable name does not make it inherited" +fi + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +agent <<< "exit 7" +expect_exit "a failed model run is an operational failure" 1 generate + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +agent <<< "echo project > .git/opencode; echo '// touched' >> src/main.rs" +expect_status "generate accepts the project identifier that OpenCode stores" ok generate + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +agent <<< "touch src/main.rs" +expect_status "generate accepts a file that was rewritten without a change" ok generate +if [ -z "$(find "$OUT/generated/files" -type f)" ]; then + pass +else + fail "a file rewritten without a change is not part of the repair" +fi + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +expect_status "generate refuses to run without the credential" fail \ + "$engine" generate "$REPO" "$OUT/plan.json" "$OUT/context.md" "$OUT/generated" + +for name in opencode.json opencode.jsonc .opencode; do + new_repository + "$engine" prepare "$REPO" "$OUT/plan.json" + echo '{}' > "$REPO/$name" + expect_status "generate refuses repository agent configuration $name" fail generate + if [ -e "$AGENT_RECORD/arguments" ]; then + fail "the model ran beside repository agent configuration $name" + else + pass + fi +done + +new_repository +(cd "$REPO" && git checkout --quiet update && echo '{}' > opencode.json && git add -A \ + && git commit --quiet -m configuration) +REPO_HEAD=$(git -C "$REPO" rev-parse update) +repair_plan false +expect_status "prepare refuses a head that carries agent configuration" fail \ + "$engine" prepare "$REPO" "$OUT/plan.json" + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +mkdir "$OUT/home" "$OUT/home/.opencode" +HOME="$OUT/home" expect_status "generate refuses agent configuration in the home directory" fail generate + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> "%s/audit-calls"\n' "$OUT" > "$OUT/audit-cli" +chmod +x "$OUT/audit-cli" +AGENT_AUDIT_LOG_CLI="$OUT/audit-cli" generate > /dev/null 2>&1 +if [ "$(cat "$OUT/audit-calls")" = "dependency-repair generate 1 0 -- generate $REPO $OUT/plan.json $OUT/context.md $OUT/generated" ]; then + pass +else + fail "the invocation is recorded once through the shared audit command: $(cat "$OUT/audit-calls" 2>&1)" +fi + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +echo "0.0.1" > "$AGENT_RECORD/version" +expect_exit "generate refuses another OpenCode release" 1 generate +if [ -e "$AGENT_RECORD/arguments" ]; then + fail "the model ran with another OpenCode release" +else + pass +fi + +# Merge with a lockfile conflict. + +new_repository +repair_plan true +expect_status "prepare merges main" ok "$engine" prepare "$REPO" "$OUT/plan.json" +if [ "$(git -C "$REPO" rev-parse HEAD)" = "$REPO_HEAD" ] \ + && [ "$(git -C "$REPO" show "$REPO_MAIN:Cargo.lock")" = "$(cat "$REPO/Cargo.lock")" ] \ + && [ "$(cat "$(git -C "$REPO" rev-parse --path-format=absolute --git-path dependency-repair)/lock-conflicts")" = "Cargo.lock" ] \ + && [ "$(cat "$REPO/src/main.rs")" = "fn main() { run() }" ]; then + pass +else + fail "a lockfile conflict takes the main lockfile and keeps the main source" +fi +expect_status "generate runs on the merged tree" ok generate +if grep -q 'Lockfiles reset to main' "$AGENT_RECORD/context"; then + pass +else + fail "the model run is told that the lockfile was reset" +fi +git -C "$REPO" reset --quiet --hard +expect_status "prepare repeats the merge in a fresh checkout" ok "$engine" prepare "$REPO" "$OUT/plan.json" +expect_status "apply restores the planned updates after a lockfile conflict" ok apply +if [ "$(cat "$CARGO_RECORD/calls")" = "update --quiet --manifest-path Cargo.toml --workspace +update --quiet --manifest-path Cargo.toml --package clap --precise 4.6.7 +metadata --quiet --locked --manifest-path Cargo.toml --format-version 1" ]; then + pass +else + fail "apply restores well-formed planned updates only: $(cat "$CARGO_RECORD/calls")" +fi +if [ "$(cd "$OUT/candidate/files" && find . -type f | tr '\n' ' ')" = "./Cargo.lock " ] \ + && jq -e --arg main "$REPO_MAIN" '.merge == true and .main_sha == $main' \ + "$OUT/candidate/manifest.json" > /dev/null; then + pass +else + fail "a merge candidate holds only the files that differ from main" +fi + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +agent <<< "echo 'rustls' > .dependency-repair/cargo-update" +generate > /dev/null 2>&1 +touch "$CARGO_RECORD/fail" +expect_status "apply fails when Cargo cannot resolve the lockfile" fail apply + +new_repository +"$engine" prepare "$REPO" "$OUT/plan.json" +mkdir -p "$OUT/generated/files" +printf 'changed\n' > "$OUT/generated/files/README.md" +expect_status "apply refuses generated files outside the policy" fail apply +if [ -s "$CARGO_RECORD/calls" ] || [ "$(cat "$REPO/README.md")" != "# guard" ]; then + fail "apply acted on generated files outside the policy" +else + pass +fi + +# Local candidate. + +new_repository +git -C "$REPO" checkout --quiet update +agent <<< "echo 'rustls@0.23.42 0.23.45' > .dependency-repair/cargo-update" +expect_status "a local candidate runs generation and then Cargo" ok \ + env -C "$REPO" OPENROUTER_API_KEY="$credential" TMPDIR="$OUT" "$engine" candidate "$OUT/context.md" +if [ "$(git -C "$REPO" status --porcelain)" = " M Cargo.lock" ] && [ ! -e "$CARGO_RECORD/credential-seen" ] \ + && [ "$(git -C "$REPO" rev-parse HEAD)" = "$REPO_HEAD" ]; then + pass +else + fail "a local candidate leaves only the lockfile changed, uncommitted, with Cargo credential-free" +fi +expect_status "a local candidate refuses a checkout with uncommitted changes" fail \ + env -C "$REPO" OPENROUTER_API_KEY="$credential" TMPDIR="$OUT" "$engine" candidate "$OUT/context.md" + +new_repository +git -C "$REPO" checkout --quiet update +agent <<< "printf '[core]\n\tfsmonitor = touch %s/executed\n' \"\$(dirname \"\$0\")\" >> .git/config" +expect_exit "a local candidate stops when the model run changed Git metadata" 3 \ + env -C "$REPO" OPENROUTER_API_KEY="$credential" TMPDIR="$OUT" "$engine" candidate "$OUT/context.md" +if [ -e "$tmp/bin/executed" ] || [ -s "$CARGO_RECORD/calls" ]; then + fail "a local candidate ran Git or Cargo after the model changed Git metadata" +else + pass +fi + +# Linked worktree: its Git directory is not /.git. + +new_repository +LINKED="$OUT/linked" +git -C "$REPO" worktree add --quiet --detach "$LINKED" update +if [ -f "$LINKED/.git" ]; then + pass +else + fail "the linked worktree fixture has a .git file" +fi +agent <<< "echo 'rustls@0.23.42 0.23.45' > .dependency-repair/cargo-update; echo 'fn main() { linked() }' > src/main.rs" +expect_status "a local candidate runs in a linked worktree" ok \ + env -C "$LINKED" OPENROUTER_API_KEY="$credential" TMPDIR="$OUT" "$engine" candidate "$OUT/context.md" +if [ "$(git -C "$LINKED" status --porcelain)" = " M Cargo.lock + M src/main.rs" ] && [ -z "$(git -C "$REPO" status --porcelain)" ]; then + pass +else + fail "a linked worktree candidate changes only that worktree: $(git -C "$LINKED" status --porcelain)" +fi +git -C "$LINKED" reset --quiet --hard +repair_plan true +expect_status "prepare merges main in a linked worktree" ok "$engine" prepare "$LINKED" "$OUT/plan.json" +if [ "$(cat "$(git -C "$LINKED" rev-parse --path-format=absolute --git-path dependency-repair)/lock-conflicts")" = "Cargo.lock" ] \ + && [ ! -e "$REPO/.git/dependency-repair" ]; then + pass +else + fail "prepare keeps its state in the Git directory of the linked worktree" +fi +agent <<< ":" +expect_status "generate runs in a linked worktree" ok \ + env OPENROUTER_API_KEY="$credential" "$engine" generate "$LINKED" "$OUT/plan.json" "$OUT/context.md" "$OUT/generated" +git -C "$LINKED" reset --quiet --hard +"$engine" prepare "$LINKED" "$OUT/plan.json" +expect_status "apply runs in a linked worktree" ok \ + "$engine" apply "$LINKED" "$OUT/plan.json" "$OUT/generated" "$OUT/candidate" +if [ "$(cd "$OUT/candidate/files" && find . -type f | tr '\n' ' ')" = "./Cargo.lock " ]; then + pass +else + fail "a linked worktree merge candidate holds only the lockfile" +fi + +# --- Workflow and configuration contract ------------------------------------- + +workflows="$root/.github/workflows" +reconcile="$workflows/dependabot-reconcile.yml" +gate="$workflows/dependabot-automerge.yml" + +contract() { + local name="$1" + shift + if "$@" > /dev/null 2>&1; then + pass + else + fail "contract: $name" + fi +} + +refute() { + local name="$1" + shift + if "$@" > /dev/null 2>&1; then + fail "contract: $name" + else + pass + fi +} + +# job : prints the lines of one job. +job() { + awk -v job=" $2:" ' + /^ [a-z_-]+:$/ { inside = ($0 == job) } + inside { print } + ' "$1" +} + +refute "no workflow uses pull_request_target or workflow_run" \ + grep -rnE '^\s*(pull_request_target|workflow_run):' "$workflows" +refute "the privileged workflow has no pull request trigger" \ + grep -nE '^\s*pull_request' "$reconcile" +contract "the privileged workflow runs on a schedule" grep -qE '^ schedule:$' "$reconcile" +contract "the privileged workflow accepts manual dispatch" grep -qE '^ workflow_dispatch:$' "$reconcile" +contract "the privileged workflow grants nothing by default" grep -qx 'permissions: {}' "$reconcile" +contract "the review gate grants nothing by default" grep -qx 'permissions: {}' "$gate" +refute "the review gate holds no write permission" grep -nE ':\s*write\b' "$gate" +refute "the review gate checks out no pull request ref" \ + grep -nE 'ref:.*(head|pull_request\.(number|head)|refs/pull)' "$gate" +refute "neither workflow interpolates event text into a script" \ + grep -nE 'run:.*\$\{\{|^\s+[^#]*\$\{\{\s*github\.event\.pull_request\.(title|body|head\.ref)' "$reconcile" "$gate" +contract "checkouts do not persist credentials" \ + test "$(grep -c 'persist-credentials: false' "$reconcile")" -eq "$(grep -c 'uses: actions/checkout@' "$reconcile")" +contract "the review gate keeps its required context name" \ + grep -qx ' name: Classify dependency update' "$gate" +contract "the review gate reruns when a review is submitted" grep -qE '^ pull_request_review:$' "$gate" +contract "the review gate accepts dispatch for a repaired head" grep -qE '^ workflow_dispatch:$' "$gate" +contract "the required context list still names the review gate" \ + grep -qx 'Classify dependency update' <(required_contexts) +contract "thirteen required contexts are declared" test "$(required_contexts | wc -l)" -eq 13 + +for name in merge plan publish runs; do + contract "the $name job runs only on main" \ + grep -q "github.ref == 'refs/heads/main'" <(job "$reconcile" "$name") + refute "the $name job checks out no pull request content" \ + grep -nE 'ref:|fetch-depth' <(job "$reconcile" "$name") + refute "the $name job holds no model credential" \ + grep -n 'OPENROUTER_API_KEY' <(job "$reconcile" "$name") + refute "the $name job runs no repair engine or Cargo" \ + grep -nE 'dependency-repair\.sh|cargo ' <(job "$reconcile" "$name") +done +for name in generate apply; do + refute "the $name job holds no write permission" grep -nE ':\s*write\b' <(job "$reconcile" "$name") + refute "the $name job holds no repository token" grep -nE 'GH_TOKEN|github\.token' <(job "$reconcile" "$name") + contract "the $name job runs the default-branch repair engine" \ + grep -q 'trusted/scripts/dependency-repair.sh' <(job "$reconcile" "$name") + refute "the $name job runs no script from the pull request" \ + grep -nE 'run:.*\bwork/|^\s+work/' <(job "$reconcile" "$name") +done +contract "only the generate job names the model credential" \ + test "$(grep -c 'secrets\.' "$reconcile")" -eq 1 +contract "the generate job names the model credential in one step" \ + test "$(grep -c 'OPENROUTER_API_KEY' <(job "$reconcile" generate))" -eq 1 +refute "the apply job holds no model credential" grep -n 'OPENROUTER_API_KEY\|secrets\.' <(job "$reconcile" apply) +refute "the generate job runs no Cargo" grep -nE '\bcargo\b' <(job "$reconcile" generate) +contract "the OpenCode release is pinned by digest" \ + grep -qE '^ OPENCODE_SHA256: [0-9a-f]{64}$' "$reconcile" +contract "the OpenCode digest is verified before use" grep -q 'sha256sum --check' "$reconcile" +contract "write permission is limited to merging and publishing" \ + test "$(grep -cE 'contents: write' "$reconcile")" -eq 2 +contract "only the runs job can dispatch workflows" \ + test "$(grep -c 'actions: write' "$reconcile")" -eq 1 + +contract "CI accepts dispatch" grep -qE '^ workflow_dispatch:$' "$workflows/ci.yml" +contract "the audit accepts dispatch" grep -qE '^ workflow_dispatch:$' "$workflows/audit.yml" +contract "Dependency Review runs for a dispatched repair validation" \ + grep -q "inputs.dependency_review_base != ''" "$workflows/audit.yml" +# shellcheck disable=SC2016 # A literal workflow expression. +contract "Dependency Review compares against the dispatched base" \ + grep -qF 'base-ref: ${{ inputs.dependency_review_base ||' "$workflows/audit.yml" +contract "the Scorecard job does not run for a repair validation" \ + grep -q "github.event_name != 'pull_request' && inputs.dependency_review_base == ''" "$workflows/audit.yml" +refute "validation workflows name no secret" \ + grep -n 'secrets\.' "$workflows/ci.yml" "$workflows/audit.yml" "$gate" +contract "release validation accepts dispatched mainline CI" \ + grep -q 'event == \\"workflow_dispatch\\"' "$workflows/release.yml" +contract "release validation requires the main branch" \ + grep -q 'headBranch == \\"main\\"' "$workflows/release.yml" + +contract "the script writes only blobs, trees, commits, a scratch ref, fast-forward refs, and a branch merge" \ + test "$(grep -cE 'api_write (POST|PATCH|PUT|DELETE)' "$subject")" -eq 7 +contract "the branch merge is the only write outside the Git database" \ + test "$(grep -cE 'api_write (POST|PATCH) "repos/\$\{REPO\}/(git/|merges")' "$subject")" -eq 7 +refute "the script never forces, bypasses, or arms a merge" \ + grep -nE 'force: true|--admin|--auto|--force|api_write (PUT|DELETE)|gh api [^|]*(-X|--method) *(POST|PUT|PATCH)' "$subject" +# shellcheck disable=SC2016 # A literal line of the script. +contract "the only removal is of a scratch branch that the run created" \ + test "$(grep -E '(-X|--method) *DELETE|api_write DELETE' "$subject")" \ + = ' if gh api --method DELETE "repos/${REPO}/git/refs/heads/dependency-refresh/${name}" > /dev/null; then' +contract "every ref update is a fast-forward" \ + test "$(grep -c "'{sha: \$sha, force: false}'" "$subject")" -eq "$(grep -c 'api_write PATCH' "$subject")" +# shellcheck disable=SC2016 # A literal argument of the script. +refute "the branch merge never targets a pull request branch" \ + grep -nE -- '--arg base "\$ref"' "$subject" +contract "the repair commit carries no custom author" \ + grep -q "'{message: \$message, tree: \$tree, parents: \$parents}'" "$subject" +refute "the repair engine uses no repository token" grep -nE '\bgh |GH_TOKEN|GITHUB_TOKEN' "$engine" +refute "the repair engine never puts the credential on a command line" \ + grep -nE '(env|exec|timeout|opencode) [^|<]*"\$\{?OPENROUTER_API_KEY' "$engine" +# shellcheck disable=SC2016 # A literal line of the engine. +contract "the repair engine runs OpenCode by name with its isolation switches" \ + grep -q 'exec timeout "\$TIMEOUT" opencode run --pure ' "$engine" +refute "the repair engine resolves no executable path and sets no home directory" \ + grep -nE 'command -v [^>]*\)|export [^#]*\bHOME=' "$engine" +for name in generate apply publish; do + # shellcheck disable=SC2016 # A literal workflow expression. + contract "the $name job records a repair under the name of its plan" \ + grep -qF 'name: ${{ needs.plan.outputs.attempt }}' <(job "$reconcile" "$name") +done +refute "the model is not selectable" grep -rn 'DEPENDENCY_REPAIR_MODEL' "$engine" "$workflows" +contract "the workflow installs the OpenCode release that the engine requires" \ + grep -qx " OPENCODE_VERSION: \"$pinned_opencode\"" "$reconcile" +refute "the engine runs no Git command on a tree after the model edited it" \ + grep -nE '(^|[ (|;&])git +(-C|[a-z])' <(sed -n '/^# Copies every file that differs from the snapshot/,/^prepare() {/p; /From here on the checkout is model-edited/,/^# Runs the lockfile operations/p; /^apply() {/,/^candidate() {/p' "$engine") +refute "the engine exports the credential only to the model process" \ + grep -nE '^(export )?OPENROUTER_API_KEY=' "$engine" +contract "the engine drops the credential from its environment at entry" \ + grep -qx 'unset OPENROUTER_API_KEY' "$engine" + +dependabot="$root/.github/dependabot.yml" +contract "every update entry keeps its cooldown" \ + test "$(grep -c 'default-days: 7' "$dependabot")" -eq "$(grep -c 'package-ecosystem:' "$dependabot")" +contract "the CodeQL actions are grouped" grep -q '"github/codeql-action\*"' "$dependabot" +contract "transitive cargo dependencies are updated" grep -q 'dependency-type: "all"' "$dependabot" + +echo "${tests} checks, ${failures} failures" +[ "$failures" -eq 0 ] diff --git a/src/mcp.rs b/src/mcp.rs index 674c26b8..8c5ebd6c 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -211,6 +211,10 @@ impl From for GuardToolResponse { } } +#[allow( + clippy::double_must_use, + reason = "async_trait marks boxed futures as must_use" +)] #[async_trait] trait GuardExecutor: Send + Sync { async fn execute(&self, args: GuardToolArgs) -> Result; @@ -220,6 +224,10 @@ trait GuardExecutor: Send + Sync { /// tools surface. These map one-to-one onto existing `AdminRequest` variants; /// they self-scope inside the daemon by caller uid/handle ownership and never /// bypass the gate (no command runs through this path). +#[allow( + clippy::double_must_use, + reason = "async_trait marks boxed futures as must_use" +)] #[async_trait] trait GuardAdmin: Send + Sync { async fn send_admin(&self, request: server::AdminRequest) -> Result; diff --git a/src/proxy/gate.rs b/src/proxy/gate.rs index 9d7dc53e..a6684df6 100644 --- a/src/proxy/gate.rs +++ b/src/proxy/gate.rs @@ -70,6 +70,10 @@ pub enum HoldDecision { /// Implemented by the daemon to arm the proxy's synthesized reverts in its /// consequence machinery. +#[allow( + clippy::double_must_use, + reason = "async_trait marks boxed futures as must_use" +)] #[async_trait] pub trait GateSink: Send + Sync { /// Durably stage an auto-revert before a mutation is sent upstream. @@ -331,6 +335,10 @@ pub struct ApiSessionEvent { pub credential_ref: String, } +#[allow( + clippy::double_must_use, + reason = "async_trait marks boxed futures as must_use" +)] #[async_trait] pub trait ApiSessionSink: Send + Sync { async fn resolve(&self, token: &str) -> Option; @@ -348,6 +356,10 @@ pub trait ApiSessionSink: Send + Sync { /// One-shot proxy-owned upstream handoff. Authority providers can invoke this /// only while their retained lease is in lexical scope; they cannot construct /// or return a stand-in capability. +#[allow( + clippy::double_must_use, + reason = "async_trait marks boxed futures as must_use" +)] #[async_trait] pub trait ApiForwardHandoff: Send { async fn forward(&mut self) -> Result<(), String>; @@ -397,6 +409,10 @@ pub enum ApiCoverageVerdict { }, } +#[allow( + clippy::double_must_use, + reason = "async_trait marks boxed futures as must_use" +)] #[async_trait] pub trait ApiJudge: Send + Sync { /// Whether this resolver can invoke an evaluator. A coverage-only resolver diff --git a/src/secrets/mod.rs b/src/secrets/mod.rs index 6128c6d9..3486d569 100644 --- a/src/secrets/mod.rs +++ b/src/secrets/mod.rs @@ -45,6 +45,10 @@ pub fn legacy_sentinel() -> PrincipalKey { /// Trait for secret storage backends. /// /// Implementors must be safe to share across threads (Send + Sync). +#[allow( + clippy::double_must_use, + reason = "async_trait marks boxed futures as must_use" +)] #[async_trait] pub trait SecretBackend: Send + Sync { /// Returns the backend name for logging/debugging. diff --git a/src/server/execute.rs b/src/server/execute.rs index 63b2996a..2d38f22c 100644 --- a/src/server/execute.rs +++ b/src/server/execute.rs @@ -4338,7 +4338,7 @@ fn bounded_error_detail(error: impl std::fmt::Display) -> String { } fn reserve_bounded_output(total: &AtomicUsize, amount: usize) -> Result<(), BoundedOutputError> { - let reserved = total.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + let reserved = total.try_update(Ordering::AcqRel, Ordering::Acquire, |current| { current .checked_add(amount) .filter(|next| *next <= MAX_OUTPUT_BYTES) @@ -4994,7 +4994,7 @@ async fn forward_stream_chunks( Ok(0) => break, Ok(read) => { stream_bytes = stream_bytes.saturating_add(read); - let reserved = total.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + let reserved = total.try_update(Ordering::AcqRel, Ordering::Acquire, |current| { current .checked_add(read) .filter(|next| *next <= MAX_OUTPUT_BYTES) diff --git a/tests/kube_proxy_integration.rs b/tests/kube_proxy_integration.rs index ace0b92d..b6f45011 100644 --- a/tests/kube_proxy_integration.rs +++ b/tests/kube_proxy_integration.rs @@ -349,7 +349,7 @@ impl ApiSessionSink for BudgetSessionSink { } let admitted = self .remaining_resolutions - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + .try_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { (remaining > 0).then(|| remaining - 1) }) .is_ok();