diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4558e09..5b3b411 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,12 +122,340 @@ jobs: # follow at lint time; not a real finding. shellcheck --shell=bash --exclude=SC1091 "${scripts[@]}" + self-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Extract composite scripts + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/action-scripts" + python3 - <<'PY' + import os + import re + import yaml + + with open("action.yml") as source: + action = yaml.safe_load(source) + + outdir = os.path.join(os.environ["RUNNER_TEMP"], "action-scripts") + for index, step in enumerate(action["runs"]["steps"]): + script = step.get("run") + if not script: + continue + slug = re.sub(r"[^a-zA-Z0-9]", "-", step.get("name", f"step-{index}")) + path = os.path.join(outdir, f"{index}-{slug}.sh") + with open(path, "w") as output: + output.write(script if script.endswith("\n") else script + "\n") + PY + + - name: Reject invalid provider input without exposing credentials + run: | + set -euo pipefail + validate_script=("$RUNNER_TEMP"/action-scripts/*-Validate-inputs.sh) + [ "${#validate_script[@]}" -eq 1 ] + CLI_REF=0123456789abcdef0123456789abcdef01234567 + + env CLI_REF="$CLI_REF" DEPRECATED_REF='' API_FORMAT='' \ + ENDPOINT_AUTH_HEADER='' ENDPOINT_AUTH_VALUE='' \ + ALLOW_PRIVATE_API_BASE=false bash "${validate_script[0]}" + env CLI_REF="$CLI_REF" DEPRECATED_REF='' API_FORMAT=anthropic \ + ENDPOINT_AUTH_HEADER=X-Gateway-Token \ + ENDPOINT_AUTH_VALUE=private-value-never-log \ + ALLOW_PRIVATE_API_BASE=true bash "${validate_script[0]}" + + expect_failure() { + expected="$1" + shift + log="$RUNNER_TEMP/validation-error.log" + set +e + "$@" >"$log" 2>&1 + status=$? + set -e + [ "$status" -ne 0 ] + grep -F "$expected" "$log" + if grep -F "private-value-never-log" "$log"; then + echo "::error::endpoint authentication value appeared in validation output" + exit 1 + fi + } + + expect_failure "api-format must be" env \ + CLI_REF="$CLI_REF" DEPRECATED_REF='' API_FORMAT=messages \ + ENDPOINT_AUTH_HEADER='' ENDPOINT_AUTH_VALUE='' \ + ALLOW_PRIVATE_API_BASE=false bash "${validate_script[0]}" + expect_failure "must be set together" env \ + ENDPOINT_AUTH_HEADER=X-Gateway-Token \ + ENDPOINT_AUTH_VALUE='' \ + CLI_REF="$CLI_REF" DEPRECATED_REF='' API_FORMAT='' \ + ALLOW_PRIVATE_API_BASE=false bash "${validate_script[0]}" + expect_failure "must be set together" env \ + ENDPOINT_AUTH_HEADER='' \ + ENDPOINT_AUTH_VALUE=private-value-never-log \ + CLI_REF="$CLI_REF" DEPRECATED_REF='' API_FORMAT='' \ + ALLOW_PRIVATE_API_BASE=false bash "${validate_script[0]}" + expect_failure "allow-private-api-base must be" env \ + CLI_REF="$CLI_REF" DEPRECATED_REF='' API_FORMAT='' \ + ENDPOINT_AUTH_HEADER='' ENDPOINT_AUTH_VALUE='' \ + ALLOW_PRIVATE_API_BASE=1 bash "${validate_script[0]}" + + - name: Preserve the complete envelope and fail closed without a model + run: | + set -euo pipefail + review_script=("$RUNNER_TEMP"/action-scripts/*-Review.sh) + [ "${#review_script[@]}" -eq 1 ] + fake_bin="$RUNNER_TEMP/fake-review-bin" + mkdir -p "$fake_bin" + cat >"$fake_bin/postil" <<'SH' + #!/usr/bin/env bash + set -euo pipefail + printf '%s\n' "$@" >"$ARGS_LOG" + if [ -z "${REVIEW_MODEL:-}" ] && [ -z "${INPUT_CONFIG:-}" ]; then + echo "no review model is configured" >&2 + exit 2 + fi + [ "${POSTIL_API_FORMAT:-}" = "anthropic" ] + [ "${POSTIL_ENDPOINT_AUTH_HEADER:-}" = "X-Gateway-Token" ] + [ "${POSTIL_ENDPOINT_AUTH_VALUE:-}" = "private-endpoint-secret" ] + [ "${POSTIL_ALLOW_PRIVATE_API_BASE:-}" = "true" ] + cat "$FIXTURE_ENVELOPE" + exit "${FIXTURE_EXIT:-0}" + SH + chmod +x "$fake_bin/postil" + + fixture="$RUNNER_TEMP/envelope-fixture.json" + cat >"$fixture" <<'JSON' + {"schemaVersion":"1.0","gate":{"failing":false},"findings":[],"reviewCoverage":{"mode":"bounded","selectedBatches":5,"totalBatches":17,"plannerFallback":true,"nested":{"retained":[1,2,3]}}} + JSON + + output="$RUNNER_TEMP/review-outputs.txt" + args_log="$RUNNER_TEMP/review-args.txt" + review_temp="$RUNNER_TEMP/review-success" + mkdir -p "$review_temp" + PATH="$fake_bin:$PATH" \ + RUNNER_TEMP="$review_temp" GITHUB_OUTPUT="$output" \ + GITHUB_REPOSITORY=postil-dev/postil-action GITHUB_TOKEN=test-token \ + POSTIL_API_KEY=test-key POSTIL_API_BASE=https://provider.example/v1 \ + POSTIL_API_FORMAT=anthropic POSTIL_ENDPOINT_AUTH_HEADER=X-Gateway-Token \ + POSTIL_ENDPOINT_AUTH_VALUE=private-endpoint-secret \ + POSTIL_ALLOW_PRIVATE_API_BASE=true REVIEW_MODEL=qualified/model \ + REVIEW_MODEL_CASCADE='' INPUT_PR=7 INPUT_FAIL_ON='' INPUT_CONFIG='' \ + INPUT_SINCE_SHA='' INPUT_BASELINE='' INPUT_SOFT_FAIL=false \ + INPUT_SARIF_PATH='' EVENT_PR='' FIXTURE_ENVELOPE="$fixture" ARGS_LOG="$args_log" \ + bash "${review_script[0]}" + envelope_path=$(awk -F= '$1 == "envelope-path" { print substr($0, index($0, "=") + 1) }' "$output") + cmp "$fixture" "$envelope_path" + jq -e '.reviewCoverage == {mode:"bounded",selectedBatches:5,totalBatches:17,plannerFallback:true,nested:{retained:[1,2,3]}}' "$envelope_path" + grep -Fx -- '--bounded' "$args_log" + + config_temp="$RUNNER_TEMP/review-config" + config_output="$RUNNER_TEMP/config-outputs.txt" + config_args="$RUNNER_TEMP/config-args.txt" + mkdir -p "$config_temp" + PATH="$fake_bin:$PATH" \ + RUNNER_TEMP="$config_temp" GITHUB_OUTPUT="$config_output" \ + GITHUB_REPOSITORY=postil-dev/postil-action GITHUB_TOKEN=test-token \ + POSTIL_API_KEY=test-key POSTIL_API_BASE=https://provider.example/v1 \ + POSTIL_API_FORMAT=anthropic POSTIL_ENDPOINT_AUTH_HEADER=X-Gateway-Token \ + POSTIL_ENDPOINT_AUTH_VALUE=private-endpoint-secret \ + POSTIL_ALLOW_PRIVATE_API_BASE=true REVIEW_MODEL='' \ + REVIEW_MODEL_CASCADE='' INPUT_PR=7 INPUT_FAIL_ON='' \ + INPUT_CONFIG=postil.toml INPUT_SINCE_SHA='' INPUT_BASELINE='' \ + INPUT_SOFT_FAIL=false INPUT_SARIF_PATH='' EVENT_PR='' \ + FIXTURE_ENVELOPE="$fixture" ARGS_LOG="$config_args" \ + bash "${review_script[0]}" + grep -Fx -- '--bounded' "$config_args" + grep -Fx -- '--config' "$config_args" + grep -Fx -- 'postil.toml' "$config_args" + + expect_envelope_failure() { + failure_fixture="$1" + case_name="$2" + fixture_exit="$3" + expected_error="$4" + case_temp="$RUNNER_TEMP/review-invalid-$case_name" + case_output="$RUNNER_TEMP/invalid-$case_name-outputs.txt" + case_args="$RUNNER_TEMP/invalid-$case_name-args.txt" + case_log="$RUNNER_TEMP/invalid-$case_name.log" + mkdir -p "$case_temp" + set +e + PATH="$fake_bin:$PATH" \ + RUNNER_TEMP="$case_temp" GITHUB_OUTPUT="$case_output" \ + GITHUB_REPOSITORY=postil-dev/postil-action GITHUB_TOKEN=test-token \ + POSTIL_API_KEY=test-key POSTIL_API_BASE=https://provider.example/v1 \ + POSTIL_API_FORMAT=anthropic POSTIL_ENDPOINT_AUTH_HEADER=X-Gateway-Token \ + POSTIL_ENDPOINT_AUTH_VALUE=private-endpoint-secret \ + POSTIL_ALLOW_PRIVATE_API_BASE=true REVIEW_MODEL=qualified/model \ + REVIEW_MODEL_CASCADE='' INPUT_PR=7 INPUT_FAIL_ON='' INPUT_CONFIG='' \ + INPUT_SINCE_SHA='' INPUT_BASELINE='' INPUT_SOFT_FAIL=false \ + INPUT_SARIF_PATH='' EVENT_PR='' FIXTURE_ENVELOPE="$failure_fixture" \ + FIXTURE_EXIT="$fixture_exit" ARGS_LOG="$case_args" \ + bash "${review_script[0]}" >"$case_log" 2>&1 + case_status=$? + set -e + [ "$case_status" -eq 2 ] + grep -F "$expected_error" "$case_log" + [ ! -s "$case_output" ] + } + + invalid_json="$RUNNER_TEMP/invalid-json.txt" + missing_gate="$RUNNER_TEMP/missing-gate.json" + string_gate="$RUNNER_TEMP/string-gate.json" + number_gate="$RUNNER_TEMP/number-gate.json" + true_gate="$RUNNER_TEMP/true-gate.json" + printf 'not json\n' >"$invalid_json" + printf '{"schemaVersion":"1.0","findings":[]}\n' >"$missing_gate" + printf '{"gate":{"failing":"false"}}\n' >"$string_gate" + printf '{"gate":{"failing":0}}\n' >"$number_gate" + printf '{"gate":{"failing":true}}\n' >"$true_gate" + expect_envelope_failure "$invalid_json" json 0 "postil review returned an invalid envelope" + expect_envelope_failure "$missing_gate" missing-gate 0 "postil review returned an invalid envelope" + expect_envelope_failure "$string_gate" string-gate 0 "postil review returned an invalid envelope" + expect_envelope_failure "$number_gate" number-gate 0 "postil review returned an invalid envelope" + expect_envelope_failure "$true_gate" clean-exit-failing-gate 0 "postil review exit status and gate verdict disagree" + expect_envelope_failure "$fixture" failing-exit-clean-gate 1 "postil review exit status and gate verdict disagree" + + no_model_temp="$RUNNER_TEMP/review-no-model" + no_model_output="$RUNNER_TEMP/no-model-outputs.txt" + no_model_args="$RUNNER_TEMP/no-model-args.txt" + no_model_log="$RUNNER_TEMP/no-model.log" + mkdir -p "$no_model_temp" + set +e + PATH="$fake_bin:$PATH" \ + RUNNER_TEMP="$no_model_temp" GITHUB_OUTPUT="$no_model_output" \ + GITHUB_REPOSITORY=postil-dev/postil-action GITHUB_TOKEN=test-token \ + POSTIL_API_KEY=test-key POSTIL_API_BASE=https://provider.example/v1 \ + POSTIL_API_FORMAT=openai-compatible POSTIL_ENDPOINT_AUTH_HEADER='' \ + POSTIL_ENDPOINT_AUTH_VALUE='' POSTIL_ALLOW_PRIVATE_API_BASE=false \ + REVIEW_MODEL='' REVIEW_MODEL_CASCADE='' INPUT_PR=7 INPUT_FAIL_ON='' \ + INPUT_CONFIG='' INPUT_SINCE_SHA='' INPUT_BASELINE='' INPUT_SOFT_FAIL=false \ + INPUT_SARIF_PATH='' EVENT_PR='' FIXTURE_ENVELOPE="$fixture" \ + ARGS_LOG="$no_model_args" \ + bash "${review_script[0]}" >"$no_model_log" 2>&1 + status=$? + set -e + [ "$status" -eq 2 ] + grep -F "postil review did not complete (exit 2)" "$no_model_log" + + - name: Fall back safely for missing or tampered release bundles + run: | + set -euo pipefail + install_script=("$RUNNER_TEMP"/action-scripts/*-Install-postil.sh) + [ "${#install_script[@]}" -eq 1 ] + CLI_REF=0123456789abcdef0123456789abcdef01234567 + + run_case() { + mode="$1" + case_dir="$RUNNER_TEMP/release-$mode" + fake_bin="$case_dir/bin" + mkdir -p "$fake_bin" + + cat >"$fake_bin/curl" <<'SH' + #!/usr/bin/env bash + set -euo pipefail + output="" + url="" + while [ "$#" -gt 0 ]; do + case "$1" in + -o) output="$2"; shift 2 ;; + -K|--retry|--retry-delay) shift 2 ;; + --retry-connrefused|-f|-s|-S|-L|-fsSL) shift ;; + *) url="$1"; shift ;; + esac + done + if [[ "$url" == *"/commits/"* ]]; then + printf '{"sha":"%s"}\n' "$CLI_REF" + exit 0 + fi + if [ "$RELEASE_TEST_MODE" = "missing" ] && [[ "$url" == *.tar.gz ]]; then + exit 22 + fi + mkdir -p "$(dirname "$output")" + case "$url" in + *.tar.gz) printf 'tampered release bytes' >"$output" ;; + *.sha256) printf '%064d postil.tar.gz\n' 0 >"$output" ;; + *.sig) printf 'invalid signature\n' >"$output" ;; + *.pem) printf 'invalid certificate\n' >"$output" ;; + *) exit 22 ;; + esac + SH + cat >"$fake_bin/cosign" <<'SH' + #!/usr/bin/env bash + exit 1 + SH + cat >"$fake_bin/git" <<'SH' + #!/usr/bin/env bash + set -euo pipefail + printf '%s\n' "$*" >>"$FAKE_GIT_LOG" + if [[ "$*" == *"verify-commit"* ]] && [ "$RELEASE_TEST_MODE" = "untrusted" ]; then + printf '%s\n' '[GNUPG:] VALIDSIG 1111111111111111111111111111111111111111 2026-07-14 0 4 0 1 10 00 1111111111111111111111111111111111111111' >&2 + exit 0 + fi + if [[ "$*" == *"verify-commit"* ]]; then + printf '%s\n' '[GNUPG:] VALIDSIG 02E45A9532C85D4432AA048151A8809EA950397A 2026-07-14 0 4 0 1 10 00 02E45A9532C85D4432AA048151A8809EA950397A' >&2 + fi + exit 0 + SH + cat >"$fake_bin/cargo" <<'SH' + #!/usr/bin/env bash + set -euo pipefail + printf '%s\n' "$*" >>"$FAKE_CARGO_LOG" + root="" + previous="" + for argument in "$@"; do + if [ "$previous" = "--root" ]; then root="$argument"; fi + previous="$argument" + done + mkdir -p "$root/bin" + cat >"$root/bin/postil" <<'BIN' + #!/usr/bin/env bash + printf 'postil test-build\n' + BIN + chmod +x "$root/bin/postil" + SH + chmod +x "$fake_bin"/* + + mkdir -p "$case_dir/temp" + : >"$case_dir/github-path" + : >"$case_dir/git.log" + : >"$case_dir/cargo.log" + set +e + PATH="$fake_bin:$PATH" RUNNER_TEMP="$case_dir/temp" RUNNER_OS=Linux \ + GITHUB_PATH="$case_dir/github-path" CLI_REF="$CLI_REF" CLI_RELEASE=v0.6.1 \ + GH_TOKEN=test-token POSTIL_TARGET=x86_64-unknown-linux-gnu \ + RELEASE_TEST_MODE="$mode" FAKE_GIT_LOG="$case_dir/git.log" \ + FAKE_CARGO_LOG="$case_dir/cargo.log" \ + bash "${install_script[0]}" >"$case_dir/output.log" 2>&1 + status=$? + set -e + + if [ "$mode" = "untrusted" ]; then + [ "$status" -ne 0 ] + grep -F "valid signature from an untrusted signer" "$case_dir/output.log" + [ ! -s "$case_dir/cargo.log" ] + return + fi + + [ "$status" -eq 0 ] + grep -F "verify-commit --raw $CLI_REF" "$case_dir/git.log" + grep -F -- "--rev $CLI_REF" "$case_dir/cargo.log" + if [ "$mode" = "missing" ]; then + grep -F "no prebuilt artifact (or signature)" "$case_dir/output.log" + else + grep -F "checksum or signature verification failed" "$case_dir/output.log" + fi + } + + run_case missing + run_case tampered + run_case untrusted + commit-signature-verification: runs-on: ubuntu-latest steps: - name: Signed postil-cli commit verifies env: - CLI_REF: e448377c918c1d6fb91f9347d8be5153a4518480 + CLI_REF: f0228748d00cc5713b02994793826fe4acdaf91f run: | set -euo pipefail export GNUPGHOME="$RUNNER_TEMP/postil-gnupg" @@ -188,7 +516,7 @@ jobs: # its install/verify path (same commands, same cosign-verifier invocation) # and stops there, before anything that would need an LLM API key. smoke-install: - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} permissions: contents: read strategy: @@ -196,39 +524,36 @@ jobs: matrix: include: - target: x86_64-unknown-linux-gnu - artifact-required: "true" + runner: ubuntu-latest - target: x86_64-unknown-linux-musl - artifact-required: "true" + runner: ubuntu-latest - target: aarch64-unknown-linux-gnu - artifact-required: "true" + runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - artifact-required: "false" + runner: ubuntu-24.04-arm steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 - name: Fetch, cosign-verify, and smoke test the pinned CLI release env: - CLI_REF: 87f4bf08b63712d3600030a7c458f0b790cfc0d5 # postil-cli v0.1.1 - CLI_RELEASE: v0.1.1 + CLI_REF: f0228748d00cc5713b02994793826fe4acdaf91f # postil-cli v0.6.1 + CLI_RELEASE: v0.6.1 + EXPECTED_VERSION: postil 0.6.1 TARGET: ${{ matrix.target }} - ARTIFACT_REQUIRED: ${{ matrix.artifact-required }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail DEST="$RUNNER_TEMP/postil-bin" mkdir -p "$DEST" base="https://github.com/postil-dev/postil-cli/releases/download/$CLI_RELEASE" - if ! 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"; then - if [ "$ARTIFACT_REQUIRED" = "false" ]; then - echo "::notice::no $TARGET artifact on $CLI_RELEASE; the action falls back to a source build for this target" - exit 0 - fi - echo "::error::missing required $TARGET artifact on $CLI_RELEASE" - exit 1 - fi + tag_sha=$(gh api "repos/postil-dev/postil-cli/commits/$CLI_RELEASE" --jq .sha) + [ "$tag_sha" = "$CLI_REF" ] + + 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}') @@ -238,16 +563,46 @@ jobs: "$DEST/postil.tar.gz" \ --signature "$DEST/postil.tar.gz.sig" \ --certificate "$DEST/postil.tar.gz.pem" \ - --certificate-identity-regexp 'https://github.com/postil-dev/postil-cli/\.github/workflows/release\.yml@refs/tags/.*' \ + --certificate-identity "https://github.com/postil-dev/postil-cli/.github/workflows/release.yml@refs/tags/$CLI_RELEASE" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com tar -xzf "$DEST/postil.tar.gz" -C "$DEST" chmod +x "$DEST/postil" - if [ "$TARGET" = "x86_64-unknown-linux-gnu" ] || [ "$TARGET" = "x86_64-unknown-linux-musl" ]; then - "$DEST/postil" --version - else - file "$DEST/postil" - fi + actual_version=$("$DEST/postil" --version) + [ "$actual_version" = "$EXPECTED_VERSION" ] + + "$DEST/postil" review --help | grep -F -- '--bounded' + + no_model_diff="$DEST/no-model.diff" + cat >"$no_model_diff" <<'DIFF' + diff --git a/src/example.rs b/src/example.rs + new file mode 100644 + --- /dev/null + +++ b/src/example.rs + @@ -0,0 +1 @@ + +pub fn example() {} + DIFF + set +e + env -u REVIEW_MODEL -u MODEL_API_KEY -u OPENROUTER_API_KEY \ + POSTIL_IGNORE_REPOSITORY_MODEL_CONFIG=1 \ + "$DEST/postil" review --bounded --diff-file "$no_model_diff" --no-post \ + >"$DEST/no-model.log" 2>&1 + no_model_status=$? + set -e + [ "$no_model_status" -eq 2 ] + + config="$DEST/postil.yaml" + empty_diff="$DEST/empty.diff" + output="$DEST/config-envelope.json" + cat >"$config" <<'YAML' + model: + name: fixture/not-contacted + YAML + : >"$empty_diff" + POSTIL_API_KEY=unused \ + "$DEST/postil" review --bounded --config "$config" \ + --diff-file "$empty_diff" --no-post --output json --output-file "$output" + jq -e '.gate.failing == false' "$output" # Boundary: this is as far as the smoke test goes. The action's # "Review" step, deliberately not exercised here, takes it from diff --git a/README.md b/README.md index 89d0dce..091ba50 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: postil-dev/postil-action@9c8cf2c2f650f5946774c6d01626da507836b418 + - uses: postil-dev/postil-action@77a3349f41799facba618371fb19726d163ba520 with: - cli-ref: dd1381381d4791475a277333837394f2f5032d27 - cli-release: v0.6.0 + cli-ref: f0228748d00cc5713b02994793826fe4acdaf91f + cli-release: v0.6.1 api-key: ${{ secrets.OPENROUTER_API_KEY }} + model: ${{ vars.POSTIL_REVIEW_MODEL }} ``` -Pin both repositories to immutable commit SHAs. `cli-release` selects a prebuilt binary only when the release resolves to `cli-ref` and its checksum and Sigstore signature verify. Source fallback requires the runner's Git and GPG configuration to trust the CLI signing key; the action fails closed when it cannot verify the pinned commit. +Set the `POSTIL_REVIEW_MODEL` repository variable to a model qualified for your review profile. Pin both repositories to immutable commit SHAs. `cli-release` selects a prebuilt binary only when the release resolves to `cli-ref` and its checksum and tag-bound Sigstore signature verify. Source fallback accepts only the Postil maintainer or GitHub web-flow signing fingerprints and requires the runner's GPG configuration to resolve that key. ## Inputs @@ -39,9 +40,12 @@ Pin both repositories to immutable commit SHAs. `cli-release` selects a prebuilt | `cli-ref` | yes | Full 40-character `postil-cli` commit SHA | | `api-key` | yes | Model provider credential | | `cli-release` | no | Matching signed release tag for a prebuilt Linux binary | -| `api-base` | no | OpenAI-compatible model endpoint | -| `model` | no | Primary model override | -| `model-cascade` | no | Comma-separated fallback models | +| `api-base` | no | Model endpoint, defaulting to the CLI provider endpoint | +| `api-format` | no | `openai-compatible` or `anthropic` | +| `endpoint-auth-header`, `endpoint-auth-value` | no | Paired additional private-gateway authentication | +| `allow-private-api-base` | no | Permit a local or private-network endpoint | +| `model` | no | Primary model; required unless trusted config supplies one | +| `model-cascade` | no | Comma-separated qualified fallback models | | `fail-on` | no | `info`, `warn`, `error`, or `never` | | `config` | no | Explicit Postil configuration path | | `pr` | no | Pull-request number when not inferred from the event | @@ -52,6 +56,10 @@ Pin both repositories to immutable commit SHAs. `cli-release` selects a prebuilt Outputs are `envelope-path`, `gate-failing`, and `sarif-path`. +The action does not select an unverified fallback model. Set `model` to a model qualified for your review profile, or provide trusted configuration with `config`. If resolution produces no model, Postil fails before contacting a provider. `envelope-path` points to the complete CLI envelope, including `reviewCoverage` when the review uses bounded source selection. + +For the native Anthropic Messages API, set `api-format: anthropic` and its API base. Private gateways can use `endpoint-auth-header` and `endpoint-auth-value` for one additional credential; set both or neither. Set `allow-private-api-base: true` only for an endpoint whose network boundary you trust. + ## Gate setup Postil publishes an advisory `postil/review` check and a blocking `postil/gate` check. Mark only `postil/gate` as required in branch protection. `soft-fail: true` keeps the workflow job green, but does not change the dedicated gate check. diff --git a/action.yml b/action.yml index 0538cc5..dee0e35 100644 --- a/action.yml +++ b/action.yml @@ -23,11 +23,27 @@ inputs: description: "API key for the model endpoint (OpenRouter by default). Postil never proxies inference." required: true api-base: - description: "OpenAI-compatible endpoint base URL." + description: "Model endpoint base URL. Defaults to the CLI provider endpoint." required: false default: "" + api-format: + description: "Provider interface: openai-compatible or anthropic." + required: false + default: "" + endpoint-auth-header: + description: "Additional private-gateway authentication header name. Must be paired with endpoint-auth-value." + required: false + default: "" + endpoint-auth-value: + description: "Additional private-gateway authentication value. Must be paired with endpoint-auth-header." + required: false + default: "" + allow-private-api-base: + description: "Permit a local or private-network model endpoint (true/false)." + required: false + default: "false" model: - description: "Model to use (defaults to repo config or deepseek/deepseek-v4-pro)." + description: "Primary model. Required unless trusted config supplies one." required: false default: "" model-cascade: @@ -92,6 +108,10 @@ runs: env: CLI_REF: ${{ inputs.cli-ref }} DEPRECATED_REF: ${{ inputs.reviewer-ref }} + API_FORMAT: ${{ inputs.api-format }} + ENDPOINT_AUTH_HEADER: ${{ inputs.endpoint-auth-header }} + ENDPOINT_AUTH_VALUE: ${{ inputs.endpoint-auth-value }} + ALLOW_PRIVATE_API_BASE: ${{ inputs.allow-private-api-base }} run: | set -euo pipefail if [ -n "$DEPRECATED_REF" ]; then @@ -102,6 +122,25 @@ runs: echo "::error::cli-ref must be a full 40-character lowercase commit SHA, got '$CLI_REF'" exit 1 fi + case "$API_FORMAT" in + ""|openai-compatible|anthropic) ;; + *) + echo "::error::api-format must be 'openai-compatible' or 'anthropic'" + exit 1 + ;; + esac + if { [ -n "$ENDPOINT_AUTH_HEADER" ] && [ -z "$ENDPOINT_AUTH_VALUE" ]; } \ + || { [ -z "$ENDPOINT_AUTH_HEADER" ] && [ -n "$ENDPOINT_AUTH_VALUE" ]; }; then + echo "::error::endpoint-auth-header and endpoint-auth-value must be set together" + exit 1 + fi + case "$ALLOW_PRIVATE_API_BASE" in + true|false) ;; + *) + echo "::error::allow-private-api-base must be 'true' or 'false'" + exit 1 + ;; + esac - name: Install cosign if: inputs.cli-release != '' @@ -159,10 +198,12 @@ runs: # Auth header goes through a curl config file, not argv, so the # token never shows up in `ps`/`/proc//cmdline` on the runner. auth_cfg="$RUNNER_TEMP/postil-gh-auth.curlrc" - printf 'header = "Authorization: Bearer %s"\n' "$GH_TOKEN" > "$auth_cfg" + (umask 077; printf 'header = "Authorization: Bearer %s"\n' "$GH_TOKEN" > "$auth_cfg") + trap 'rm -f "$auth_cfg"' EXIT tag_sha=$(curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 -K "$auth_cfg" \ "$api/commits/$CLI_RELEASE" | jq -r '.sha' || echo "") rm -f "$auth_cfg" + trap - EXIT 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. @@ -184,7 +225,7 @@ runs: "$DEST/postil.tar.gz" \ --signature "$DEST/postil.tar.gz.sig" \ --certificate "$DEST/postil.tar.gz.pem" \ - --certificate-identity-regexp 'https://github.com/postil-dev/postil-cli/\.github/workflows/release\.yml@refs/tags/.*' \ + --certificate-identity "https://github.com/postil-dev/postil-cli/.github/workflows/release.yml@refs/tags/$CLI_RELEASE" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com; then tar -xzf "$DEST/postil.tar.gz" -C "$DEST" chmod +x "$DEST/postil" @@ -210,13 +251,24 @@ runs: 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 + # Source builds accept only commits signed by the Postil maintainer + # or GitHub's web-flow key used for protected squash merges. + set +e + verify_output=$(git -C "$verify_repo" verify-commit --raw "$CLI_REF" 2>&1) + verify_status=$? + set -e + if [ "$verify_status" -ne 0 ]; 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 + signer_fingerprint=$(printf '%s\n' "$verify_output" | awk '$1 == "[GNUPG:]" && $2 == "VALIDSIG" { print $3; exit }') + case "$signer_fingerprint" in + 02E45A9532C85D4432AA048151A8809EA950397A|968479A1AFF927E37D1A566BB5690EEEBB952194) ;; + *) + echo "::error::postil-cli commit $CLI_REF has a valid signature from an untrusted signer" + exit 1 + ;; + esac 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" @@ -237,6 +289,10 @@ runs: GITHUB_TOKEN: ${{ inputs.github-token }} POSTIL_API_KEY: ${{ inputs.api-key }} POSTIL_API_BASE: ${{ inputs.api-base }} + POSTIL_API_FORMAT: ${{ inputs.api-format }} + POSTIL_ENDPOINT_AUTH_HEADER: ${{ inputs.endpoint-auth-header }} + POSTIL_ENDPOINT_AUTH_VALUE: ${{ inputs.endpoint-auth-value }} + POSTIL_ALLOW_PRIVATE_API_BASE: ${{ inputs.allow-private-api-base }} REVIEW_MODEL: ${{ inputs.model }} REVIEW_MODEL_CASCADE: ${{ inputs.model-cascade }} INPUT_PR: ${{ inputs.pr }} @@ -257,7 +313,7 @@ runs: exit 1 fi ENVELOPE="$RUNNER_TEMP/postil-envelope.json" - args=(review --repo "$GITHUB_REPOSITORY" --pr "$PR" --output-json) + args=(review --bounded --repo "$GITHUB_REPOSITORY" --pr "$PR" --output-json) [ -n "$INPUT_FAIL_ON" ] && args+=(--fail-on "$INPUT_FAIL_ON") [ -n "$INPUT_CONFIG" ] && args+=(--config "$INPUT_CONFIG") [ -n "$INPUT_SINCE_SHA" ] && args+=(--since-sha "$INPUT_SINCE_SHA") @@ -269,14 +325,6 @@ runs: code=$? set -e - echo "envelope-path=$ENVELOPE" >> "$GITHUB_OUTPUT" - # Only advertise SARIF that was actually written; a crashed run may - # have died before producing it. - [ -n "$INPUT_SARIF_PATH" ] && [ -f "$INPUT_SARIF_PATH" ] \ - && echo "sarif-path=$INPUT_SARIF_PATH" >> "$GITHUB_OUTPUT" - gate=$(jq -r '.gate.failing // false' "$ENVELOPE" 2>/dev/null || echo "unknown") - echo "gate-failing=$gate" >> "$GITHUB_OUTPUT" - # Fail closed on every outcome except clean (0) and gate-failing (1): # a panic (101), timeout (124), or OOM kill (137) must never read as # a pass. @@ -284,6 +332,23 @@ runs: echo "::error::postil review did not complete (exit $code)" exit "$code" fi + if ! gate=$(jq -er 'if (.gate.failing | type) == "boolean" then (.gate.failing | tostring) else error("gate.failing must be boolean") end' "$ENVELOPE" 2>/dev/null); then + echo "::error::postil review returned an invalid envelope" + exit 2 + fi + if { [ "$code" -eq 0 ] && [ "$gate" != false ]; } \ + || { [ "$code" -eq 1 ] && [ "$gate" != true ]; }; then + echo "::error::postil review exit status and gate verdict disagree" + exit 2 + fi + + echo "envelope-path=$ENVELOPE" >> "$GITHUB_OUTPUT" + # Only advertise SARIF that was actually written; a crashed run may + # have died before producing it. + [ -n "$INPUT_SARIF_PATH" ] && [ -f "$INPUT_SARIF_PATH" ] \ + && echo "sarif-path=$INPUT_SARIF_PATH" >> "$GITHUB_OUTPUT" + echo "gate-failing=$gate" >> "$GITHUB_OUTPUT" + if [ "$code" -eq 1 ] && [ "$INPUT_SOFT_FAIL" != "true" ]; then echo "::error::postil gate is failing (see the postil/gate check for details)" exit 1