name: ZERO description: >- Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is model- and provider-agnostic: you supply the provider and API key as inputs. author: Twigpine branding: icon: terminal color: green inputs: prompt: description: The instruction for ZERO to execute. Mutually exclusive with prompt-file. required: false default: "" prompt-file: description: Path (relative to working-directory) to a file whose contents are the prompt. required: false default: "" provider: description: >- Provider id to activate (for example openai, anthropic, gemini, ollama, or any configured OpenAI-/Anthropic-compatible endpoint). Optional when the repository already carries a .zero/config.json with an active provider. required: false default: "" api-key: description: >- The provider API key. Pass it from a repository or organization secret; it is exported only for the ZERO step and never written to the log. required: false default: "" api-key-env: description: >- Name of the environment variable the chosen provider reads its key from (for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with api-key, that variable is exported only for the ZERO step. Leave the action provider-agnostic by passing the env name your provider expects rather than hardcoding one here. required: false default: "" model: description: Model id to use for the run. Defaults to the resolved provider's default. required: false default: "" mode: description: Run mode passed to `zero exec --mode` (for example smart, deep, or fast). required: false default: "" auto: description: >- Autonomy ceiling passed to `zero exec --auto` (low, medium, or high). Defaults to the conservative `low` so an unattended CI run cannot take high-impact actions implicitly. required: false default: low self-correct: description: >- When true, allow ZERO to request a stronger model mid-run if it hits a wall (maps to `zero exec --allow-escalation`). required: false default: "false" add-dir: description: >- Newline- or comma-separated extra write roots granted to the run (each passed as `zero exec --add-dir`). The checked-out repository is always writable; everything else stays read-only unless listed here. required: false default: "" worktree: description: When true, run inside an isolated git worktree (`zero exec --worktree`). required: false default: "false" output-format: description: >- Output format for `zero exec` (text, json, or stream-json). Defaults to stream-json, which is captured to a file and exposed as the output-file output and uploaded by the caller if desired. required: false default: stream-json post-to: description: >- Optional destination for a run summary after ZERO finishes: `pr-comment` posts to the triggering pull request using github-token, `slack` posts to slack-webhook-url, or `none` (default) posts nowhere. required: false default: none slack-webhook-url: description: >- Slack incoming-webhook (or generic webhook) URL used when post-to is slack. Pass it from a secret; it is never written to the log. required: false default: "" github-token: description: >- Token used to post a PR comment when post-to is pr-comment. Defaults to the workflow's GITHUB_TOKEN; the workflow must grant pull-requests: write. required: false default: ${{ github.token }} working-directory: description: Directory to run ZERO in. Defaults to the workspace root. required: false default: ${{ github.workspace }} zero-version: description: >- ZERO release version/tag to install (for example v1.2.3 or latest). Defaults to the ref this action was resolved at, falling back to latest. required: false default: "" zero-repo: description: Repository to install the ZERO release from (owner/repo). required: false default: Twigpine/zero outputs: exit-code: description: The exit code ZERO returned (0 success, 2 usage, 3 provider, non-zero otherwise). value: ${{ steps.run.outputs.exit-code }} output-file: description: Path to the captured ZERO stdout (the raw output-format stream). value: ${{ steps.run.outputs.output-file }} summary: description: A short human-readable summary line parsed from the run, when available. value: ${{ steps.run.outputs.summary }} runs: using: composite steps: - name: Install ZERO shell: bash env: ZERO_REPO: ${{ inputs.zero-repo }} ZERO_VERSION_INPUT: ${{ inputs.zero-version }} ACTION_REF: ${{ github.action_ref }} ACTION_PATH: ${{ github.action_path }} RUNNER_TEMP: ${{ runner.temp }} run: | set -euo pipefail if [ "${RUNNER_OS}" = "Windows" ]; then echo "::error::The ZERO action currently supports Linux and macOS runners only." >&2 exit 1 fi # Resolve the version to install: explicit input wins; otherwise the ref # this action was checked out at (a tag); otherwise the latest release. version="${ZERO_VERSION_INPUT}" if [ -z "${version}" ]; then version="${ACTION_REF}" fi # A commit-SHA pin (github.action_ref is the 40-hex SHA) is not a release # tag, so fall back to latest rather than fetching releases/download/v. case "${version}" in ""|refs/*|*/*) version="latest" ;; esac if printf '%s' "${version}" | grep -qiE '^[0-9a-f]{40}$'; then version="latest" fi install_dir="${RUNNER_TEMP}/zero-bin" mkdir -p "${install_dir}" # Reuse ZERO's own checksum-verifying installer, shipped with the action. ZERO_REPO="${ZERO_REPO}" ZERO_VERSION="${version}" ZERO_INSTALL_DIR="${install_dir}" \ bash "${ACTION_PATH}/scripts/install.sh" echo "${install_dir}" >> "${GITHUB_PATH}" - name: Run ZERO id: run shell: bash working-directory: ${{ inputs.working-directory }} env: # Secrets are passed via the environment and never echoed. `set +x` and # the absence of any `echo` of these values keep them out of the log. ZERO_INPUT_API_KEY: ${{ inputs.api-key }} ZERO_INPUT_API_KEY_ENV: ${{ inputs.api-key-env }} ZERO_INPUT_PROVIDER: ${{ inputs.provider }} ZERO_INPUT_PROMPT: ${{ inputs.prompt }} ZERO_INPUT_PROMPT_FILE: ${{ inputs.prompt-file }} ZERO_INPUT_MODEL: ${{ inputs.model }} ZERO_INPUT_MODE: ${{ inputs.mode }} ZERO_INPUT_AUTO: ${{ inputs.auto }} ZERO_INPUT_SELF_CORRECT: ${{ inputs.self-correct }} ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }} ZERO_INPUT_WORKTREE: ${{ inputs.worktree }} ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }} ACTION_PATH: ${{ github.action_path }} RUNNER_TEMP: ${{ runner.temp }} run: | set -euo pipefail # Export the provider key under the env name the provider expects. This # keeps the action provider-agnostic: no provider->env mapping is baked # in. The value is never printed. if [ -n "${ZERO_INPUT_API_KEY}" ] && [ -n "${ZERO_INPUT_API_KEY_ENV}" ]; then if ! printf '%s' "${ZERO_INPUT_API_KEY_ENV}" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then echo "::error::api-key-env must be a valid environment variable name." >&2 exit 2 fi # Export in-process for this Run ZERO step so zero can read it; do # not persist provider credentials to later job steps. export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}" fi if [ -n "${ZERO_INPUT_PROVIDER}" ]; then export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}" fi args=(exec) if [ -n "${ZERO_INPUT_PROMPT_FILE}" ] && [ -n "${ZERO_INPUT_PROMPT}" ]; then echo "::error::The 'prompt' and 'prompt-file' inputs are mutually exclusive." >&2 exit 2 fi if [ -n "${ZERO_INPUT_PROMPT_FILE}" ]; then args+=(--file "${ZERO_INPUT_PROMPT_FILE}") elif [ -n "${ZERO_INPUT_PROMPT}" ]; then args+=(--prompt "${ZERO_INPUT_PROMPT}") else echo "::error::Provide either the 'prompt' or 'prompt-file' input." >&2 exit 2 fi [ -n "${ZERO_INPUT_MODEL}" ] && args+=(--model "${ZERO_INPUT_MODEL}") [ -n "${ZERO_INPUT_MODE}" ] && args+=(--mode "${ZERO_INPUT_MODE}") [ -n "${ZERO_INPUT_AUTO}" ] && args+=(--auto "${ZERO_INPUT_AUTO}") [ "${ZERO_INPUT_SELF_CORRECT}" = "true" ] && args+=(--allow-escalation) [ "${ZERO_INPUT_WORKTREE}" = "true" ] && args+=(--worktree) args+=(--output-format "${ZERO_INPUT_OUTPUT_FORMAT}") # No --notify in CI (no terminal); the optional Slack post is handled # separately below so it works regardless of output format. args+=(--no-notify) # Split add-dir on newlines and commas; each non-empty entry is a root. if [ -n "${ZERO_INPUT_ADD_DIR}" ]; then while IFS= read -r dir; do # Trim only surrounding whitespace so paths with internal spaces survive. dir="$(printf '%s' "${dir}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" [ -n "${dir}" ] && args+=(--add-dir "${dir}") done < <(printf '%s' "${ZERO_INPUT_ADD_DIR}" | tr ',' '\n') fi output_file="${RUNNER_TEMP}/zero-output.txt" # Run ZERO. Capture stdout to a file AND echo it to the log. Surface the # real exit code as the step's status. The sandbox is active by default; # this action never enables unsafe permission-skipping. set +e zero "${args[@]}" | tee "${output_file}" code=${PIPESTATUS[0]} set -e echo "exit-code=${code}" >> "${GITHUB_OUTPUT}" echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}" # Best-effort one-line summary. Structured output ends with lifecycle # metadata, so parse final/error events instead of tailing stdout. summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)" { echo "summary<> "${GITHUB_OUTPUT}" exit "${code}" - name: Post run summary to pull request if: ${{ always() && inputs.post-to == 'pr-comment' && github.event.pull_request.number != '' }} shell: bash env: GH_TOKEN: ${{ inputs.github-token }} PR_NUMBER: ${{ github.event.pull_request.number }} ZERO_EXIT: ${{ steps.run.outputs.exit-code }} ZERO_SUMMARY: ${{ steps.run.outputs.summary }} run: | set -euo pipefail status="succeeded" [ "${ZERO_EXIT}" != "0" ] && status="failed (exit ${ZERO_EXIT})" body="$(printf 'ZERO run %s.\n\n%s' "${status}" "${ZERO_SUMMARY}")" gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ -f body="${body}" >/dev/null - name: Post run summary to Slack if: ${{ always() && inputs.post-to == 'slack' && inputs.slack-webhook-url != '' }} shell: bash env: SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }} ZERO_EXIT: ${{ steps.run.outputs.exit-code }} ZERO_SUMMARY: ${{ steps.run.outputs.summary }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail status="succeeded" [ "${ZERO_EXIT}" != "0" ] && status="failed (exit ${ZERO_EXIT})" text="ZERO run ${status} for ${GITHUB_REPOSITORY}@${GITHUB_REF_NAME}. ${ZERO_SUMMARY} ${RUN_URL}" # Slack incoming webhooks accept {"text": "..."}; jq escapes the body and # the URL is never echoed. A delivery failure is a warning, not a failure. payload="$(jq -n --arg text "${text}" '{text: $text}')" curl --fail --silent --show-error -X POST -H 'Content-Type: application/json' \ -d "${payload}" "${SLACK_WEBHOOK_URL}" >/dev/null || \ echo "::warning::Slack webhook delivery failed (run not affected)."