Skip to content

Rewrite action in Rust using octocrab#16

Merged
williamdes merged 13 commits intomainfrom
claude/rewrite-rust-action-ve35h
Apr 15, 2026
Merged

Rewrite action in Rust using octocrab#16
williamdes merged 13 commits intomainfrom
claude/rewrite-rust-action-ve35h

Conversation

@williamdes
Copy link
Copy Markdown
Member

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 _patch for git/refs)
  • tokio — async runtime
  • serde / serde_json — JSON (de)serialisation
  • regex — mirrors the JS new RegExp(...) for allowed-usernames-regex and filter-label
  • anyhow + async-trait — error propagation and mockable client trait

No widely-used "actions-toolkit" crate exists, so the small @actions/core subset we need (workflow commands, input env-var decoding) is implemented inline in src/logger.rs and src/inputs.rs.

Structure

src/
  main.rs           # binary wrapper, exits 1 on failure
  action.rs         # decision flow ported 1:1 from index.js
  inputs.rs         # INPUT_* env-var parsing
  context.rs        # GITHUB_REPOSITORY / GITHUB_ACTOR / GITHUB_API_URL
  github_client.rs  # GithubClient trait + OctocrabClient
  logger.rs         # Logger trait + StdoutLogger + CaptureLogger
tests/integration.rs
Dockerfile          # multi-stage rust:1.82-alpine → alpine:3.20
action.yml          # now: using docker / image: Dockerfile

What's covered by tests (30/30 passing)

  • input parsing: required/optional/default/empty/invalid numeric/invalid method
  • env-var name normalisation
  • context parsing including GHES GITHUB_API_URL
  • workflow-command % / \r / \n escaping
  • all five action outcomes: SkippedActor, SkippedClosed, SkippedLabelMissing, FastForwarded, Merged
  • filter-label regex matching (matches JS new RegExp(filter_label))
  • label removal failure is warning-only, never fails the action
  • merge API failure propagates as error
  • end-to-end wiring from an INPUT_*-style map to concrete merge / update_ref calls

Also clean under cargo clippy --all-targets -- -D warnings, cargo fmt --check, and cargo build --release.

Test plan

  • CI lint-node → replaced by Rust test & lint (fmt + clippy + cargo test) and Build Docker image jobs
  • Confirm cargo test passes in CI
  • Confirm Docker image builds in CI
  • Smoke-test the action on a real PR once merged to a release tag

https://claude.ai/code/session_01NeSewqCZN17eNFt5VKWkcf

claude added 8 commits April 15, 2026 08:31
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
@williamdes williamdes self-assigned this Apr 15, 2026
claude added 5 commits April 15, 2026 10:57
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
@williamdes williamdes merged commit d130618 into main Apr 15, 2026
5 checks passed
@williamdes williamdes deleted the claude/rewrite-rust-action-ve35h branch April 15, 2026 11:29
@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Apr 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants