github-actions-maintainer 0.7.10

General-purpose GitHub Actions maintenance toolkit with secure workflow pinning
Documentation
# GitHub Actions Maintainer static runtime adaptation
# Multi-stage build for single-crate or workspace-based applications.
#
# Build a fully static musl CLI and tini, then ship only those executables,
# CA certificates and nonroot account data in distroless/static. No glibc,
# zlib, GCC shared runtime, shell or package manager is shipped. Keep all
# Cargo features and explicit runtime trust roots.
# Repo-specific values are build ARGs; .github/workflows/docker.yml supplies
# them from repository variables.
#
# Base images are pinned by digest for reproducibility (Scorecard
# Pinned-Dependencies). Refresh with:
#   docker buildx imagetools inspect <image> | awk '/^Digest:/{print $2}'
# Dependabot refreshes the Rust builder; refresh the runtime digest with the
# command above when it is updated.

# Refresh trust data and its package provenance together. Distroless currently
# has fixed CA data but stale package metadata; never globally dismiss the
# advisory while older published images still trust the distrusted roots.
FROM --platform=$BUILDPLATFORM debian:trixie-slim@sha256:a72b4433de1cf7d6bae0c4cee74d0547f58601a643b3375f070c51600d63b6b2 AS trust-store

# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
    dpkg --compare-versions "$(dpkg-query -W -f='${Version}' ca-certificates)" ge 20250419+deb13u1 && \
    dpkg-query -s ca-certificates > /ca-certificates.status && \
    rm -rf /var/lib/apt/lists/*

# rust 1.99.1 on Alpine 3.24 (musl); multi-arch index digest
FROM rust:1.99.1-alpine3.24@sha256:44819b27be4c97b0e4d7203bd2e02c4820f97700211b6a37fb4c246a2de59383 AS rust-base

# The 1.100.0 compiler is stable; its official Docker indexes are not published
# yet. Use the verified digest only to bootstrap rustup, then replace the old
# compiler before building or exporting this toolchain base image.
RUN rustup toolchain install 1.100.0 --profile minimal --no-self-update \
      --component rustfmt --component clippy --component llvm-tools-preview && \
    rustup default 1.100.0 && \
    rustup toolchain uninstall 1.99.1 && \
    test "$(rustc --version | cut -d ' ' -f 2)" = 1.100.0

ARG VERSION=0.0.0
ARG BUILD_DATE=unknown
ARG VCS_REF=unknown
ARG BINARY_NAME=github-actions-maintainer
ARG BINARY_PACKAGE=
ARG CLI_NAME=github-actions-maintainer
ARG SBOM_MANIFEST_PATH=Cargo.toml
ARG OCI_IMAGE_TITLE="github_actions runtime"
ARG OCI_IMAGE_DESCRIPTION="github_actions maintained static runtime"
ARG OCI_IMAGE_VENDOR=ThreatFlux
ARG OCI_IMAGE_SOURCE=https://github.com/ThreatFlux/github_actions

SHELL ["/bin/ash", "-eo", "pipefail", "-c"]

# Refresh these direct Alpine package pins together with the builder digest.
# tini-static preserves signal forwarding and zombie reaping without a libc
# dependency. Build tools stay in the builder; OpenSSL is vendored by Cargo.
RUN apk add --no-cache \
    binutils=2.45.1-r1 \
    build-base=0.5-r4 \
    ca-certificates=20261020-r0 \
    cmake=4.2.3-r0 \
    linux-headers=7.0.0-r1 \
    perl=5.42.3-r0 \
    pkgconf=2.5.1-r0 \
    tini-static=0.19.0-r3

FROM rust-base AS builder

# Bound compilation parallelism on shared builders and emulated platforms.
ENV CARGO_BUILD_JOBS=2

RUN adduser -D -u 1000 builder
USER builder
WORKDIR /build

ENV CARGO_HOME=/home/builder/.cargo
ENV PATH="/home/builder/.cargo/bin:${PATH}"

COPY --chown=builder:builder . .

# An explicit target keeps host proc macros separate from target static flags.
# Disable Rust's older bundled musl CRT: link the maintained Alpine libc.a.
# Keep only Rust's LLVM unwinder in a separate search path (never bundled libc).
# Prove the selected archive from the link map and reject dynamic dependencies.
RUN rustc --version --verbose && cargo --version && \
    build_target="$(rustc -vV | sed -n 's/^host: //p')" && \
    mkdir -p /home/builder/native-unwind && \
    cp "$(rustc --print sysroot)/lib/rustlib/${build_target}/lib/self-contained/libunwind.a" /home/builder/native-unwind/ && \
    RUSTFLAGS="-C target-feature=+crt-static -C link-self-contained=no -C linker=cc -C relocation-model=pic -C link-arg=-static-pie -L native=/home/builder/native-unwind -C link-arg=-Wl,-Map=/home/builder/native-link.map" OPENSSL_STATIC=1 \
      cargo build --locked --release --target "${build_target}" \
        ${BINARY_PACKAGE:+-p "${BINARY_PACKAGE}"} --bin "${BINARY_NAME}" --all-features && \
    musl_archive="$(grep -oE '/[^[:space:](]+/libc\.a' /home/builder/native-link.map | sort -u)" && \
    test "$(readlink -f "${musl_archive}")" = /usr/lib/libc.a && \
    readelf -h "target/${build_target}/release/${BINARY_NAME}" | grep -q 'Type:.*DYN' && \
    for executable in "target/${build_target}/release/${BINARY_NAME}" /sbin/tini-static; do \
      if readelf -l "${executable}" | grep -q INTERP || \
         readelf -d "${executable}" | grep -q NEEDED; then \
        echo "Dynamic dependency in ${executable}" >&2; exit 1; \
      fi; \
    done

# cargo-cyclonedx writes the SBOM beside the manifest it was handed, which in a
# workspace is not necessarily /build. The find normalizes the output location.
RUN cargo install cargo-cyclonedx --locked --version 0.5.10 && \
    cargo cyclonedx \
      --manifest-path "${SBOM_MANIFEST_PATH}" \
      --all-features \
      --format json \
      --spec-version 1.5 \
      --override-filename "${BINARY_NAME}-sbom" && \
    find /build -name "${BINARY_NAME}-sbom.json" -exec cp {} /build/sbom.cdx.json \; -quit && \
    test -s /build/sbom.cdx.json

# Stage the runtime filesystem under a fixed layout.
#
# The binary is staged as `app` at a FIXED path: exec-form ENTRYPOINT and
# HEALTHCHECK do not expand build ARGs. A symlink keeps the CLI's own name
# (`github-actions-maintainer`) available on PATH.
#
# Writable directories are created here because the distroless runtime has no
# shell to mkdir with; ownership is applied on the way in via COPY --chown.
RUN mkdir -p /home/builder/out/bin /home/builder/out/doc \
             /home/builder/runtime-skel/data \
             /home/builder/runtime-skel/config \
             /home/builder/runtime-skel/output && \
    build_target="$(rustc -vV | sed -n 's/^host: //p')" && \
    cp "target/${build_target}/release/${BINARY_NAME}" /home/builder/out/bin/app && \
    if [ "${CLI_NAME}" != "app" ]; then \
      ln -s app "/home/builder/out/bin/${CLI_NAME}"; \
    fi && \
    cp /build/sbom.cdx.json /home/builder/out/doc/sbom.cdx.json && \
    { rustc -vV; apk list --installed musl musl-dev tini-static; \
      sha256sum /usr/lib/libc.a /sbin/tini-static /home/builder/native-unwind/libunwind.a; \
      grep -oE '/[^[:space:](]+/libc\.a' /home/builder/native-link.map | sort -u; \
    } > /home/builder/out/doc/native-build.txt

# Static distroless: certificates/account data, no dynamic libc; multi-arch digest
FROM gcr.io/distroless/static-debian13:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329 AS runtime

ARG VERSION=0.0.0
ARG BUILD_DATE=unknown
ARG VCS_REF=unknown
ARG OCI_IMAGE_TITLE="github_actions runtime"
ARG OCI_IMAGE_DESCRIPTION="github_actions maintained static runtime"
ARG OCI_IMAGE_VENDOR=ThreatFlux
ARG OCI_IMAGE_SOURCE=https://github.com/ThreatFlux/github_actions
ARG OCI_IMAGE_LICENSES=MIT
ARG OCI_IMAGE_DOCUMENTATION=https://github.com/ThreatFlux/github_actions/blob/main/README.md

LABEL org.opencontainers.image.title="${OCI_IMAGE_TITLE}" \
      org.opencontainers.image.description="${OCI_IMAGE_DESCRIPTION}" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.created="${BUILD_DATE}" \
      org.opencontainers.image.revision="${VCS_REF}" \
      org.opencontainers.image.vendor="${OCI_IMAGE_VENDOR}" \
      org.opencontainers.image.source="${OCI_IMAGE_SOURCE}" \
      org.opencontainers.image.licenses="${OCI_IMAGE_LICENSES}" \
      org.opencontainers.image.documentation="${OCI_IMAGE_DOCUMENTATION}"

# These are the only CA trust files in the pinned static base. Copy the fixed
# supplier data and exact installed-package record, not a relabeled version.
COPY --from=trust-store /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=trust-store /ca-certificates.status /var/lib/dpkg/status.d/ca-certificates

ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt

COPY --from=builder /sbin/tini-static /usr/bin/tini

# The binary and SBOM stay root-owned so the runtime user cannot modify them;
# only the working directories belong to the nonroot user.
COPY --from=builder --chown=0:0 /home/builder/out/bin/ /usr/local/bin/
COPY --from=builder --chown=0:0 /home/builder/out/doc/ /usr/share/doc/app/
COPY --from=builder --chown=65532:65532 /home/builder/runtime-skel/data /data
COPY --from=builder --chown=65532:65532 /home/builder/runtime-skel/config /config
COPY --from=builder --chown=65532:65532 /home/builder/runtime-skel/output /output

USER 65532:65532
WORKDIR /home/nonroot

# Exec form (there is no shell in distroless); a nonzero exit means unhealthy.
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD ["/usr/local/bin/app", "--version"]

# Arguments go straight to the CLI: `docker run <image> --help`.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/app"]