Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/test-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,69 @@ jobs:
fi
echo "Issues match: $ISSUES"

test-native-annotations:
name: Test native annotation fastpath
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22

- name: Run action with annotations enabled
id: fallow
uses: ./
env:
FALLOW_SKIP_BINARY_VERIFY: "1"
with:
command: dead-code
root: tests/fixtures/basic-project
format: json
annotations: true
fail-on-issues: false

- name: Verify native annotation fastpath ran
env:
RESULTS_PATH: ${{ steps.fallow.outputs.results }}
ISSUES: ${{ steps.fallow.outputs.issues }}
run: |
if [ ! -f "$RESULTS_PATH" ]; then
echo "::error::Results output file not found"
exit 1
fi
if [ -z "$ISSUES" ] || [ "$ISSUES" -eq 0 ]; then
echo "::error::Expected issues > 0 for basic-project fixture, got: $ISSUES"
exit 1
fi

# The composite already emitted annotations via the native path, but a
# later step cannot capture that step's log. Re-run annotate.sh with
# the installed binary against the saved envelope to assert the native
# renderer ran. HAS_NATIVE_REPORT mirrors what analyze.sh exports for a
# report-capable (3.4.2+) binary.
OUT=$(HAS_NATIVE_REPORT=true \
FALLOW_COMMAND=dead-code \
MAX_ANNOTATIONS=50 \
ACTION_JQ_DIR="${GITHUB_WORKSPACE}/action/jq" \
FALLOW_RESULTS_FILE="$RESULTS_PATH" \
bash "${GITHUB_WORKSPACE}/action/scripts/annotate.sh" 2>&1)
printf '%s\n' "$OUT"

if ! printf '%s\n' "$OUT" | grep -q 'annotations rendered via native github-annotations'; then
echo "::error::annotate.sh did not take the native path"
exit 1
fi
if ! printf '%s\n' "$OUT" | grep -qE '^::(warning|error) '; then
echo "::error::native annotation stream emitted no ::warning or ::error lines"
exit 1
fi
echo "Native annotation fastpath verified ($ISSUES issues)"

test-zero-issues:
name: Test clean project (zero issues)
runs-on: ubuntu-latest
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **The bundled GitHub Action renders inline annotations and the job summary
natively when the installed fallow supports it.** The annotate and summary
steps previously rendered both surfaces through the action's bundled jq. On
fallow >= 3.4.2 they now call `fallow report --from <results> --format
github-annotations|github-summary`, re-rendering the same saved analysis JSON
the run already produced (no extra analysis cost) and making the binary the
single source of truth for GitHub presentation. Older binaries without
`fallow report` stay on the jq renderers automatically via a capability probe
(there is no version floor), so nothing changes for them, and `version:
latest` users flip to the native path silently. A step log line names which
renderer ran. Two behavior deltas on the native path: the annotation stream
ends with a `fallow emitted N annotation(s)` budget notice that counts toward
`max-annotations`, and the job summary uses fallow's purpose-built
step-summary rendering instead of the comment-shaped body. The `fix` command
keeps the jq summary (there is no native fix rendering yet), and the jq
renderers remain in place as the fallback.

## [3.4.2] - 2026-07-13

