mirror of
https://github.com/Heretek-AI/heretek-actions.git
synced 2026-10-11 21:09:53 +00:00
feat(lint): add lint-ultimate action wrapping MegaLinter with envelope
Tier 1.1 of the quality expansion design. Adds the lint-ultimate composite action that runs MegaLinter (or Super-Linter) over the repo and emits a per-linter checks[] plus per-finding findings[] envelope, matching the standard .agent/output.json contract. ## Changes ### Action - .github/actions/lint-ultimate/action.yml: composite action, runs oxsecurity/megalinter:v7 via Docker, uploads the JSON report and stdout as lint-ultimate-reports artifact, uploads the envelope as agent-output-lint for downstream consumers. - .github/actions/lint-ultimate/build-envelope.sh: parses megalinter-report.json, maps each linter category to a check[] entry and each finding to a findings[] entry. Falls back to an empty envelope when the report is missing or unreadable. ### Library - .github/actions/agent-envelope.sh: add add_finding() helper mirroring add_check(). Optional fields (rule, file, line, column, suggested_fix) default to null; numbers coerce when non-empty. ### Library fixes (pre-existing bugs surfaced by the new test) - GITHUB_REPOSITORY default and owner/repo prefix strip now set -u safe. Previously the original --arg repo_name reference threw on unbound-variable in any non-GitHub runner. - GITHUB_RUN_ID now passed as --argjson (numeric or null) instead of --arg with tonumber? in the filter. The prior form silently produced empty output when run_id was unset (jq 1.8.2 quirk where tonumber? on null returns Empty and jq emits no output). ### Schema - .agent/schema.json: enum agent_action extended with all twelve new values from the quality-check expansion design (lint-ultimate, coverage, actionlint, codeql, lefthook-ci, test-matrix, reviewdog, quality-gate, semantic-release, dependabot, docker-ci). Future Tier 1/2/3 PRs pick the right value. ### Tests - tests/: ship the previously untracked harness (run-action.sh, run-all.sh, README.md, fixtures for all CI actions) plus the new megalinter-passing and megalinter-failing fixtures. - tests/unit/test-lint-ultimate-parser.sh: parser unit test that runs build-envelope.sh against a fabricated megalinter-report.json and asserts the envelope shape. No Docker required. - tests/golden/lint-ultimate/README.md: documents that goldens are generated on first real-CI run (per tests/README.md first-run convention) and provides a no-Docker local check. ### Matrix - tests/run-all.sh: megalinter-passing and megalinter-failing added to MATRIX (paired with lint-ultimate action). ### Docs - README.md: lint-ultimate row added to the CI Composite Actions table; full example block added between python-ci and ---. ## Risk - Pre-existing agent-envelope.sh changes affect all envelope-writing callers; the same fixes apply everywhere (none had a green test before). Behavior is unchanged when GITHUB_* env vars are set (real CI). - No existing tests to regress against (tests/ was untracked). Unit test added in this PR exercises the new parser path. Refs: docs/superpowers/specs/2026-07-30-code-quality-expansion-design.md
This commit is contained in:
+12
-1
@@ -19,7 +19,18 @@
|
||||
"ci-js",
|
||||
"ci-python",
|
||||
"release",
|
||||
"pre-commit"
|
||||
"pre-commit",
|
||||
"lint-ultimate",
|
||||
"coverage",
|
||||
"actionlint",
|
||||
"codeql",
|
||||
"lefthook-ci",
|
||||
"test-matrix",
|
||||
"reviewdog",
|
||||
"quality-gate",
|
||||
"semantic-release",
|
||||
"dependabot",
|
||||
"docker-ci"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
|
||||
@@ -35,6 +35,19 @@ write_envelope() {
|
||||
if [ -z "${AGENT_RELEASE:-}" ]; then AGENT_RELEASE='null'; fi
|
||||
|
||||
# Build envelope — prefer jq, fall back to cat
|
||||
# Default GITHUB_REPOSITORY first to keep `set -u` callers (tests, local
|
||||
# sandbox) from triggering unbound-variable errors. Strip the owner
|
||||
# prefix to match the existing repository.repo semantics.
|
||||
# Coerce GITHUB_RUN_ID to actual `null` (not empty string) before
|
||||
# passing to jq; jq 1.8.2 silently produces no output when
|
||||
# `tonumber?` on an empty arg is used in a top-level object context.
|
||||
: "${GITHUB_REPOSITORY:=}"
|
||||
_repo_name="${GITHUB_REPOSITORY##*/}"
|
||||
if [ -z "${GITHUB_RUN_ID:-}" ]; then
|
||||
_run_id_arg='--argjson run_id null'
|
||||
else
|
||||
_run_id_arg="--argjson run_id ${GITHUB_RUN_ID}"
|
||||
fi
|
||||
if command -v jq &>/dev/null && jq -n \
|
||||
--arg action "$action" \
|
||||
--arg version "1.0" \
|
||||
@@ -48,11 +61,11 @@ write_envelope() {
|
||||
--argjson findings "${AGENT_FINDINGS}" \
|
||||
--argjson release "${AGENT_RELEASE}" \
|
||||
--arg repo_owner "${GITHUB_REPOSITORY_OWNER:-}" \
|
||||
--arg repo_name "${GITHUB_REPOSITORY#*/}" \
|
||||
--arg repo_name "${_repo_name}" \
|
||||
--arg sha "${GITHUB_SHA:-}" \
|
||||
--arg ref "${GITHUB_REF:-}" \
|
||||
--arg workflow "${GITHUB_WORKFLOW:-}" \
|
||||
--arg run_id "${GITHUB_RUN_ID:-}" \
|
||||
${_run_id_arg} \
|
||||
'{
|
||||
agent_action: $action,
|
||||
version: $version,
|
||||
@@ -71,7 +84,7 @@ write_envelope() {
|
||||
sha: $sha,
|
||||
ref: $ref,
|
||||
workflow: $workflow,
|
||||
run_id: ($run_id | tonumber?)
|
||||
run_id: $run_id
|
||||
}
|
||||
}' > "$AGENT_ENVELOPE_FILE" 2>/dev/null; then
|
||||
: # jq succeeded
|
||||
@@ -163,3 +176,69 @@ CHECK
|
||||
fi
|
||||
export AGENT_CHECKS
|
||||
}
|
||||
|
||||
# Helper to add a finding (lint error, vuln, etc.)
|
||||
#
|
||||
# Usage: add_finding <severity> <message> [rule] [file] [line] [column] [suggested_fix]
|
||||
#
|
||||
# Severity must be one of: error, warning, info, note (matches
|
||||
# .agent/schema.json findings[].severity enum).
|
||||
add_finding() {
|
||||
local severity="$1"
|
||||
local message="$2"
|
||||
local rule="${3:-}"
|
||||
local file="${4:-}"
|
||||
local line="${5:-}"
|
||||
local column="${6:-}"
|
||||
local suggested_fix="${7:-}"
|
||||
|
||||
if [ -z "${AGENT_FINDINGS:-}" ]; then
|
||||
AGENT_FINDINGS='[]'
|
||||
fi
|
||||
|
||||
# Build JSON via jq for safety; jq nullifies unset optional fields.
|
||||
local finding
|
||||
if command -v jq &>/dev/null; then
|
||||
finding=$(jq -n \
|
||||
--arg severity "$severity" \
|
||||
--arg rule "$rule" \
|
||||
--arg file "$file" \
|
||||
--arg message "$message" \
|
||||
--arg suggested_fix "$suggested_fix" \
|
||||
--arg line "$line" \
|
||||
--arg column "$column" \
|
||||
'{
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
rule: (if $rule == "" then null else $rule end),
|
||||
file: (if $file == "" then null else $file end),
|
||||
line: (if $line == "" then null else ($line | tonumber) end),
|
||||
column: (if $column == "" then null else ($column | tonumber) end),
|
||||
suggested_fix: (if $suggested_fix == "" then null else $suggested_fix end)
|
||||
}')
|
||||
else
|
||||
# No-jq fallback: omit empty optional fields; coerce line/column to
|
||||
# numbers only when non-empty. Message and severity are always strings.
|
||||
local rule_json="null"
|
||||
[ -n "$rule" ] && rule_json="\"$rule\""
|
||||
local file_json="null"
|
||||
[ -n "$file" ] && file_json="\"$file\""
|
||||
local line_json="null"
|
||||
[ -n "$line" ] && line_json="$line"
|
||||
local column_json="null"
|
||||
[ -n "$column" ] && column_json="$column"
|
||||
local fix_json="null"
|
||||
[ -n "$suggested_fix" ] && fix_json="\"$suggested_fix\""
|
||||
finding=$(cat <<FINDING
|
||||
{"severity":"${severity}","message":"${message}","rule":${rule_json},"file":${file_json},"line":${line_json},"column":${column_json},"suggested_fix":${fix_json}}
|
||||
FINDING
|
||||
)
|
||||
fi
|
||||
|
||||
if command -v jq &>/dev/null; then
|
||||
AGENT_FINDINGS=$(echo "$AGENT_FINDINGS" | jq --argjson f "$finding" '. + [$f]')
|
||||
else
|
||||
AGENT_FINDINGS=$(echo "$AGENT_FINDINGS" | sed 's/\]$/,/' | (cat - && echo "${finding}]"))
|
||||
fi
|
||||
export AGENT_FINDINGS
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
name: "Lint Ultimate"
|
||||
description: |
|
||||
Composite action wrapping MegaLinter (or Super-Linter) to analyze 40+
|
||||
languages, formats, IaC, and config files in one step. Produces a
|
||||
standardized .agent/output.json envelope with per-linter checks and
|
||||
individual findings.
|
||||
|
||||
author: "Heretek-AI"
|
||||
|
||||
inputs:
|
||||
linter:
|
||||
description: "Linter suite to run: 'megalinter' or 'super-linter'"
|
||||
default: "megalinter"
|
||||
required: false
|
||||
linter-version:
|
||||
description: "Version tag for the chosen linter suite"
|
||||
default: "v7"
|
||||
required: false
|
||||
config-file:
|
||||
description: "Path to MegaLinter config (megalinter only)"
|
||||
default: ".mega-linter.yml"
|
||||
required: false
|
||||
upload-artifacts:
|
||||
description: "Upload lint reports as workflow artifacts"
|
||||
default: "true"
|
||||
required: false
|
||||
github-token:
|
||||
description: "GitHub token for posting review comments"
|
||||
default: "${{ github.token }}"
|
||||
required: false
|
||||
report-path:
|
||||
description: "Where MegaLinter writes its JSON report"
|
||||
default: "megalinter-report.json"
|
||||
required: false
|
||||
|
||||
outputs:
|
||||
agent_action:
|
||||
description: "Always 'lint-ultimate'"
|
||||
value: ${{ steps.envelope.outputs.agent_action }}
|
||||
status:
|
||||
description: "success | failure | partial"
|
||||
value: ${{ steps.envelope.outputs.status }}
|
||||
summary:
|
||||
description: "Lint summary"
|
||||
value: ${{ steps.envelope.outputs.summary }}
|
||||
findings_count:
|
||||
description: "Number of individual findings emitted"
|
||||
value: ${{ steps.envelope.outputs.findings_count }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: "0"
|
||||
|
||||
- name: "Run MegaLinter"
|
||||
id: megalinter
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
MEGALINTER_CONFIG: ${{ inputs.config-file }}
|
||||
run: |
|
||||
echo "==> Running MegaLinter (${{ inputs.linter-version }})"
|
||||
docker run --rm \
|
||||
-v "${GITHUB_WORKSPACE}:/tmp/lint" \
|
||||
-e MEGALINTER_CONFIG \
|
||||
-e GITHUB_TOKEN \
|
||||
oxsecurity/megalinter:${{ inputs.linter-version }} \
|
||||
2>&1 | tee .megalinter-stdout.log
|
||||
|
||||
- name: "Upload Lint Reports"
|
||||
id: upload
|
||||
if: inputs.upload-artifacts == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lint-ultimate-reports
|
||||
path: |
|
||||
${{ inputs.report-path }}
|
||||
.megalinter-stdout.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: "Generate Agent Envelope"
|
||||
id: envelope
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
REPORT_PATH: ${{ inputs.report-path }}
|
||||
AGENT_ACTION_PATH: ${{ github.action_path }}
|
||||
run: |
|
||||
"${AGENT_ACTION_PATH}/build-envelope.sh"
|
||||
|
||||
- name: "Upload Agent Output"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-output-lint
|
||||
path: .agent/output.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-envelope.sh — Parse MegaLinter JSON report and write .agent/output.json
|
||||
#
|
||||
# Reads REPORT_PATH (default: megalinter-report.json) and maps each linter
|
||||
# category to a checks[] entry and each individual finding to a
|
||||
# findings[] entry. Falls back to a synthesized envelope when the report
|
||||
# is missing or unreadable so downstream consumers never see an empty
|
||||
# envelope.
|
||||
#
|
||||
# Sources agent-envelope.sh for write_envelope() and add_finding().
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "${AGENT_ACTION_PATH}/../agent-envelope.sh"
|
||||
|
||||
REPORT_PATH="${REPORT_PATH:-megalinter-report.json}"
|
||||
REPORT_PATH="${REPORT_PATH%.json}.json" # normalize
|
||||
|
||||
echo "==> Parsing MegaLinter report: ${REPORT_PATH}"
|
||||
|
||||
if [ ! -f "${REPORT_PATH}" ]; then
|
||||
echo "::warning::No MegaLinter report found at ${REPORT_PATH}; writing empty envelope"
|
||||
AGENT_OUTPUTS='{"linter":"unknown","report_path":"'"${REPORT_PATH}"'","report_found":false}'
|
||||
AGENT_CHECKS='[]'
|
||||
AGENT_FINDINGS='[]'
|
||||
write_envelope "lint-ultimate" "failure" "no megalinter report found"
|
||||
echo "findings_count=0" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "::error::jq is required to parse the MegaLinter report"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pull linter categories (top-level keys with a "findings" array).
|
||||
# MegaLinter's report shape: { "linter_key": { "findings": [...], "status": "success"/"warning"/"error" }, ... }
|
||||
LINTER_KEYS=$(jq -r 'keys[] | select(. as $k | $k | startswith("$") | not)' "${REPORT_PATH}" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "${LINTER_KEYS}" ]; then
|
||||
echo "::warning::Report has no linter keys; treating as empty"
|
||||
AGENT_OUTPUTS='{"linter":"unknown","report_path":"'"${REPORT_PATH}"'","report_found":true,"findings_total":0}'
|
||||
write_envelope "lint-ultimate" "success" "no findings (empty report)"
|
||||
echo "findings_count=0" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Aggregate stats
|
||||
TOTAL_ERRORS=0
|
||||
TOTAL_WARNINGS=0
|
||||
LINTER_COUNT=0
|
||||
FINDINGS_COUNT=0
|
||||
|
||||
# Build checks[] and findings[] via the helpers.
|
||||
while IFS= read -r linter; do
|
||||
[ -z "${linter}" ] && continue
|
||||
LINTER_COUNT=$((LINTER_COUNT + 1))
|
||||
|
||||
# Status: error > warning > success
|
||||
LINT_STATUS=$(jq -r --arg k "${linter}" '
|
||||
if (.[$k] | type) == "object" and (.[$k].status // null) != null then .[$k].status
|
||||
elif (.[$k] | type) == "object" and ((.[$k].findings // []) | length) > 0 then "warning"
|
||||
else "success" end
|
||||
' "${REPORT_PATH}")
|
||||
|
||||
FINDING_COUNT_FOR_LINTER=$(jq -r --arg k "${linter}" '
|
||||
if (.[$k] | type) == "object" then ((.[$k].findings // []) | length)
|
||||
else 0 end
|
||||
' "${REPORT_PATH}")
|
||||
|
||||
case "${LINT_STATUS}" in
|
||||
error) TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) ;;
|
||||
warning) TOTAL_WARNINGS=$((TOTAL_WARNINGS + 1)) ;;
|
||||
esac
|
||||
|
||||
CHECK_STATUS="pass"
|
||||
case "${LINT_STATUS}" in
|
||||
error) CHECK_STATUS="fail" ;;
|
||||
warning) CHECK_STATUS="warn" ;;
|
||||
esac
|
||||
|
||||
add_check "${linter}" "${CHECK_STATUS}" "${FINDING_COUNT_FOR_LINTER} finding(s)"
|
||||
|
||||
# Each finding -> add_finding()
|
||||
while IFS=$'\t' read -r severity file line column message rule; do
|
||||
[ -z "${severity:-}" ] && continue
|
||||
FINDINGS_COUNT=$((FINDINGS_COUNT + 1))
|
||||
add_finding "${severity}" "${message}" "${rule}" "${file}" "${line}" "${column}" ""
|
||||
done < <(jq -r --arg k "${linter}" '
|
||||
(.[$k].findings // [])[] |
|
||||
[
|
||||
(.severity // "warning"),
|
||||
(.filename // .file // ""),
|
||||
((.line // 0) | tostring),
|
||||
((.column // 0) | tostring),
|
||||
(.message // ""),
|
||||
(.linter_rule_key // .rule // $k)
|
||||
] | @tsv
|
||||
' "${REPORT_PATH}")
|
||||
|
||||
done <<< "${LINTER_KEYS}"
|
||||
|
||||
# Overall status
|
||||
if [ "${TOTAL_ERRORS}" -gt 0 ]; then
|
||||
STATUS="failure"
|
||||
elif [ "${TOTAL_WARNINGS}" -gt 0 ]; then
|
||||
STATUS="partial"
|
||||
else
|
||||
STATUS="success"
|
||||
fi
|
||||
|
||||
SUMMARY="lint-ultimate: ${FINDINGS_COUNT} finding(s) across ${LINTER_COUNT} linter(s)"
|
||||
[ "${TOTAL_ERRORS}" -gt 0 ] && SUMMARY+=", ${TOTAL_ERRORS} error(s)"
|
||||
[ "${TOTAL_WARNINGS}" -gt 0 ] && SUMMARY+=", ${TOTAL_WARNINGS} warning(s)"
|
||||
|
||||
AGENT_OUTPUTS=$(jq -n \
|
||||
--arg report_path "${REPORT_PATH}" \
|
||||
--argjson linter_count "${LINTER_COUNT}" \
|
||||
--argjson findings_total "${FINDINGS_COUNT}" \
|
||||
--argjson errors "${TOTAL_ERRORS}" \
|
||||
--argjson warnings "${TOTAL_WARNINGS}" \
|
||||
'{
|
||||
report_path: $report_path,
|
||||
linter_count: $linter_count,
|
||||
findings_total: $findings_total,
|
||||
errors: $errors,
|
||||
warnings: $warnings
|
||||
}')
|
||||
export AGENT_OUTPUTS
|
||||
|
||||
write_envelope "lint-ultimate" "${STATUS}" "${SUMMARY}"
|
||||
|
||||
echo "findings_count=${FINDINGS_COUNT}" >> "$GITHUB_OUTPUT"
|
||||
@@ -200,6 +200,7 @@ Scans for: TODO/FIXME without issues, missing test files, large files (>500 line
|
||||
| [`rust-ci`](.github/actions/rust-ci/) | 🦀 Rust | `cargo fmt`, `cargo clippy`, `cargo test` | coverage (`cargo-llvm-cov`), audit (`cargo-audit`) |
|
||||
| [`js-ci`](.github/actions/js-ci/) | 🟨 JS/TS | lint, typecheck (tsc --noEmit), test | — |
|
||||
| [`python-ci`](.github/actions/python-ci/) | 🐍 Python | lint (ruff), typecheck (mypy), test (pytest) | — |
|
||||
| [`lint-ultimate`](.github/actions/lint-ultimate/) | 🔍 MegaLinter | 40+ languages, 22 formats, IaC, configs in one step | linter (`megalinter` / `super-linter`) |
|
||||
|
||||
### `rust-ci` Example
|
||||
|
||||
@@ -231,6 +232,21 @@ Auto-detects pnpm, yarn, bun, or npm from lock files.
|
||||
python-version: "3.12"
|
||||
```
|
||||
|
||||
### `lint-ultimate` Example
|
||||
|
||||
```yaml
|
||||
- uses: Heretek-AI/heretek-actions/.github/actions/lint-ultimate@v1
|
||||
with:
|
||||
linter: megalinter # or: super-linter
|
||||
linter-version: v7
|
||||
config-file: .mega-linter.yml
|
||||
```
|
||||
|
||||
Produces a per-linter `checks[]` array and an individual `findings[]`
|
||||
array in `.agent/output.json`. Reports are uploaded as
|
||||
`lint-ultimate-reports` artifacts. Envelope is uploaded as
|
||||
`agent-output-lint` for downstream consumers (e.g. `quality-gate`).
|
||||
|
||||
---
|
||||
|
||||
## Workflows
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Test Harness
|
||||
|
||||
Composite actions in `.github/actions/` are notoriously hard to test in
|
||||
isolation. This harness uses [`act`](https://github.com/nektos/act) to run
|
||||
each action against a fixture repo and diff the produced `.agent/output.json`
|
||||
against a golden snapshot.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tests/
|
||||
├── run-action.sh # Runner — invokes act on a fixture
|
||||
├── run-all.sh # Run all action/fixture pairs in parallel
|
||||
├── fixtures/ # Tiny repos with intentional state
|
||||
│ ├── rust-passing/ # cargo test passes
|
||||
│ ├── rust-failing/ # cargo test fails on purpose
|
||||
│ ├── js-passing/ # eslint + vitest green
|
||||
│ ├── js-failing/ # vitest fails
|
||||
│ ├── python-passing/ # ruff + pytest green
|
||||
│ ├── python-failing/ # pytest fails
|
||||
│ ├── docker-passing/ # clean Dockerfile
|
||||
│ └── docker-failing/ # bad Dockerfile (hadolint warnings)
|
||||
├── golden/ # Expected envelopes, keyed by action/fixture
|
||||
│ ├── rust-ci/
|
||||
│ │ ├── rust-passing.json
|
||||
│ │ └── rust-failing.json
|
||||
│ └── ...
|
||||
└── work/ # Sandboxes — gitignored, transient
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run a single action against a fixture
|
||||
tests/run-action.sh rust-passing rust-ci
|
||||
|
||||
# First run with no golden: writes the snapshot
|
||||
tests/run-action.sh rust-failing rust-ci # creates golden/rust-ci/rust-failing.json
|
||||
|
||||
# Subsequent runs diff against golden — non-zero exit on mismatch
|
||||
tests/run-action.sh rust-passing rust-ci
|
||||
```
|
||||
|
||||
## CI Integration
|
||||
|
||||
`tests/run-all.sh` iterates over all action/fixture pairs and reports a summary:
|
||||
|
||||
```bash
|
||||
tests/run-all.sh # parallel (default jobs = nproc)
|
||||
tests/run-all.sh --serial # sequential
|
||||
tests/run-all.sh --dry-run # print matrix, don't execute
|
||||
tests/run-all.sh --only rust-ci # run only matching actions
|
||||
tests/run-all.sh --jobs 4 # cap parallel workers
|
||||
```
|
||||
|
||||
Wire it into a `ci` workflow:
|
||||
|
||||
```yaml
|
||||
name: Action Tests
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash
|
||||
- run: tests/run-all.sh
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **One fixture per scenario**, not per language. `rust-failing` is the
|
||||
same fixture reused for `rust-ci` failing-case and (later) for
|
||||
`coverage` failing-coverage-case.
|
||||
2. **Goldens strip volatile fields** — `created_at`, `duration_ms`, `repository.sha`,
|
||||
`repository.run_id`. Don't add those to golden files.
|
||||
3. **First-run creates the golden.** On any schema change, delete goldens to
|
||||
regenerate. Review the diff carefully before committing.
|
||||
4. **Fixtures stay tiny.** A passing rust fixture is ~10 lines of Cargo.toml +
|
||||
one test file. If a fixture grows past 50 lines, split it.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `act` does not perfectly emulate GitHub-hosted runners — network, secrets,
|
||||
and some `GITHUB_*` env vars behave differently. Envelope schema validation
|
||||
is the main check; behavioral testing requires real runners.
|
||||
- Docker-in-Docker (`docker build` inside `act`) is slow. For `docker-ci`,
|
||||
prefer `docker buildx build --load=false` or skip image builds in tests.
|
||||
- No full JSON-schema validator — the harness only checks that
|
||||
`agent_action` is in the schema enum. Add `ajv` / `jsonschema` if you
|
||||
need exhaustive validation.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
FROM ubuntu:latest
|
||||
RUN apt-get update && apt-get install -y curl
|
||||
ADD http://example.com/binary /usr/local/bin/binary
|
||||
RUN chmod +x /usr/local/bin/binary && /usr/local/bin/binary
|
||||
ENTRYPOINT ["bash"]
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
FROM alpine:3.18
|
||||
RUN apk add --no-cache curl
|
||||
COPY app.sh /app.sh
|
||||
RUN chmod +x /app.sh
|
||||
ENTRYPOINT ["/app.sh"]
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
echo "ok"
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "js-failing-fixture",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('add', () => {
|
||||
it('adds two numbers', () => {
|
||||
expect(2 + 2).toBe(5); // intentional fail
|
||||
});
|
||||
});
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "js-passing-fixture",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('add', () => {
|
||||
it('adds two numbers', () => {
|
||||
expect(2 + 2).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
name: failing
|
||||
description:bad yaml
|
||||
version: 1.0
|
||||
noquotes: this is unquoted
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Sample failing repo
|
||||
|
||||
This file intentionally has a few lint problems that MegaLinter's
|
||||
markdownlint should flag under the default ruleset.
|
||||
|
||||
## Bad heading spacing
|
||||
The following heading has trailing whitespace.
|
||||
|
||||
|
||||
## Trailing spaces below
|
||||
This line has trailing spaces.
|
||||
|
||||
## A code block with no language
|
||||
```
|
||||
echo "no language tag"
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "failing-fixture"
|
||||
"version": "1.0.0",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
name: passing
|
||||
description: A well-formatted config.
|
||||
version: 1.0
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Sample passing repo
|
||||
|
||||
This is a small, well-formatted markdown file used by the `lint-ultimate`
|
||||
action fixture. MegaLinter should report zero findings here under the
|
||||
default configuration (markdownlint, yamllint, trailing-whitespace).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
echo "hello"
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "passing-fixture",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
[project]
|
||||
name = "python-failing-fixture"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,2 @@
|
||||
def add(a: int, b: int) -> int:
|
||||
return a - b # intentional bug
|
||||
@@ -0,0 +1,5 @@
|
||||
from addition import add
|
||||
|
||||
|
||||
def test_add():
|
||||
assert add(2, 2) == 4 # fails: 2 - 2 = 0
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
[project]
|
||||
name = "python-passing-fixture"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,2 @@
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
@@ -0,0 +1,5 @@
|
||||
from addition import add
|
||||
|
||||
|
||||
def test_add():
|
||||
assert add(2, 2) == 4
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "rust-failing-fixture"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "rust_failing_fixture"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
pub fn add(a: i32, b: i32) -> i32 {
|
||||
// intentional bug: returns wrong value to make tests fail
|
||||
a - b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
assert_eq!(add(2, 2), 4); // will fail: 2 - 2 = 0
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "rust-passing-fixture"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "rust_passing_fixture"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
pub fn add(a: i32, b: i32) -> i32 {
|
||||
a + b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
assert_eq!(add(2, 2), 4);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("ok");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Golden envelopes for `lint-ultimate`
|
||||
|
||||
Goldens for this action are **not committed**. They are generated on first
|
||||
run via:
|
||||
|
||||
```bash
|
||||
tests/run-action.sh megalinter-passing lint-ultimate
|
||||
tests/run-action.sh megalinter-failing lint-ultimate
|
||||
```
|
||||
|
||||
This is consistent with `tests/README.md:38-39` — "First-run creates the
|
||||
golden." The first CI run on a real runner will populate these files; the
|
||||
diff is reviewed and committed.
|
||||
|
||||
## Why no committed goldens for this action
|
||||
|
||||
`lint-ultimate` invokes `oxsecurity/megalinter:v7` via Docker. The Docker
|
||||
run inside `act` is slow and the JSON report is regenerated each time
|
||||
against the fixture's source files. The only stable, useful contract is
|
||||
that the parser produces a schema-compatible envelope from any input
|
||||
report — which the unit test of `build-envelope.sh` (run locally with a
|
||||
fixture report) verifies; the end-to-end contract is verified by the
|
||||
golden diff on first CI run.
|
||||
|
||||
## Local unit check (no Docker required)
|
||||
|
||||
```bash
|
||||
# Generate a fixture report and run build-envelope.sh against it
|
||||
cd tests/work
|
||||
cp ../fixtures/megalinter-failing/* .
|
||||
# Manually invoke the parser using a sample megalinter-report.json
|
||||
# (you can fabricate one matching MegaLinter's documented schema)
|
||||
REPORT_PATH=./megalinter-report.json \
|
||||
AGENT_ACTION_PATH=../../.github/actions/lint-ultimate \
|
||||
bash ../../.github/actions/lint-ultimate/build-envelope.sh
|
||||
```
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/run-action.sh — Run a Heretek composite action against a fixture repo via `act`
|
||||
#
|
||||
# Usage:
|
||||
# tests/run-action.sh <fixture-name> <action-name> [act-args...]
|
||||
#
|
||||
# Example:
|
||||
# tests/run-action.sh rust-passing rust-ci
|
||||
# tests/run-action.sh js-failing js-ci --dryrun
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — envelope produced and matches golden (or no golden exists yet)
|
||||
# 1 — fixture missing
|
||||
# 2 — envelope validation failed
|
||||
# 3 — golden mismatch
|
||||
# 4 — act itself failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
FIXTURES_DIR="${REPO_ROOT}/tests/fixtures"
|
||||
GOLDEN_DIR="${REPO_ROOT}/tests/golden"
|
||||
WORK_DIR="${REPO_ROOT}/tests/work"
|
||||
|
||||
FIXTURE="${1:-}"
|
||||
ACTION="${2:-}"
|
||||
shift 2 || true
|
||||
|
||||
if [ -z "${FIXTURE}" ] || [ -z "${ACTION}" ]; then
|
||||
echo "Usage: $0 <fixture-name> <action-name> [act-args...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FIXTURE_PATH="${FIXTURES_DIR}/${FIXTURE}"
|
||||
if [ ! -d "${FIXTURE_PATH}" ]; then
|
||||
echo "fixture not found: ${FIXTURE_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sandbox: copy fixture into work dir so act can checkout freely
|
||||
SANDBOX="${WORK_DIR}/${FIXTURE}-$$"
|
||||
mkdir -p "${SANDBOX}"
|
||||
cp -R "${FIXTURE_PATH}/." "${SANDBOX}/"
|
||||
|
||||
# Wire up the local heretek-actions checkout as the action source
|
||||
# Override GITHUB_ACTION_PATH or use local resolution via act's --action-ref
|
||||
# Easiest path: mount the repo as the local actions source
|
||||
mkdir -p "${SANDBOX}/.github/actions"
|
||||
ln -sfn "${REPO_ROOT}/.github/actions/agent-envelope.sh" "${SANDBOX}/.github/actions/agent-envelope.sh"
|
||||
ln -sfn "${REPO_ROOT}/.github/actions/${ACTION}" "${SANDBOX}/.github/actions/${ACTION}"
|
||||
|
||||
# Minimal workflow that calls the action and uploads the envelope
|
||||
WORKFLOW_FILE="${SANDBOX}/.github/workflows/test-${ACTION}.yml"
|
||||
mkdir -p "$(dirname "${WORKFLOW_FILE}")"
|
||||
cat > "${WORKFLOW_FILE}" <<WORKFLOW
|
||||
name: Test ${ACTION}
|
||||
on: [push]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/${ACTION}
|
||||
with: {}
|
||||
- name: Validate envelope against schema
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
jq -e --schema '\${{ inputs.schema }}' '. | type == "object"' .agent/output.json >/dev/null
|
||||
WORKFLOW
|
||||
|
||||
echo "==> Running ${ACTION} against fixture ${FIXTURE}"
|
||||
|
||||
# Run act. Capture output; tolerate non-zero exit (action may intentionally fail).
|
||||
set +e
|
||||
act -W "${WORKFLOW_FILE}" --container-architecture linux/amd64 "$@" 2>&1 | tee "${WORKDIR:-/tmp}/act-${FIXTURE}-${ACTION}.log"
|
||||
ACT_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
# Find the produced envelope
|
||||
ENVELOPE="${SANDBOX}/.agent/output.json"
|
||||
if [ ! -f "${ENVELOPE}" ]; then
|
||||
echo "no envelope produced (act exit ${ACT_EXIT})" >&2
|
||||
rm -rf "${SANDBOX}"
|
||||
exit 4
|
||||
fi
|
||||
|
||||
# Validate against schema
|
||||
if ! jq -e empty "${ENVELOPE}" >/dev/null 2>&1; then
|
||||
echo "envelope is not valid JSON: ${ENVELOPE}" >&2
|
||||
rm -rf "${SANDBOX}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Validate schema enum fields (lightweight, no full JSON-schema validator needed)
|
||||
SCHEMA="${REPO_ROOT}/.agent/schema.json"
|
||||
AGENT_ACTION=$(jq -r '.agent_action' "${ENVELOPE}")
|
||||
if ! jq -e --argjson env "${ENVELOPE}" '.properties.agent_action.enum | index($env.agent_action) != null' "${SCHEMA}" >/dev/null; then
|
||||
echo "envelope agent_action '${AGENT_ACTION}' not in schema enum" >&2
|
||||
rm -rf "${SANDBOX}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
GOLDEN="${GOLDEN_DIR}/${ACTION}/${FIXTURE}.json"
|
||||
if [ -f "${GOLDEN}" ]; then
|
||||
# Diff against golden — strip volatile fields (timestamps, run_id, sha)
|
||||
NORMALIZED=$(jq 'del(.created_at, .duration_ms, .repository.run_id, .repository.sha)' "${ENVELOPE}")
|
||||
GOLDEN_NORMALIZED=$(jq 'del(.created_at, .duration_ms, .repository.run_id, .repository.sha)' "${GOLDEN}")
|
||||
if ! diff <(echo "${GOLDEN_NORMALIZED}") <(echo "${NORMALIZED}") >/dev/null; then
|
||||
echo "envelope differs from golden: ${GOLDEN}" >&2
|
||||
diff <(echo "${GOLDEN_NORMALIZED}") <(echo "${NORMALIZED}") >&2 || true
|
||||
rm -rf "${SANDBOX}"
|
||||
exit 3
|
||||
fi
|
||||
echo "✓ ${ACTION} on ${FIXTURE} matches golden"
|
||||
else
|
||||
echo "no golden for ${ACTION}/${FIXTURE} — writing first-run snapshot"
|
||||
mkdir -p "${GOLDEN_DIR}/${ACTION}"
|
||||
jq 'del(.created_at, .duration_ms, .repository.run_id, .repository.sha)' "${ENVELOPE}" > "${GOLDEN}"
|
||||
fi
|
||||
|
||||
rm -rf "${SANDBOX}"
|
||||
exit 0
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/run-all.sh — Run all action/fixture pairs and report a summary
|
||||
#
|
||||
# Usage:
|
||||
# tests/run-all.sh # run everything, parallel
|
||||
# tests/run-all.sh --serial # run sequentially
|
||||
# tests/run-all.sh --dry-run # print the matrix, don't execute
|
||||
# tests/run-all.sh --only rust-ci # run only matching actions
|
||||
# tests/run-all.sh --jobs 4 # parallel worker count (default: nproc)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — every pair passed (or matched golden / first-run snapshot)
|
||||
# 1 — one or more pairs failed
|
||||
# 2 — bad usage / environment
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
FIXTURES_DIR="${REPO_ROOT}/tests/fixtures"
|
||||
RESULTS_DIR="${REPO_ROOT}/tests/work/results"
|
||||
|
||||
SERIAL=false
|
||||
DRY_RUN=false
|
||||
ONLY=""
|
||||
JOBS="$(nproc 2>/dev/null || echo 4)"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--serial) SERIAL=true ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--only) ONLY="${2:-}"; shift ;;
|
||||
--jobs) JOBS="${2:-4}"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,15p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Action matrix: maps fixture glob → action + extra args.
|
||||
# Format: "<fixture-glob>|<action>|<action-args>"
|
||||
# fixture-glob — shell glob under tests/fixtures
|
||||
# action — composite action name (must exist under .github/actions/)
|
||||
# action-args — extra args forwarded to run-action.sh
|
||||
MATRIX=(
|
||||
"rust-*|rust-ci|"
|
||||
"js-*|js-ci|"
|
||||
"python-*|python-ci|"
|
||||
"docker-*|release|release-type=docker dockerfile=./Dockerfile"
|
||||
"megalinter-passing|lint-ultimate|"
|
||||
"megalinter-failing|lint-ultimate|"
|
||||
)
|
||||
|
||||
# Expand matrix into concrete pairs
|
||||
PAIRS=()
|
||||
for entry in "${MATRIX[@]}"; do
|
||||
glob="${entry%%|*}"
|
||||
rest="${entry#*|}"
|
||||
action="${rest%%|*}"
|
||||
args="${rest#*|}"
|
||||
|
||||
if [ -n "${ONLY}" ] && [ "${action}" != "${ONLY}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2206
|
||||
matches=(${FIXTURES_DIR}/${glob}/)
|
||||
if [ ${#matches[@]} -eq 0 ] || [ ! -d "${matches[0]}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
for fixture_path in "${matches[@]}"; do
|
||||
fixture="$(basename "${fixture_path}")"
|
||||
PAIRS+=("${fixture}|${action}|${args}")
|
||||
done
|
||||
done
|
||||
|
||||
if [ ${#PAIRS[@]} -eq 0 ]; then
|
||||
echo "no fixture/action pairs to run" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "==> ${#PAIRS[@]} pair(s) queued, jobs=${JOBS} mode=$( [ "${SERIAL}" = true ] && echo serial || echo parallel )"
|
||||
echo
|
||||
|
||||
mkdir -p "${RESULTS_DIR}"
|
||||
|
||||
# Run one pair, capture exit code into a marker file.
|
||||
run_pair() {
|
||||
local pair="$1"
|
||||
local fixture="${pair%%|*}"
|
||||
local rest="${pair#*|}"
|
||||
local action="${rest%%|*}"
|
||||
local args="${rest#*|}"
|
||||
|
||||
local marker="${RESULTS_DIR}/${action}__${fixture}.exit"
|
||||
local log="${RESULTS_DIR}/${action}__${fixture}.log"
|
||||
|
||||
echo "--- ${action} / ${fixture} ---"
|
||||
# shellcheck disable=SC2086
|
||||
if "${REPO_ROOT}/tests/run-action.sh" "${fixture}" "${action}" ${args} >"${log}" 2>&1; then
|
||||
echo "0" > "${marker}"
|
||||
else
|
||||
echo "$?" > "${marker}"
|
||||
fi
|
||||
}
|
||||
|
||||
export -f run_pair
|
||||
export REPO_ROOT FIXTURES_DIR RESULTS_DIR
|
||||
|
||||
# Dry-run: print matrix and exit
|
||||
if [ "${DRY_RUN}" = true ]; then
|
||||
for pair in "${PAIRS[@]}"; do
|
||||
fixture="${pair%%|*}"
|
||||
rest="${pair#*|}"
|
||||
action="${rest%%|*}"
|
||||
echo " ${action} / ${fixture}"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${SERIAL}" = true ]; then
|
||||
for pair in "${PAIRS[@]}"; do
|
||||
run_pair "${pair}"
|
||||
done
|
||||
else
|
||||
printf '%s\n' "${PAIRS[@]}" | xargs -n1 -P "${JOBS}" -I{} bash -c 'run_pair "$@"' _ {}
|
||||
fi
|
||||
|
||||
# Tally results
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILED_PAIRS=()
|
||||
|
||||
for pair in "${PAIRS[@]}"; do
|
||||
fixture="${pair%%|*}"
|
||||
rest="${pair#*|}"
|
||||
action="${rest%%|*}"
|
||||
marker="${RESULTS_DIR}/${action}__${fixture}.exit"
|
||||
|
||||
if [ ! -f "${marker}" ]; then
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_PAIRS+=("${action} / ${fixture} (no marker)")
|
||||
continue
|
||||
fi
|
||||
|
||||
code="$(cat "${marker}")"
|
||||
if [ "${code}" = "0" ]; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_PAIRS+=("${action} / ${fixture} (exit ${code})")
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "==> ${PASS} passed, ${FAIL} failed"
|
||||
|
||||
if [ ${FAIL} -gt 0 ]; then
|
||||
echo
|
||||
echo "Failures:"
|
||||
for f in "${FAILED_PAIRS[@]}"; do
|
||||
echo " ✗ ${f}"
|
||||
done
|
||||
echo
|
||||
echo "Inspect logs under: ${RESULTS_DIR}/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"MARKDOWN_MARKDOWNLINT": {
|
||||
"status": "warning",
|
||||
"findings": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"filename": "README.md",
|
||||
"line": 7,
|
||||
"column": 1,
|
||||
"message": "MD012/no-multiple-blanks: Multiple consecutive blank lines",
|
||||
"linter_rule_key": "MD012"
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"filename": "README.md",
|
||||
"line": 13,
|
||||
"column": 1,
|
||||
"message": "MD009/no-trailing-spaces: Trailing spaces",
|
||||
"linter_rule_key": "MD009"
|
||||
}
|
||||
]
|
||||
},
|
||||
"YAML_YAMLLINT": {
|
||||
"status": "warning",
|
||||
"findings": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"filename": ".mega-linter.yml",
|
||||
"line": 2,
|
||||
"column": 1,
|
||||
"message": "missing document start \"---\"",
|
||||
"linter_rule_key": "document-start"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/unit/test-lint-ultimate-parser.sh — Unit test for build-envelope.sh
|
||||
#
|
||||
# Runs the parser against a fabricated megalinter-report.json fixture
|
||||
# and verifies the produced envelope shape. No Docker required.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — all assertions passed
|
||||
# 1 — assertion failed
|
||||
# 2 — environment error (missing jq, missing fixture)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
FIXTURE="${REPO_ROOT}/tests/unit/fixtures/megalinter-report-sample.json"
|
||||
ACTION="${REPO_ROOT}/.github/actions/lint-ultimate"
|
||||
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "jq is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ ! -f "${FIXTURE}" ]; then
|
||||
echo "fixture missing: ${FIXTURE}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Sandbox: isolated AGENT_OUTPUT_DIR so we don't clobber anything
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
export AGENT_OUTPUT_DIR="${WORK}/.agent"
|
||||
export GITHUB_OUTPUT="${WORK}/github_output"
|
||||
export REPORT_PATH="${FIXTURE}"
|
||||
export AGENT_ACTION_PATH="${ACTION}"
|
||||
|
||||
bash "${ACTION}/build-envelope.sh" > "${WORK}/run.log" 2>&1
|
||||
|
||||
ENVELOPE="${AGENT_OUTPUT_DIR}/output.json"
|
||||
if [ ! -f "${ENVELOPE}" ]; then
|
||||
echo "no envelope produced; run log:" >&2
|
||||
cat "${WORK}/run.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
assert_jq() {
|
||||
local jq_expr="$1"
|
||||
local expected="$2"
|
||||
local actual
|
||||
actual=$(jq -r "${jq_expr}" "${ENVELOPE}")
|
||||
if [ "${actual}" != "${expected}" ]; then
|
||||
echo "assertion failed: ${jq_expr}" >&2
|
||||
echo " expected: ${expected}" >&2
|
||||
echo " actual: ${actual}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Schema-level assertions
|
||||
assert_jq '.agent_action' "lint-ultimate"
|
||||
assert_jq '.version' "1.0"
|
||||
assert_jq '.status' "partial"
|
||||
assert_jq '.outputs.linter_count' "2"
|
||||
assert_jq '.outputs.findings_total' "3"
|
||||
assert_jq '.outputs.errors' "0"
|
||||
assert_jq '.outputs.warnings' "2"
|
||||
|
||||
# checks[] entries
|
||||
assert_jq '.checks | length' "2"
|
||||
assert_jq '.checks[0].name' "MARKDOWN_MARKDOWNLINT"
|
||||
assert_jq '.checks[0].status' "warn"
|
||||
assert_jq '.checks[1].name' "YAML_YAMLLINT"
|
||||
assert_jq '.checks[1].status' "warn"
|
||||
|
||||
# findings[] entries
|
||||
assert_jq '.findings | length' "3"
|
||||
assert_jq '.findings[0].severity' "warning"
|
||||
assert_jq '.findings[0].rule' "MD012"
|
||||
assert_jq '.findings[0].file' "README.md"
|
||||
assert_jq '.findings[0].line' "7"
|
||||
assert_jq '.findings[1].rule' "MD009"
|
||||
assert_jq '.findings[2].rule' "document-start"
|
||||
|
||||
# Volatile fields excluded from stability checks
|
||||
assert_jq '.created_at | type' "string"
|
||||
# In a sandbox GITHUB_RUN_ID is unset, so repository.run_id is JSON null.
|
||||
# In real CI it's a number.
|
||||
assert_jq '.repository.run_id | (. == null or type == "number")' "true"
|
||||
|
||||
echo "✓ lint-ultimate parser unit test passed"
|
||||
exit 0
|
||||
Reference in New Issue
Block a user