diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ac9680b..bb41c73 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,12 +2,18 @@ version: 2 updates: - package-ecosystem: "cargo" directory: "/" + # Scheduled dependency automation replaces version-update PRs. Dependabot + # security updates remain enabled when this limit is zero. + open-pull-requests-limit: 0 schedule: interval: "cron" cronjob: "0 5 2 * *" # Second day of each month at 05:00 UTC - package-ecosystem: "github-actions" directory: "/" + # Merge only after the external combiner excludes this repository; the + # scheduled workflow then owns version updates. + open-pull-requests-limit: 0 schedule: interval: "cron" cronjob: "0 5 2 * *" # Second day of each month at 05:00 UTC diff --git a/.github/skills/update-deps/SKILL.md b/.github/skills/update-deps/SKILL.md new file mode 100644 index 0000000..0eadd28 --- /dev/null +++ b/.github/skills/update-deps/SKILL.md @@ -0,0 +1,75 @@ +--- +name: update-deps +description: Use when the scheduled dependency workflow asks Copilot to finish a Cargo or GitHub Actions update in this repository. +user-invocable: true +--- + +# Update dependencies + +Finish one deterministic dependency update without crossing the workflow's read-only trust boundary. + +## Repository context + +- The default branch is `main`. +- Rust is pinned by `rust-toolchain.toml` to 1.94.0. +- This is a Cargo workspace with a tracked root `Cargo.lock`. +- CI installs protoc with `script/install-protoc`, then runs `make build`, `make test`, and `make lint`. +- Protobuf output is generated in `OUT_DIR`; it is not checked in. Do not claim `script/install-protoc` regenerates repository files, and do not add generated protobuf output. +- The automation maintains one reserved draft PR per ecosystem: `automation/cargo-dependencies` and `automation/github-actions-dependencies`. + +## Trust boundary + +The `generate` job has read-only repository access. It runs the native updater, captures checks, uploads an immutable post-updater baseline, and then invokes Copilot. The separate `apply` job has write access but never executes agent output. + +Do not commit, push, create or edit pull requests, change workflow permissions, or run schedules. The apply job owns all GitHub writes. + +For Cargo, worktree changes are limited to `Cargo.toml`, `Cargo.lock`, `crates/**`, and `example/**`. For GitHub Actions, only the revision and tracked ref on an existing remote `uses:` line may change; the action repository, path, quoting, file mode, and every other line must remain identical. + +## Workflow + +1. Read `/tmp/dependency-update-context/native-update.log` and every `*-initial.log` and `*-initial.exit` file. +2. Inspect the complete ecosystem delta, not only the first failing package or action. +3. Fix compatibility failures for Cargo within the allowlist. Do not revert dependency updates just to make checks pass unless the update is unsafe and the PR should be a no-op. +4. Run the ecosystem validation below. +5. Write a one-line PR title to `/tmp/dependency-pr-title.txt` and a concise Markdown body to `/tmp/dependency-pr-body.md`. Write exactly `noop` as the title when no safe dependency update remains; the body is then optional. + +## Cargo updates + +`script/update-cargo-dependencies` runs native `cargo update` first. Also inspect direct dependency constraints in every workspace manifest for available releases, including major versions that `cargo update` cannot select without a manifest change. Use Cargo and crates.io source metadata rather than guessing versions. + +Use `cargo tree -d` and the compiler output to identify incompatible duplicate major versions. The September 2026 combined dependency PR is the reference failure mode: `prettyplease` 0.3 accepts `syn` 3 syntax trees while `twirp-build` used `syn` 2. A useful update adapts the manifest and consumer together instead of pinning the old dependency or adding conversion fallbacks. + +Run: + +```bash +make build +make test +make lint +``` + +The workflow installs the repository's pinned protoc before invoking you. Protobuf dependency changes require normal build/test coverage only because generated files live in `OUT_DIR`. + +## GitHub Actions updates + +`script/update-github-actions` inventories `.github/workflows/**/*.{yml,yaml}` and `.github/actions/**/*.{yml,yaml}`, resolves each tracked tag or branch, and replaces the action revision with its full 40-character commit SHA. The comment after each pin is the tracked ref for the next run. Inspect upstream releases for newer release lines too; to adopt one, change only the tracked ref comment on an existing `uses:` line and rerun `script/update-github-actions` to resolve its SHA. + +Do not change action owners, repositories, paths, workflow behavior, permissions, triggers, or file modes. Confirm: + +```bash +script/update-github-actions --check +git diff --check +``` + +The PR body should identify every updated action and tracked ref. + +## PR metadata + +Use `Update Cargo dependencies` or `Update GitHub Actions dependencies` as the title unless a more specific focused title is justified. The body should explain material compatibility changes and risks without restating every diff line. Do not include a `Co-authored-by` trailer in the body. + +## Conflicts, security, and rollout + +`script/check-dependency-pr-conflicts` blocks a run while a Dependabot PR, a combined Dependabot PR, a human-owned reserved branch, a non-draft reserved PR, or an approved reserved PR overlaps the ecosystem. When replacing an unapproved bot draft, the write job closes it, reruns the trusted checker immediately before pushing, and opens a fresh draft so reviews cannot carry across material dependency updates. A failure after closing restores the old branch as needed and reopens the original draft. Never create another dependency PR to work around a refusal. + +Dependabot version PRs are disabled only as this replacement lands; Dependabot security updates remain enabled. Rollout order is: exclude this repository from the external dependency combiner, resolve or close the existing #343-#348 dependency PRs, then allow these schedules to create their reserved drafts. + +Draft PRs still require human review, and required PR CI remains approval-required. `GITHUB_TOKEN` is intentionally used for Copilot and PR creation, but its PR events cannot drive automatic post-PR CI/fixup. That remains blocked until a repository-scoped GitHub App or PAT is provisioned. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91ad68d..809b7f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install protoc run: script/install-protoc - name: Build @@ -28,7 +28,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install protoc run: script/install-protoc - name: Lint diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index a7221c6..ca7ad1d 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -23,13 +23,13 @@ jobs: cancel-in-progress: false steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - name: Run release-plz - uses: release-plz/action@v0.5 + uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5 with: command: release-pr env: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index c9a08e7..83577c9 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -20,13 +20,13 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - name: Run release-plz - uses: release-plz/action@v0.5 + uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5 with: command: release env: diff --git a/.github/workflows/update-dependencies.yml b/.github/workflows/update-dependencies.yml new file mode 100644 index 0000000..41d1ce2 --- /dev/null +++ b/.github/workflows/update-dependencies.yml @@ -0,0 +1,700 @@ +name: Update dependencies + +on: + schedule: + - cron: "37 6 * * 2" + - cron: "17 7 * * 2" + workflow_dispatch: + inputs: + ecosystem: + description: Dependency ecosystem to update + required: true + type: choice + options: + - cargo + - github-actions + +permissions: {} + +defaults: + run: + shell: bash + +concurrency: + group: update-dependencies-${{ (github.event_name == 'workflow_dispatch' && inputs.ecosystem) || (github.event.schedule == '37 6 * * 2' && 'cargo') || (github.event.schedule == '17 7 * * 2' && 'github-actions') }} + cancel-in-progress: false + +jobs: + generate: + name: Generate dependency update + runs-on: ubuntu-latest + timeout-minutes: 75 + permissions: + contents: read + pull-requests: read + copilot-requests: write + outputs: + ecosystem: ${{ steps.ecosystem.outputs.name }} + branch: ${{ steps.ecosystem.outputs.branch }} + base_sha: ${{ steps.base.outputs.sha }} + blocked: ${{ steps.preflight.outputs.blocked }} + has_changes: ${{ steps.changes.outputs.has_changes }} + steps: + - name: Select ecosystem + id: ecosystem + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_SCHEDULE: ${{ github.event.schedule }} + INPUT_ECOSYSTEM: ${{ inputs.ecosystem }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + ecosystem="$INPUT_ECOSYSTEM" + elif [[ "$EVENT_SCHEDULE" == "37 6 * * 2" ]]; then + ecosystem="cargo" + elif [[ "$EVENT_SCHEDULE" == "17 7 * * 2" ]]; then + ecosystem="github-actions" + else + echo "::error::Unknown dependency update schedule: $EVENT_SCHEDULE" + exit 2 + fi + + case "$ecosystem" in + cargo) + branch="automation/cargo-dependencies" + ;; + github-actions) + branch="automation/github-actions-dependencies" + ;; + *) + echo "::error::Unsupported ecosystem: $ecosystem" + exit 2 + ;; + esac + + echo "name=$ecosystem" >> "$GITHUB_OUTPUT" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: refs/heads/main + fetch-depth: 0 + persist-credentials: false + + - name: Record main revision + id: base + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Refuse overlapping dependency pull requests + id: preflight + env: + GH_TOKEN: ${{ github.token }} + ECOSYSTEM: ${{ steps.ecosystem.outputs.name }} + run: | + set +e + set -o pipefail + script/check-dependency-pr-conflicts "$ECOSYSTEM" 2>&1 \ + | tee /tmp/dependency-pr-conflicts.log + rc=${PIPESTATUS[0]} + set -e + + if [[ "$rc" == "3" ]]; then + echo "blocked=true" >> "$GITHUB_OUTPUT" + { + echo "## Dependency update blocked" + echo + echo '```' + cat /tmp/dependency-pr-conflicts.log + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if [[ "$rc" != "0" ]]; then + exit "$rc" + fi + echo "blocked=false" >> "$GITHUB_OUTPUT" + + - name: Run deterministic updater + if: steps.preflight.outputs.blocked != 'true' + env: + ECOSYSTEM: ${{ steps.ecosystem.outputs.name }} + run: | + set -euo pipefail + mkdir -p /tmp/dependency-update-context + if [[ "$ECOSYSTEM" == "cargo" ]]; then + script/update-cargo-dependencies 2>&1 \ + | tee /tmp/dependency-update-context/native-update.log + else + script/update-github-actions 2>&1 \ + | tee /tmp/dependency-update-context/native-update.log + fi + + - name: Detect updater changes + id: changes + if: steps.preflight.outputs.blocked != 'true' + run: | + set -euo pipefail + if [[ -n "$(git status --porcelain)" ]]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + echo "The deterministic updater produced no changes; the agent will check direct dependencies and newer action release lines." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Install pinned protoc + if: steps.changes.outputs.has_changes == 'true' && steps.ecosystem.outputs.name == 'cargo' + run: script/install-protoc + + - name: Capture initial checks + if: steps.changes.outputs.has_changes == 'true' + env: + ECOSYSTEM: ${{ steps.ecosystem.outputs.name }} + run: | + set +e + set -o pipefail + context=/tmp/dependency-update-context + + if [[ "$ECOSYSTEM" == "cargo" ]]; then + make build 2>&1 | tee "$context/build-initial.log" + echo "${PIPESTATUS[0]}" > "$context/build-initial.exit" + make test 2>&1 | tee "$context/test-initial.log" + echo "${PIPESTATUS[0]}" > "$context/test-initial.exit" + make lint 2>&1 | tee "$context/lint-initial.log" + echo "${PIPESTATUS[0]}" > "$context/lint-initial.exit" + else + script/update-github-actions --check 2>&1 \ + | tee "$context/actions-initial.log" + echo "${PIPESTATUS[0]}" > "$context/actions-initial.exit" + git diff --check 2>&1 | tee "$context/diff-initial.log" + echo "${PIPESTATUS[0]}" > "$context/diff-initial.exit" + fi + exit 0 + + - name: Create post-updater baseline + if: steps.changes.outputs.has_changes == 'true' + run: | + set -euo pipefail + tar -cf /tmp/post-updater-baseline.tar \ + --exclude='.git' \ + --exclude='target' \ + -C . . + + - name: Upload post-updater baseline + if: steps.changes.outputs.has_changes == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dependency-update-${{ github.run_id }}-${{ steps.ecosystem.outputs.name }}-baseline + path: /tmp/post-updater-baseline.tar + retention-days: 7 + if-no-files-found: error + + - name: Install pinned Copilot CLI + id: install_copilot + if: steps.changes.outputs.has_changes == 'true' + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set +e + set -o pipefail + version=1.0.83-2 + case "$(uname -m)" in + x86_64|amd64) arch=x64 ;; + aarch64|arm64) arch=arm64 ;; + *) + echo "::error::Unsupported runner architecture: $(uname -m)" + echo "1" > /tmp/dependency-update-context/copilot-install.exit + echo "install_rc=1" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + + asset="copilot-linux-${arch}.tar.gz" + release="https://github.com/github/copilot-cli/releases/download/v${version}" + install_dir="$RUNNER_TEMP/copilot-bin" + mkdir -p "$install_dir" + curl -fsSL --retry 3 --retry-connrefused \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + "$release/$asset" \ + -o "$RUNNER_TEMP/$asset" + rc=$? + if [[ "$rc" == "0" ]]; then + curl -fsSL --retry 3 --retry-connrefused \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + "$release/SHA256SUMS.txt" \ + -o "$RUNNER_TEMP/copilot-SHA256SUMS.txt" + rc=$? + fi + if [[ "$rc" == "0" ]]; then + expected=$(grep " $asset$" "$RUNNER_TEMP/copilot-SHA256SUMS.txt") + if [[ -z "$expected" ]]; then + echo "::error::Copilot CLI checksum entry not found for $asset" + rc=1 + else + printf '%s\n' "$expected" \ + | (cd "$RUNNER_TEMP" && sha256sum --check --status -) + rc=$? + fi + fi + if [[ "$rc" == "0" ]]; then + tar -xzf "$RUNNER_TEMP/$asset" -C "$install_dir" + rc=$? + fi + if [[ "$rc" == "0" ]]; then + export PATH="$install_dir:$PATH" + echo "$install_dir" >> "$GITHUB_PATH" + installed=$("$install_dir/copilot" --version) + if [[ "$installed" != *"1.0.83-2"* ]]; then + echo "::error::Expected Copilot CLI 1.0.83-2, got: $installed" + rc=1 + fi + fi + echo "$rc" > /tmp/dependency-update-context/copilot-install.exit + echo "install_rc=$rc" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Run update-deps agent + id: agent + if: steps.changes.outputs.has_changes == 'true' && steps.install_copilot.outputs.install_rc == '0' + env: + GITHUB_TOKEN: ${{ github.token }} + ECOSYSTEM: ${{ steps.ecosystem.outputs.name }} + run: | + set +e + set -o pipefail + prompt=$(cat <&1 | tee /tmp/dependency-update-context/copilot-agent.log + rc=${PIPESTATUS[0]} + echo "$rc" > /tmp/dependency-update-context/copilot-agent.exit + echo "agent_rc=$rc" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Run final machine checks + id: final_checks + if: always() && steps.changes.outputs.has_changes == 'true' + env: + ECOSYSTEM: ${{ steps.ecosystem.outputs.name }} + run: | + set +e + set -o pipefail + context=/tmp/dependency-update-context + rc=0 + + if [[ "$ECOSYSTEM" == "cargo" ]]; then + make build 2>&1 | tee "$context/build-final.log" + [[ "${PIPESTATUS[0]}" == "0" ]] || rc=1 + make test 2>&1 | tee "$context/test-final.log" + [[ "${PIPESTATUS[0]}" == "0" ]] || rc=1 + make lint 2>&1 | tee "$context/lint-final.log" + [[ "${PIPESTATUS[0]}" == "0" ]] || rc=1 + else + script/update-github-actions 2>&1 \ + | tee "$context/actions-final.log" + [[ "${PIPESTATUS[0]}" == "0" ]] || rc=1 + script/update-github-actions --check 2>&1 \ + | tee -a "$context/actions-final.log" + [[ "${PIPESTATUS[0]}" == "0" ]] || rc=1 + git diff --check 2>&1 | tee "$context/diff-final.log" + [[ "${PIPESTATUS[0]}" == "0" ]] || rc=1 + fi + + echo "$rc" > "$context/final-checks.exit" + echo "checks_rc=$rc" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Stage post-agent artifact + if: always() && steps.changes.outputs.has_changes == 'true' + env: + ECOSYSTEM: ${{ steps.ecosystem.outputs.name }} + run: | + set -euo pipefail + bundle=/tmp/dependency-update-bundle + mkdir -p "$bundle" + tar -cf "$bundle/post-agent.tar" \ + --exclude='.git' \ + --exclude='target' \ + -C . . + cp -R /tmp/dependency-update-context "$bundle/context" + printf '%s\n' "$ECOSYSTEM" > "$bundle/ecosystem" + if [[ -f /tmp/dependency-pr-title.txt ]]; then + cp /tmp/dependency-pr-title.txt "$bundle/pr-title.txt" + fi + if [[ -f /tmp/dependency-pr-body.md ]]; then + cp /tmp/dependency-pr-body.md "$bundle/pr-body.md" + fi + + - name: Upload post-agent artifact + if: always() && steps.changes.outputs.has_changes == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dependency-update-${{ github.run_id }}-${{ steps.ecosystem.outputs.name }}-post-agent + path: /tmp/dependency-update-bundle/ + retention-days: 7 + if-no-files-found: error + + - name: Enforce bounded success + if: always() && steps.changes.outputs.has_changes == 'true' + run: | + set -euo pipefail + context=/tmp/dependency-update-context + failed=0 + for result in copilot-install copilot-agent final-checks; do + file="$context/$result.exit" + if [[ ! -f "$file" ]]; then + echo "::error::$result did not produce an exit status" + failed=1 + elif [[ "$(cat "$file")" != "0" ]]; then + echo "::error::$result failed with exit $(cat "$file")" + failed=1 + fi + done + exit "$failed" + + apply: + name: Apply dependency update + needs: generate + if: needs.generate.outputs.blocked != 'true' && needs.generate.outputs.has_changes == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.generate.outputs.base_sha }} + fetch-depth: 0 + + - name: Preserve trusted conflict checker + run: | + cp script/check-dependency-pr-conflicts "$RUNNER_TEMP/check-dependency-pr-conflicts" + cp script/push-dependency-update "$RUNNER_TEMP/push-dependency-update" + + - name: Download post-updater baseline + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: dependency-update-${{ github.run_id }}-${{ needs.generate.outputs.ecosystem }}-baseline + path: ${{ runner.temp }}/dependency-update-baseline + + - name: Download post-agent artifact + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: dependency-update-${{ github.run_id }}-${{ needs.generate.outputs.ecosystem }}-post-agent + path: ${{ runner.temp }}/dependency-update-post-agent + + - name: Validate agent outputs + id: agent_outputs + env: + BUNDLE: ${{ runner.temp }}/dependency-update-post-agent + run: | + set -euo pipefail + title_file="$BUNDLE/pr-title.txt" + body_file="$BUNDLE/pr-body.md" + if [[ ! -s "$title_file" ]]; then + echo "::error::Agent did not write /tmp/dependency-pr-title.txt" + exit 1 + fi + + title=$(tr -d '\r\n' < "$title_file") + if [[ "$title" == "noop" ]]; then + echo "noop=true" >> "$GITHUB_OUTPUT" + echo "Agent reported a clean no-op." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if [[ ${#title} -gt 120 || ! -s "$body_file" ]]; then + echo "::error::Agent PR title or body is invalid" + exit 1 + fi + echo "noop=false" >> "$GITHUB_OUTPUT" + + - name: Unpack artifact trees + if: steps.agent_outputs.outputs.noop != 'true' + env: + BASELINE: ${{ runner.temp }}/dependency-update-baseline + BUNDLE: ${{ runner.temp }}/dependency-update-post-agent + RUNNER_TEMP_DIR: ${{ runner.temp }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP_DIR/dependency-baseline-tree" + mkdir -p "$RUNNER_TEMP_DIR/dependency-post-agent-tree" + tar -xf "$BASELINE/post-updater-baseline.tar" \ + -C "$RUNNER_TEMP_DIR/dependency-baseline-tree" + tar -xf "$BUNDLE/post-agent.tar" \ + -C "$RUNNER_TEMP_DIR/dependency-post-agent-tree" + + - name: Enforce agent path allowlist + if: steps.agent_outputs.outputs.noop != 'true' + env: + ECOSYSTEM: ${{ needs.generate.outputs.ecosystem }} + BASELINE_TREE: ${{ runner.temp }}/dependency-baseline-tree + POST_AGENT_TREE: ${{ runner.temp }}/dependency-post-agent-tree + run: | + python3 - <<'PY' + import hashlib + import os + import pathlib + import re + import stat + import sys + + baseline = pathlib.Path(os.environ["BASELINE_TREE"]) + post_agent = pathlib.Path(os.environ["POST_AGENT_TREE"]) + ecosystem = os.environ["ECOSYSTEM"] + + def inventory(root): + entries = {} + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + mode = stat.S_IMODE(path.lstat().st_mode) + if path.is_symlink(): + entries[relative] = ("symlink", mode, os.readlink(path)) + elif path.is_file(): + entries[relative] = ( + "file", + mode, + hashlib.sha256(path.read_bytes()).hexdigest(), + ) + return entries + + before = inventory(baseline) + after = inventory(post_agent) + changed = sorted( + path for path in set(before) | set(after) if before.get(path) != after.get(path) + ) + + if ecosystem == "cargo": + violations = [ + path + for path in changed + if not ( + path in {"Cargo.toml", "Cargo.lock"} + or path.startswith("crates/") + or path.startswith("example/") + ) + ] + else: + action = re.compile( + r"^(?P\s*(?:-\s*)?uses:\s*)" + r"(?P['\"]?)" + r"(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)" + r"(?P/[^@\s#]+)?@" + r"(?P[^\s#]+)" + r"(?P=quote)\s+#\s+(?P\S+).*$" + ) + violations = [] + for relative in changed: + before_entry = before.get(relative) + after_entry = after.get(relative) + if ( + before_entry is None + or after_entry is None + or before_entry[0] != "file" + or after_entry[0] != "file" + or before_entry[1] != after_entry[1] + or not ( + relative.startswith(".github/workflows/") + or relative.startswith(".github/actions/") + ) + ): + violations.append(relative) + continue + + old_lines = (baseline / relative).read_text(encoding="utf-8").splitlines() + new_lines = (post_agent / relative).read_text(encoding="utf-8").splitlines() + if len(old_lines) != len(new_lines): + violations.append(relative) + continue + + valid = True + for old_line, new_line in zip(old_lines, new_lines): + if old_line == new_line: + continue + old_action = action.match(old_line) + new_action = action.match(new_line) + if ( + old_action is None + or new_action is None + or old_action.group("prefix") != new_action.group("prefix") + or old_action.group("quote") != new_action.group("quote") + or old_action.group("repository") + != new_action.group("repository") + or old_action.group("path") != new_action.group("path") + or re.fullmatch( + r"[0-9a-f]{40}", new_action.group("revision") + ) + is None + ): + valid = False + break + if not valid: + violations.append(relative) + + if violations: + print("Agent changed paths outside its ecosystem allowlist:", file=sys.stderr) + for path in violations: + print(f" {path}", file=sys.stderr) + sys.exit(1) + + print(f"Verified {len(changed)} agent worktree changes.") + PY + + - name: Refuse human-owned reserved state + if: steps.agent_outputs.outputs.noop != 'true' + env: + BRANCH: ${{ needs.generate.outputs.branch }} + BASE_SHA: ${{ needs.generate.outputs.base_sha }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + existing=$(gh pr list --head "$BRANCH" --state open \ + --json number,author,isDraft) + count=$(jq 'length' <<<"$existing") + if [[ "$count" -gt 1 ]]; then + echo "::error::Multiple open pull requests use reserved branch $BRANCH" + exit 1 + fi + if [[ "$count" == "1" ]]; then + author=$(jq -r '.[0].author.login // ""' <<<"$existing") + draft=$(jq -r '.[0].isDraft' <<<"$existing") + if [[ "$author" != "github-actions[bot]" && "$author" != "app/github-actions" ]]; then + echo "::error::Reserved pull request must be owned by GitHub Actions" + exit 1 + fi + if [[ "$draft" != "true" ]]; then + echo "::error::Reserved pull request must remain a github-actions[bot] draft" + exit 1 + fi + fi + + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + git fetch origin "refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" + unexpected=$(git log "$BASE_SHA..origin/$BRANCH" \ + --format='%an <%ae>|%cn <%ce>' \ + | grep -v -E '^github-actions\[bot\] <41898282\+github-actions\[bot\]@users\.noreply\.github\.com>\|github-actions\[bot\] <41898282\+github-actions\[bot\]@users\.noreply\.github\.com>$' \ + || true) + if [[ -n "$unexpected" ]]; then + echo "::error::Reserved branch contains a human-authored or human-committed change" + printf '%s\n' "$unexpected" + exit 1 + fi + fi + + - name: Overlay and validate update tree + id: staged + if: steps.agent_outputs.outputs.noop != 'true' + env: + BASE_SHA: ${{ needs.generate.outputs.base_sha }} + ECOSYSTEM: ${{ needs.generate.outputs.ecosystem }} + POST_AGENT_TREE: ${{ runner.temp }}/dependency-post-agent-tree + run: | + set -euo pipefail + git checkout -B dependency-update "$BASE_SHA" + rsync -a --delete --exclude='.git' "$POST_AGENT_TREE/" ./ + git add -A + + if git diff --cached --quiet; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "Artifact matched main; skipping push and pull request." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + violations= + while IFS= read -r path; do + if [[ "$ECOSYSTEM" == "cargo" ]]; then + case "$path" in + Cargo.toml|Cargo.lock|crates/*|example/*) ;; + *) violations+="${path}"$'\n' ;; + esac + else + case "$path" in + .github/workflows/*.yml|.github/workflows/*.yaml|.github/actions/*) ;; + *) violations+="${path}"$'\n' ;; + esac + fi + done < <(git diff --cached --name-only) + + if [[ -n "$violations" ]]; then + echo "::error::Dependency update contains paths outside the ecosystem allowlist" + printf '%s' "$violations" + exit 1 + fi + + git diff --cached --check + echo "has_changes=true" >> "$GITHUB_OUTPUT" + + - name: Commit and push bot branch + id: push + if: steps.staged.outputs.has_changes == 'true' + env: + BRANCH: ${{ needs.generate.outputs.branch }} + BUNDLE: ${{ runner.temp }}/dependency-update-post-agent + CONFLICT_CHECKER: ${{ runner.temp }}/check-dependency-pr-conflicts + ECOSYSTEM: ${{ needs.generate.outputs.ecosystem }} + GH_TOKEN: ${{ github.token }} + PUSHER: ${{ runner.temp }}/push-dependency-update + run: | + set -euo pipefail + title=$(tr -d '\r\n' < "$BUNDLE/pr-title.txt") + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit --no-verify -m "$title" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" + + python3 "$PUSHER" "$ECOSYSTEM" "$BRANCH" + echo "pushed=true" >> "$GITHUB_OUTPUT" + + - name: Open fresh draft pull request + if: steps.push.outputs.pushed == 'true' + env: + BRANCH: ${{ needs.generate.outputs.branch }} + ECOSYSTEM: ${{ needs.generate.outputs.ecosystem }} + BUNDLE: ${{ runner.temp }}/dependency-update-post-agent + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + title=$(tr -d '\r\n' < "$BUNDLE/pr-title.txt") + cp "$BUNDLE/pr-body.md" "$RUNNER_TEMP/dependency-pr-body.md" + cat >> "$RUNNER_TEMP/dependency-pr-body.md" <<'EOF' + + Required pull request CI remains approval-required, and human review still applies. This workflow cannot perform post-PR fixups until the repository has a scoped GitHub App or PAT whose pull request events can trigger CI. + +   Generated via Copilot (GPT-5.6 Sol) on behalf of @tclem + EOF + + jq -n \ + --arg title "$title" \ + --arg head "$BRANCH" \ + --rawfile body "$RUNNER_TEMP/dependency-pr-body.md" \ + '{title: $title, head: $head, base: "main", body: $body, draft: true}' \ + | gh api -X POST "repos/${GITHUB_REPOSITORY}/pulls" --input - \ + > "$RUNNER_TEMP/dependency-pr.json" + number=$(jq -r '.number' "$RUNNER_TEMP/dependency-pr.json") + + if [[ "$ECOSYSTEM" == "cargo" ]]; then + labels='["dependencies","rust"]' + else + labels='["dependencies","github_actions"]' + fi + jq -n --argjson labels "$labels" '{labels: $labels}' \ + | gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/$number/labels" --input - + echo "Draft dependency pull request: #$number" >> "$GITHUB_STEP_SUMMARY" diff --git a/Makefile b/Makefile index 06a827a..66e60d9 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ build: .PHONY: test test: cargo test --features test-support + script/test-dependency-automation .PHONY: lint lint: diff --git a/script/check-dependency-pr-conflicts b/script/check-dependency-pr-conflicts new file mode 100755 index 0000000..fa58976 --- /dev/null +++ b/script/check-dependency-pr-conflicts @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 + +import argparse +import json +import re +import subprocess +import sys + + +RESERVED_BRANCHES = { + "cargo": "automation/cargo-dependencies", + "github-actions": "automation/github-actions-dependencies", +} +BOT_LOGINS = {"github-actions[bot]", "app/github-actions"} + + +def conflicts(ecosystem, pull_requests): + reserved = RESERVED_BRANCHES[ecosystem] + found = [] + + for pull_request in pull_requests: + if pull_request.get("isCrossRepository", False): + continue + branch = pull_request["headRefName"] + author = (pull_request.get("author") or {}).get("login", "") + title = pull_request["title"] + + if branch == reserved: + if author not in BOT_LOGINS: + found.append( + f"#{pull_request['number']} uses reserved branch {reserved} " + f"but is owned by {author or 'an unknown author'}" + ) + elif not pull_request.get("isDraft", False): + found.append( + f"#{pull_request['number']} uses reserved branch {reserved} " + "but is no longer a draft" + ) + elif any( + review.get("state") == "APPROVED" + for review in pull_request.get("latestReviews", []) + ): + found.append( + f"#{pull_request['number']} uses reserved branch {reserved} " + "and has an approval; refusing to replace its reviewed diff" + ) + continue + + combined = author == "code-search-bot" and re.search( + r"\bcombined dependabot\b", title, re.IGNORECASE + ) + cargo = branch.startswith("dependabot/cargo/") + actions = branch.startswith("dependabot/github_actions/") + + if combined or (ecosystem == "cargo" and cargo) or ( + ecosystem == "github-actions" and actions + ): + found.append( + f"#{pull_request['number']} {title} " + f"({branch}, {author or 'unknown author'})" + ) + + return found + + +def main(): + parser = argparse.ArgumentParser( + description="Refuse overlapping dependency update pull requests." + ) + parser.add_argument("ecosystem", choices=sorted(RESERVED_BRANCHES)) + args = parser.parse_args() + + result = subprocess.run( + [ + "gh", + "pr", + "list", + "--state", + "open", + "--base", + "main", + "--limit", + "100", + "--json", + "number,title,headRefName,author,isDraft,isCrossRepository,latestReviews", + ], + check=True, + capture_output=True, + text=True, + ) + pull_requests = json.loads(result.stdout) + found = conflicts(args.ecosystem, pull_requests) + if found: + print( + f"Dependency automation is blocked for {args.ecosystem} by open pull requests:", + file=sys.stderr, + ) + for conflict in found: + print(f" {conflict}", file=sys.stderr) + return 3 + + print(f"No conflicting {args.ecosystem} dependency pull requests.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/push-dependency-update b/script/push-dependency-update new file mode 100755 index 0000000..c93f4d4 --- /dev/null +++ b/script/push-dependency-update @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import subprocess +import sys + + +def output(args): + result = subprocess.run(args, check=True, capture_output=True, text=True) + return result.stdout + + +def run(args, input_text=None): + subprocess.run(args, check=True, input=input_text, text=True) + + +def reserved_pull_request(branch): + pull_requests = json.loads( + output( + [ + "gh", + "pr", + "list", + "--head", + branch, + "--state", + "open", + "--json", + "number,headRefOid", + ] + ) + ) + if len(pull_requests) > 1: + raise RuntimeError(f"Multiple open pull requests use reserved branch {branch}") + return pull_requests[0] if pull_requests else None + + +def close_pull_request(repository, number): + set_pull_request_state(repository, number, "closed") + + +def reopen_pull_request(repository, number): + set_pull_request_state(repository, number, "open") + + +def set_pull_request_state(repository, number, state): + body = json.dumps({"state": state}) + run( + [ + "gh", + "api", + "--method", + "PATCH", + f"repos/{repository}/pulls/{number}", + "--input", + "-", + "--silent", + ], + input_text=body, + ) + + +def remote_branch_sha(branch): + lines = output(["git", "ls-remote", "--heads", "origin", branch]).splitlines() + if len(lines) > 1: + raise RuntimeError(f"Multiple remote refs matched reserved branch {branch}") + return lines[0].split()[0] if lines else None + + +def current_sha(): + return output(["git", "rev-parse", "HEAD"]).strip() + + +def run_conflict_check(checker, ecosystem): + run(["python3", checker, ecosystem]) + + +def push_replacement(branch, previous_sha): + if previous_sha: + run( + [ + "git", + "push", + f"--force-with-lease=refs/heads/{branch}:{previous_sha}", + "origin", + f"HEAD:refs/heads/{branch}", + ] + ) + else: + run(["git", "push", "origin", f"HEAD:refs/heads/{branch}"]) + + +def rollback_replacement(branch, previous_sha, replacement_sha): + lease = f"--force-with-lease=refs/heads/{branch}:{replacement_sha}" + if previous_sha: + run( + [ + "git", + "push", + lease, + "origin", + f"{previous_sha}:refs/heads/{branch}", + ] + ) + else: + run(["git", "push", lease, "origin", f":refs/heads/{branch}"]) + + +def restore_original_state( + branch, previous_sha, replacement_sha, repository, pull_request +): + failures = [] + try: + observed_sha = remote_branch_sha(branch) + if observed_sha == replacement_sha: + rollback_replacement(branch, previous_sha, replacement_sha) + elif observed_sha != previous_sha: + failures.append( + f"reserved branch is at unexpected SHA {observed_sha or 'missing'}" + ) + except Exception as error: + failures.append(f"branch restore failed: {error}") + + if pull_request: + try: + reopen_pull_request(repository, pull_request["number"]) + except Exception as error: + failures.append(f"pull request reopen failed: {error}") + return failures + + +def replace_dependency_branch(ecosystem, branch, repository, checker): + pull_request = reserved_pull_request(branch) + previous_sha = remote_branch_sha(branch) + if pull_request and not previous_sha: + raise RuntimeError( + f"Reserved pull request #{pull_request['number']} has no remote branch" + ) + if pull_request and pull_request["headRefOid"] != previous_sha: + raise RuntimeError( + f"Reserved pull request #{pull_request['number']} does not match {branch}" + ) + + replacement_sha = current_sha() + run_conflict_check(checker, ecosystem) + + try: + if pull_request: + close_pull_request(repository, pull_request["number"]) + run_conflict_check(checker, ecosystem) + push_replacement(branch, previous_sha) + if remote_branch_sha(branch) != replacement_sha: + raise RuntimeError(f"Reserved branch {branch} did not reach replacement SHA") + if reserved_pull_request(branch): + raise RuntimeError( + "A pull request appeared on the reserved branch during replacement" + ) + except Exception as replacement_error: + failures = restore_original_state( + branch, previous_sha, replacement_sha, repository, pull_request + ) + if failures: + raise RuntimeError( + f"{replacement_error}; restore failed: {'; '.join(failures)}" + ) from replacement_error + raise RuntimeError( + f"{replacement_error}; restored original dependency pull request state" + ) from replacement_error + + +def main(): + parser = argparse.ArgumentParser( + description="Safely replace a reserved dependency update branch." + ) + parser.add_argument("ecosystem", choices=["cargo", "github-actions"]) + parser.add_argument("branch") + args = parser.parse_args() + + repository = os.environ["GITHUB_REPOSITORY"] + checker = os.environ["CONFLICT_CHECKER"] + try: + replace_dependency_branch(args.ecosystem, args.branch, repository, checker) + except (RuntimeError, subprocess.CalledProcessError) as error: + print(f"Dependency branch replacement failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/test-dependency-automation b/script/test-dependency-automation new file mode 100755 index 0000000..90a8a09 --- /dev/null +++ b/script/test-dependency-automation @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 + +import importlib.machinery +import importlib.util +import pathlib +import sys +import unittest +from unittest import mock + + +sys.dont_write_bytecode = True +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def load_script(name): + module_name = name.replace("-", "_") + path = ROOT / "script" / name + loader = importlib.machinery.SourceFileLoader(module_name, str(path)) + spec = importlib.util.spec_from_loader(module_name, loader) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +conflicts = load_script("check-dependency-pr-conflicts") +push = load_script("push-dependency-update") + + +class ConflictTests(unittest.TestCase): + def test_bot_draft_with_approval_blocks_replacement(self): + pull_requests = [ + { + "number": 7, + "title": "Update Cargo dependencies", + "headRefName": "automation/cargo-dependencies", + "author": {"login": "github-actions[bot]"}, + "isDraft": True, + "isCrossRepository": False, + "latestReviews": [ + {"author": {"login": "reviewer"}, "state": "APPROVED"} + ], + } + ] + + self.assertEqual( + conflicts.conflicts("cargo", pull_requests), + [ + "#7 uses reserved branch automation/cargo-dependencies " + "and has an approval; refusing to replace its reviewed diff" + ], + ) + + def test_non_draft_reserved_pull_request_blocks_replacement(self): + pull_requests = [ + { + "number": 7, + "title": "Update Cargo dependencies", + "headRefName": "automation/cargo-dependencies", + "author": {"login": "github-actions[bot]"}, + "isDraft": False, + "isCrossRepository": False, + } + ] + + self.assertEqual( + conflicts.conflicts("cargo", pull_requests), + [ + "#7 uses reserved branch automation/cargo-dependencies " + "but is no longer a draft" + ], + ) + + +class ReplacementTests(unittest.TestCase): + def setUp(self): + self.pull_request = {"number": 7, "headRefOid": "old"} + self.patches = [ + mock.patch.object( + push, + "reserved_pull_request", + side_effect=[self.pull_request, self.pull_request], + ), + mock.patch.object( + push, "remote_branch_sha", side_effect=["old", "new"] + ), + mock.patch.object(push, "current_sha", return_value="new"), + mock.patch.object(push, "run_conflict_check"), + mock.patch.object(push, "push_replacement"), + mock.patch.object(push, "rollback_replacement"), + mock.patch.object(push, "close_pull_request"), + mock.patch.object(push, "reopen_pull_request"), + ] + ( + self.reserved_pull_request, + self.remote_branch_sha, + self.current_sha, + self.run_conflict_check, + self.push_replacement, + self.rollback_replacement, + self.close_pull_request, + self.reopen_pull_request, + ) = [patch.start() for patch in self.patches] + self.addCleanup(lambda: [patch.stop() for patch in reversed(self.patches)]) + + def test_closes_old_pr_and_rechecks_before_push(self): + self.remote_branch_sha.side_effect = ["old", "new"] + calls = [] + self.run_conflict_check.side_effect = lambda *_: calls.append("conflict") + self.close_pull_request.side_effect = lambda *_: calls.append("close") + self.push_replacement.side_effect = lambda *_: calls.append("push") + self.reserved_pull_request.side_effect = [self.pull_request, None] + + push.replace_dependency_branch( + "cargo", + "automation/cargo-dependencies", + "github/twirp-rs", + "checker", + ) + + self.assertEqual( + self.run_conflict_check.call_args_list, + [mock.call("checker", "cargo"), mock.call("checker", "cargo")], + ) + self.close_pull_request.assert_called_once_with("github/twirp-rs", 7) + self.push_replacement.assert_called_once_with( + "automation/cargo-dependencies", "old" + ) + self.rollback_replacement.assert_not_called() + self.reopen_pull_request.assert_not_called() + self.assertEqual(calls, ["conflict", "close", "conflict", "push"]) + + def test_close_failure_reopens_pr_and_prevents_push(self): + self.remote_branch_sha.side_effect = ["old", "old"] + self.close_pull_request.side_effect = RuntimeError("not permitted") + with self.assertRaisesRegex( + RuntimeError, "restored original dependency pull request state" + ): + push.replace_dependency_branch( + "cargo", + "automation/cargo-dependencies", + "github/twirp-rs", + "checker", + ) + + self.push_replacement.assert_not_called() + self.rollback_replacement.assert_not_called() + self.reopen_pull_request.assert_called_once_with("github/twirp-rs", 7) + + def test_second_conflict_check_reopens_pr_and_prevents_push(self): + self.remote_branch_sha.side_effect = ["old", "old"] + self.run_conflict_check.side_effect = [ + None, + RuntimeError("conflicting PR appeared"), + ] + with self.assertRaisesRegex( + RuntimeError, "restored original dependency pull request state" + ): + push.replace_dependency_branch( + "cargo", + "automation/cargo-dependencies", + "github/twirp-rs", + "checker", + ) + + self.close_pull_request.assert_called_once_with("github/twirp-rs", 7) + self.push_replacement.assert_not_called() + self.rollback_replacement.assert_not_called() + self.reopen_pull_request.assert_called_once_with("github/twirp-rs", 7) + + def test_verification_failure_rolls_back_branch_and_reopens_pr(self): + self.remote_branch_sha.side_effect = ["old", "new", "new"] + self.reserved_pull_request.side_effect = [ + self.pull_request, + self.pull_request, + ] + with self.assertRaisesRegex( + RuntimeError, "restored original dependency pull request state" + ): + push.replace_dependency_branch( + "cargo", + "automation/cargo-dependencies", + "github/twirp-rs", + "checker", + ) + + self.rollback_replacement.assert_called_once_with( + "automation/cargo-dependencies", "old", "new" + ) + self.reopen_pull_request.assert_called_once_with("github/twirp-rs", 7) + + +class WorkflowTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.workflow = ( + ROOT / ".github" / "workflows" / "update-dependencies.yml" + ).read_text() + + def test_dispatch_checkout_is_pinned_to_current_main(self): + generate_checkout = self.workflow.index("- name: Checkout repository") + record_main = self.workflow.index("- name: Record main revision") + self.assertIn( + "ref: refs/heads/main", + self.workflow[generate_checkout:record_main], + ) + self.assertIn( + "ref: ${{ needs.generate.outputs.base_sha }}", + self.workflow[record_main:], + ) + + def test_apply_uses_trusted_push_and_conflict_scripts(self): + self.assertIn( + 'cp script/check-dependency-pr-conflicts ' + '"$RUNNER_TEMP/check-dependency-pr-conflicts"', + self.workflow, + ) + self.assertIn( + 'cp script/push-dependency-update ' + '"$RUNNER_TEMP/push-dependency-update"', + self.workflow, + ) + self.assertIn( + 'python3 "$PUSHER" "$ECOSYSTEM" "$BRANCH"', self.workflow + ) + + def test_apply_opens_a_fresh_pr_after_replacement(self): + self.assertIn("- name: Open fresh draft pull request", self.workflow) + self.assertNotIn( + "- name: Open or update draft pull request", self.workflow + ) + + def test_scheduled_and_manual_runs_share_ecosystem_concurrency_lane(self): + self.assertIn( + "group: update-dependencies-${{ " + "(github.event_name == 'workflow_dispatch' && inputs.ecosystem) || " + "(github.event.schedule == '37 6 * * 2' && 'cargo') || " + "(github.event.schedule == '17 7 * * 2' && 'github-actions') }}", + self.workflow, + ) + self.assertNotIn( + "github.event.schedule || inputs.ecosystem", self.workflow + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/script/update-cargo-dependencies b/script/update-cargo-dependencies new file mode 100755 index 0000000..debdae8 --- /dev/null +++ b/script/update-cargo-dependencies @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ ! -f Cargo.toml || ! -f Cargo.lock ]]; then + echo "error: run from the Cargo workspace root" >&2 + exit 2 +fi + +cargo update --verbose diff --git a/script/update-github-actions b/script/update-github-actions new file mode 100755 index 0000000..e7e0a85 --- /dev/null +++ b/script/update-github-actions @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 + +import argparse +import pathlib +import re +import subprocess +import sys + + +ACTION = re.compile( + r"^(?P\s*(?:-\s*)?uses:\s*)" + r"(?P['\"]?)" + r"(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)" + r"(?P/[^@\s#]+)?@" + r"(?P[^\s#]+)" + r"(?P=quote)" + r"(?P\s*)" + r"(?:#\s*(?P\S+)(?P.*))?$" +) +USES = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P.+?)\s*$") +SHA = re.compile(r"^[0-9a-f]{40}$") +TRACKED_REF = re.compile(r"^(?:stable|v[0-9]+(?:\.[0-9]+)?)$") +WORKFLOW_ROOTS = (pathlib.Path(".github/workflows"), pathlib.Path(".github/actions")) + + +def workflow_files(): + files = [] + for root in WORKFLOW_ROOTS: + if not root.exists(): + continue + for extension in ("*.yml", "*.yaml"): + files.extend(root.rglob(extension)) + return sorted(set(files)) + + +def resolve(repository, tracked_ref): + remote = f"https://github.com/{repository}.git" + refs = ( + f"refs/tags/{tracked_ref}^{{}}", + f"refs/tags/{tracked_ref}", + f"refs/heads/{tracked_ref}", + ) + result = subprocess.run( + ["git", "ls-remote", remote, *refs], + check=True, + capture_output=True, + text=True, + ) + found = {} + for line in result.stdout.splitlines(): + revision, ref = line.split(maxsplit=1) + found[ref] = revision + for ref in refs: + if ref in found: + return found[ref] + raise RuntimeError(f"{repository}@{tracked_ref} does not resolve to a tag or branch") + + +def update_file(path, check): + original = path.read_text(encoding="utf-8") + updated = [] + errors = [] + + for number, line in enumerate(original.splitlines(keepends=True), start=1): + newline = "\n" if line.endswith("\n") else "" + content = line.removesuffix("\n") + match = ACTION.match(content) + if not match: + uses = USES.match(content) + if uses: + value = uses.group("value").strip("'\"") + if not value.startswith(("./", "docker://")): + errors.append( + f"{path}:{number}: unsupported remote action declaration" + ) + updated.append(line) + continue + + revision = match.group("revision") + tracked_ref = match.group("tracked_ref") + if check: + if not SHA.fullmatch(revision): + errors.append(f"{path}:{number}: action is not pinned to a full commit SHA") + if not tracked_ref: + errors.append(f"{path}:{number}: pinned action is missing its tracked ref comment") + elif not TRACKED_REF.fullmatch(tracked_ref): + errors.append( + f"{path}:{number}: tracked ref must be stable, vMAJOR, or vMAJOR.MINOR" + ) + updated.append(line) + continue + + if SHA.fullmatch(revision): + if not tracked_ref: + errors.append( + f"{path}:{number}: cannot update pinned action without a tracked ref comment" + ) + updated.append(line) + continue + else: + tracked_ref = revision + + if not TRACKED_REF.fullmatch(tracked_ref): + errors.append( + f"{path}:{number}: tracked ref must be stable, vMAJOR, or vMAJOR.MINOR" + ) + updated.append(line) + continue + + try: + resolved = resolve(match.group("repository"), tracked_ref) + except (RuntimeError, subprocess.CalledProcessError) as error: + errors.append(f"{path}:{number}: {error}") + updated.append(line) + continue + + suffix = f" # {tracked_ref}{match.group('comment') or ''}" + updated.append( + f"{match.group('prefix')}{match.group('quote')}{match.group('repository')}" + f"{match.group('path') or ''}@{resolved}{match.group('quote')}" + f"{suffix}{newline}" + ) + + if errors: + return errors, False + + replacement = "".join(updated) + changed = replacement != original + if changed and not check: + path.write_text(replacement, encoding="utf-8") + return [], changed + + +def main(): + parser = argparse.ArgumentParser( + description="Pin tracked GitHub Actions refs to their current full commit SHAs." + ) + parser.add_argument( + "--check", + action="store_true", + help="verify every remote action is SHA-pinned with a tracked ref comment", + ) + args = parser.parse_args() + + files = workflow_files() + if not files: + print("error: no GitHub Actions workflow or action files found", file=sys.stderr) + return 2 + + errors = [] + changed = [] + for path in files: + file_errors, file_changed = update_file(path, args.check) + errors.extend(file_errors) + if file_changed: + changed.append(str(path)) + + if errors: + print("\n".join(errors), file=sys.stderr) + return 1 + + if args.check: + print(f"Verified {len(files)} workflow/action files.") + elif changed: + print("Updated action pins:") + for path in changed: + print(f" {path}") + else: + print("GitHub Actions pins are current.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())