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:
John Smith
2026-07-30 14:38:20 -04:00
parent 2db7db4fd5
commit 6ae3e16aba
35 changed files with 1080 additions and 4 deletions
+12 -1
View File
@@ -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": {
+82 -3
View File
@@ -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
}
+101
View File
@@ -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
View File
@@ -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"
+16
View File
@@ -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
+92
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
echo "ok"
+12
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('add', () => {
it('adds two numbers', () => {
expect(2 + 2).toBe(5); // intentional fail
});
});
+12
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('add', () => {
it('adds two numbers', () => {
expect(2 + 2).toBe(4);
});
});
+4
View File
@@ -0,0 +1,4 @@
name: failing
description:bad yaml
version: 1.0
noquotes: this is unquoted
+16
View File
@@ -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"
```
+4
View File
@@ -0,0 +1,4 @@
{
"name": "failing-fixture"
"version": "1.0.0",
}
+3
View File
@@ -0,0 +1,3 @@
name: passing
description: A well-formatted config.
version: 1.0
+11
View File
@@ -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"
```
+4
View File
@@ -0,0 +1,4 @@
{
"name": "passing-fixture",
"version": "1.0.0"
}
+10
View File
@@ -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"]
+2
View File
@@ -0,0 +1,2 @@
def add(a: int, b: int) -> int:
return a - b # intentional bug
+5
View File
@@ -0,0 +1,5 @@
from addition import add
def test_add():
assert add(2, 2) == 4 # fails: 2 - 2 = 0
+10
View File
@@ -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"]
+2
View File
@@ -0,0 +1,2 @@
def add(a: int, b: int) -> int:
return a + b
+5
View File
@@ -0,0 +1,5 @@
from addition import add
def test_add():
assert add(2, 2) == 4
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,3 @@
fn main() {
println!("ok");
}
+36
View File
@@ -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
```
+123
View File
@@ -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
+172
View File
@@ -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"
}
]
}
}
+91
View File
@@ -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