mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 13:31:26 -10:00
* Fix action summary for structured output * Address action summary review comments * Keep action summary extraction best effort --------- Co-authored-by: Gautam Manchandani <[email protected]>
297 lines
12 KiB
YAML
297 lines
12 KiB
YAML
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: Gitlawb
|
|
|
|
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: Gitlawb/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<sha>.
|
|
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<<ZERO_EOF"
|
|
printf '%s\n' "${summary}"
|
|
echo "ZERO_EOF"
|
|
} >> "${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)."
|