### Added
Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ inputs:
required: false
default: 'https://api.fallow.cloud'
annotations:
description: 'Emit findings as inline PR annotations via workflow commands (no Advanced Security required)'
description: 'Emit findings as inline PR annotations via workflow commands (no Advanced Security required). On fallow >= 3.4.2 the action renders these natively via `fallow report --format github-annotations`; older binaries fall back to the bundled jq renderers, and version: latest users flip to the native path silently. The step log line "fallow: annotations rendered via native github-annotations" (or "via jq fallback") tells you which renderer ran.'
required: false
default: 'true'
review-comments:
Expand All @@ -100,7 +100,7 @@ inputs:
required: false
default: 'false'
max-annotations:
description: 'Maximum number of inline annotations to emit (default: 50)'
description: 'Maximum number of inline annotations to emit (default: 50). On the native path the action applies this cap to the rendered stream (GitHub itself displays at most 10 annotations per type per step).'
required: false
default: '50'
max-comments:
Expand Down
16 changes: 16 additions & 0 deletions action/scripts/analyze.sh
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,22 @@ if { [ "$INPUT_COMMAND" = "dead-code" ] || [ "$INPUT_COMMAND" = "check" ] || [ -
rm -f "$HELP_TMP"
fi

# --- Check for native `fallow report` support ---
# `fallow report --from <results.json> --format github-annotations|github-summary`
# lets the annotate / summary steps re-render the saved envelope instead of the
# bundled jq. One probe covers both formats (they shipped together). The
# annotate / summary steps run in separate step processes, so the result flows
# through $GITHUB_ENV like the other analyze outputs above; unset on older
# binaries keeps those steps on the jq fallback.

HAS_NATIVE_REPORT=false
if fallow report --help > /dev/null 2>&1; then
HAS_NATIVE_REPORT=true
fi
if [ -n "${GITHUB_ENV:-}" ]; then
echo "HAS_NATIVE_REPORT=${HAS_NATIVE_REPORT}" >> "$GITHUB_ENV"
fi

# --- Auto-detect changed-since in PR context ---

AUTO_CHANGED_SINCE=false
Expand Down
115 changes: 86 additions & 29 deletions action/scripts/annotate.sh
Original file line number Diff line number Diff line change
@@ -1,18 +1,95 @@
#!/usr/bin/env bash
set -eo pipefail

# Emit inline PR annotations via workflow commands
# Emit inline PR annotations via workflow commands.
#
# Render precedence (fail-open, most preferred first):
# 1. native - `fallow report --from <results> --format github-annotations`
# when the analyze step probed HAS_NATIVE_REPORT=true and the
# command carries a report kind (i.e. not fix)
# 2. typed - the pr-comment decision JSON, present only when the comment
# step ran; strict escaping the native format is byte-compatible with
# 3. jq - the bundled annotations-*.jq renderers (older binaries)
#
# Required env: FALLOW_COMMAND, MAX_ANNOTATIONS, ACTION_JQ_DIR
# Optional env: CHANGED_SINCE, INPUT_ROOT, FALLOW_RESULTS_FILE,
# FALLOW_SCOPED_RESULTS_FILE, FALLOW_CHANGED_FILES_FILE,
# FALLOW_PR_DECISION_FILE
# FALLOW_PR_DECISION_FILE, HAS_NATIVE_REPORT, FALLOW_BIN

MAX="${MAX_ANNOTATIONS:-50}"
if ! [[ "$MAX" =~ ^[0-9]+$ ]]; then
echo "::warning::max-annotations must be a positive integer, got: ${MAX_ANNOTATIONS}. Using default: 50"
MAX=50
fi

_FALLOW_TMPS=()
trap 'rm -f "${_FALLOW_TMPS[@]:-}"' EXIT

# Resolve the results file the render paths consume, scoping it to the changed
# files when --changed-since is active. Native and jq select the same input.
resolve_results_file() {
local results_file="${FALLOW_RESULTS_FILE:-fallow-results.json}"
local scoped_file="${FALLOW_SCOPED_RESULTS_FILE:-fallow-results-scoped.json}"
local changed_files_file="${FALLOW_CHANGED_FILES_FILE:-fallow-changed-files.json}"
if [ -n "${CHANGED_SINCE:-}" ]; then
local changed_json=""

# Prefer pre-computed list from analyze step (handles shallow clones via API fallback)
if [ -f "$changed_files_file" ]; then
changed_json=$(cat "$changed_files_file")
else
# Fallback: compute locally (for standalone usage outside the action)
local root="${INPUT_ROOT:-.}"
local changed_files
changed_files=$(cd "$root" && git diff --name-only --relative "${CHANGED_SINCE}...HEAD" -- . 2>/dev/null || true)
if [ -n "$changed_files" ]; then
changed_json=$(echo "$changed_files" | jq -R -s 'split("\n") | map(select(length > 0))')
fi
fi

if [ -n "$changed_json" ] && [ "$changed_json" != "[]" ]; then
if jq --argjson changed "$changed_json" -f "${ACTION_JQ_DIR}/filter-changed.jq" "$results_file" > "$scoped_file" 2>/dev/null; then
results_file="$scoped_file"
fi
fi
fi
printf '%s\n' "$results_file"
}

# 1. Native renderer. The action adds only the MAX cap and its truncation
# notice on top of the native stream, so the emitted annotations stay
# byte-identical to `fallow report --from ... | head -n MAX`.
emit_native_annotations_if_available() {
[ "${HAS_NATIVE_REPORT:-false}" = "true" ] || return 1
# fix has no report kind; the step gate already excludes it, belt and braces.
[ "$FALLOW_COMMAND" = "fix" ] && return 1

local input_file
input_file=$(resolve_results_file)

local native_file
native_file=$(mktemp)
_FALLOW_TMPS+=("$native_file")

if ! "${FALLOW_BIN:-fallow}" report --from "$input_file" --format github-annotations > "$native_file" 2>/dev/null; then
echo "::warning::fallow native annotation render failed; falling back to jq"
return 1
fi

local total
total=$(wc -l < "$native_file" | tr -d ' ')
if [ "$total" -gt 0 ]; then
head -n "$MAX" "$native_file"
if [ "$total" -gt "$MAX" ]; then
echo "::notice::Showing ${MAX} of ${total} annotations. Increase max-annotations to see more."
fi
fi
echo "fallow: annotations rendered via native github-annotations" >&2
return 0
}

# 2. Typed renderer: the pr-comment decision JSON, present only when the
# comment step produced it. Same strict escaping as the native format.
emit_typed_annotations_if_available() {
local decision_file="${FALLOW_PR_DECISION_FILE:-}"
if [ -z "$decision_file" ] || [ ! -f "$decision_file" ]; then
Expand Down Expand Up @@ -57,13 +134,16 @@ emit_typed_annotations_if_available() {
return 0
}

_FALLOW_TMPS=()
trap 'rm -f "${_FALLOW_TMPS[@]:-}"' EXIT
if emit_native_annotations_if_available; then
exit 0
fi

if emit_typed_annotations_if_available; then
exit 0
fi

# 3. jq fallback for binaries without `fallow report`.

# Detect package manager from lock files
PKG_MANAGER="npm"
ROOT="${FALLOW_ROOT:-.}"
Expand All @@ -74,31 +154,7 @@ elif [ -f "${ROOT}/yarn.lock" ] || [ -f "yarn.lock" ]; then
fi
export PKG_MANAGER

# Scope results to changed files when --changed-since is active
RESULTS_FILE="${FALLOW_RESULTS_FILE:-fallow-results.json}"
SCOPED_RESULTS_FILE="${FALLOW_SCOPED_RESULTS_FILE:-fallow-results-scoped.json}"
CHANGED_FILES_FILE="${FALLOW_CHANGED_FILES_FILE:-fallow-changed-files.json}"
if [ -n "${CHANGED_SINCE:-}" ]; then
CHANGED_JSON=""

# Prefer pre-computed list from analyze step (handles shallow clones via API fallback)
if [ -f "$CHANGED_FILES_FILE" ]; then
CHANGED_JSON=$(cat "$CHANGED_FILES_FILE")
else
# Fallback: compute locally (for standalone usage outside the action)
_ROOT="${INPUT_ROOT:-.}"
CHANGED_FILES=$(cd "$_ROOT" && git diff --name-only --relative "${CHANGED_SINCE}...HEAD" -- . 2>/dev/null || true)
if [ -n "$CHANGED_FILES" ]; then
CHANGED_JSON=$(echo "$CHANGED_FILES" | jq -R -s 'split("\n") | map(select(length > 0))')
fi
fi

if [ -n "$CHANGED_JSON" ] && [ "$CHANGED_JSON" != "[]" ]; then
if jq --argjson changed "$CHANGED_JSON" -f "${ACTION_JQ_DIR}/filter-changed.jq" "$RESULTS_FILE" > "$SCOPED_RESULTS_FILE" 2>/dev/null; then
RESULTS_FILE="$SCOPED_RESULTS_FILE"
fi
fi
fi
RESULTS_FILE=$(resolve_results_file)

ANNOTATIONS_FILE=$(mktemp)
_FALLOW_TMPS+=("$ANNOTATIONS_FILE")
Expand Down Expand Up @@ -133,3 +189,4 @@ if [ "$TOTAL" -gt 0 ]; then
echo "::notice::Showing ${MAX} of ${TOTAL} annotations. Increase max-annotations to see more."
fi
fi
echo "fallow: annotations rendered via jq fallback" >&2
Loading
Loading