From 78f82da519bf8f3916d393dcd589b3b02dbd0186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Thu, 8 Oct 2026 22:52:09 +0000 Subject: [PATCH 1/8] ci: merge eligible Dependabot updates from a trusted reconcile workflow Dependabot pull requests had no merge actor, so updates that passed every required check stayed open. A scheduled and manually dispatchable workflow runs the default-branch policy script, classifies each open Dependabot pull request from GitHub API metadata, and squash-merges one only when it is a verified single Dependabot commit confined to one manifest set, every update is a compatible patch or minor version, and every required status check succeeded for the exact head commit. Major updates, 0.x minor updates, Docker updates, and anything unclassified stay open for manual review. The workflow has no pull request trigger and never checks out pull request content. The review gate reports the same classification and succeeds for updates that need manual review, so a reviewed major update is no longer blocked by a required check that cannot pass. Merges made with the Actions token start no push workflow. The reconcile workflow dispatches CI and the security audit for the main head when no run exists for it, and release validation accepts a dispatched CI run on main for the exact release commit. Dependabot groups the CodeQL actions, which fail when updated separately, groups the remaining action updates by update type, and updates transitive Cargo dependencies so an advisory against a lockfile-only crate is resolved by a routine update. The cooldown and the required status check names are unchanged. --- .github/dependabot.yml | 21 + .github/workflows/ci.yml | 8 + .github/workflows/dependabot-automerge.yml | 143 ++---- .github/workflows/dependabot-reconcile.yml | 68 +++ .github/workflows/release.yml | 8 +- scripts/dependabot-automerge.sh | 342 +++++++++++++ scripts/sync-branch-protection.sh | 12 +- scripts/test-dependabot-automerge.sh | 538 +++++++++++++++++++++ 8 files changed, 1019 insertions(+), 121 deletions(-) create mode 100644 .github/workflows/dependabot-reconcile.yml create mode 100755 scripts/dependabot-automerge.sh create mode 100755 scripts/test-dependabot-automerge.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4db0dc77..c114ba70 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,10 @@ updates: interval: "daily" cooldown: default-days: 7 + # Lockfile-only dependencies are updated too, so an advisory against a + # transitive crate is resolved by a routine update. + allow: + - dependency-type: "all" labels: - "dependencies" - "rust" @@ -35,6 +39,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/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..791106a8 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -1,140 +1,51 @@ 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) -# -# 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. +# The job succeeds for updates that need manual review, so a reviewed major +# update can merge. Merging eligible updates is the separate, privileged +# dependabot-reconcile.yml workflow, which repeats the classification itself. -on: +"on": pull_request: types: - opened - reopened - synchronize - workflow_dispatch: - inputs: - pr_number: - description: "Dependabot pull request number to process" - required: true - type: number -permissions: - contents: read +permissions: {} concurrency: - group: dependabot-review-${{ github.event.pull_request.number || inputs.pr_number }} + group: dependabot-review-${{ github.event.pull_request.number }} 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.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, 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 }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} 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 + result=$(scripts/dependabot-automerge.sh classify "$PR_NUMBER") + jq -r '"Decision: \(.decision). Reason: \(.reason)."' <<< "$result" \ + | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/dependabot-reconcile.yml b/.github/workflows/dependabot-reconcile.yml new file mode 100644 index 00000000..6ef9c2d6 --- /dev/null +++ b/.github/workflows/dependabot-reconcile.yml @@ -0,0 +1,68 @@ +name: Dependabot reconcile + +# Merges eligible Dependabot pull requests and keeps mainline workflow runs +# present for the commits those merges create. +# +# Write permission is isolated here. This workflow has no pull request +# trigger, runs only the default-branch copy of scripts/dependabot-automerge.sh, +# and decides from GitHub API metadata without checking out or executing pull +# request content. A pull request merges only when the policy classifies it as +# eligible and every required status check succeeded for its exact head +# commit; the branch ruleset still applies to the merge. +# +# 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 + +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. + 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 + + mainline-runs: + name: Ensure mainline workflow runs + needs: merge + if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write # Lists workflow runs and dispatches missing mainline runs. + contents: 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 missing mainline runs + 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/scripts/dependabot-automerge.sh b/scripts/dependabot-automerge.sh new file mode 100755 index 00000000..1cd06485 --- /dev/null +++ b/scripts/dependabot-automerge.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Classifies 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 reconcile +# 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. +# +# 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. +# +# classify prints one JSON object with a decision of "eligible" or "manual". +# A pull request is eligible only when all of the following hold: +# - it is open, not a draft, authored by dependabot[bot], targets main, and +# its head branch lives in this repository under dependabot/; +# - it consists of exactly one commit, authored by dependabot[bot], committed +# by GitHub, with a verified signature; +# - every changed file is a modification inside one eligible manifest set +# (root Cargo manifests, fuzz Cargo manifests, or workflow files) that +# matches the ecosystem named by the head branch; +# - the commit's update metadata lists at least one dependency and every +# entry is a patch or minor update that Cargo treats as compatible. Major +# updates, 0.x minor updates, 0.0.x patch updates, and entries without a +# recognized update type or version require manual review. +# +# reconcile merges an eligible pull request only when every required status +# check declared in scripts/sync-branch-protection.sh succeeded for the exact +# head commit. The merge is bound to that commit, and the branch ruleset still +# applies to it. +# +# ensure-mainline-runs dispatches the push-triggered mainline workflows for the +# current main commit when no run exists for it. Merges made with the Actions +# token do not start push workflows, and workflow_dispatch is the supported +# event that the same token can raise. + +BOT_LOGIN="dependabot[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) + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +usage() { + echo "usage: $0 classify | reconcile | ensure-mainline-runs" >&2 + 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) +trap 'rm -rf "$work_dir"' EXIT + +api() { + gh api --method GET "$@" +} + +api_pages() { + gh api --method GET --paginate "$@" +} + +report() { + echo "$1" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo "- $1" >> "$GITHUB_STEP_SUMMARY" + fi +} + +# Prints {decision, reason, number, head_sha, mergeable_state}. Returns +# nonzero only when metadata is unreadable. +classify() { + local number="$1" + [[ "$number" =~ ^[1-9][0-9]*$ ]] || { + echo "error: pull request number is malformed" >&2 + return 2 + } + + api "repos/${REPO}/pulls/${number}" > "$work_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 + }]' > "$work_dir/commits.json" || return 2 + api_pages "repos/${REPO}/pulls/${number}/files?per_page=100" \ + | jq -s '[.[][] | {filename, status}]' > "$work_dir/files.json" || return 2 + + jq -n \ + --argjson number "$number" \ + --arg repo "$REPO" \ + --arg bot "$BOT_LOGIN" \ + --arg branch "$BRANCH" \ + --slurpfile pull "$work_dir/pull.json" \ + --slurpfile commits "$work_dir/commits.json" \ + --slurpfile files "$work_dir/files.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" then + (if $version | test("^0\\.0\\.") then "breaking" else "compatible" end) + elif $type == "version-update:semver-minor" then + (if $version | test("^0\\.") then "breaking" else "compatible" end) + elif $type == "version-update:semver-major" then "breaking" + 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 branch_prefix: + if . == "github-actions" then "dependabot/github_actions/" else "dependabot/cargo/" end; + + def decide: + $pull[0] as $p + | $commits[0] as $c + | $files[0] as $f + | 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) != 1 or $p.commits != 1 or $c[0].sha != $p.head.sha then + "the pull request is not a single Dependabot commit" + elif $c[0].author != $bot or $c[0].committer != "web-flow" or $c[0].verified != true then + "the head commit is not a verified Dependabot commit" + elif ($f | length) == 0 or ($f | length) != $p.changed_files then + "the changed file list is empty or incomplete" + elif any($f[]; .status != "modified") then + "the update adds, removes, or renames files" + else + ($f | map(.filename | file_class) | unique) as $classes + | if ($classes | length) != 1 or $classes[0] == "other" then + "the changed files are outside one eligible manifest set" + elif $p.head.ref | startswith($classes[0] | branch_prefix) | not then + "the changed files do not match the head branch ecosystem" + else + (($c[0].message // "") | metadata) as $items + | if $items == null then + "the update metadata is missing or malformed" + else + ($items | map(level)) as $levels + | if any($levels[]; . == "unclassified") then + "an update has no recognized type or version" + elif any($levels[]; . == "breaking") then + "the update includes a major or otherwise incompatible version" + else null + end + end + end + end; + + decide as $reason + | { + decision: (if $reason == null then "eligible" else "manual" end), + reason: ($reason // "every update is a compatible patch or minor version"), + number: $number, + head_sha: ($pull[0].head.sha // ""), + mergeable_state: ($pull[0].mergeable_state // "unknown") + } + | if (.decision == "eligible") and (.head_sha | test("^[0-9a-f]{40}$") | not) + then error("head commit is malformed") + else . + end + ' || return 2 +} + +# Succeeds when the newest GitHub Actions check run for every required context +# completed successfully on the commit. Prints the first unmet context. +required_checks_succeeded() { + local sha="$1" context + local -a contexts + mapfile -t contexts < <("$script_dir/sync-branch-protection.sh" --list) + [ "${#contexts[@]}" -gt 0 ] || return 2 + + api_pages "repos/${REPO}/commits/${sha}/check-runs?filter=latest&per_page=100" \ + | jq -s '[.[].check_runs[]]' > "$work_dir/check-runs.json" || return 2 + + for context in "${contexts[@]}"; do + if ! jq -e --arg sha "$sha" --arg context "$context" --argjson app_id "$APP_ID" ' + [.[] | select(.head_sha == $sha and .name == $context and .app.id == $app_id)] + | sort_by(.started_at // "", .id) + | last + | . != null and .status == "completed" and .conclusion == "success" + ' "$work_dir/check-runs.json" > /dev/null; then + echo "$context" + return 1 + fi + done +} + +reconcile() { + local numbers number result decision reason sha state unmet status failures=0 + + numbers=$(api_pages "repos/${REPO}/pulls?state=open&base=${BRANCH}&per_page=100" \ + | jq -s -r --arg bot "$BOT_LOGIN" '.[][] | select(.user.login == $bot) | .number') + + 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") + reason=$(jq -r '.reason' <<< "$result") + sha=$(jq -r '.head_sha' <<< "$result") + state=$(jq -r '.mergeable_state' <<< "$result") + + if [ "$decision" != "eligible" ]; then + report "#${number}: manual review, ${reason}" + continue + fi + + status=0 + unmet=$(required_checks_succeeded "$sha") || status=$? + if [ "$status" -eq 1 ]; then + report "#${number}: waiting, required check has not succeeded: ${unmet}" + continue + elif [ "$status" -ne 0 ]; then + report "#${number}: error, required checks are unreadable" + failures=$((failures + 1)) + continue + fi + + case "$state" in + clean|unstable|has_hooks) ;; + *) + report "#${number}: waiting, mergeable state is ${state}" + continue + ;; + esac + + if gh pr merge "$number" --repo "$REPO" --squash --match-head-commit "$sha"; then + report "#${number}: merged at ${sha}" + else + report "#${number}: error, merge was refused at ${sha}" + failures=$((failures + 1)) + fi + done + + [ "$failures" -eq 0 ] +} + +main_head() { + api "repos/${REPO}/git/ref/heads/${BRANCH}" \ + | jq -er '.object.sha | strings | select(test("^[0-9a-f]{40}$"))' +} + +ensure_mainline_runs() { + local head workflow count failures=0 + + head=$(main_head) || { + echo "error: the ${BRANCH} head commit is unreadable" >&2 + return 2 + } + + for workflow in "${MAINLINE_WORKFLOWS[@]}"; do + if ! count=$(api "repos/${REPO}/actions/workflows/${workflow}/runs?head_sha=${head}&per_page=100" \ + | jq -e --arg head "$head" --arg branch "$BRANCH" ' + [.workflow_runs[] | select(.head_sha == $head and .head_branch == $branch)] | length + '); then + report "${workflow}: error, runs are unreadable for ${head}" + failures=$((failures + 1)) + continue + fi + if [ "$count" -gt 0 ]; then + report "${workflow}: a run exists for ${head}" + continue + fi + if [ "$(main_head)" != "$head" ]; then + report "${workflow}: ${BRANCH} moved past ${head}; the next reconcile covers the new head" + continue + fi + if gh workflow run "$workflow" --repo "$REPO" --ref "$BRANCH"; then + report "${workflow}: dispatched for ${head}" + else + report "${workflow}: error, dispatch failed for ${head}" + failures=$((failures + 1)) + fi + done + + [ "$failures" -eq 0 ] +} + +case "${1:-}" in + classify) + [ "$#" -eq 2 ] || usage + classify "$2" + ;; + reconcile) + [ "$#" -eq 1 ] || usage + reconcile + ;; + ensure-mainline-runs) + [ "$#" -eq 1 ] || usage + ensure_mainline_runs + ;; + *) 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..bd5ad4b5 --- /dev/null +++ b/scripts/test-dependabot-automerge.sh @@ -0,0 +1,538 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Exercises scripts/dependabot-automerge.sh against a stub gh CLI, and checks +# the trust boundaries of the workflows that run it. No network access or +# token is used: the 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" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +export GITHUB_REPOSITORY="example/guard" +unset GITHUB_STEP_SUMMARY GH_TOKEN GITHUB_TOKEN || true +bot="dependabot[bot]" +failures=0 +tests=0 + +mkdir "$tmp/bin" +cat > "$tmp/bin/gh" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +if [ "$1" != "api" ]; then + printf '%s\n' "$*" >> "$FIXTURES/writes" + [ ! -e "$FIXTURES/refuse-writes" ] + exit +fi +shift +path="" +method="GET" +while [ "$#" -gt 0 ]; do + case "$1" in + --method) method="$2"; shift ;; + --paginate) ;; + -*) echo "stub gh: unsupported option $1" >&2; exit 1 ;; + *) path="$1" ;; + esac + shift +done +if [ "$method" != "GET" ]; then + printf 'api %s %s\n' "$method" "$path" >> "$FIXTURES/writes" + exit 1 +fi +file="$FIXTURES/$(printf '%s' "$path" | tr -c 'A-Za-z0-9\n' '_')" +if [ ! -f "$file" ]; then + echo "stub gh: no fixture for $path" >&2 + exit 1 +fi +cat "$file" +STUB +chmod +x "$tmp/bin/gh" +export PATH="$tmp/bin:$PATH" + +fixture() { + cat > "$FIXTURES/$(printf '%s' "$1" | tr -c 'A-Za-z0-9\n' '_')" +} + +new_case() { + FIXTURES=$(mktemp -d "$tmp/case.XXXXXX") + export FIXTURES + : > "$FIXTURES/writes" +} + +sha_for() { + printf '%s' "$1" | sha1sum | cut -d ' ' -f 1 +} + +# 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" +} + +required_contexts() { + "$root/scripts/sync-branch-protection.sh" --list +} + +# Writes fixtures for one pull request. Arguments: number, head ref, commit +# message, then changed files. Overrides come from the P_* variables. +pull() { + local number="$1" ref="$2" text="$3" sha + shift 3 + sha=$(sha_for "$number") + + 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}" --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 "repos/$GITHUB_REPOSITORY/pulls/$number" + + jq -n \ + --arg sha "${P_COMMIT_SHA:-$sha}" --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; $count) | { + sha: $sha, author: {login: $author}, committer: {login: $committer}, + commit: {message: $message, verification: {verified: $verified}} + }]' | fixture "repos/$GITHUB_REPOSITORY/pulls/$number/commits?per_page=100" + + printf '%s\n' "$@" \ + | jq -R --arg status "${P_FILE_STATUS:-modified}" '{filename: ., status: $status, patch: "ignored"}' \ + | jq -s . | fixture "repos/$GITHUB_REPOSITORY/pulls/$number/files?per_page=100" +} + +# Writes check runs for a pull request head. Arguments: number, then +# name=conclusion overrides ("name=" removes the run, "name=pending" leaves it +# in progress, "name=foreign" reports success from another app). +checks() { + local number="$1" sha context override conclusion + shift + sha=$(sha_for "$number") + 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" '{ + id: 1, name: $name, head_sha: $sha, started_at: "2026-01-01T00:00:00Z", + 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: .}' \ + | fixture "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?filter=latest&per_page=100" +} + +open_pulls() { + local number + for number in "$@"; do + cat "$FIXTURES/$(printf '%s' "repos/$GITHUB_REPOSITORY/pulls/$number" | tr -c 'A-Za-z0-9\n' '_')" + done | jq -s . | fixture "repos/$GITHUB_REPOSITORY/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 +} + +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 dirs:7.0.0:major)" Cargo.toml Cargo.lock +expect_decision "major update" manual 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" manual 1 + +new_case +pull 1 "$cargo_ref" "$(message base64:0.23.1:minor)" Cargo.toml Cargo.lock +expect_decision "0.x minor update" manual 1 + +new_case +pull 1 "$cargo_ref" "$(message tiny:0.0.4:patch)" Cargo.lock +expect_decision "0.0.x patch update" manual 1 + +new_case +pull 1 "$actions_ref" "$(message actions/checkout:8:major)" .github/workflows/ci.yml +expect_decision "major action update" manual 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:)" Cargo.lock +expect_decision "missing update type" manual 1 + +new_case +pull 1 "$cargo_ref" "$(message clap::patch)" Cargo.lock +expect_decision "missing version" manual 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:5.0.0-rc.1:minor)" Cargo.lock +expect_decision "prerelease version" manual 1 + +new_case +pull 1 "$cargo_ref" "chore(deps): bump clap" Cargo.lock +expect_decision "missing metadata" manual 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" manual 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" manual 1 + +new_case +pull 1 "dependabot/docker/.clusterfuzzlite/base-1.2.3" "$(message base:1.2.3:patch)" .clusterfuzzlite/Dockerfile +expect_decision "docker update" manual 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock src/main.rs +expect_decision "source file change" manual 1 + +new_case +pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock fuzz/Cargo.lock +expect_decision "mixed manifest sets" manual 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" manual 1 + +new_case +pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/actions/setup/action.yml +expect_decision "non-workflow file" manual 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" manual 1 + +new_case +P_CHANGED=3 pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock +expect_decision "incomplete file list" manual 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 on the branch" 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 + +# --- 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 +eligible_pull 1 +checks 1 +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 +open_pulls 1 2 3 4 +"$subject" reconcile > /dev/null || fail "backfill: reconcile failed" +expect_writes "backfill merges only eligible Dependabot pull requests" \ + "$(merge_call 1; echo; merge_call 4)" + +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" +if "$subject" reconcile > /dev/null 2>&1; then + fail "refused merge: reconcile succeeded" +else + pass +fi + +new_case +open_pulls +"$subject" reconcile > /dev/null || fail "no pull requests: reconcile failed" +expect_writes "no pull requests" "" + +# --- Mainline runs ----------------------------------------------------------- + +head=$(sha_for main) +runs_path() { + printf 'repos/%s/actions/workflows/%s/runs?head_sha=%s&per_page=100' \ + "$GITHUB_REPOSITORY" "$1" "$head" +} +main_ref() { + jq -n --arg sha "$1" '{object: {sha: $sha}}' \ + | fixture "repos/$GITHUB_REPOSITORY/git/ref/heads/main" +} +runs() { + jq -n --arg sha "$head" --arg branch "$2" --argjson count "$3" '{ + workflow_runs: [range(0; $count) | {head_sha: $sha, head_branch: $branch, event: "push"}] + }' | fixture "$(runs_path "$1")" +} + +new_case +main_ref "$head" +runs ci.yml main 0 +runs audit.yml main 0 +"$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 +main_ref "$head" +runs ci.yml main 1 +runs audit.yml main 2 +"$subject" ensure-mainline-runs > /dev/null || fail "existing runs: command failed" +expect_writes "existing mainline runs are not repeated" "" + +new_case +main_ref "$head" +runs ci.yml feature 1 +runs audit.yml 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" "" + +# --- 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 +} + +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 "neither workflow checks out a pull request ref" \ + grep -nE 'ref:.*(head|pull_request\.(number|head)|refs/pull)' "$reconcile" "$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 "the privileged jobs run only on main" \ + test "$(grep -c "github.ref == 'refs/heads/main'" "$reconcile")" -eq 2 +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 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 +contract "CI accepts dispatch for mainline runs" grep -qE '^ workflow_dispatch:$' "$workflows/ci.yml" +contract "the audit accepts dispatch for mainline runs" grep -qE '^ workflow_dispatch:$' "$workflows/audit.yml" +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" +refute "the script makes no write request through the raw API" \ + grep -nE 'gh api .*(-X|--method) *(POST|PUT|PATCH|DELETE)|--admin|--auto' "$subject" + +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 ] From 2345725dc2a945012a553d2a2e1beba0bdcce6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Thu, 8 Oct 2026 22:53:18 +0000 Subject: [PATCH 2/8] Fix Rust 1.99 warning failures --- src/mcp.rs | 8 ++++++++ src/proxy/gate.rs | 16 ++++++++++++++++ src/secrets/mod.rs | 4 ++++ src/server/execute.rs | 4 ++-- tests/kube_proxy_integration.rs | 2 +- 5 files changed, 31 insertions(+), 3 deletions(-) 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(); From af1667ba93fe3c1dd707678ea55e528645fda1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Fri, 9 Oct 2026 00:04:04 +0000 Subject: [PATCH 3/8] ci: repair failing Dependabot updates and verify merges independently A Cargo update whose required checks fail, or whose branch conflicts with main, had no path to a merge. The reconcile workflow now plans one repair per run and carries it through three separated jobs. A generate job runs OpenCode with the model credential; the model reads the failure logs and edits Cargo manifests and Rust source, but cannot run commands, so no dependency code runs beside the credential. An apply job without the credential runs the requested `cargo update` operations and verifies that the lockfile resolves. A publish job commits the validated files to the pull request branch through the Git database API, which GitHub signs, and advances the branch only as a fast-forward once the signature is confirmed. A conflict is resolved by merging main into the branch, never by a rebase or a forced update. The same repair runs locally with `scripts/dependency-repair.sh candidate`. A branch update made with the Actions token leaves pull request workflows waiting for approval, so a repaired head gets its required checks by dispatch. The review gate and Dependency Review accept that dispatch, and it is refused unless the branch workflows equal the main workflows. The classifier accepts verified repair commits by github-actions[bot] on top of the Dependabot commit. A repair confined to the lockfile keeps the update routine; one that changes a manifest or source needs an approving review. Cargo patch and minor updates, including 0.x versions, and GitHub Actions updates pinned to a full commit SHA are routine again. The review gate fails for a major or unclassified update until a repository owner, member, or collaborator approves the exact head commit, and reruns when a review is submitted. Before a merge, the reconcile workflow now compares the effective branch rules with the declared required checks and fails closed on a rule it does not verify, requires every review thread to be resolved, and confirms that the pull request metadata and main are unchanged. It merges one update per run, holds merges until the required checks completed for the current main commit, and confirms that each dispatched run exists for the intended commit. Workflow updates are not repaired: the Actions token cannot write workflow files. The Dependabot configuration comment no longer states that updating transitive dependencies resolves an advisory. --- .github/dependabot.yml | 3 +- .github/workflows/audit.yml | 19 +- .github/workflows/dependabot-automerge.yml | 39 +- .github/workflows/dependabot-reconcile.yml | 244 +++- DEVELOPMENT.md | 38 + scripts/dependabot-automerge.sh | 922 +++++++++++-- scripts/dependency-repair.sh | 522 +++++++ scripts/test-dependabot-automerge.sh | 1448 ++++++++++++++++++-- 8 files changed, 2990 insertions(+), 245 deletions(-) create mode 100755 scripts/dependency-repair.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c114ba70..ffd9f48f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,8 +6,7 @@ updates: interval: "daily" cooldown: default-days: 7 - # Lockfile-only dependencies are updated too, so an advisory against a - # transitive crate is resolved by a routine update. + # Lockfile-only dependencies are updated too. allow: - dependency-type: "all" labels: 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/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 791106a8..de743c3a 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -6,9 +6,15 @@ name: Dependabot review gate # metadata only; it does not execute pull request code, post comments, approve # reviews, or merge changes. # -# The job succeeds for updates that need manual review, so a reviewed major -# update can merge. Merging eligible updates is the separate, privileged -# dependabot-reconcile.yml workflow, which repeats the classification itself. +# 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. +# +# Merging is the separate, privileged dependabot-reconcile.yml workflow, which +# repeats the classification itself. That workflow dispatches this one on a +# repaired branch, because a branch update made with the Actions token leaves +# pull request workflows waiting for approval. "on": pull_request: @@ -16,22 +22,34 @@ name: Dependabot review gate - opened - reopened - synchronize + pull_request_review: + types: + - submitted + - dismissed + workflow_dispatch: + inputs: + pull_request: + description: Number of the Dependabot pull request whose head is the dispatched commit. + required: true + type: string permissions: {} concurrency: - group: dependabot-review-${{ github.event.pull_request.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.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 pull request, commit, and changed-file metadata. + pull-requests: read # Reads pull request, commit, review, and changed-file metadata. steps: - name: Check out the default-branch policy uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -43,9 +61,6 @@ jobs: - name: Classify pull request env: GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - result=$(scripts/dependabot-automerge.sh classify "$PR_NUMBER") - jq -r '"Decision: \(.decision). Reason: \(.reason)."' <<< "$result" \ - | tee -a "$GITHUB_STEP_SUMMARY" + 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 index 6ef9c2d6..2122948a 100644 --- a/.github/workflows/dependabot-reconcile.yml +++ b/.github/workflows/dependabot-reconcile.yml @@ -1,14 +1,24 @@ name: Dependabot reconcile -# Merges eligible Dependabot pull requests and keeps mainline workflow runs -# present for the commits those merges create. +# Merges eligible Dependabot pull requests, repairs Cargo updates whose +# required checks fail or whose branch conflicts with main, and keeps workflow +# runs present for the commits this automation creates. # -# Write permission is isolated here. This workflow has no pull request -# trigger, runs only the default-branch copy of scripts/dependabot-automerge.sh, -# and decides from GitHub API metadata without checking out or executing pull -# request content. A pull request merges only when the policy classifies it as -# eligible and every required status check succeeded for its exact head -# commit; the branch ruleset still applies to the merge. +# 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 head gets its required +# checks by dispatch, and merges like any other update once they pass. # # The schedule covers every open Dependabot pull request, including ones that # were opened before this workflow existed. @@ -24,6 +34,11 @@ 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 @@ -46,15 +61,21 @@ jobs: GH_TOKEN: ${{ github.token }} run: scripts/dependabot-automerge.sh reconcile - mainline-runs: - name: Ensure mainline workflow runs + plan: + name: Plan a dependency repair needs: merge if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: - actions: write # Lists workflow runs and dispatches missing mainline runs. + 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 @@ -62,7 +83,208 @@ jobs: 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]{40}-[0-9a-f]{40}$"))' "$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 + run: trusted/scripts/dependency-repair.sh prepare work "$RUNNER_TEMP/plan/plan.json" + + # 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. + - name: Generate the repair + env: + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + DEPENDENCY_REPAIR_MODEL: ${{ vars.DEPENDENCY_REPAIR_MODEL || 'openrouter/anthropic/claude-sonnet-5.5' }} + run: >- + trusted/scripts/dependency-repair.sh generate work + "$RUNNER_TEMP/plan/plan.json" "$RUNNER_TEMP/plan/context.md" + "$RUNNER_TEMP/generated" + + - 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 + run: | + set -euo pipefail + 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" + + - 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=$? + if [ "$status" -eq 3 ]; then + echo "no_progress=true" >> "$GITHUB_OUTPUT" + fi + exit "$status" + + # The planner skips this pull request head and main commit while the + # record exists. A failed model run or job leaves no record, so it is + # tried again on the next run. + - name: Record a repair without progress + if: ${{ !cancelled() && steps.publish.outputs.no_progress == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ needs.plan.outputs.attempt }} + path: ${{ runner.temp }}/candidate/manifest.json + retention-days: 1 + + 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 and dispatches missing 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 validation for repaired pull requests + env: + GH_TOKEN: ${{ github.token }} + run: scripts/dependabot-automerge.sh ensure-validation-runs + - name: Dispatch missing mainline runs + if: ${{ !cancelled() }} env: GH_TOKEN: ${{ github.token }} run: scripts/dependabot-automerge.sh ensure-mainline-runs diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9cbf7dd1..1bb27e44 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -11,11 +11,49 @@ 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. +- One update merges per run. Further merges wait until the required checks + completed for the new `main` commit. + +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. A repair confined to the lockfile +keeps the update routine. A repair that changes a manifest or source needs an +approving review before it merges. Workflow updates are not repaired. + +The same repair runs locally against the current checkout 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` and reaches only the +model process. The repository variable `DEPENDENCY_REPAIR_MODEL` selects the +model in CI. + ## Building ```bash diff --git a/scripts/dependabot-automerge.sh b/scripts/dependabot-automerge.sh index 1cd06485..b1e82225 100755 --- a/scripts/dependabot-automerge.sh +++ b/scripts/dependabot-automerge.sh @@ -1,57 +1,119 @@ #!/usr/bin/env bash set -euo pipefail -# Classifies and merges Dependabot pull requests, and keeps mainline workflow -# evidence present for commits that the Actions token merged. +# 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. +# 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 with a decision of "eligible" or "manual". -# A pull request is eligible only when all of the following hold: -# - it is open, not a draft, authored by dependabot[bot], targets main, and -# its head branch lives in this repository under dependabot/; -# - it consists of exactly one commit, authored by dependabot[bot], committed -# by GitHub, with a verified signature; -# - every changed file is a modification inside one eligible manifest set -# (root Cargo manifests, fuzz Cargo manifests, or workflow files) that -# matches the ecosystem named by the head branch; -# - the commit's update metadata lists at least one dependency and every -# entry is a patch or minor update that Cargo treats as compatible. Major -# updates, 0.x minor updates, 0.0.x patch updates, and entries without a -# recognized update type or version require manual review. +# 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. # -# reconcile merges an eligible pull request only when every required status -# check declared in scripts/sync-branch-protection.sh succeeded for the exact -# head commit. The merge is bound to that commit, and the branch ruleset still -# applies to it. +# 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 only when the repair is confined to the +# lockfile: every other changed file carries exactly the lines the Dependabot +# commit changed. A repair that changes a manifest or Rust source needs review. +# +# 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, every required +# check succeeded for the exact head commit, every review thread is resolved, +# and the classification is unchanged immediately before the merge. It holds +# all merges until the required mainline checks completed for the current main +# commit. While main fails those checks, only an update whose checks started +# after that commit can merge. +# +# 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. It +# plans at most three repair commits per pull request, and skips a pull +# request head and main commit whose repair made no progress. That record is a +# workflow artifact that expires after a day; a failed model run or a failed +# job 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 signature +# is confirmed, as a fast-forward. It exits with status 3 when the candidate +# made no progress: it changes nothing, violates the policy, or was refused. +# +# ensure-validation-runs dispatches the required workflows on a repaired head. +# 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. # # ensure-mainline-runs dispatches the push-triggered mainline workflows for the -# current main commit when no run exists for it. Merges made with the Actions -# token do not start push workflows, and workflow_dispatch is the supported -# event that the same token can raise. +# current main commit when no run exists for it. 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) +# Required contexts that report only for a pull request. +PULL_REQUEST_CONTEXTS=("Classify dependency update" "Dependency Review") +GATE_CONTEXT="Classify dependency update" +MAX_REPAIRS=3 +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() { - echo "usage: $0 classify | reconcile | ensure-mainline-runs" >&2 + cat >&2 < + $0 gate + $0 reconcile + $0 repair-plan + $0 publish-repair + $0 ensure-validation-runs + $0 ensure-mainline-runs +EOF exit 64 } @@ -72,6 +134,11 @@ 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 @@ -79,35 +146,87 @@ report() { fi } -# Prints {decision, reason, number, head_sha, mergeable_state}. Returns -# nonzero only when metadata is unreadable. +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" + local number="$1" dir first 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}" > "$work_dir/pull.json" || return 2 + 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 - }]' > "$work_dir/commits.json" || return 2 + 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}]' > "$work_dir/files.json" || return 2 + | 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 + + # A repaired pull request needs the Dependabot commit's own changes, and + # proof that every merged commit is part of main. + echo '[]' > "$dir/original.json" + repairs=$(jq '[.[1:][]] | length' "$dir/commits.json") + if [ "$repairs" -gt 0 ]; then + first=$(jq -r '.[0].sha // ""' "$dir/commits.json") + is_sha "$first" || return 2 + api "repos/${REPO}/commits/${first}" \ + | jq '[.files[]? | {filename, patch: (.patch // null)}]' > "$dir/original.json" || return 2 + 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" \ - --slurpfile pull "$work_dir/pull.json" \ - --slurpfile commits "$work_dir/commits.json" \ - --slurpfile files "$work_dir/files.json" ' + --arg fingerprint "$fingerprint" \ + --argjson merges_ok "$merges_ok" \ + --slurpfile pull "$dir/pull.json" \ + --slurpfile commits "$dir/commits.json" \ + --slurpfile files "$dir/files.json" \ + --slurpfile original "$dir/original.json" \ + --slurpfile reviews "$dir/reviews.json" ' def metadata: split("\n") as $lines | [range(0; $lines | length) @@ -133,11 +252,9 @@ classify() { (."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" then - (if $version | test("^0\\.0\\.") then "breaking" else "compatible" end) - elif $type == "version-update:semver-minor" then - (if $version | test("^0\\.") then "breaking" else "compatible" end) - elif $type == "version-update:semver-major" then "breaking" + 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; @@ -148,13 +265,32 @@ classify() { else "other" end; + def is_lockfile: test("^(fuzz/)?Cargo\\.lock$"); + + 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 decide: + 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 - | $files[0] as $f | 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 @@ -163,80 +299,288 @@ classify() { "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) != 1 or $p.commits != 1 or $c[0].sha != $p.head.sha then - "the pull request is not a single Dependabot commit" + elif ($c | length) == 0 or ($c | length) > 8 or $p.commits != ($c | length) + or $c[-1].sha != $p.head.sha then + "the commit list is empty, too long, 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 head commit is not a verified Dependabot commit" - elif ($f | length) == 0 or ($f | length) != $p.changed_files then - "the changed file list is empty or incomplete" + "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 - "the update adds, removes, or renames files" + {class: "other", reason: "the update adds, removes, or renames files"} else - ($f | map(.filename | file_class) | unique) as $classes - | if ($classes | length) != 1 or $classes[0] == "other" then - "the changed files are outside one eligible manifest set" - elif $p.head.ref | startswith($classes[0] | branch_prefix) | not then - "the changed files do not match the head branch ecosystem" + ($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 - "the update metadata is missing or malformed" + {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"} + elif $repaired and (($outside | length) > 0 or any($f[]; + (.filename | is_lockfile | not) + and (. as $file + | ($original[0] | map(select(.filename == $file.filename)) | first) as $before + | $before == null or $before.patch == null or $file.patch == null + or ($before.patch | changed_lines) != ($file.patch | changed_lines)))) then + {class: $class, reason: "the repair changes a manifest or source file"} else - ($items | map(level)) as $levels - | if any($levels[]; . == "unclassified") then - "an update has no recognized type or version" - elif any($levels[]; . == "breaking") then - "the update includes a major or otherwise incompatible version" - else null - end + {class: $class, reason: null} end end end; - decide as $reason + 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 $reason == null then "eligible" else "manual" end), - reason: ($reason // "every update is a compatible patch or minor version"), + 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 is confined to the lockfile" + else "the update is routine" end)), + approved: approved, number: $number, + ecosystem: $policy.class, head_sha: ($pull[0].head.sha // ""), - mergeable_state: ($pull[0].mergeable_state // "unknown") + head_ref: ($pull[0].head.ref // ""), + 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 == "eligible") and (.head_sha | test("^[0-9a-f]{40}$") | not) + | if (.decision != "manual") and (.head_sha | test("^[0-9a-f]{40}$") | not) then error("head commit is malformed") else . end ' || return 2 } -# Succeeds when the newest GitHub Actions check run for every required context -# completed successfully on the commit. Prints the first unmet context. -required_checks_succeeded() { - local sha="$1" context +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 < <("$script_dir/sync-branch-protection.sh" --list) + mapfile -t contexts < <(declared_contexts) [ "${#contexts[@]}" -gt 0 ] || return 2 + printf '%s\n' "${contexts[@]}" +} - api_pages "repos/${REPO}/commits/${sha}/check-runs?filter=latest&per_page=100" \ - | jq -s '[.[].check_runs[]]' > "$work_dir/check-runs.json" || return 2 - - for context in "${contexts[@]}"; do - if ! jq -e --arg sha "$sha" --arg context "$context" --argjson app_id "$APP_ID" ' - [.[] | select(.head_sha == $sha and .name == $context and .app.id == $app_id)] - | sort_by(.started_at // "", .id) - | last - | . != null and .status == "completed" and .conclusion == "success" - ' "$work_dir/check-runs.json" > /dev/null; then - echo "$context" - return 1 - fi +mainline_contexts() { + local context skip exempt + required_contexts | while IFS= read -r context; do + skip=0 + for exempt in "${PULL_REQUEST_CONTEXTS[@]}"; do + [ "$context" = "$exempt" ] && skip=1 + done + [ "$skip" -eq 1 ] || printf '%s\n' "$context" done } +main_head() { + api "repos/${REPO}/git/ref/heads/${BRANCH}" \ + | jq -er '.object.sha | strings | select(test("^[0-9a-f]{40}$"))' +} + +# Prints validated, pending, or failed for the required mainline checks of a +# main commit. +mainline_state() { + local sha="$1" states + local -a contexts + mapfile -t contexts < <(mainline_contexts) + [ "${#contexts[@]}" -gt 0 ] || return 2 + read_check_runs "$sha" "$work_dir/main-runs.json" || return 2 + states=$(context_states "$work_dir/main-runs.json" "${contexts[@]}") || return 2 + if grep -qE '^(missing|pending)'$'\t' <<< "$states"; then + echo pending + elif grep -q '^failed'$'\t' <<< "$states"; then + echo failed + else + echo validated + fi +} + +# 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' +} + reconcile() { - local numbers number result decision reason sha state unmet status failures=0 + local numbers number result decision approved reason sha state states unmet main main_state + local main_date="" problem again failures=0 - numbers=$(api_pages "repos/${REPO}/pulls?state=open&base=${BRANCH}&per_page=100" \ - | jq -s -r --arg bot "$BOT_LOGIN" '.[][] | select(.user.login == $bot) | .number') + main=$(main_head) || { + report "error, the ${BRANCH} head commit is unreadable" + return 1 + } + main_state=$(mainline_state "$main") || { + report "error, the mainline checks are unreadable for ${main}" + return 1 + } + if [ "$main_state" = "pending" ]; then + report "holding merges until the required mainline checks complete for ${main}" + return 0 + fi + if [ "$main_state" = "failed" ]; then + main_date=$(api "repos/${REPO}/commits/${main}" | jq -er '.commit.committer.date | strings') || { + report "error, the ${BRANCH} head commit date is unreadable" + return 1 + } + report "${BRANCH} fails its required checks at ${main}; only an update validated after it can merge" + fi + + numbers=$(open_dependabot_pulls) || { + report "error, open pull requests are unreadable" + return 1 + } for number in $numbers; do if ! result=$(classify "$number"); then @@ -245,25 +589,52 @@ reconcile() { 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") - if [ "$decision" != "eligible" ]; then - report "#${number}: manual review, ${reason}" + 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 - status=0 - unmet=$(required_checks_succeeded "$sha") || status=$? - if [ "$status" -eq 1 ]; then - report "#${number}: waiting, required check has not succeeded: ${unmet}" + 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 - elif [ "$status" -ne 0 ]; then + 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 + if [ -n "$main_date" ] && ! jq -e --arg date "$main_date" --argjson contexts \ + "$(printf '%s\n' "${contexts[@]}" | jq -R . | jq -s .)" ' + . as $runs | all($contexts[]; . as $context + | $runs | map(select(.name == $context)) | first + | (.started_at // "") > $date) + ' "$work_dir/runs.json" > /dev/null; then + report "#${number}: waiting, its checks predate the failing ${BRANCH} commit" + continue + fi case "$state" in clean|unstable|has_hooks) ;; @@ -273,8 +644,24 @@ reconcile() { ;; 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 "further merges wait for the mainline checks of the new ${BRANCH} commit" + break else report "#${number}: error, merge was refused at ${sha}" failures=$((failures + 1)) @@ -284,9 +671,324 @@ reconcile() { [ "$failures" -eq 0 ] } -main_head() { - api "repos/${REPO}/git/ref/heads/${BRANCH}" \ - | jq -er '.object.sha | strings | select(test("^[0-9a-f]{40}$"))' +# 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' +} + +# 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 behind + local ecosystem manifest job context attempt + 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) + + behind=false + is_ancestor "$main" "$sha" || behind=true + merge=false + if [ "$state" = "dirty" ]; then + merge=true + elif grep -q '^pending'$'\t' <<< "$states"; then + continue + elif [ -n "$failed" ]; then + merge=$behind + elif grep -q '^missing'$'\t' <<< "$states" && [ "$(jq -r '.repairs' <<< "$result")" -gt 0 ] \ + && [ "$(workflow_tree "$sha")" != "$(workflow_tree "$main")" ]; then + # A repaired head whose workflows differ from main cannot be validated + # by dispatch until main is merged into it. + merge=true + else + continue + fi + + if [ "$(jq -r '.repairs' <<< "$result")" -ge "$MAX_REPAIRS" ]; then + report "#${number}: repair budget of ${MAX_REPAIRS} commits is spent; manual attention needed" + continue + fi + + # The workflow records a repair that made no progress as an artifact of + # this name, so the same head and main commit are not generated again + # until the record expires. + attempt="repair-attempt-${number}-${sha}-${main}" + if ! api "repos/${REPO}/actions/artifacts?name=${attempt}&per_page=100" \ + | jq -e '[.artifacts[]? | select(.expired == false)] | length == 0' > /dev/null; then + report "#${number}: the last repair at ${sha} on ${main} made no progress, 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 or lags behind ${BRANCH}." + fi + while IFS= read -r context; do + [ -n "$context" ] || continue + job=$(jq -r --arg context "$context" \ + 'map(select(.name == $context)) | first | .id // empty' "$work_dir/runs.json") + 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" ] \ + || [ "$(jq -r '.repairs' <<< "$result")" -ge "$MAX_REPAIRS" ]; then + report "#${number}: repair dropped, the pull request changed or is no longer repairable" + return 0 + fi + case "$ecosystem" in + cargo-root|cargo-fuzz) ;; + *) + report "#${number}: repair dropped, the ecosystem is not repairable" + return 0 + ;; + 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}" | jq -e --arg bot "$REPAIR_LOGIN" \ + '.commit.verification.verified == true and .author.login == $bot' > /dev/null; 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 +} + +# Succeeds when a run of the workflow exists for the commit on the branch. +run_exists() { + api "repos/${REPO}/actions/workflows/${1}/runs?head_sha=${2}&per_page=100" \ + | jq -e --arg head "$2" --arg branch "$3" ' + [.workflow_runs[] | select(.head_sha == $head and .head_branch == $branch + and (.event == "push" or .event == "workflow_dispatch"))] | length' +} + +# Dispatches a workflow and confirms that a run exists for the intended commit. +dispatch_and_confirm() { + local workflow="$1" ref="$2" sha="$3" count 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 count=$(run_exists "$workflow" "$sha" "$ref") && [ "$count" -gt 0 ]; 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 count 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 + [ "$(jq -r '.repairs' <<< "$result")" -gt 0 ] || continue + sha=$(jq -r '.head_sha' <<< "$result") + ref=$(jq -r '.head_ref' <<< "$result") + [[ "$ref" =~ ^dependabot/[A-Za-z0-9._/+-]+$ ]] || continue + + # The dispatched run executes the branch copy of each workflow. + if [ "$(workflow_tree "$sha")" != "$(workflow_tree "$main")" ]; then + report "#${number}: validation not dispatched, the branch workflows differ from ${BRANCH}" + continue + fi + + for workflow in "${VALIDATION_WORKFLOWS[@]}"; do + if ! count=$(run_exists "$workflow" "$sha" "$ref"); then + report "#${number}: error, ${workflow} runs are unreadable for ${sha}" + failures=$((failures + 1)) + continue + fi + [ "$count" -eq 0 ] || continue + 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() { @@ -298,10 +1000,7 @@ ensure_mainline_runs() { } for workflow in "${MAINLINE_WORKFLOWS[@]}"; do - if ! count=$(api "repos/${REPO}/actions/workflows/${workflow}/runs?head_sha=${head}&per_page=100" \ - | jq -e --arg head "$head" --arg branch "$BRANCH" ' - [.workflow_runs[] | select(.head_sha == $head and .head_branch == $branch)] | length - '); then + if ! count=$(run_exists "$workflow" "$head" "$BRANCH"); then report "${workflow}: error, runs are unreadable for ${head}" failures=$((failures + 1)) continue @@ -314,12 +1013,7 @@ ensure_mainline_runs() { report "${workflow}: ${BRANCH} moved past ${head}; the next reconcile covers the new head" continue fi - if gh workflow run "$workflow" --repo "$REPO" --ref "$BRANCH"; then - report "${workflow}: dispatched for ${head}" - else - report "${workflow}: error, dispatch failed for ${head}" - failures=$((failures + 1)) - fi + dispatch_and_confirm "$workflow" "$BRANCH" "$head" || failures=$((failures + 1)) done [ "$failures" -eq 0 ] @@ -330,10 +1024,26 @@ case "${1:-}" in [ "$#" -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 diff --git a/scripts/dependency-repair.sh b/scripts/dependency-repair.sh new file mode 100755 index 00000000..09533005 --- /dev/null +++ b/scripts/dependency-repair.sh @@ -0,0 +1,522 @@ +#!/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. +# 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 writes the candidate: manifest.json and files/. +# candidate runs generate and then apply in the current checkout and leaves +# the result uncommitted, for a reviewed local repair. +# +# No stage holds a GitHub write token. A candidate is published separately by +# scripts/dependabot-automerge.sh publish-repair, which repeats check-policy. +# +# Environment: +# OPENROUTER_API_KEY model credential; generate and candidate only. +# DEPENDENCY_REPAIR_MODEL OpenCode model identifier under openrouter/. +# 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="${DEPENDENCY_REPAIR_MODEL:-openrouter/anthropic/claude-sonnet-5.5}" +TIMEOUT="${DEPENDENCY_REPAIR_TIMEOUT:-900}" +REQUESTS=".dependency-repair/cargo-update" +REGISTRY='source = "registry+https://github.com/rust-lang/crates.io-index"' +MAX_FILES=40 +MAX_FILE_BYTES=1048576 +MAX_REQUESTS=20 +AUDIT_LOG="${DEPENDENCY_REPAIR_AUDIT_LOG:-}" + +usage() { + sed -n '6,11p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,3\}//' >&2 + exit 64 +} + +die() { + echo "error: $1" >&2 + audit "${stage:-unknown}" failed "$1" + exit 1 +} + +# Records the invocation in the shared audit log when one is configured. A +# failure to record never changes the outcome. +record_invocation() { + local status=$? write=1 + [ -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" +} + +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 count=0 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/"} + count=$((count + 1)) + if ! path_allowed "$ecosystem" "$path" "$stage_name"; then + echo "policy: path is not repairable: ${path}" >&2 + return 1 + fi + if [ "$(stat -c %s "$dir/files/$path")" -gt "$MAX_FILE_BYTES" ]; then + echo "policy: file is too large: ${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 [ "$count" -gt "$MAX_FILES" ]; then + echo "policy: the candidate changes too many files" >&2 + return 1 + fi + if [ -f "$dir/cargo-update" ]; then + if [ "$(wc -l < "$dir/cargo-update")" -gt "$MAX_REQUESTS" ]; then + echo "policy: too many lockfile requests" >&2 + return 1 + fi + 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" ] || die "the repository carries agent configuration: ${name}" + done + [ ! -e "${HOME:-/nonexistent}/.opencode" ] || die "the home directory carries agent configuration: .opencode" +} + +# Prints the tree of the working directory without touching the real index. +worktree_tree() { + local index + index=$(mktemp) + rm -f "$index" + GIT_INDEX_FILE="$index" git -C "$1" read-tree HEAD + GIT_INDEX_FILE="$index" git -C "$1" add -A + GIT_INDEX_FILE="$index" git -C "$1" write-tree + rm -f "$index" +} + +# Copies every file that differs from the base tree into /files. Prints +# nothing and fails when a change is not a modification inside the policy. +collect_changes() { + local repo="$1" base="$2" out="$3" ecosystem="$4" stage_name="$5" tree status path + tree=$(worktree_tree "$repo") + mkdir -p "$out/files" + while IFS= read -r -d '' status && IFS= read -r -d '' path; do + case "$path" in .dependency-repair/*) continue ;; esac + [ "$status" = "M" ] || die "the repair adds, removes, or retypes a file: ${path}" + path_allowed "$ecosystem" "$path" "$stage_name" || die "the repair changes a path outside the policy: ${path}" + mkdir -p "$out/files/$(dirname "$path")" + git -C "$repo" cat-file blob "${tree}:${path}" > "$out/files/$path" + done < <(git -C "$repo" diff-tree -r -z --no-renames --name-status "$base" "$tree") +} + +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="$repo/.git/dependency-repair" + 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 + die "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 removed. +run_agent() { + local repo="$1" ecosystem="$2" manifest="$3" agent_state config prompt name status=0 + [ -n "${OPENROUTER_API_KEY:-}" ] || die "OPENROUTER_API_KEY is not set" + [[ "$MODEL" =~ ^openrouter/[A-Za-z0-9._/:~-]+$ ]] || die "the model identifier is malformed" + [[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || die "the timeout is malformed" + command -v opencode > /dev/null || die "OpenCode is not installed" + refuse_agent_configuration "$repo" + + agent_state=$(mktemp -d) + config=$(agent_configuration "$ecosystem") + prompt=$(agent_prompt "$manifest") + ( + while IFS= read -r name; do + case "$name" in + PATH|HOME|OPENROUTER_API_KEY) ;; + *) unset "$name" 2> /dev/null || true ;; + esac + done < <(compgen -e) + 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 + cd "$repo" + exec timeout "$TIMEOUT" opencode run --pure --model "$MODEL" \ + --title dependency-repair "$prompt" < /dev/null + ) || status=$? + rm -rf "$agent_state" + [ "$status" -eq 0 ] || die "the model run failed with status ${status}" +} + +generate() { + local repo="$1" plan="$2" context="$3" out="$4" manifest ecosystem base state + 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" + + state="$repo/.git/dependency-repair" + 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" + + base=$(worktree_tree "$repo") + audit generate started "model ${MODEL}; manifest ${manifest}" + run_agent "$repo" "$ecosystem" "$manifest" + + 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 || die "the generated repair violates the policy" + + # The credential must never leave through the repair itself. + if grep -rqF -f <(printf '%s\n' "$OPENROUTER_API_KEY") "$out"; then + rm -rf "${out:?}/files" "$out/cargo-update" + die "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 + (cd "$repo" && cargo update --quiet --manifest-path "$manifest" "$@") +} + +# Runs the lockfile operations and writes the candidate. With an overlay +# directory the generated files are copied in first. +apply() { + local repo="$1" plan="$2" generated="$3" out="$4" overlay="$5" + local manifest ecosystem state base spec precise line name version path + [ -z "${OPENROUTER_API_KEY:-}" ] || die "apply refuses to run with the model credential present" + 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" + check_policy "$generated" "$ecosystem" generated || die "the generated repair violates the policy" + state="$repo/.git/dependency-repair" + + 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 + die "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 git -C "$repo" grep -nE '^(<<<<<<<|>>>>>>>)( |$)' -- "$manifest" > /dev/null 2>&1; then + die "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 || die "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" \ + || die "the requested lockfile update failed: ${line}" + else + cargo_update "$repo" "$manifest" --package "$spec" \ + || die "the requested lockfile update failed: ${line}" + fi + audit apply ok "cargo update ${line}" + done < "$generated/cargo-update" + fi + + (cd "$repo" && cargo metadata --quiet --locked --manifest-path "$manifest" --format-version 1 > /dev/null) \ + || die "the repaired lockfile does not resolve" + + if [ "$(plan_field "$plan" merge)" = "true" ]; then + base=$(plan_field "$plan" main_sha) + else + base=$(plan_field "$plan" head_sha) + fi + collect_changes "$repo" "$base" "$out" "$ecosystem" final + jq '{number, head_sha, main_sha, merge}' "$plan" > "$out/manifest.json" + check_policy "$out" "$ecosystem" final || die "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" + + stage=generate + generate "$repo" "$work/plan.json" "$context" "$work/generated" + unset OPENROUTER_API_KEY + stage=apply + apply "$repo" "$work/plan.json" "$work/generated" "$work/candidate" false + + 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=("$@") +trap record_invocation EXIT +case "$stage" in + prepare) + [ "$#" -eq 3 ] || usage + prepare "$2" "$3" + ;; + generate) + [ "$#" -eq 5 ] || usage + generate "$2" "$3" "$4" "$5" + ;; + apply) + [ "$#" -eq 5 ] || usage + apply "$2" "$3" "$4" "$5" true + ;; + 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/test-dependabot-automerge.sh b/scripts/test-dependabot-automerge.sh index bd5ad4b5..411cab78 100755 --- a/scripts/test-dependabot-automerge.sh +++ b/scripts/test-dependabot-automerge.sh @@ -1,19 +1,24 @@ #!/usr/bin/env bash set -euo pipefail -# Exercises scripts/dependabot-automerge.sh against a stub gh CLI, and checks -# the trust boundaries of the workflows that run it. No network access or -# token is used: the stub serves fixtures for read requests and records every -# write request so the tests can assert exactly which ones were made. +# 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" -unset GITHUB_STEP_SUMMARY GH_TOKEN GITHUB_TOKEN || true +export DISPATCH_POLLS=2 DISPATCH_POLL_SECONDS=0 +unset GITHUB_STEP_SUMMARY GH_TOKEN GITHUB_TOKEN EXPECTED_HEAD OPENROUTER_API_KEY \ + DEPENDENCY_REPAIR_MODEL DEPENDENCY_REPAIR_AUDIT_LOG AGENT_AUDIT_LOG_CLI || true bot="dependabot[bot]" +repair_bot="github-actions[bot]" failures=0 tests=0 @@ -21,18 +26,32 @@ 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 @@ -40,30 +59,113 @@ while [ "$#" -gt 0 ]; do done if [ "$method" != "GET" ]; then printf 'api %s %s\n' "$method" "$path" >> "$FIXTURES/writes" - exit 1 + [ "$input" -eq 0 ] || jq -c . >> "$FIXTURES/bodies" + [ ! -e "$FIXTURES/refuse-writes" ] || exit 1 + file="$FIXTURES/$(key "$method $path")" + [ -f "$file" ] || exit 1 + cat "$file" + 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 -file="$FIXTURES/$(printf '%s' "$path" | tr -c 'A-Za-z0-9\n' '_')" 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 > "$FIXTURES/$(printf '%s' "$1" | tr -c 'A-Za-z0-9\n' '_')" + 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 + 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}" '{ + id: 77, 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: .}' \ + | fixture "$api/commits/$sha/check-runs?filter=latest&per_page=100" +} + +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 a validated main commit and the declared branch rules. new_case() { FIXTURES=$(mktemp -d "$tmp/case.XXXXXX") export FIXTURES : > "$FIXTURES/writes" -} - -sha_for() { - printf '%s' "$1" | sha1sum | cut -d ' ' -f 1 + : > "$FIXTURES/bodies" + main_ref "$main_sha" + rules + check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" + jq -n '{commit: {committer: {date: "2026-02-01T00:00:00Z"}}}' | fixture "$api/commits/$main_sha" + jq -n '[{name: "workflows", type: "dir", sha: "workflows-tree"}]' \ + | fixture "$api/contents/.github?ref=$main_sha" } # message ... @@ -80,16 +182,43 @@ message() { printf '...\n\nSigned-off-by: %s \n' "$bot" } -required_contexts() { - "$root/scripts/sync-branch-protection.sh" --list +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. +# message, then changed files. Overrides come from the P_* variables: +# P_REPAIRS later commits, each "update" or "merge"; +# P_ORIGINAL files the Dependabot commit changed (default: all files); +# P_PATCH patch of every changed file ("none" omits it); +# P_ORIGINAL_PATCH patch of every file in the Dependabot commit. pull() { - local number="$1" ref="$2" text="$3" sha + local number="$1" ref="$2" text="$3" sha first previous index=0 kind commits original + 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" \ @@ -97,59 +226,84 @@ pull() { --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}" --argjson changed "${P_CHANGED:-$#}" '{ + --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 "repos/$GITHUB_REPOSITORY/pulls/$number" + }' | fixture "$api/pulls/$number" - jq -n \ - --arg sha "${P_COMMIT_SHA:-$sha}" --arg author "${P_AUTHOR:-$bot}" \ + 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; $count) | { + [range(0; if $count > 1 then $count else 1 end) | { sha: $sha, author: {login: $author}, committer: {login: $committer}, - commit: {message: $message, verification: {verified: $verified}} - }]' | fixture "repos/$GITHUB_REPOSITORY/pulls/$number/commits?per_page=100" + 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" + + files_json "${P_PATCH:-}" "$@" | fixture "$api/pulls/$number/files?per_page=100" + if [ -n "${P_ORIGINAL:-}" ]; then + read -r -a original <<< "$P_ORIGINAL" + else + original=("$@") + fi + files_json "${P_ORIGINAL_PATCH:-}" "${original[@]}" | jq '{files: .}' | fixture "$api/commits/$first" - printf '%s\n' "$@" \ - | jq -R --arg status "${P_FILE_STATUS:-modified}" '{filename: ., status: $status, patch: "ignored"}' \ - | jq -s . | fixture "repos/$GITHUB_REPOSITORY/pulls/$number/files?per_page=100" + echo '[]' | fixture "$api/pulls/$number/reviews?per_page=100" + threads "$number" true } -# Writes check runs for a pull request head. Arguments: number, then -# name=conclusion overrides ("name=" removes the run, "name=pending" leaves it -# in progress, "name=foreign" reports success from another app). -checks() { - local number="$1" sha context override conclusion +# 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 - sha=$(sha_for "$number") - 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" '{ - id: 1, name: $name, head_sha: $sha, started_at: "2026-01-01T00:00:00Z", - 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: .}' \ - | fixture "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?filter=latest&per_page=100" + 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 "$FIXTURES/$(printf '%s' "repos/$GITHUB_REPOSITORY/pulls/$number" | tr -c 'A-Za-z0-9\n' '_')" - done | jq -s . | fixture "repos/$GITHUB_REPOSITORY/pulls?state=open&base=main&per_page=100" + cat "$(fixture_path "$api/pulls/$number")" + done | jq -s . | fixture "$api/pulls?state=open&base=main&per_page=100" } pass() { @@ -185,6 +339,18 @@ expect_writes() { 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" @@ -204,76 +370,88 @@ pull 1 "$actions_ref" "$(message github/codeql-action/init:4.37.3:patch github/c expect_decision "grouped actions patch" eligible 1 new_case -pull 1 "$cargo_ref" "$(message dirs:7.0.0:major)" Cargo.toml Cargo.lock -expect_decision "major update" manual 1 +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 clap:4.6.7:patch dirs:7.0.0:major)" Cargo.toml Cargo.lock -expect_decision "group containing a major update" manual 1 +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 "$cargo_ref" "$(message base64:0.23.1:minor)" Cargo.toml Cargo.lock -expect_decision "0.x minor update" manual 1 +pull 1 "$actions_ref" "$(message actions/checkout:8:major)" .github/workflows/ci.yml +expect_decision "SHA-pinned major action update" eligible 1 new_case -pull 1 "$cargo_ref" "$(message tiny:0.0.4:patch)" Cargo.lock -expect_decision "0.0.x patch update" manual 1 +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 -pull 1 "$actions_ref" "$(message actions/checkout:8:major)" .github/workflows/ci.yml -expect_decision "major action update" manual 1 +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" manual 1 +expect_decision "missing update type" review 1 new_case pull 1 "$cargo_ref" "$(message clap::patch)" Cargo.lock -expect_decision "missing version" manual 1 +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" manual 1 +expect_decision "prerelease version" review 1 new_case pull 1 "$cargo_ref" "chore(deps): bump clap" Cargo.lock -expect_decision "missing metadata" manual 1 +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" manual 1 +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" manual 1 +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" manual 1 +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" manual 1 +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" manual 1 +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" manual 1 +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" manual 1 +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" manual 1 +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" manual 1 +expect_decision "incomplete file list" review 1 # --- Classifier trust boundaries --------------------------------------------- @@ -307,7 +485,7 @@ 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 on the branch" manual 1 +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 @@ -339,6 +517,123 @@ 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=update P_ORIGINAL=Cargo.toml pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock +expect_decision "repair that adds the lockfile to the update" 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 P_ORIGINAL_PATCH=$'@@ -1 +1 @@\n-clap = "4.6.6"\n+clap = "4.6.7"' \ + pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock +expect_decision "repair that changes the manifest" review 1 + +new_case +P_REPAIRS=update P_ORIGINAL=Cargo.lock pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock +expect_decision "repair that adds the manifest to the update" review 1 + +new_case +P_REPAIRS=update P_ORIGINAL=Cargo.lock pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock src/server/execute.rs +expect_decision "repair that changes Rust source" review 1 + +new_case +P_REPAIRS=update P_ORIGINAL=Cargo.lock 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 P_ORIGINAL=Cargo.lock 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 P_PATCH=none pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock +expect_decision "repaired manifest without a readable patch" review 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() { @@ -358,18 +653,32 @@ open_pulls 1 expect_writes "eligible and green merges at the exact head" "$(merge_call 1)" new_case -eligible_pull 1 -checks 1 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 -open_pulls 1 2 3 4 +eligible_pull 5 +checks 5 +open_pulls 2 3 4 5 "$subject" reconcile > /dev/null || fail "backfill: reconcile failed" -expect_writes "backfill merges only eligible Dependabot pull requests" \ - "$(merge_call 1; echo; merge_call 4)" +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=" \ @@ -412,54 +721,592 @@ eligible_pull 1 checks 1 open_pulls 1 touch "$FIXTURES/refuse-writes" -if "$subject" reconcile > /dev/null 2>&1; then - fail "refused merge: reconcile succeeded" -else - pass -fi +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" "" -# --- Mainline runs ----------------------------------------------------------- +# 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" "" + +# Mainline validation of the integrated commit. + +new_case +check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" "Test & Lint=pending" +eligible_pull 1 +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "main pending: reconcile failed" +expect_writes "no merge while the mainline checks are running" "" -head=$(sha_for main) -runs_path() { - printf 'repos/%s/actions/workflows/%s/runs?head_sha=%s&per_page=100' \ - "$GITHUB_REPOSITORY" "$1" "$head" +new_case +check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" "CodeQL (rust)=" +eligible_pull 1 +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "main unvalidated: reconcile failed" +expect_writes "no merge while a mainline check is missing" "" + +new_case +check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" "Security Audit (CVEs)=failure" +eligible_pull 1 +checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "main failed, stale checks: reconcile failed" +expect_writes "no merge of stale checks onto a failing main" "" + +new_case +check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" "Security Audit (CVEs)=failure" +eligible_pull 1 +CHECKS_STARTED=2026-02-02T00:00:00Z checks 1 +open_pulls 1 +"$subject" reconcile > /dev/null || fail "main failed, fresh checks: reconcile failed" +expect_writes "an update validated after a failing main commit merges" "$(merge_call 1)" + +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 } -main_ref() { - jq -n --arg sha "$1" '{object: {sha: $sha}}' \ - | fixture "repos/$GITHUB_REPOSITORY/git/ref/heads/main" + +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 } + +# contains : whether the head contains the main commit. No +# repair was attempted yet unless ATTEMPTS says otherwise. +contains() { + jq -n --arg status "$2" '{status: $status}' \ + | fixture "$api/compare/$main_sha...$(sha_for "$1")" + jq -n "{artifacts: ${ATTEMPTS:-[]}}" \ + | fixture "$api/actions/artifacts?name=repair-attempt-$1-$(sha_for "$1")-$main_sha&per_page=100" +} + +new_case +eligible_pull 1 +checks 1 "Security Audit (CVEs)=failure" "License & Dependency Policy=failure" +contains 1 ahead +open_pulls 1 +printf '2026-10-08T00:00:00.0000000Z error: 1 vulnerability found!\n' \ + | fixture "$api/actions/jobs/77/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\",\"repair-attempt-1-$(sha_for 1)-$main_sha\"]" +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 repair without progress is not generated again for the same commits" '.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=repair-attempt-1-$(sha_for 1)-$main_sha&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 +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 +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" eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +open_pulls 1 +expect_plan "a spent repair budget stops further repairs" '.repair' false + +new_case +P_REPAIRS=update eligible_pull 1 +required_contexts | sed 's/$/=/' > "$FIXTURES/none" +mapfile -t none < "$FIXTURES/none" +check_runs "$(sha_for 1)" "${none[@]}" +contains 1 diverged +jq -n '[{name: "workflows", type: "dir", sha: "older-workflows-tree"}]' \ + | fixture "$api/contents/.github?ref=$(sha_for 1)" +open_pulls 1 +expect_plan "a repaired head with outdated workflows plans a merge" '[.repair, .merge]' '[true,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}" + 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" \ + '{author: {login: $author}, commit: {verification: {verified: $verified}}}' \ + | 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 +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")" + +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_status "a repair for a head that moved is dropped" ok "$subject" publish-repair "$CANDIDATE" +expect_writes "no write 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_status "a repair for an unrecognized pull request is dropped" ok "$subject" publish-repair "$CANDIDATE" +expect_writes "no write 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_status "a repair for a workflow update is dropped" ok "$subject" publish-repair "$CANDIDATE" +expect_writes "no write 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" eligible_pull 1 +git_database 1 +candidate 1 false Cargo.lock +expect_status "a repair beyond the budget is dropped" ok "$subject" publish-repair "$CANDIDATE" +expect_writes "no write beyond the repair budget" "" + +# --- Workflow runs ----------------------------------------------------------- + +# runs [fixture suffix] runs() { - jq -n --arg sha "$head" --arg branch "$2" --argjson count "$3" '{ - workflow_runs: [range(0; $count) | {head_sha: $sha, head_branch: $branch, event: "push"}] - }' | fixture "$(runs_path "$1")" + jq -n --arg sha "$2" --arg branch "$3" --argjson count "$4" --arg event "${RUN_EVENT:-push}" '{ + workflow_runs: [range(0; $count) | {head_sha: $sha, head_branch: $branch, event: $event}] + }' > "$(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 -main_ref "$head" -runs ci.yml main 0 -runs audit.yml main 0 +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 +"$subject" ensure-validation-runs > /dev/null || fail "differing workflows: command failed" +expect_writes "no dispatch when the branch workflows differ from main" "" + +new_case +eligible_pull 1 +open_pulls 1 +"$subject" ensure-validation-runs > /dev/null || fail "unrepaired: command failed" +expect_writes "no dispatch for a head that Dependabot pushed" "" + +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 -main_ref "$head" -runs ci.yml main 1 -runs audit.yml main 2 +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 -main_ref "$head" -runs ci.yml feature 1 -runs audit.yml main 1 +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" @@ -471,6 +1318,318 @@ if "$subject" ensure-mainline-runs > /dev/null 2>&1; then 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 '/+=') + +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" +compgen -e | LC_ALL=C sort > "$AGENT_RECORD/environment" +printf '%s' "$HOME" > "$AGENT_RECORD/home" +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 +printf '%s\n' "$*" >> "$CARGO_RECORD/calls" +[ -z "${OPENROUTER_API_KEY:-}" ] || touch "$CARGO_RECORD/credential-seen" +case "$*" in + *"--package"*) printf '# %s\n' "$*" >> "${CARGO_LOCKFILE:-Cargo.lock}" ;; +esac +[ ! -e "$CARGO_RECORD/fail" ] +STUB +chmod +x "$tmp/bin/opencode" "$tmp/bin/cargo" + +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") + export CARGO_RECORD + ln -sfn "$AGENT_RECORD" "$tmp/agent-record" + printf '%s' "$credential" > "$AGENT_RECORD/credential" + : > "$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 commit.gpgsign false + 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 +} + +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 +"$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|_' "$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" ] && ! grep -qF "$credential" "$AGENT_RECORD/arguments" \ + && grep -qx -- '--pure' "$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_status "apply refuses to run beside the model credential" fail 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" \ + "seq 1 30 | sed 's/^/crate/' > .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 +done + +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 +DEPENDENCY_REPAIR_MODEL="anthropic/model; id" expect_status "generate refuses a malformed model identifier" fail generate + +# 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 "$REPO/.git/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" + # --- Workflow and configuration contract ------------------------------------- workflows="$root/.github/workflows" @@ -497,6 +1656,14 @@ refute() { 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" \ @@ -506,27 +1673,84 @@ contract "the privileged workflow accepts manual dispatch" grep -qE '^ workflow 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 "neither workflow checks out a pull request ref" \ - grep -nE 'ref:.*(head|pull_request\.(number|head)|refs/pull)' "$reconcile" "$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 "the privileged jobs run only on main" \ - test "$(grep -c "github.ref == 'refs/heads/main'" "$reconcile")" -eq 2 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 -contract "CI accepts dispatch for mainline runs" grep -qE '^ workflow_dispatch:$' "$workflows/ci.yml" -contract "the audit accepts dispatch for mainline runs" grep -qE '^ workflow_dispatch:$' "$workflows/audit.yml" + +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" -refute "the script makes no write request through the raw API" \ - grep -nE 'gh api .*(-X|--method) *(POST|PUT|PATCH|DELETE)|--admin|--auto' "$subject" + +contract "the script writes only blobs, trees, commits, and a fast-forward ref" \ + test "$(grep -cE 'api_write (POST|PATCH|PUT|DELETE)' "$subject")" -eq 4 +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|DELETE)' "$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" +contract "a repair without progress is recorded for one day" \ + grep -q 'retention-days: 1$' <(job "$reconcile" publish) dependabot="$root/.github/dependabot.yml" contract "every update entry keeps its cooldown" \ From ec6b1cb310c23dcb42fe949a2781bb7cf4f278b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Fri, 9 Oct 2026 00:49:46 +0000 Subject: [PATCH 4/8] ci: require exact-base evidence and isolate the dependency repair model run Merging and recovery: - Merge a Dependabot update only when its head contains the current main commit, so its required checks ran on the tree that the squash merge produces. Refresh one branch per run that lacks the main commit through the branch merge API, with the marker that lets Dependabot keep rebasing it. - Dispatch validation for a refreshed workflow update whose only difference from the main workflows is its own pinned actions. - Rerun a workflow run that was cancelled or failed once. A failed check counts as a defect of the update once a rerun reproduced it. - Keep a repaired patch or minor update routine when the repair stays inside its manifest set and Rust source. Major Cargo updates still need review. - Record a repair by Dependabot commit, main commit, and failing checks, for published repairs and for repairs that cannot proceed in any stage, in place of a fixed repair count. - Verify author, committer, signature, and parents of a repair commit before the branch advances. Repair engine: - Remove the model credential from the environment at entry and export it only to the model process. Run Cargo with a reduced environment. - Record the tree before the model runs and compare files afterwards without running Git on the edited checkout. Any change besides the content of a repairable tracked file fails the stage. - Fix the model and require the pinned OpenCode release. - Refuse a newly locked version published on crates.io within the last hour. - Resolve stage state through the Git directory of the checkout, so a linked worktree works. Drop the file count, request count, and file size limits. - Exit with status 3 when a repair cannot proceed, apart from operational failures. Tests sign their fixture commits with a key generated for the run. --- .github/workflows/dependabot-automerge.yml | 4 +- .github/workflows/dependabot-reconcile.yml | 97 ++- DEVELOPMENT.md | 29 +- scripts/dependabot-automerge.sh | 368 ++++++----- scripts/dependency-repair.sh | 308 +++++++--- scripts/test-dependabot-automerge.sh | 674 +++++++++++++++++---- 6 files changed, 1087 insertions(+), 393 deletions(-) diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index de743c3a..c92e60e9 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -13,8 +13,8 @@ name: Dependabot review gate # # Merging is the separate, privileged dependabot-reconcile.yml workflow, which # repeats the classification itself. That workflow dispatches this one on a -# repaired branch, because a branch update made with the Actions token leaves -# pull request workflows waiting for approval. +# branch that it repaired or refreshed, because a branch update made with the +# Actions token leaves pull request workflows waiting for approval. "on": pull_request: diff --git a/.github/workflows/dependabot-reconcile.yml b/.github/workflows/dependabot-reconcile.yml index 2122948a..88dc2b1d 100644 --- a/.github/workflows/dependabot-reconcile.yml +++ b/.github/workflows/dependabot-reconcile.yml @@ -1,8 +1,9 @@ name: Dependabot reconcile -# Merges eligible Dependabot pull requests, repairs Cargo updates whose -# required checks fail or whose branch conflicts with main, and keeps workflow -# runs present for the commits this automation creates. +# 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 @@ -17,8 +18,13 @@ name: Dependabot reconcile # 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 head gets its required -# checks by dispatch, and merges like any other update once they pass. +# 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. @@ -47,7 +53,7 @@ jobs: timeout-minutes: 15 permissions: checks: read # Reads required check results for pull request head commits. - contents: write # Merges eligible pull requests. + 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 @@ -93,7 +99,7 @@ jobs: { 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]{40}-[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 @@ -146,19 +152,40 @@ jobs: echo "$RUNNER_TEMP/opencode" >> "$GITHUB_PATH" - name: Prepare the repair tree - run: trusted/scripts/dependency-repair.sh prepare work "$RUNNER_TEMP/plan/plan.json" + 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 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 }} - DEPENDENCY_REPAIR_MODEL: ${{ vars.DEPENDENCY_REPAIR_MODEL || 'openrouter/anthropic/claude-sonnet-5.5' }} - run: >- - trusted/scripts/dependency-repair.sh generate work - "$RUNNER_TEMP/plan/plan.json" "$RUNNER_TEMP/plan/context.md" - "$RUNNER_TEMP/generated" + 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 @@ -203,11 +230,23 @@ jobs: path: ${{ runner.temp }}/generated - name: Apply the repair without credentials + id: apply run: | - set -euo pipefail - 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=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 @@ -245,21 +284,19 @@ jobs: run: | status=0 scripts/dependabot-automerge.sh publish-repair "$RUNNER_TEMP/candidate" || status=$? - if [ "$status" -eq 3 ]; then - echo "no_progress=true" >> "$GITHUB_OUTPUT" - fi + # 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" - # The planner skips this pull request head and main commit while the - # record exists. A failed model run or job leaves no record, so it is - # tried again on the next run. - - name: Record a repair without progress - if: ${{ !cancelled() && steps.publish.outputs.no_progress == 'true' }} + - 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 - retention-days: 1 runs: name: Ensure workflow runs @@ -268,7 +305,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - actions: write # Lists workflow runs and dispatches missing runs. + actions: write # Lists workflow runs, dispatches missing runs, and reruns failed runs. contents: read pull-requests: read steps: @@ -278,12 +315,12 @@ jobs: persist-credentials: false sparse-checkout: scripts - - name: Dispatch validation for repaired pull requests + - name: Dispatch and rerun validation for pull requests env: GH_TOKEN: ${{ github.token }} run: scripts/dependabot-automerge.sh ensure-validation-runs - - name: Dispatch missing mainline runs + - name: Dispatch and rerun mainline runs if: ${{ !cancelled() }} env: GH_TOKEN: ${{ github.token }} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1bb27e44..00376254 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -30,8 +30,13 @@ never checks out or runs pull request content with a repository token. - 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. -- One update merges per run. Further merges wait until the required checks - completed for the new `main` 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. A conflicting workflow update waits for the + rebase that Dependabot performs on a conflict. +- A workflow run that was cancelled, or that failed once, is rerun. A failure + counts as a defect of the update once a rerun reproduced it. A Cargo update whose required checks fail, or whose branch conflicts with `main`, is repaired by `scripts/dependency-repair.sh`. A language model reads @@ -39,20 +44,24 @@ 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. A repair confined to the lockfile -keeps the update routine. A repair that changes a manifest or source needs an -approving review before it merges. Workflow updates are not repaired. +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. The model runs once for a given +Dependabot commit, `main` commit, and set of failing checks. Workflow updates +are not repaired. -The same repair runs locally against the current checkout and leaves its result -uncommitted. The context file holds the failing check output: +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` and reaches only the -model process. The repository variable `DEPENDENCY_REPAIR_MODEL` selects the -model in CI. +The model credential is read from `OPENROUTER_API_KEY`, removed from the +script's environment, and handed to the model process alone. The model and +the OpenCode release are fixed in the script, and `opencode` on `PATH` must be +that release. ## Building diff --git a/scripts/dependabot-automerge.sh b/scripts/dependabot-automerge.sh index b1e82225..3742d6fa 100755 --- a/scripts/dependabot-automerge.sh +++ b/scripts/dependabot-automerge.sh @@ -45,9 +45,9 @@ set -euo pipefail # full commit SHA, whatever the update type. # Major Cargo updates, unclassified updates, and other ecosystems need review. # -# A repaired update stays eligible only when the repair is confined to the -# lockfile: every other changed file carries exactly the lines the Dependabot -# commit changed. A repair that changes a manifest or Rust source needs 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. @@ -57,34 +57,44 @@ set -euo pipefail # update. # # reconcile merges at most one pull request per run. It merges only when the -# effective branch rules match the declared required checks, every required -# check succeeded for the exact head commit, every review thread is resolved, -# and the classification is unchanged immediately before the merge. It holds -# all merges until the required mainline checks completed for the current main -# commit. While main fails those checks, only an update whose checks started -# after that commit can merge. +# 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 +# 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. It -# plans at most three repair commits per pull request, and skips a pull -# request head and main commit whose repair made no progress. That record is a -# workflow artifact that expires after a day; a failed model run or a failed -# job leaves no record and is tried again on the next run. +# whose branch conflicts with main, and writes plan.json and context.md. A +# failed check counts once a rerun reproduced it. 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 +# 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 signature -# is confirmed, as a fast-forward. It exits with status 3 when the candidate -# made no progress: it changes nothing, violates the policy, or was refused. +# 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 repaired head. -# 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. +# 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 failed and has not been rerun yet. # # ensure-mainline-runs dispatches the push-triggered mainline workflows for the -# current main commit when no run exists for it. +# current main commit when no run exists for it, and reruns them the same way. BOT_LOGIN="dependabot[bot]" REPAIR_LOGIN="github-actions[bot]" @@ -94,10 +104,9 @@ BRANCH="main" APP_ID=15368 MAINLINE_WORKFLOWS=(ci.yml audit.yml) VALIDATION_WORKFLOWS=(ci.yml audit.yml dependabot-automerge.yml) -# Required contexts that report only for a pull request. -PULL_REQUEST_CONTEXTS=("Classify dependency update" "Dependency Review") GATE_CONTEXT="Classify dependency update" -MAX_REPAIRS=3 +# 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}" @@ -159,7 +168,7 @@ is_ancestor() { # Prints the classification object. Returns nonzero only when metadata is # unreadable. classify() { - local number="$1" dir first parent merges_ok=true repairs fingerprint + 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 @@ -188,15 +197,9 @@ classify() { submitted_at: (.submitted_at // "") }]' > "$dir/reviews.json" || return 2 - # A repaired pull request needs the Dependabot commit's own changes, and - # proof that every merged commit is part of main. - echo '[]' > "$dir/original.json" + # Every commit that a repair merged must be part of main. repairs=$(jq '[.[1:][]] | length' "$dir/commits.json") if [ "$repairs" -gt 0 ]; then - first=$(jq -r '.[0].sha // ""' "$dir/commits.json") - is_sha "$first" || return 2 - api "repos/${REPO}/commits/${first}" \ - | jq '[.files[]? | {filename, patch: (.patch // null)}]' > "$dir/original.json" || return 2 while IFS= read -r parent; do is_sha "$parent" || return 2 is_ancestor "$parent" "$BRANCH" || merges_ok=false @@ -225,7 +228,6 @@ classify() { --slurpfile pull "$dir/pull.json" \ --slurpfile commits "$dir/commits.json" \ --slurpfile files "$dir/files.json" \ - --slurpfile original "$dir/original.json" \ --slurpfile reviews "$dir/reviews.json" ' def metadata: split("\n") as $lines @@ -265,8 +267,6 @@ classify() { else "other" end; - def is_lockfile: test("^(fuzz/)?Cargo\\.lock$"); - def is_source($class): if $class == "cargo-root" then test("^(src|tests|benches)/([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\\.rs$") @@ -299,9 +299,8 @@ classify() { "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 ($c | length) > 8 or $p.commits != ($c | length) - or $c[-1].sha != $p.head.sha then - "the commit list is empty, too long, or does not end at the head" + 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 @@ -347,13 +346,6 @@ classify() { {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"} - elif $repaired and (($outside | length) > 0 or any($f[]; - (.filename | is_lockfile | not) - and (. as $file - | ($original[0] | map(select(.filename == $file.filename)) | first) as $before - | $before == null or $before.patch == null or $file.patch == null - or ($before.patch | changed_lines) != ($file.patch | changed_lines)))) then - {class: $class, reason: "the repair changes a manifest or source file"} else {class: $class, reason: null} end @@ -379,13 +371,14 @@ classify() { elif $policy.reason == null then "eligible" else "review" end), reason: ($policy.reason // (if ($c | length) > 1 - then "the update is routine and its repair is confined to the lockfile" + 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 @@ -486,40 +479,11 @@ required_contexts() { printf '%s\n' "${contexts[@]}" } -mainline_contexts() { - local context skip exempt - required_contexts | while IFS= read -r context; do - skip=0 - for exempt in "${PULL_REQUEST_CONTEXTS[@]}"; do - [ "$context" = "$exempt" ] && skip=1 - done - [ "$skip" -eq 1 ] || printf '%s\n' "$context" - done -} - main_head() { api "repos/${REPO}/git/ref/heads/${BRANCH}" \ | jq -er '.object.sha | strings | select(test("^[0-9a-f]{40}$"))' } -# Prints validated, pending, or failed for the required mainline checks of a -# main commit. -mainline_state() { - local sha="$1" states - local -a contexts - mapfile -t contexts < <(mainline_contexts) - [ "${#contexts[@]}" -gt 0 ] || return 2 - read_check_runs "$sha" "$work_dir/main-runs.json" || return 2 - states=$(context_states "$work_dir/main-runs.json" "${contexts[@]}") || return 2 - if grep -qE '^(missing|pending)'$'\t' <<< "$states"; then - echo pending - elif grep -q '^failed'$'\t' <<< "$states"; then - echo failed - else - echo validated - fi -} - # Succeeds when every review thread is resolved and the thread list was read in # full for the given head commit. review_threads_resolved() { @@ -553,30 +517,51 @@ open_dependabot_pulls() { | jq -s -r --arg bot "$BOT_LOGIN" '.[][] | select(.user.login == $bot) | .number' } -reconcile() { - local numbers number result decision approved reason sha state states unmet main main_state - local main_date="" problem again failures=0 +# 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 +} - main=$(main_head) || { - report "error, the ${BRANCH} head commit is unreadable" +# Merges the main commit into a pull request branch. GitHub computes the merge +# and creates the commit; nothing is checked out. +refresh_branch() { + local number="$1" ref="$2" sha="$3" main="$4" response commit + if ! [[ "$ref" =~ ^dependabot/[A-Za-z0-9._/+-]+$ ]]; then + report "#${number}: error, the head branch name is malformed" return 1 - } - main_state=$(mainline_state "$main") || { - report "error, the mainline checks are unreadable for ${main}" + fi + if ! response=$(jq -n --arg base "$ref" --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 refresh of ${ref} with ${main} was refused" return 1 - } - if [ "$main_state" = "pending" ]; then - report "holding merges until the required mainline checks complete for ${main}" + fi + commit=$(jq -r '.sha // ""' <<< "$response") + if ! is_sha "$commit"; then + report "#${number}: the branch already contains ${main}, or moved during the refresh" return 0 fi - if [ "$main_state" = "failed" ]; then - main_date=$(api "repos/${REPO}/commits/${main}" | jq -er '.commit.committer.date | strings') || { - report "error, the ${BRANCH} head commit date is unreadable" - return 1 - } - report "${BRANCH} fails its required checks at ${main}; only an update validated after it can merge" + 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}" + return 1 fi + report "#${number}: refreshed with ${main} as ${commit}; it merges once its checks pass" +} +reconcile() { + local numbers number result decision approved reason sha state states unmet main ecosystem + local problem again 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 @@ -593,6 +578,7 @@ reconcile() { 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}" @@ -614,6 +600,24 @@ reconcile() { 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 + refreshed=1 + refresh_branch "$number" "$(jq -r '.head_ref' <<< "$result")" "$sha" "$main" \ + || failures=$((failures + 1)) + 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 @@ -626,15 +630,6 @@ reconcile() { report "#${number}: waiting, required check has not succeeded: ${unmet}" continue fi - if [ -n "$main_date" ] && ! jq -e --arg date "$main_date" --argjson contexts \ - "$(printf '%s\n' "${contexts[@]}" | jq -R . | jq -s .)" ' - . as $runs | all($contexts[]; . as $context - | $runs | map(select(.name == $context)) | first - | (.started_at // "") > $date) - ' "$work_dir/runs.json" > /dev/null; then - report "#${number}: waiting, its checks predate the failing ${BRANCH} commit" - continue - fi case "$state" in clean|unstable|has_hooks) ;; @@ -660,7 +655,7 @@ reconcile() { if gh pr merge "$number" --repo "$REPO" --squash --match-head-commit "$sha"; then report "#${number}: merged at ${sha}" - report "further merges wait for the mainline checks of the new ${BRANCH} commit" + report "every other update now lacks the new ${BRANCH} commit and is refreshed first" break else report "#${number}: error, merge was refused at ${sha}" @@ -685,10 +680,29 @@ workflow_tree() { | jq -er '.[] | select(.name == "workflows" and .type == "dir") | .sha' } +# Succeeds when a rerun reproduced the failed required checks: every failed +# job ran at least twice and at least one of them failed on its own, as +# opposed to being cancelled. Takes the failed context names. +failures_reproduced() { + local context job detail own=1 + for context in "$@"; do + job=$(jq -r --arg context "$context" \ + 'map(select(.name == $context)) | first | .id // empty' "$work_dir/runs.json") + [[ "$job" =~ ^[0-9]+$ ]] || return 1 + detail=$(api "repos/${REPO}/actions/jobs/${job}") || return 1 + jq -e '(.run_attempt // 1) >= 2' <<< "$detail" > /dev/null || return 1 + if jq -e '.conclusion == "failure" or .conclusion == "timed_out"' <<< "$detail" > /dev/null; then + 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 behind + 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" @@ -724,36 +738,27 @@ repair_plan() { states=$(grep -vF $'\t'"$GATE_CONTEXT" <<< "$states" || true) failed=$(grep '^failed'$'\t' <<< "$states" | cut -f 2- || true) - behind=false - is_ancestor "$main" "$sha" || behind=true merge=false if [ "$state" = "dirty" ]; then merge=true - elif grep -q '^pending'$'\t' <<< "$states"; then + elif grep -q '^pending'$'\t' <<< "$states" || [ -z "$failed" ]; then continue - elif [ -n "$failed" ]; then - merge=$behind - elif grep -q '^missing'$'\t' <<< "$states" && [ "$(jq -r '.repairs' <<< "$result")" -gt 0 ] \ - && [ "$(workflow_tree "$sha")" != "$(workflow_tree "$main")" ]; then - # A repaired head whose workflows differ from main cannot be validated - # by dispatch until main is merged into it. - merge=true else - continue - fi - - if [ "$(jq -r '.repairs' <<< "$result")" -ge "$MAX_REPAIRS" ]; then - report "#${number}: repair budget of ${MAX_REPAIRS} commits is spent; manual attention needed" - continue + 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 records a repair that made no progress as an artifact of - # this name, so the same head and main commit are not generated again - # until the record expires. - attempt="repair-attempt-${number}-${sha}-${main}" + # 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}: the last repair at ${sha} on ${main} made no progress, or its record is unreadable" + report "#${number}: a repair for the same update, ${BRANCH} commit, and failing checks was already made, or its record is unreadable" continue fi @@ -775,7 +780,7 @@ repair_plan() { echo "# Failing required checks" echo if [ -z "$failed" ]; then - echo "None reported. The branch conflicts with or lags behind ${BRANCH}." + echo "None reported. The branch conflicts with ${BRANCH}." fi while IFS= read -r context; do [ -n "$context" ] || continue @@ -823,16 +828,15 @@ publish_repair() { ref=$(jq -r '.head_ref' <<< "$result") ecosystem=$(jq -r '.ecosystem' <<< "$result") if [ "$(jq -r '.decision' <<< "$result")" = "manual" ] \ - || [ "$(jq -r '.head_sha' <<< "$result")" != "$head" ] \ - || [ "$(jq -r '.repairs' <<< "$result")" -ge "$MAX_REPAIRS" ]; then + || [ "$(jq -r '.head_sha' <<< "$result")" != "$head" ]; then report "#${number}: repair dropped, the pull request changed or is no longer repairable" - return 0 + return 4 fi case "$ecosystem" in cargo-root|cargo-fuzz) ;; *) report "#${number}: repair dropped, the ecosystem is not repairable" - return 0 + return 4 ;; esac @@ -905,8 +909,7 @@ publish_repair() { report "#${number}: error, the commit could not be created" return 1 } - if ! is_sha "$commit" || ! api "repos/${REPO}/commits/${commit}" | jq -e --arg bot "$REPAIR_LOGIN" \ - '.commit.verification.verified == true and .author.login == $bot' > /dev/null; then + 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 @@ -921,24 +924,51 @@ publish_repair() { fi } -# Succeeds when a run of the workflow exists for the commit on the branch. -run_exists() { +# 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 again after a rerun, which makes it a finding; +# retry the run was cancelled, or failed and has not been rerun. +run_state() { api "repos/${REPO}/actions/workflows/${1}/runs?head_sha=${2}&per_page=100" \ - | jq -e --arg head "$2" --arg branch "$3" ' + | jq -er --arg head "$2" --arg branch "$3" --argjson events "$4" ' [.workflow_runs[] | select(.head_sha == $head and .head_branch == $branch - and (.event == "push" or .event == "workflow_dispatch"))] | length' + and (.event as $event | $events | index($event) != null))] + | sort_by(.run_started_at // "", .id) | last + | if . == null then "missing 0" + elif .status != "completed" then "active \(.id)" + elif .conclusion == "success" then "passed \(.id)" + elif .conclusion == "failure" and (.run_attempt // 1) >= 2 then "failed \(.id)" + else "retry \(.id)" + end' +} + +# 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" count attempt + 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 count=$(run_exists "$workflow" "$sha" "$ref") && [ "$count" -gt 0 ]; then + if state=$(run_state "$workflow" "$sha" "$ref" '["push", "workflow_dispatch"]') \ + && [ "${state%% *}" != "missing" ]; then report "${workflow}: dispatched for ${sha} on ${ref}" return 0 fi @@ -949,7 +979,7 @@ dispatch_and_confirm() { } ensure_validation_runs() { - local numbers number result sha ref main workflow count failures=0 + local numbers number result sha ref main workflow state events dispatchable failures=0 local -a inputs main=$(main_head) || return 2 @@ -957,24 +987,49 @@ ensure_validation_runs() { for number in $numbers; do result=$(classify "$number") || continue [ "$(jq -r '.decision' <<< "$result")" != "manual" ] || continue - [ "$(jq -r '.repairs' <<< "$result")" -gt 0 ] || continue sha=$(jq -r '.head_sha' <<< "$result") ref=$(jq -r '.head_ref' <<< "$result") [[ "$ref" =~ ^dependabot/[A-Za-z0-9._/+-]+$ ]] || continue - # The dispatched run executes the branch copy of each workflow. - if [ "$(workflow_tree "$sha")" != "$(workflow_tree "$main")" ]; then - report "#${number}: validation not dispatched, the branch workflows differ from ${BRANCH}" - 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 ! count=$(run_exists "$workflow" "$sha" "$ref"); then + if ! state=$(run_state "$workflow" "$sha" "$ref" "$events"); then report "#${number}: error, ${workflow} runs are unreadable for ${sha}" failures=$((failures + 1)) continue fi - [ "$count" -eq 0 ] || continue + 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 @@ -992,7 +1047,7 @@ ensure_validation_runs() { } ensure_mainline_runs() { - local head workflow count failures=0 + local head workflow state failures=0 head=$(main_head) || { echo "error: the ${BRANCH} head commit is unreadable" >&2 @@ -1000,15 +1055,22 @@ ensure_mainline_runs() { } for workflow in "${MAINLINE_WORKFLOWS[@]}"; do - if ! count=$(run_exists "$workflow" "$head" "$BRANCH"); then + if ! state=$(run_state "$workflow" "$head" "$BRANCH" '["push", "workflow_dispatch"]'); then report "${workflow}: error, runs are unreadable for ${head}" failures=$((failures + 1)) continue fi - if [ "$count" -gt 0 ]; then - report "${workflow}: a run exists for ${head}" - 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 diff --git a/scripts/dependency-repair.sh b/scripts/dependency-repair.sh index 09533005..034361cd 100755 --- a/scripts/dependency-repair.sh +++ b/scripts/dependency-repair.sh @@ -29,20 +29,37 @@ set -euo pipefail # 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. +# 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 writes the candidate: manifest.json and files/. +# `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_MODEL OpenCode model identifier under openrouter/. # 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). @@ -51,30 +68,41 @@ set -euo pipefail # exit, as: dependency-repair # -- . -MODEL="${DEPENDENCY_REPAIR_MODEL:-openrouter/anthropic/claude-sonnet-5.5}" +MODEL_KEY="${OPENROUTER_API_KEY:-}" +unset OPENROUTER_API_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"' -MAX_FILES=40 -MAX_FILE_BYTES=1048576 -MAX_REQUESTS=20 +INDEX="https://index.crates.io" +MINIMUM_PUBLISH_AGE=3600 AUDIT_LOG="${DEPENDENCY_REPAIR_AUDIT_LOG:-}" usage() { - sed -n '6,11p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,3\}//' >&2 + 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 1 + exit "${2:-1}" +} + +# The repair cannot proceed for this plan; see exit status 3 above. +refuse() { + die "$1" 3 } -# Records the invocation in the shared audit log when one is configured. A -# failure to record never changes the outcome. -record_invocation() { +# 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" \ @@ -90,6 +118,17 @@ audit() { >> "$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" } @@ -125,7 +164,7 @@ valid_request() { } check_policy() { - local dir="$1" ecosystem="$2" stage_name="$3" path count=0 line + 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 @@ -135,15 +174,10 @@ check_policy() { if [ -d "$dir/files" ]; then while IFS= read -r -d '' path; do path=${path#"$dir/files/"} - count=$((count + 1)) if ! path_allowed "$ecosystem" "$path" "$stage_name"; then echo "policy: path is not repairable: ${path}" >&2 return 1 fi - if [ "$(stat -c %s "$dir/files/$path")" -gt "$MAX_FILE_BYTES" ]; then - echo "policy: file is too large: ${path}" >&2 - return 1 - fi if grep -qE '^(<<<<<<<|>>>>>>>)( |$)' "$dir/files/$path"; then echo "policy: file holds a conflict marker: ${path}" >&2 return 1 @@ -158,15 +192,7 @@ check_policy() { esac done < <(find "$dir/files" -type f -print0) fi - if [ "$count" -gt "$MAX_FILES" ]; then - echo "policy: the candidate changes too many files" >&2 - return 1 - fi if [ -f "$dir/cargo-update" ]; then - if [ "$(wc -l < "$dir/cargo-update")" -gt "$MAX_REQUESTS" ]; then - echo "policy: too many lockfile requests" >&2 - return 1 - fi while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue if ! valid_request "$line"; then @@ -183,35 +209,80 @@ check_policy() { refuse_agent_configuration() { local name for name in opencode.json opencode.jsonc .opencode; do - [ ! -e "$1/$name" ] || die "the repository carries agent configuration: ${name}" + [ ! -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 tree of the working directory without touching the real index. -worktree_tree() { - local index - index=$(mktemp) - rm -f "$index" - GIT_INDEX_FILE="$index" git -C "$1" read-tree HEAD - GIT_INDEX_FILE="$index" git -C "$1" add -A - GIT_INDEX_FILE="$index" git -C "$1" write-tree - rm -f "$index" +# 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 base tree into /files. Prints -# nothing and fails when a change is not a modification inside the policy. +# 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" tree status path - tree=$(worktree_tree "$repo") + local repo="$1" base="$2" out="$3" ecosystem="$4" stage_name="$5" path before after mkdir -p "$out/files" - while IFS= read -r -d '' status && IFS= read -r -d '' path; do - case "$path" in .dependency-repair/*) continue ;; esac - [ "$status" = "M" ] || die "the repair adds, removes, or retypes a file: ${path}" - path_allowed "$ecosystem" "$path" "$stage_name" || die "the repair changes a path outside the policy: ${path}" + 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")" - git -C "$repo" cat-file blob "${tree}:${path}" > "$out/files/$path" - done < <(git -C "$repo" diff-tree -r -z --no-renames --name-status "$base" "$tree") + 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() { @@ -227,7 +298,7 @@ prepare() { 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="$repo/.git/dependency-repair" + state=$(state_directory "$repo") rm -rf "$state" mkdir -p "$state" : > "$state/lock-conflicts" @@ -238,7 +309,7 @@ prepare() { 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 - die "the planned main commit could not be merged" + refuse "the planned main commit could not be merged" fi while IFS= read -r path; do case "$path" in @@ -320,44 +391,42 @@ EOF # Runs the model. The credential reaches only this process, through its # environment, with every other variable removed. run_agent() { - local repo="$1" ecosystem="$2" manifest="$3" agent_state config prompt name status=0 - [ -n "${OPENROUTER_API_KEY:-}" ] || die "OPENROUTER_API_KEY is not set" - [[ "$MODEL" =~ ^openrouter/[A-Za-z0-9._/:~-]+$ ]] || die "the model identifier is malformed" + 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" - command -v opencode > /dev/null || die "OpenCode is not installed" + 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") ( - while IFS= read -r name; do - case "$name" in - PATH|HOME|OPENROUTER_API_KEY) ;; - *) unset "$name" 2> /dev/null || true ;; - esac - done < <(compgen -e) + keep_environment PATH HOME 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 + 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" + [ "$status" -ne 124 ] || refuse "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" manifest ecosystem base state + 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" - state="$repo/.git/dependency-repair" mkdir -p "$repo/.dependency-repair" { cat "$context" @@ -372,20 +441,23 @@ generate() { } > "$repo/.dependency-repair/context.md" : > "$repo/$REQUESTS" - base=$(worktree_tree "$repo") 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 || die "the generated repair violates the policy" + 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' "$OPENROUTER_API_KEY") "$out"; then + if grep -rqF -f <(printf '%s\n' "$MODEL_KEY") "$out"; then rm -rf "${out:?}/files" "$out/cargo-update" - die "the generated repair contains the model credential" + 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)" } @@ -393,38 +465,76 @@ generate() { cargo_update() { local repo="$1" manifest="$2" shift 2 - (cd "$repo" && cargo update --quiet --manifest-path "$manifest" "$@") + ( + 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 } -# Runs the lockfile operations and writes the candidate. With an overlay -# directory the generated files are copied in first. +# 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" - local manifest ecosystem state base spec precise line name version path - [ -z "${OPENROUTER_API_KEY:-}" ] || die "apply refuses to run with the model credential present" + 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 || die "the generated repair violates the policy" - state="$repo/.git/dependency-repair" + 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 - die "a generated path is not an existing regular file: ${path}" + 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 git -C "$repo" grep -nE '^(<<<<<<<|>>>>>>>)( |$)' -- "$manifest" > /dev/null 2>&1; then - die "the manifest still holds a conflict marker" + 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 || die "the lockfile does not resolve" + 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 @@ -446,26 +556,25 @@ apply() { [ "$spec" = "$line" ] || precise=${line#* } if [ -n "$precise" ]; then cargo_update "$repo" "$manifest" --package "$spec" --precise "$precise" \ - || die "the requested lockfile update failed: ${line}" + || refuse "the requested lockfile update failed: ${line}" else cargo_update "$repo" "$manifest" --package "$spec" \ - || die "the requested lockfile update failed: ${line}" + || refuse "the requested lockfile update failed: ${line}" fi audit apply ok "cargo update ${line}" done < "$generated/cargo-update" fi - (cd "$repo" && cargo metadata --quiet --locked --manifest-path "$manifest" --format-version 1 > /dev/null) \ - || die "the repaired lockfile does not resolve" + ( + 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" - if [ "$(plan_field "$plan" merge)" = "true" ]; then - base=$(plan_field "$plan" main_sha) - else - base=$(plan_field "$plan" head_sha) - fi collect_changes "$repo" "$base" "$out" "$ecosystem" final jq '{number, head_sha, main_sha, merge}' "$plan" > "$out/manifest.json" - check_policy "$out" "$ecosystem" final || die "the candidate violates the policy" + 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' ' ')" } @@ -481,12 +590,14 @@ candidate() { 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" - unset OPENROUTER_API_KEY + 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 + 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) @@ -496,7 +607,8 @@ candidate() { stage="${1:-}" invocation=("$@") -trap record_invocation EXIT +scratch="" +trap finish EXIT case "$stage" in prepare) [ "$#" -eq 3 ] || usage @@ -504,11 +616,19 @@ case "$stage" in ;; generate) [ "$#" -eq 5 ] || usage - generate "$2" "$3" "$4" "$5" + scratch=$(mktemp -d) + snapshot "$2" "" "$scratch" + generate "$2" "$3" "$4" "$5" "$(state_directory "$2")" "$scratch" ;; apply) [ "$#" -eq 5 ] || usage - apply "$2" "$3" "$4" "$5" true + 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 diff --git a/scripts/test-dependabot-automerge.sh b/scripts/test-dependabot-automerge.sh index 411cab78..f7ca01c1 100755 --- a/scripts/test-dependabot-automerge.sh +++ b/scripts/test-dependabot-automerge.sh @@ -16,7 +16,7 @@ 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_MODEL DEPENDENCY_REPAIR_AUDIT_LOG AGENT_AUDIT_LOG_CLI || true + DEPENDENCY_REPAIR_AUDIT_LOG AGENT_AUDIT_LOG_CLI || true bot="dependabot[bot]" repair_bot="github-actions[bot]" failures=0 @@ -154,7 +154,8 @@ main_ref() { jq -n --arg sha "$1" '{object: {sha: $sha}}' | fixture "$api/git/ref/heads/main" } -# Starts a case with a validated main commit and the declared branch rules. +# 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 @@ -162,10 +163,15 @@ new_case() { : > "$FIXTURES/bodies" main_ref "$main_sha" rules - check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" - jq -n '{commit: {committer: {date: "2026-02-01T00:00:00Z"}}}' | fixture "$api/commits/$main_sha" jq -n '[{name: "workflows", type: "dir", sha: "workflows-tree"}]' \ | fixture "$api/contents/.github?ref=$main_sha" + job_attempt 2 failure +} + +# job_attempt : the job behind every check run. +job_attempt() { + jq -n --argjson attempt "$1" --arg conclusion "$2" '{run_attempt: $attempt, conclusion: $conclusion}' \ + | fixture "$api/actions/jobs/77" } # message ... @@ -207,12 +213,12 @@ files_json() { # 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_ORIGINAL files the Dependabot commit changed (default: all files); -# P_PATCH patch of every changed file ("none" omits it); -# P_ORIGINAL_PATCH patch of every file in the Dependabot commit. +# 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 original + local number="$1" ref="$2" text="$3" sha first previous index=0 kind commits local -a repairs shift 3 sha=$(sha_for "$number") @@ -262,13 +268,10 @@ pull() { 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" - if [ -n "${P_ORIGINAL:-}" ]; then - read -r -a original <<< "$P_ORIGINAL" - else - original=("$@") - fi - files_json "${P_ORIGINAL_PATCH:-}" "${original[@]}" | jq '{files: .}' | fixture "$api/commits/$first" echo '[]' | fixture "$api/pulls/$number/reviews?per_page=100" threads "$number" true @@ -523,10 +526,6 @@ 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=update P_ORIGINAL=Cargo.toml pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock -expect_decision "repair that adds the lockfile to the update" 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 @@ -552,29 +551,33 @@ P_REPAIRS=update P_REPAIR_PARENT=$(sha_for elsewhere) pull 1 "$cargo_ref" "$(mes expect_decision "repair that is not on top of the Dependabot commit" manual 1 new_case -P_REPAIRS=update P_ORIGINAL_PATCH=$'@@ -1 +1 @@\n-clap = "4.6.6"\n+clap = "4.6.7"' \ - pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock -expect_decision "repair that changes the manifest" review 1 +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 P_ORIGINAL=Cargo.lock pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock -expect_decision "repair that adds the manifest to the update" review 1 +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 P_ORIGINAL=Cargo.lock pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock src/server/execute.rs -expect_decision "repair that changes Rust source" review 1 +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 P_ORIGINAL=Cargo.lock pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock build.rs +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 P_ORIGINAL=Cargo.lock pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.lock scripts/dependabot-automerge.sh +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 P_PATCH=none pull 1 "$cargo_ref" "$(message clap:4.6.7:patch)" Cargo.toml Cargo.lock -expect_decision "repaired manifest without a readable patch" review 1 +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 @@ -825,39 +828,128 @@ main_ref "$main_sha" "$subject" reconcile > /dev/null || fail "moved main: reconcile failed" expect_writes "no merge when main moves during evaluation" "" -# Mainline validation of the integrated commit. +# 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] +refresh_result() { + 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)" +} new_case -check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" "Test & Lint=pending" -eligible_pull 1 +P_CONTAINS=diverged eligible_pull 1 checks 1 +refresh_result 1 open_pulls 1 -"$subject" reconcile > /dev/null || fail "main pending: reconcile failed" -expect_writes "no merge while the mainline checks are running" "" +"$subject" reconcile > /dev/null || fail "stale head: reconcile failed" +expect_writes "a green head without the main commit is refreshed, not merged" "api POST $api/merges" +if jq -s -e --arg ref "$cargo_ref" --arg main "$main_sha" --arg message "$refresh_message" \ + '. == [{base: $ref, head: $main, commit_message: $message}]' "$FIXTURES/bodies" > /dev/null; then + pass +else + fail "the refresh merges the exact main commit with the Dependabot marker" +fi new_case -check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" "CodeQL (rust)=" -eligible_pull 1 +P_CONTAINS=behind pull 1 "$actions_ref" "$(message actions/checkout:7.0.2:patch)" .github/workflows/ci.yml checks 1 +refresh_result 1 open_pulls 1 -"$subject" reconcile > /dev/null || fail "main unvalidated: reconcile failed" -expect_writes "no merge while a mainline check is missing" "" +"$subject" reconcile > /dev/null || fail "stale workflow update: reconcile failed" +expect_writes "a stale workflow update is refreshed" "api POST $api/merges" new_case -check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" "Security Audit (CVEs)=failure" -eligible_pull 1 +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" "api POST $api/merges" + +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" "api POST $api/merges" + +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 'api POST %s/merges\n%s' "$api" "$(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 "main failed, stale checks: reconcile failed" -expect_writes "no merge of stale checks onto a failing main" "" +"$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 + +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 +done + +new_case +P_CONTAINS=diverged eligible_pull 1 +checks 1 +echo '' | fixture "POST $api/merges" +open_pulls 1 +expect_status "a refresh that finds nothing to merge is not an error" ok "$subject" reconcile new_case -check_runs "$main_sha" "Classify dependency update=" "Dependency Review=" "Security Audit (CVEs)=failure" eligible_pull 1 -CHECKS_STARTED=2026-02-02T00:00:00Z checks 1 +checks 1 +rm "$(fixture_path "$api/compare/$main_sha...$(sha_for 1)")" open_pulls 1 -"$subject" reconcile > /dev/null || fail "main failed, fresh checks: reconcile failed" -expect_writes "an update validated after a failing main commit merges" "$(merge_call 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" @@ -888,25 +980,42 @@ expect_plan() { fi } -# contains : whether the head contains the main commit. No -# repair was attempted yet unless ATTEMPTS says otherwise. +# 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() { - jq -n --arg status "$2" '{status: $status}' \ - | fixture "$api/compare/$main_sha...$(sha_for "$1")" + 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=repair-attempt-$1-$(sha_for "$1")-$main_sha&per_page=100" + | 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 +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/77/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\",\"repair-attempt-1-$(sha_for 1)-$main_sha\"]" + "[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 @@ -919,7 +1028,39 @@ eligible_pull 1 checks 1 "Test & Lint=failure" ATTEMPTS='[{expired: false}]' contains 1 ahead open_pulls 1 -expect_plan "a repair without progress is not generated again for the same commits" '.repair' false +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 + +new_case +eligible_pull 1 +checks 1 "Test & Lint=failure" +contains 1 ahead +rm "$(fixture_path "$api/actions/jobs/77")" +open_pulls 1 +expect_plan "an unreadable job is not repaired" '.repair' false new_case eligible_pull 1 @@ -932,14 +1073,14 @@ new_case eligible_pull 1 checks 1 "Test & Lint=failure" contains 1 ahead -rm "$(fixture_path "$api/actions/artifacts?name=repair-attempt-1-$(sha_for 1)-$main_sha&per_page=100")" +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 +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\"]" @@ -947,7 +1088,7 @@ expect_plan "failing checks on a stale branch plan a merge" '[.repair, .merge, . new_case P_MERGEABLE=dirty eligible_pull 1 checks 1 -contains 1 diverged +contains 1 diverged true - open_pulls 1 expect_plan "a conflicting branch plans a merge" '[.repair, .merge]' '[true,true]' @@ -1002,22 +1143,13 @@ open_pulls 1 expect_plan "an unrecognized pull request is not repaired" '.repair' false new_case -P_REPAIRS="update update update" eligible_pull 1 +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 "a spent repair budget stops further repairs" '.repair' false - -new_case -P_REPAIRS=update eligible_pull 1 -required_contexts | sed 's/$/=/' > "$FIXTURES/none" -mapfile -t none < "$FIXTURES/none" -check_runs "$(sha_for 1)" "${none[@]}" -contains 1 diverged -jq -n '[{name: "workflows", type: "dir", sha: "older-workflows-tree"}]' \ - | fixture "$api/contents/.github?ref=$(sha_for 1)" -open_pulls 1 -expect_plan "a repaired head with outdated workflows plans a merge" '[.repair, .merge]' '[true,true]' +expect_plan "earlier repairs do not stop a repair for a new failure record" '.repair' true # --- Repair publication ------------------------------------------------------ @@ -1038,7 +1170,7 @@ candidate() { # Fixtures for the Git database requests of one publication. git_database() { - local number="$1" verified="${2:-true}" author="${3:-$repair_bot}" + 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 @@ -1053,9 +1185,12 @@ git_database() { 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" \ - '{author: {login: $author}, commit: {verification: {verified: $verified}}}' \ - | fixture "$api/commits/$(sha_for repaired)" + 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" } @@ -1083,7 +1218,7 @@ fi new_case eligible_pull 1 -git_database 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" \ @@ -1123,28 +1258,52 @@ expect_status "a repair commit by another author fails publication" fail "$subje 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_status "a repair for a head that moved is dropped" ok "$subject" publish-repair "$CANDIDATE" -expect_writes "no write for a head that moved" "" +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_status "a repair for an unrecognized pull request is dropped" ok "$subject" publish-repair "$CANDIDATE" -expect_writes "no write for an unrecognized pull request" "" +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_status "a repair for a workflow update is dropped" ok "$subject" publish-repair "$CANDIDATE" -expect_writes "no write for a workflow update" "" +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 @@ -1209,19 +1368,24 @@ fi expect_writes "no commit for a repair without a change" "$(head -n 2 <<< "$publication")" new_case -P_REPAIRS="update update update" eligible_pull 1 +P_REPAIRS="update update update update" eligible_pull 1 git_database 1 -candidate 1 false Cargo.lock -expect_status "a repair beyond the budget is dropped" ok "$subject" publish-repair "$CANDIDATE" -expect_writes "no write beyond the repair budget" "" +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] +# 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}" '{ - workflow_runs: [range(0; $count) | {head_sha: $sha, head_branch: $branch, event: $event}] - }' > "$(fixture_path "$api/actions/workflows/$1/runs?head_sha=$2&per_page=100")${5:-}" + 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() { @@ -1273,15 +1437,119 @@ 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 + +for outcome in "failure 2" "success 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 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 +"$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 0 runs audit.yml "$main_sha" main 0 @@ -1324,12 +1592,22 @@ expect_writes "no dispatch when the main head is unreadable" "" # 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" +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' "$XDG_CONFIG_HOME" > "$AGENT_RECORD/state" @@ -1343,14 +1621,29 @@ 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_LOCKFILE:-Cargo.lock}" ;; + *"--package"*) + printf '# %s\n' "$*" >> Cargo.lock + [ ! -e "$CARGO_RECORD/lock-entry" ] || cat "$CARGO_RECORD/lock-entry" >> Cargo.lock + ;; esac [ ! -e "$CARGO_RECORD/fail" ] STUB -chmod +x "$tmp/bin/opencode" "$tmp/bin/cargo" +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"' @@ -1361,9 +1654,10 @@ new_repository() { AGENT_RECORD=$(mktemp -d "$tmp/agent.XXXXXX") CARGO_RECORD=$(mktemp -d "$tmp/cargo.XXXXXX") OUT=$(mktemp -d "$tmp/out.XXXXXX") - export CARGO_RECORD 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" ( @@ -1371,7 +1665,10 @@ new_repository() { git init --quiet --initial-branch main git config user.name test git config user.email test@example.invalid - git config commit.gpgsign false + 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 @@ -1393,6 +1690,18 @@ new_repository() { 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}" '{ @@ -1415,6 +1724,12 @@ apply() { } 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 @@ -1434,8 +1749,10 @@ if [ "$(grep -vxE 'PWD|SHLVL|OLDPWD|_' "$AGENT_RECORD/environment" | tr '\n' ' ' else fail "the model run sees only its own environment: $(tr '\n' ' ' < "$AGENT_RECORD/environment")" fi -if [ -e "$AGENT_RECORD/credential-seen" ] && ! grep -qF "$credential" "$AGENT_RECORD/arguments" \ - && grep -qx -- '--pure' "$AGENT_RECORD/arguments"; then +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" @@ -1495,7 +1812,46 @@ if [ -e "$CARGO_RECORD/credential-seen" ]; then else pass fi -OPENROUTER_API_KEY="$credential" expect_status "apply refuses to run beside the model credential" fail apply +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" \ @@ -1506,15 +1862,52 @@ for edit in \ "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" \ - "seq 1 30 | sed 's/^/crate/' > .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 a repair that cannot proceed" 3 generate + +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 \ @@ -1557,7 +1950,14 @@ else fi new_repository -DEPENDENCY_REPAIR_MODEL="anthropic/model; id" expect_status "generate refuses a malformed model identifier" fail generate +"$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. @@ -1566,7 +1966,7 @@ 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 "$REPO/.git/dependency-repair/lock-conflicts")" = "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 @@ -1630,6 +2030,58 @@ 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" @@ -1735,8 +2187,10 @@ contract "release validation accepts dispatched mainline CI" \ contract "release validation requires the main branch" \ grep -q 'headBranch == \\"main\\"' "$workflows/release.yml" -contract "the script writes only blobs, trees, commits, and a fast-forward ref" \ - test "$(grep -cE 'api_write (POST|PATCH|PUT|DELETE)' "$subject")" -eq 4 +contract "the script writes only blobs, trees, commits, a fast-forward ref, and a branch merge" \ + test "$(grep -cE 'api_write (POST|PATCH|PUT|DELETE)' "$subject")" -eq 5 +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 5 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|DELETE)' "$subject" contract "the repair commit carries no custom author" \ @@ -1749,8 +2203,20 @@ 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" -contract "a repair without progress is recorded for one day" \ - grep -q 'retention-days: 1$' <(job "$reconcile" publish) +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" \ From 240e128d5253375dcf6a4cce42feebf324d47095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Fri, 9 Oct 2026 01:04:37 +0000 Subject: [PATCH 5/8] Verify Actions rerun metadata before dependency repair --- scripts/dependabot-automerge.sh | 47 ++++++++++++---- scripts/test-dependabot-automerge.sh | 84 +++++++++++++++++++++++++--- 2 files changed, 112 insertions(+), 19 deletions(-) diff --git a/scripts/dependabot-automerge.sh b/scripts/dependabot-automerge.sh index 3742d6fa..cc743f57 100755 --- a/scripts/dependabot-automerge.sh +++ b/scripts/dependabot-automerge.sh @@ -680,18 +680,42 @@ workflow_tree() { | jq -er '.[] | select(.name == "workflows" and .type == "dir") | .sha' } -# Succeeds when a rerun reproduced the failed required checks: every failed -# job ran at least twice and at least one of them failed on its own, as -# opposed to being cancelled. Takes the failed context names. +# 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" +} + +# Succeeds when every failed check belongs to the latest attempt of its run, +# the attempt is at least two, and at least one job failed or timed out. failures_reproduced() { - local context job detail own=1 + local context job run jobs run_id attempt own=1 for context in "$@"; do - job=$(jq -r --arg context "$context" \ - 'map(select(.name == $context)) | first | .id // empty' "$work_dir/runs.json") - [[ "$job" =~ ^[0-9]+$ ]] || return 1 - detail=$(api "repos/${REPO}/actions/jobs/${job}") || return 1 - jq -e '(.run_attempt // 1) >= 2' <<< "$detail" > /dev/null || return 1 - if jq -e '.conclusion == "failure" or .conclusion == "timed_out"' <<< "$detail" > /dev/null; then + 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 + jobs=$(api_pages "repos/${REPO}/actions/runs/${run_id}/attempts/${attempt}/jobs?per_page=100") || return 1 + jq -se --argjson job "$job" ' + [.[].jobs[] | select(.id == $job.id and .run_id == $job.run_id + and .head_sha == $job.head_sha and .conclusion == $job.conclusion)] + | length == 1 + ' <<< "$jobs" > /dev/null || return 1 + if jq -e '.conclusion == "failure" or .conclusion == "timed_out"' <<< "$job" > /dev/null; then own=0 fi done @@ -784,8 +808,7 @@ repair_plan() { fi while IFS= read -r context; do [ -n "$context" ] || continue - job=$(jq -r --arg context "$context" \ - 'map(select(.name == $context)) | first | .id // empty' "$work_dir/runs.json") + job=$(verified_job_for_context "$context" | jq -r '.id') || job="" echo "## ${context}" echo echo '```text' diff --git a/scripts/test-dependabot-automerge.sh b/scripts/test-dependabot-automerge.sh index f7ca01c1..56a07006 100755 --- a/scripts/test-dependabot-automerge.sh +++ b/scripts/test-dependabot-automerge.sh @@ -124,6 +124,7 @@ rules() { # 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" @@ -133,15 +134,16 @@ check_runs() { [ -n "$conclusion" ] || continue jq -n --arg name "$context" --arg sha "$sha" --arg conclusion "$conclusion" \ --arg started "${CHECKS_STARTED:-2026-01-01T00:00:00Z}" '{ - id: 77, name: $name, head_sha: $sha, started_at: $started, + 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: .}' \ + 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() { @@ -165,13 +167,40 @@ new_case() { 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 : the job behind every check run. +# job_attempt : Actions job and workflow-run fixtures. job_attempt() { - jq -n --argjson attempt "$1" --arg conclusion "$2" '{run_attempt: $attempt, conclusion: $conclusion}' \ - | fixture "$api/actions/jobs/77" + local check id run_id job + 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" + 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 ... @@ -1012,7 +1041,7 @@ 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/77/logs" + | 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")\"]" @@ -1058,10 +1087,51 @@ new_case eligible_pull 1 checks 1 "Test & Lint=failure" contains 1 ahead -rm "$(fixture_path "$api/actions/jobs/77")" +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" From 3047040c7c2d7579dcb79e7ef97bdf38cdafa715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Fri, 9 Oct 2026 01:25:20 +0000 Subject: [PATCH 6/8] Verify refresh merges before a branch moves and retry transient failures The refresh of a stale Dependabot branch merged main directly into the pull request branch, so the branch advanced before the merge commit was checked. The merge is now made on a scratch branch that the run creates at the pull request head. The pull request branch advances only after the commit is a verified github-actions[bot] merge of main into that head and the pull request is unchanged, as a fast-forward. The scratch branch is removed in every outcome; one that cannot be removed is reported by name. A refresh that did not advance a branch no longer uses the one refresh of a run, so the next stale branch is tried. A model run that reaches its time limit is an operational failure rather than a repair that cannot proceed, so an unreachable provider leaves no record and the plan is tried again. The model process keeps TMPDIR, and the internal credential variable is never exported. A failed check counts as reproduced only when the same job failed or timed out in the attempt before, and a failed workflow run is rerun until every failing job of its latest attempt meets that condition. A first failure after a cancelled attempt, or a job that first fails on a rerun, is rerun instead of repaired or left as a finding. --- DEVELOPMENT.md | 22 +- scripts/dependabot-automerge.sh | 215 ++++++++++--- scripts/dependency-repair.sh | 10 +- scripts/test-dependabot-automerge.sh | 458 +++++++++++++++++++++++++-- 4 files changed, 636 insertions(+), 69 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 00376254..71ae2269 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -33,10 +33,13 @@ never checks out or runs pull request content with a repository token. - 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. A conflicting workflow update waits for the - rebase that Dependabot performs on a conflict. -- A workflow run that was cancelled, or that failed once, is rerun. A failure - counts as a defect of the update once a rerun reproduced it. + 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 @@ -46,9 +49,11 @@ 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. The model runs once for a given -Dependabot commit, `main` commit, and set of failing checks. Workflow updates -are not repaired. +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 @@ -59,7 +64,8 @@ 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. The model and +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. diff --git a/scripts/dependabot-automerge.sh b/scripts/dependabot-automerge.sh index cc743f57..44917f93 100755 --- a/scripts/dependabot-automerge.sh +++ b/scripts/dependabot-automerge.sh @@ -63,19 +63,27 @@ set -euo pipefail # 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 -# 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. +# 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. Each plan has a record name +# 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 -# leaves no record and is tried again on the next run. +# 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 @@ -91,7 +99,8 @@ set -euo pipefail # 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 failed and has not been rerun yet. +# 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. @@ -133,7 +142,10 @@ if ! [[ "$REPO" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; t fi work_dir=$(mktemp -d) -trap 'rm -rf "$work_dir"' EXIT +# 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 "$@" @@ -527,36 +539,103 @@ repair_commit_verified() { 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. +# 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" response commit + 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 - if ! response=$(jq -n --arg base "$ref" --arg head "$main" --arg message "$REFRESH_MESSAGE" \ - '{base: $base, head: $head, commit_message: $message}' \ + 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 refresh of ${ref} with ${main} was refused" + 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}: the branch already contains ${main}, or moved during the refresh" - return 0 + 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}" + 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 refreshed=0 failures=0 + local problem again outcome refreshed=0 failures=0 main=$(main_head) || { report "error, the ${BRANCH} head commit is unreadable" @@ -611,9 +690,19 @@ reconcile() { elif [ "$refreshed" -eq 1 ]; then report "#${number}: waiting, the branch lacks ${main}; one branch is refreshed per run" else - refreshed=1 - refresh_branch "$number" "$(jq -r '.head_ref' <<< "$result")" "$sha" "$main" \ - || failures=$((failures + 1)) + # 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 @@ -696,10 +785,32 @@ verified_job_for_context() { ' <<< "$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 '[.[].jobs[]]' > "$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, -# the attempt is at least two, and at least one job failed or timed out. +# 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 jobs run_id attempt own=1 + 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 @@ -709,13 +820,14 @@ failures_reproduced() { and .status == "completed") | .run_attempt | numbers | select(. >= 2 and floor == .) ' <<< "$run") || return 1 - jobs=$(api_pages "repos/${REPO}/actions/runs/${run_id}/attempts/${attempt}/jobs?per_page=100") || return 1 - jq -se --argjson job "$job" ' - [.[].jobs[] | select(.id == $job.id and .run_id == $job.run_id + 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 - ' <<< "$jobs" > /dev/null || return 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 @@ -952,20 +1064,49 @@ publish_repair() { # missing no such run exists; # active the run has not completed; # passed the run succeeded; -# failed the run failed again after a rerun, which makes it a finding; -# retry the run was cancelled, or failed and has not been rerun. +# 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() { - api "repos/${REPO}/actions/workflows/${1}/runs?head_sha=${2}&per_page=100" \ - | jq -er --arg head "$2" --arg branch "$3" --argjson events "$4" ' + 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 "missing 0" - elif .status != "completed" then "active \(.id)" - elif .conclusion == "success" then "passed \(.id)" - elif .conclusion == "failure" and (.run_attempt // 1) >= 2 then "failed \(.id)" - else "retry \(.id)" - end' + | 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 + 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. diff --git a/scripts/dependency-repair.sh b/scripts/dependency-repair.sh index 034361cd..a7f5fccc 100755 --- a/scripts/dependency-repair.sh +++ b/scripts/dependency-repair.sh @@ -70,6 +70,8 @@ set -euo pipefail 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" @@ -389,7 +391,7 @@ EOF } # Runs the model. The credential reaches only this process, through its -# environment, with every other variable removed. +# 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" @@ -403,7 +405,7 @@ run_agent() { config=$(agent_configuration "$ecosystem") prompt=$(agent_prompt "$manifest") ( - keep_environment PATH HOME + 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 \ @@ -415,7 +417,9 @@ run_agent() { --title dependency-repair "$prompt" < /dev/null ) || status=$? rm -rf "$agent_state" - [ "$status" -ne 124 ] || refuse "the model run did not finish in ${TIMEOUT} seconds" + # 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}" } diff --git a/scripts/test-dependabot-automerge.sh b/scripts/test-dependabot-automerge.sh index 56a07006..b9f4c938 100755 --- a/scripts/test-dependabot-automerge.sh +++ b/scripts/test-dependabot-automerge.sh @@ -58,12 +58,26 @@ while [ "$#" -gt 0 ]; do 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" - [ "$input" -eq 0 ] || jq -c . >> "$FIXTURES/bodies" + [ -z "$body" ] || printf '%s\n' "$body" >> "$FIXTURES/bodies" [ ! -e "$FIXTURES/refuse-writes" ] || exit 1 file="$FIXTURES/$(key "$method $path")" [ -f "$file" ] || exit 1 - cat "$file" + # 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")" @@ -171,9 +185,12 @@ new_case() { job_attempt 2 failure } -# job_attempt : Actions job and workflow-run fixtures. +# 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 + local check id run_id job previous="${3:-failure}" JOB_ATTEMPT="$1" JOB_CONCLUSION="$2" [ -n "${CHECK_SHA:-}" ] || return 0 @@ -194,6 +211,15 @@ job_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")") } @@ -861,8 +887,9 @@ expect_writes "no merge when main moves during evaluation" "" refresh_message=$'chore(deps): merge main into dependency update\n\nDependency-Repair: refresh\n[dependabot skip]' -# refresh_result [verified] [author] [committer] [first parent] +# 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" '{ @@ -870,6 +897,26 @@ refresh_result() { 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 @@ -877,22 +924,34 @@ P_CONTAINS=diverged eligible_pull 1 checks 1 refresh_result 1 open_pulls 1 -"$subject" reconcile > /dev/null || fail "stale head: reconcile failed" -expect_writes "a green head without the main commit is refreshed, not merged" "api POST $api/merges" -if jq -s -e --arg ref "$cargo_ref" --arg main "$main_sha" --arg message "$refresh_message" \ - '. == [{base: $ref, head: $main, commit_message: $message}]' "$FIXTURES/bodies" > /dev/null; then +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 merges the exact main commit with the Dependabot marker" + 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 +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" "api POST $api/merges" +expect_writes "a stale workflow update is refreshed" "$(refresh_calls "$actions_ref")" new_case P_CONTAINS=diverged eligible_pull 1 @@ -900,7 +959,7 @@ 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" "api POST $api/merges" +expect_writes "a failing head without the main commit is refreshed before anything else" "$(refresh_calls)" new_case P_CONTAINS=diverged eligible_pull 1 @@ -910,7 +969,7 @@ 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" "api POST $api/merges" +expect_writes "one branch is refreshed per run" "$(refresh_calls)" new_case P_CONTAINS=diverged eligible_pull 1 @@ -921,7 +980,7 @@ 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 'api POST %s/merges\n%s' "$api" "$(merge_call 2)")" + "$(printf '%s\n%s' "$(refresh_calls)" "$(merge_call 2)")" for ref in "$cargo_ref" "$actions_ref"; do new_case @@ -949,6 +1008,42 @@ 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 @@ -959,14 +1054,165 @@ for fault in "false $repair_bot web-flow" "true contributor web-flow" "true $rep 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 @@ -1083,6 +1329,46 @@ 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" @@ -1572,10 +1858,91 @@ for outcome in "failure 1" "cancelled 1" "cancelled 3" "timed_out 2" "startup_fa 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 + +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 @@ -1617,9 +1984,19 @@ expect_status "a refused rerun fails the run" fail "$subject" ensure-validation- 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 @@ -1673,6 +2050,7 @@ 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" @@ -1680,6 +2058,7 @@ if [ "${1:-}" = "--version" ]; then 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" @@ -1813,7 +2192,7 @@ if [ "$(cat "$OUT/generated/cargo-update")" = "rustls@0.23.42 0.23.45" ] \ else fail "generate collects the request and exactly the edited file" fi -if [ "$(grep -vxE 'PWD|SHLVL|OLDPWD|_' "$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 " ] \ +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 @@ -1956,7 +2335,35 @@ 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 a repair that cannot proceed" 3 generate +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" @@ -2257,12 +2664,21 @@ contract "release validation accepts dispatched mainline CI" \ contract "release validation requires the main branch" \ grep -q 'headBranch == \\"main\\"' "$workflows/release.yml" -contract "the script writes only blobs, trees, commits, a fast-forward ref, and a branch merge" \ - test "$(grep -cE 'api_write (POST|PATCH|PUT|DELETE)' "$subject")" -eq 5 +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 5 + 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|DELETE)' "$subject" + 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" From d6d07ab54f1be254f963ccd7699f6aca9b4713bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Fri, 9 Oct 2026 03:43:20 +0000 Subject: [PATCH 7/8] Retry failed workflow attempts without failing jobs --- scripts/dependabot-automerge.sh | 14 +++++++++++++- scripts/test-dependabot-automerge.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/dependabot-automerge.sh b/scripts/dependabot-automerge.sh index 44917f93..2d5eaec3 100755 --- a/scripts/dependabot-automerge.sh +++ b/scripts/dependabot-automerge.sh @@ -789,7 +789,15 @@ verified_job_for_context() { # workflow run as one JSON array. attempt_jobs() { api_pages "repos/${REPO}/actions/runs/${1}/attempts/${2}/jobs?per_page=100" \ - | jq -se '[.[].jobs[]]' > "$3" + | 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 @@ -1092,6 +1100,10 @@ run_state() { 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=$? diff --git a/scripts/test-dependabot-automerge.sh b/scripts/test-dependabot-automerge.sh index b9f4c938..cf578354 100755 --- a/scripts/test-dependabot-automerge.sh +++ b/scripts/test-dependabot-automerge.sh @@ -1889,6 +1889,34 @@ for sequence in "failure" "timed_out"; do 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" From 28bd9b568dd7a455d2e0a31907124e56b600d3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Fri, 9 Oct 2026 03:56:14 +0000 Subject: [PATCH 8/8] Update rustls to resolve the certificate verification advisory --- Cargo.lock | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) 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]]