From f96b99967b1fd81c3e9bbd26afde10ad43f143e3 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 11 Jul 2026 02:46:52 +0000 Subject: [PATCH] Add commit signature verification before source build When prebuilt binaries are unavailable, verify the source commit's GPG signature before falling back to cargo install. This prevents building from a compromised repository and closes a supply-chain attack vector. The verification step: - Fetches the commit object from the remote repository - Uses git verify-commit to validate the GPG signature - Fails with a clear error if the commit is unsigned or verification fails - Requires that git has access to the signer's public key Also adds platform support documentation and CI tests for both signed and unsigned commit verification, plus musl target selection for prebuilt artifacts. --- .github/workflows/ci.yml | 67 +++++++++++++++++++++++++++++++++++++--- README.md | 16 ++++++++++ action.yml | 58 ++++++++++++++++++++++++++++------ 3 files changed, 127 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd3ce5..31c4784 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,57 @@ jobs: # follow at lint time; not a real finding. shellcheck --shell=bash --exclude=SC1091 "${scripts[@]}" + commit-signature-verification: + runs-on: ubuntu-latest + steps: + - name: Signed postil-cli commit verifies + env: + CLI_REF: e448377c918c1d6fb91f9347d8be5153a4518480 + run: | + set -euo pipefail + export GNUPGHOME="$RUNNER_TEMP/postil-gnupg" + install -m 700 -d "$GNUPGHOME" + curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 https://github.com/web-flow.gpg | gpg --batch --import + + verify_repo="$RUNNER_TEMP/postil-cli-signature" + rm -rf "$verify_repo" + git init -q "$verify_repo" + git -C "$verify_repo" fetch --quiet --depth=1 https://github.com/postil-dev/postil-cli "$CLI_REF" + git -C "$verify_repo" verify-commit "$CLI_REF" + + - name: Unsigned commit fails with clear error + run: | + set -euo pipefail + unsigned_repo="$RUNNER_TEMP/unsigned-source" + git init -q "$unsigned_repo" + git -C "$unsigned_repo" config user.name "Unsigned Commit" + git -C "$unsigned_repo" config user.email "unsigned@example.invalid" + git -C "$unsigned_repo" -c commit.gpgsign=false commit --quiet --allow-empty -m "unsigned fixture" + CLI_REF="$(git -C "$unsigned_repo" rev-parse HEAD)" + + verify_repo="$RUNNER_TEMP/unsigned-signature" + rm -rf "$verify_repo" + git init -q "$verify_repo" + git -C "$verify_repo" fetch --quiet --depth=1 "$unsigned_repo" "$CLI_REF" + + log="$RUNNER_TEMP/unsigned-verify.log" + set +e + ( + if ! git -C "$verify_repo" verify-commit "$CLI_REF"; then + echo "::error::postil-cli commit $CLI_REF is not signed by a trusted key, or git cannot access the signer's public key. Import the signer's public key before using source builds." + exit 42 + fi + ) >"$log" 2>&1 + status=$? + set -e + + if [ "$status" -ne 42 ]; then + cat "$log" + echo "::error::unsigned commit verification exited with status $status, expected 42" + exit 1 + fi + grep -F "is not signed by a trusted key, or git cannot access the signer's public key" "$log" + # The action has three composite steps: "Validate inputs", "Install # postil" (fetch + cosign-verify the pinned CLI, or build from source), # and "Review" (the actual LLM call). They are not separable via `uses:` @@ -140,6 +191,12 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-gnu + - x86_64-unknown-linux-musl steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 @@ -147,17 +204,17 @@ jobs: env: CLI_REF: 87f4bf08b63712d3600030a7c458f0b790cfc0d5 # postil-cli v0.1.1 CLI_RELEASE: v0.1.1 + TARGET: ${{ matrix.target }} run: | set -euo pipefail DEST="$RUNNER_TEMP/postil-bin" mkdir -p "$DEST" - target="x86_64-unknown-linux-gnu" base="https://github.com/postil-dev/postil-cli/releases/download/$CLI_RELEASE" - curl -fsSL -o "$DEST/postil.tar.gz" "$base/postil-$target.tar.gz" - curl -fsSL -o "$DEST/postil.tar.gz.sha256" "$base/postil-$target.tar.gz.sha256" - curl -fsSL -o "$DEST/postil.tar.gz.sig" "$base/postil-$target.tar.gz.sig" - curl -fsSL -o "$DEST/postil.tar.gz.pem" "$base/postil-$target.tar.gz.pem" + curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 -o "$DEST/postil.tar.gz" "$base/postil-$TARGET.tar.gz" + curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 -o "$DEST/postil.tar.gz.sha256" "$base/postil-$TARGET.tar.gz.sha256" + curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 -o "$DEST/postil.tar.gz.sig" "$base/postil-$TARGET.tar.gz.sig" + curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 -o "$DEST/postil.tar.gz.pem" "$base/postil-$TARGET.tar.gz.pem" expected=$(awk '{print $1}' "$DEST/postil.tar.gz.sha256") actual=$(sha256sum "$DEST/postil.tar.gz" | awk '{print $1}') diff --git a/README.md b/README.md index 4cc7525..cc98ad7 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,22 @@ gate without failing this job, set `soft-fail: true` and mark the `postil/gate` required in branch protection instead — that is the recommended setup: advisory comments never block, the gate check does. +## Platform support + +Linux runners use prebuilt CLI release artifacts when `cli-release` is set and +the release tag points at the pinned `cli-ref`. The action selects the target +from the runner CPU and libc: + +| Runner | Selected target | +|---|---| +| x86_64 glibc Linux | `x86_64-unknown-linux-gnu` | +| aarch64 glibc Linux | `aarch64-unknown-linux-gnu` | +| x86_64 Alpine/musl Linux | `x86_64-unknown-linux-musl` | +| aarch64 Alpine/musl Linux | `aarch64-unknown-linux-musl` | + +If a matching prebuilt artifact is not present on the pinned CLI release, the +action falls back to building `postil` from `cli-ref`. + ## Inputs | Input | Required | Description | diff --git a/action.yml b/action.yml index b42d70c..a8cb694 100644 --- a/action.yml +++ b/action.yml @@ -107,12 +107,46 @@ runs: if: inputs.cli-release != '' uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + - name: Select postil target + id: target + shell: bash + run: | + set -euo pipefail + + target="" + if [ "$RUNNER_OS" = "Linux" ]; then + libc="gnu" + if command -v ldd >/dev/null 2>&1; then + ldd_output="$(ldd --version 2>&1 || true)" + if printf '%s\n' "$ldd_output" | grep -qi musl; then + libc="musl" + fi + elif [ "$libc" != "musl" ] \ + && { compgen -G "/lib/ld-musl-*.so.1" >/dev/null \ + || compgen -G "/usr/lib/ld-musl-*.so.1" >/dev/null; }; then + libc="musl" + fi + + case "$(uname -m)" in + x86_64) target="x86_64-unknown-linux-$libc" ;; + aarch64|arm64) target="aarch64-unknown-linux-$libc" ;; + esac + fi + + if [ -n "$target" ]; then + echo "Selected postil target $target" + else + echo "::notice::no prebuilt target for $RUNNER_OS/$(uname -m); falling back to source build" + fi + echo "target=$target" >> "$GITHUB_OUTPUT" + - name: Install postil shell: bash env: CLI_REF: ${{ inputs.cli-ref }} CLI_RELEASE: ${{ inputs.cli-release }} GH_TOKEN: ${{ inputs.github-token }} + POSTIL_TARGET: ${{ steps.target.outputs.target }} run: | set -euo pipefail DEST="$RUNNER_TEMP/postil-bin" @@ -133,13 +167,7 @@ runs: if [ "$tag_sha" = "$CLI_REF" ]; then # Prebuilt artifacts are Linux-only here; other runners fall back # to the source build rather than fetching a binary that cannot run. - target="" - if [ "$RUNNER_OS" = "Linux" ]; then - case "$(uname -m)" in - x86_64) target="x86_64-unknown-linux-gnu" ;; - aarch64|arm64) target="aarch64-unknown-linux-gnu" ;; - esac - fi + target="$POSTIL_TARGET" if [ -n "$target" ]; then base="https://github.com/postil-dev/postil-cli/releases/download/$CLI_RELEASE" if curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 -o "$DEST/postil.tar.gz" "$base/postil-$target.tar.gz" \ @@ -168,8 +196,6 @@ runs: else echo "::warning::no prebuilt artifact (or signature) for $target on $CLI_RELEASE; falling back to source build" fi - else - echo "::notice::no prebuilt target for $RUNNER_OS/$(uname -m); falling back to source build" fi else echo "::warning::release $CLI_RELEASE points at ${tag_sha:-unknown}, not cli-ref $CLI_REF; falling back to source build" @@ -178,6 +204,20 @@ runs: if [ "$prebuilt_ok" = false ]; then echo "Building postil from postil-dev/postil-cli@$CLI_REF" + verify_repo="$RUNNER_TEMP/postil-cli-signature" + rm -rf "$verify_repo" + git init -q "$verify_repo" + if ! git -C "$verify_repo" fetch --quiet --depth=1 https://github.com/postil-dev/postil-cli "$CLI_REF"; then + echo "::error::failed to fetch postil-cli commit $CLI_REF for signature verification" + exit 1 + fi + # Source builds use Git's built-in GPG verification. The runner's + # git/GPG config must be able to resolve the signer's public key, + # for example from a keyserver or repository .git/config. + if ! git -C "$verify_repo" verify-commit "$CLI_REF"; then + echo "::error::postil-cli commit $CLI_REF is not signed by a trusted key, or git cannot access the signer's public key. Import the signer's public key before using source builds." + exit 1 + fi if ! command -v cargo >/dev/null 2>&1; then curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 https://sh.rustup.rs | sh -s -- -y --profile minimal . "$HOME/.cargo/env"