Rewrite action in Rust using octocrab#16
Merged
williamdes merged 13 commits intomainfrom Apr 15, 2026
Merged
Conversation
Replace the Node.js implementation with a Rust crate that is distributed as a Docker container action. Keeps the same input/output contract so existing workflows continue to work unchanged. Ecosystem packages used: tokio, octocrab (GitHub REST client), serde, regex, anyhow, async-trait. The GitHub client is behind a trait so the action flow can be exercised end-to-end with fakes — 30 unit and integration tests cover username filtering, label filtering, fast-forward vs merge paths, label cleanup, and input/context parsing. https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
The scaffolding pattern (stub src/ + cargo fetch to prime cache) breaks for crates declaring both [lib] and [[bin]], and also ran into a Cargo.lock version 4 incompatibility with the pinned Rust 1.82 image. Switch to rust:1-alpine (latest stable 1.x), copy sources in one shot, and rely on BuildKit --mount=type=cache for registry and target caching. https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
- actions/checkout: v4 → v6 - docker/setup-buildx-action: v3 → v4 - docker/build-push-action: v6 → v7 - Swatinem/rust-cache and dtolnay/rust-toolchain stay current Add explicit permissions blocks so each workflow only asks for what it needs: - build.yml: contents: read (code checkout only) - merge.yml: contents + pull-requests: write (merge + remove label) - lock.yml: issues + pull-requests: write (lock conversation) https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
Build the binary on rust:1-bookworm (non-Alpine / glibc) and cross-compile a fully static musl binary. The runtime image is scratch — no OS, no shell — containing just the binary and the TLS root store that octocrab's rustls-native-certs looks up at runtime. Sets CC_x86_64_unknown_linux_musl=musl-gcc so ring's C/asm code (pulled in via rustls) cross-compiles cleanly. https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
- Document every input in a table with required/default columns - Clarify merge-method semantics, especially fast-forward - Show the required GITHUB_TOKEN permissions block - Reshape examples: complete workflow file, squash w/ title+message, fast-forward, regex-based multi-maintainer setup - Add a Behaviour section describing the check order and which failures warn vs. fail the step https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
Consumers of this action should pull a prebuilt image rather than rebuild from Dockerfile on every run. Mirror the layout already in use at sudo-bot/action-shellcheck: - Move Dockerfile to docker/Dockerfile and add OCI labels via VCS_REF / BUILD_DATE build args - Add Makefile with docker-build / update-tags targets, same env-var knobs (IMAGE_TAG, PLATFORM, ACTION, PROGRESS_MODE) - Add .github/workflows/release.yml triggered on push of the 'latest' tag, logging into Docker Hub and running 'make docker-build' with ACTION=push IMAGE_TAG=botsudo/action-pull-request-merge - Point action.yml at docker://botsudo/action-pull-request-merge:latest so the runner pulls the prebuilt image instead of building it - build.yml's docker job now runs 'make docker-build' (ACTION=load) to exercise the same Makefile in CI Requires the secrets DOCKER_REPOSITORY_LOGIN / DOCKER_REPOSITORY_PASSWORD to already be configured on the repo (same names as action-shellcheck). https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
- Use GITHUB_TOKEN with packages: write to push the image; no per-repo Docker Hub secret is required. - Image now lives at ghcr.io/sudo-bot/action-pull-request-merge. - action.yml references docker://ghcr.io/... so the runner pulls from GitHub's own registry. https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
After loading the image into the CI daemon, exercise the scratch binary with two network-free cases to prove it actually runs: 1. No inputs → exit 1 with 'Input required and not supplied: github-token' on stdout (asserts the binary executes at all and input parsing surfaces errors correctly). 2. allowed-usernames-regex that does not match the actor → exit 0 with 'Ignored, the username does not match.' on stdout (asserts the actor-skip path, including logger output and the Tokio runtime, works end-to-end). Neither case makes an API call, so no GITHUB_TOKEN or network access is needed. https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
The musl-target binary was still dynamically linked against /lib/ld-musl-x86_64.so.1, which is absent in the scratch runtime image — hence 'exec /action-pull-request-merge: no such file or directory' at docker run time. - Set RUSTFLAGS='-C target-feature=+crt-static' so the produced binary is fully self-contained (no PT_INTERP). - Add a file(1) check in the build stage so any future regression fails the docker build instead of the smoke test. - Rework the CI smoke tests to print exit code and raw output before asserting, so failures surface the binary's actual output in the CI log. https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
Rust on the musl target with +crt-static produces a static-pie executable. file(1) reports that as 'static-pie linked', not 'statically linked', so the prior check tripped and failed the build even though the binary was fully self-contained. Accept either wording, and explicitly reject 'dynamically linked' so the intent is obvious. https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
The previous run showed the binary was still 'dynamically linked, interpreter /lib/ld-musl-x86_64.so.1' despite ENV RUSTFLAGS — the env var either didn't propagate to the nested cargo invocation or the BuildKit target/ cache mount served stale artifacts from before the flag change (cargo's fingerprint/cache mount interaction is notoriously flaky). - Write .cargo/config.toml with target-scoped rustflags so cargo reads the flag directly from disk, independent of any env-var propagation. - Drop --mount=type=cache,target=/src/target so changes to rustflags force a genuine rebuild and can never ship stale object files. The registry cache is kept so crates are only downloaded once. https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
Latest run still produced a dynamically-linked binary despite both
ENV RUSTFLAGS and .cargo/config.toml. To make the build
(a) unambiguously apply +crt-static, and
(b) emit enough diagnostics that the next failure log tells us
exactly what went wrong,
pass rustflags via `cargo --config` (highest-priority override) and
print the resolved config, RUSTFLAGS env, file(1) output, and
readelf -d dynamic section around the build. Also assert via
readelf that the binary has no NEEDED entries — catches cases where
file(1) confusingly calls a binary 'static-pie' while it still has
dynamic deps.
https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
Fighting glibc→musl cross-compile + crt-static on Debian produced four consecutive CI failures with inconsistent results (ENV RUSTFLAGS, .cargo/config.toml, and cargo --config each failed to make the binary static). Switch to the straightforward setup: - Build on rust:1-alpine (musl-native). Just 'apk add musl-dev' for ring's C deps, no cross-compile, no RUSTFLAGS, no linker overrides. - Runtime is alpine:3.23 with ca-certificates installed so rustls-native-certs finds the TLS trust store at runtime. - Drop scratch / static-pie / file(1) / readelf diagnostics — the ABI matches between build and runtime images, so dynamic linking against musl is fine. Smoke tests in build.yml continue to exercise the image as before. https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the Node.js implementation (
index.js+node_modules/) with a Rust crate distributed as a Docker container action. The input/output contract is unchanged — existing workflows using this action continue to work.Ecosystem crates used
octocrab— de-facto async GitHub REST client (pulls.get,pulls.merge,issues.remove_label, plus raw_patchforgit/refs)tokio— async runtimeserde/serde_json— JSON (de)serialisationregex— mirrors the JSnew RegExp(...)forallowed-usernames-regexandfilter-labelanyhow+async-trait— error propagation and mockable client traitNo widely-used "actions-toolkit" crate exists, so the small
@actions/coresubset we need (workflow commands, input env-var decoding) is implemented inline insrc/logger.rsandsrc/inputs.rs.Structure
What's covered by tests (30/30 passing)
GITHUB_API_URL%/\r/\nescapingSkippedActor,SkippedClosed,SkippedLabelMissing,FastForwarded,Mergedfilter-labelregex matching (matches JSnew RegExp(filter_label))INPUT_*-style map to concrete merge / update_ref callsAlso clean under
cargo clippy --all-targets -- -D warnings,cargo fmt --check, andcargo build --release.Test plan
lint-node→ replaced byRust test & lint(fmt + clippy + cargo test) andBuild Docker imagejobscargo testpasses in CIhttps://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf