sdlc-enforce.yml and branch protection; you add report generation and one upload step to your existing pipeline.
When LaneSync ships template improvements, Update LaneSync Workflows refreshes sdlc-enforce.yml only for repos with external CI — your pipeline files, including the upload action you copy below, stay untouched.
LaneSync auto-tags (
dev-* / *-rc-*) apply only to LaneSync CI templates. Keep creating those tags in your own workflow if you need RC promotion on evidence approval.LaneSync only receives the report files listed here — never your source code.
1
Add the upload action to your repository
Save this file as
.github/actions/lanesync-upload/action.yml and commit it. It detects your report files, builds the payload, authenticates, and uploads with retry.Show action.yml
Show action.yml
.github/actions/lanesync-upload/action.yml
name: 'Upload Evidence to LaneSync'
description: >
Detects native test/coverage/SAST/CVE report files, computes summary metrics,
and uploads the raw reports to LaneSync with retry. LaneSync normalizes the
reports server-side. Works identically for Full and Minimal tiers — only
report files are sent, never source code.
inputs:
api-url:
description: 'LaneSync API URL (override for self-hosted)'
required: false
default: 'https://lanesync.dev'
api-key:
description: 'LaneSync API key (optional — falls back to GitHub OIDC when empty)'
required: false
default: ''
dev-branch:
description: 'Development branch name (e.g. main)'
required: false
default: 'main'
release-prefix:
description: 'Release branch prefix (e.g. release/)'
required: false
default: 'release/'
pipeline:
description: 'CI pipeline / check name (matches sdlc.yaml required check)'
required: false
default: 'CI — Build & Test'
outputs:
evidence-id:
description: 'ID of the created evidence record in LaneSync'
value: ${{ steps.upload.outputs.evidence_id }}
upload-status:
description: 'success | skipped | failed'
value: ${{ steps.upload.outputs.status }}
runs:
using: 'composite'
steps:
# ── 1. Resolve environment from GITHUB_REF ─────────────────────────────
- name: Resolve environment
id: env
shell: bash
env:
DEV_BRANCH: ${{ inputs.dev-branch }}
RELEASE_PREFIX: ${{ inputs.release-prefix }}
run: |
REF="${GITHUB_REF#refs/heads/}"
if [ "$GITHUB_EVENT_NAME" = "release" ]; then
echo "environment=production" >> "$GITHUB_OUTPUT"
echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "should_upload=true" >> "$GITHUB_OUTPUT"
elif [ "$REF" = "$DEV_BRANCH" ]; then
echo "environment=dev" >> "$GITHUB_OUTPUT"
echo "version=dev" >> "$GITHUB_OUTPUT"
echo "should_upload=true" >> "$GITHUB_OUTPUT"
elif [[ "$REF" == "$RELEASE_PREFIX"* ]]; then
echo "environment=staging" >> "$GITHUB_OUTPUT"
echo "version=${REF#"$RELEASE_PREFIX"}" >> "$GITHUB_OUTPUT"
echo "should_upload=true" >> "$GITHUB_OUTPUT"
else
echo "environment=none" >> "$GITHUB_OUTPUT"
echo "version=none" >> "$GITHUB_OUTPUT"
echo "should_upload=false" >> "$GITHUB_OUTPUT"
fi
# ── 2. Detect report files, compute metrics, build payload ─────────────
- name: Collect reports
id: reports
if: steps.env.outputs.should_upload == 'true'
shell: bash
env:
ORG: ${{ github.repository_owner }}
PIPELINE: ${{ inputs.pipeline }}
ENVIRONMENT: ${{ steps.env.outputs.environment }}
VERSION: ${{ steps.env.outputs.version }}
run: |
# Runs under `bash -eo pipefail`: every helper below must tolerate
# "no match" / missing files without a non-zero exit.
OUT="${RUNNER_TEMP:-/tmp}/lanesync"
rm -rf "$OUT" && mkdir -p "$OUT"
MAX_FILE_BYTES=5000000
int() { local v; v=$("$@" 2>/dev/null) || v=0; [[ "$v" =~ ^[0-9]+$ ]] || v=0; echo "$v"; }
count() { { grep -oE "$1" "$2" 2>/dev/null || true; } | wc -l | tr -d ' '; }
fits() { [ -f "$1" ] && [ "$(wc -c < "$1" | tr -d ' ')" -le "$MAX_FILE_BYTES" ]; }
append_file() { # <list.json> <path> [format]
if ! fits "$2"; then
[ -f "$2" ] && echo "::warning::Skipping $2 (larger than $MAX_FILE_BYTES bytes)"
return 0
fi
jq --rawfile c "$2" --arg fn "$(basename "$2")" --arg fmt "${3:-}" \
'. + [{filename: $fn, content: $c} + (if $fmt == "" then {} else {format: $fmt} end)]' \
"$1" > "$1.tmp" && mv "$1.tmp" "$1"
}
# ── Test reports ──────────────────────────────────────────────────
TEST_FILES=()
for f in pytest-junit.xml jest-results.json go-test.json rspec-results.json rspec.json \
phpunit-junit.xml ctest-junit.xml test-results.xml junit.xml; do
if [ -f "$f" ]; then TEST_FILES+=("$f"); fi
done
while IFS= read -r f; do
[ -n "$f" ] && TEST_FILES+=("$f")
done < <(find . \( -path '*/target/surefire-reports/TEST-*.xml' -o -path '*/build/test-results/*/TEST-*.xml' \) \
-not -path '*/node_modules/*' 2>/dev/null | sort | head -500)
UNIT_TOTAL=0; UNIT_PASSED=0; UNIT_FAILED=0
for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
t=0; p=0; x=0
case "$f" in
*.xml)
t=$(count '<testcase[ >]' "$f")
x=$(count '<(failure|error)[ >/]' "$f")
s=$(count '<skipped[ >/]' "$f")
p=$((t - x - s)); [ "$p" -lt 0 ] && p=0
;;
*go-test.json)
p=$(int jq -s '[.[] | select(.Action=="pass" and (.Test // "") != "")] | length' "$f")
x=$(int jq -s '[.[] | select(.Action=="fail" and (.Test // "") != "")] | length' "$f")
t=$((p + x))
;;
*jest-results.json)
t=$(int jq '.numTotalTests // 0' "$f")
p=$(int jq '.numPassedTests // 0' "$f")
x=$(int jq '.numFailedTests // 0' "$f")
;;
*rspec*.json)
t=$(int jq '.summary.example_count // 0' "$f")
x=$(int jq '.summary.failure_count // 0' "$f")
s=$(int jq '.summary.pending_count // 0' "$f")
p=$((t - x - s)); [ "$p" -lt 0 ] && p=0
;;
esac
UNIT_TOTAL=$((UNIT_TOTAL + t)); UNIT_PASSED=$((UNIT_PASSED + p)); UNIT_FAILED=$((UNIT_FAILED + x))
done
# ── Coverage (first match wins; format detected from content) ─────
COV_FILE=""
for f in coverage/cobertura.xml coverage/cobertura-coverage.xml coverage.xml \
target/site/jacoco/jacoco.xml build/reports/jacoco/test/jacocoTestReport.xml \
coverage/clover.xml clover.xml coverage/lcov.info lcov.info coverage.out; do
if [ -f "$f" ]; then COV_FILE="$f"; break; fi
done
if [ -z "$COV_FILE" ]; then
COV_FILE=$(find . \( -path '*/site/jacoco/jacoco.xml' -o -path '*/reports/jacoco/*/*.xml' \) \
-not -path '*/node_modules/*' 2>/dev/null | head -1)
fi
COV_FORMAT=""
if [ -n "$COV_FILE" ]; then
case "$COV_FILE" in
*.info) COV_FORMAT="lcov" ;;
*.out) COV_FORMAT="gocover" ;;
*)
if grep -q '<report' "$COV_FILE"; then COV_FORMAT="jacoco"
elif grep -q 'clover=' "$COV_FILE" || grep -q '<project' "$COV_FILE"; then COV_FORMAT="clover"
elif grep -q 'line-rate=' "$COV_FILE"; then COV_FORMAT="cobertura"
fi
;;
esac
fi
pct() { awk -v c="$1" -v t="$2" 'BEGIN { if (t > 0) printf "%d", (c / t) * 100; else print 0 }'; }
COV_PCT=0
case "$COV_FORMAT" in
cobertura)
RATE=$({ grep -oE '<coverage[^>]*line-rate="[0-9.]+"' "$COV_FILE" || true; } | head -1 | sed -E 's/.*line-rate="([0-9.]+)".*/\1/')
[ -n "$RATE" ] && COV_PCT=$(awk -v r="$RATE" 'BEGIN { printf "%d", r * 100 }')
;;
jacoco)
# The report-level LINE counter is the last one in the file.
LINE=$({ grep -oE '<counter type="LINE" missed="[0-9]+" covered="[0-9]+"' "$COV_FILE" || true; } | tail -1)
M=$(echo "$LINE" | sed -nE 's/.*missed="([0-9]+)".*/\1/p'); C=$(echo "$LINE" | sed -nE 's/.*covered="([0-9]+)".*/\1/p')
COV_PCT=$(pct "${C:-0}" "$(( ${M:-0} + ${C:-0} ))")
;;
clover)
# The project-level <metrics> element is the last one in the file.
MET=$({ grep -oE '<metrics [^>]*>' "$COV_FILE" || true; } | tail -1)
S=$(echo "$MET" | sed -nE 's/.* statements="([0-9]+)".*/\1/p'); C=$(echo "$MET" | sed -nE 's/.*coveredstatements="([0-9]+)".*/\1/p')
COV_PCT=$(pct "${C:-0}" "${S:-0}")
;;
lcov)
COV_PCT=$(awk -F: '/^LH:/{h+=$2} /^LF:/{f+=$2} END { if (f > 0) printf "%d", (h / f) * 100; else print 0 }' "$COV_FILE")
;;
gocover)
COV_PCT=$(awk 'NR > 1 && NF == 3 { t += $2; if ($3 > 0) c += $2 } END { if (t > 0) printf "%d", (c / t) * 100; else print 0 }' "$COV_FILE")
;;
esac
# ── SAST (SARIF) & CVE (Trivy JSON) ───────────────────────────────
SAST_CRIT=0; SAST_HIGH=0; SAST_MED=0
SARIF="$OUT/sast.sarif"
if [ -f sast.sarif ]; then
# LaneSync never receives source code: drop every code excerpt SARIF can carry
# (snippets, context regions, embedded artifact contents, suggested-fix code).
# If the file cannot be sanitized it is not sent at all.
jq 'walk(if type == "object" then del(.snippet, .contextRegion, .contents, .artifactChanges) else . end)' \
sast.sarif > "$SARIF" 2>/dev/null || rm -f "$SARIF"
fi
if [ -f "$SARIF" ]; then
# Same grading as LaneSync's SARIF parser: a rule's CVSS `security-severity` wins,
# else the result level (falling back to the rule default) maps
# error→high, warning→medium, note→low.
jq -r '
.runs[]? as $r
| ([$r.tool.driver.rules[]? | select(.id != null) | {key: .id, value: .}] | from_entries) as $rules
| $r.results[]?
| ($rules[.ruleId // ""] // {}) as $rule
| ($rule.properties["security-severity"] // null | if . == null then null else (tostring | tonumber? // null) end) as $score
| if $score != null then
(if $score >= 9 then "critical" elif $score >= 7 then "high" elif $score >= 4 then "medium" elif $score > 0 then "low" else "info" end)
else
({"error": "high", "warning": "medium", "note": "low", "none": "info"}[.level // $rule.defaultConfiguration.level // "warning"] // "medium")
end' "$SARIF" > "$OUT/sast-severity.txt" 2>/dev/null || : > "$OUT/sast-severity.txt"
SAST_CRIT=$(count '^critical$' "$OUT/sast-severity.txt")
SAST_HIGH=$(count '^high$' "$OUT/sast-severity.txt")
SAST_MED=$(count '^medium$' "$OUT/sast-severity.txt")
fi
CVE_CRIT=0; CVE_HIGH=0; CVE_MED=0
if [ -f trivy.json ]; then
# Keep only fields LaneSync needs — full Trivy output carries large descriptions.
jq '{SchemaVersion, ArtifactName, Results: [.Results[]? | {Target, Class, Type, Vulnerabilities: [.Vulnerabilities[]? | {VulnerabilityID, PkgName, InstalledVersion, FixedVersion, Severity, Title, CVSS}]}]}' \
trivy.json > "$OUT/trivy.json" 2>/dev/null || cp trivy.json "$OUT/trivy.json"
CVE_CRIT=$(int jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' "$OUT/trivy.json")
CVE_HIGH=$(int jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' "$OUT/trivy.json")
CVE_MED=$(int jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="MEDIUM")] | length' "$OUT/trivy.json")
fi
# ── HTML views ────────────────────────────────────────────────────
# LaneSync renders the unit-test, coverage, SAST and CVE views from the raw reports
# below. unit-tests.html is sent only as a fallback for runners LaneSync cannot parse.
# integration-tests.html is not sent: its numbers are not collected yet, and a
# placeholder page must not read as a passing suite. coverage/index.html is never
# sent because tool-generated coverage HTML embeds source files.
echo '{}' > "$OUT/reports.json"
add_report() { # <key> <path>
if fits "$2"; then
jq --rawfile c "$2" --arg k "$1" '. + {($k): $c}' "$OUT/reports.json" > "$OUT/reports.tmp" \
&& mv "$OUT/reports.tmp" "$OUT/reports.json"
fi
}
add_report unit_tests unit-tests.html
# ── Raw report lists (built from files — never via argv) ──────────
for list in tests coverage sast cve; do echo '[]' > "$OUT/$list.json"; done
for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do append_file "$OUT/tests.json" "$f"; done
[ -n "$COV_FORMAT" ] && append_file "$OUT/coverage.json" "$COV_FILE" "$COV_FORMAT"
[ -f "$SARIF" ] && append_file "$OUT/sast.json" "$SARIF" sarif
[ -f "$OUT/trivy.json" ] && append_file "$OUT/cve.json" "$OUT/trivy.json" trivy
jq -n \
--argjson unit_total "$UNIT_TOTAL" --argjson unit_passed "$UNIT_PASSED" --argjson unit_failed "$UNIT_FAILED" \
--argjson cov_pct "$COV_PCT" \
--argjson sast_crit "$SAST_CRIT" --argjson sast_high "$SAST_HIGH" --argjson sast_med "$SAST_MED" \
--argjson cve_crit "$CVE_CRIT" --argjson cve_high "$CVE_HIGH" --argjson cve_med "$CVE_MED" \
'{
unit_tests: { total: $unit_total, passed: $unit_passed, failed: $unit_failed },
coverage: { line_percent: $cov_pct },
sast_scan: { vulnerabilities: { critical: $sast_crit, high: $sast_high, medium: $sast_med } },
security: { tool: "trivy", vulnerabilities: { critical: $cve_crit, high: $cve_high, medium: $cve_med } }
}' > "$OUT/metrics.json"
jq -n \
--arg org "$ORG" --arg repo "${GITHUB_REPOSITORY#*/}" \
--arg version "$VERSION" --arg env "$ENVIRONMENT" --arg pipeline "$PIPELINE" \
--arg run_id "${GITHUB_RUN_ID:-}" \
--slurpfile metrics "$OUT/metrics.json" --slurpfile reports "$OUT/reports.json" \
--slurpfile tests "$OUT/tests.json" --slurpfile cov "$OUT/coverage.json" \
--slurpfile sast "$OUT/sast.json" --slurpfile cve "$OUT/cve.json" \
'{
org: $org, repo: $repo, version: $version, environment: $env, pipeline: $pipeline,
run_id: $run_id,
metrics: $metrics[0], reports: $reports[0],
test_report_files: $tests[0], coverage_files: $cov[0],
sast_files: $sast[0], cve_files: $cve[0]
}' > "$OUT/payload.json"
gzip -c "$OUT/payload.json" > "$OUT/payload.json.gz"
echo "Detected: tests=[${TEST_FILES[*]:-none}] coverage=${COV_FILE:-none}(${COV_FORMAT:-n/a}) sast=$([ -f "$SARIF" ] && echo yes || echo no) cve=$([ -f trivy.json ] && echo yes || echo no)"
echo "Payload: $(wc -c < "$OUT/payload.json" | tr -d ' ') bytes ($(wc -c < "$OUT/payload.json.gz" | tr -d ' ') gzipped)"
{
echo "payload=$OUT/payload.json.gz"
echo "unit_total=$UNIT_TOTAL"
echo "unit_passed=$UNIT_PASSED"
echo "unit_failed=$UNIT_FAILED"
echo "cov_pct=$COV_PCT"
} >> "$GITHUB_OUTPUT"
# ── 3. Authenticate (API key, else GitHub OIDC) ────────────────────────
- name: Get auth token
id: auth
if: steps.env.outputs.should_upload == 'true'
shell: bash
env:
LANESYNC_API_KEY: ${{ inputs.api-key }}
run: |
if [ -n "$LANESYNC_API_KEY" ]; then
echo "::add-mask::$LANESYNC_API_KEY"
echo "token=$LANESYNC_API_KEY" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "::warning::No LaneSync API key and OIDC unavailable (job needs 'permissions: id-token: write') — skipping upload"
exit 0
fi
TOKEN=$(curl -sf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=lanesync" | jq -r '.value // empty' 2>/dev/null || true)
if [ -z "$TOKEN" ]; then
echo "::warning::OIDC token request failed — skipping upload"
exit 0
fi
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"
# ── 4. Upload with retry (only transient failures are retried) ─────────
- name: Upload to LaneSync
id: upload
if: steps.env.outputs.should_upload == 'true' && steps.auth.outputs.token != ''
shell: bash
env:
SDLC_API_URL: ${{ inputs.api-url }}
TOKEN: ${{ steps.auth.outputs.token }}
PAYLOAD: ${{ steps.reports.outputs.payload }}
run: |
BODY="${RUNNER_TEMP:-/tmp}/lanesync/response.json"
STATUS="failed"
for attempt in 1 2 3; do
CODE=$(curl --silent --show-error --output "$BODY" --write-out '%{http_code}' \
--connect-timeout 10 --max-time 90 \
-X POST "${SDLC_API_URL%/}/api/evidence/upload" \
-H "Content-Type: application/json" \
-H "Content-Encoding: gzip" \
-H "Authorization: Bearer $TOKEN" \
--data-binary "@$PAYLOAD" 2>/dev/null) || CODE="000"
if [[ "$CODE" == 2* ]]; then
echo "evidence_id=$(jq -r '.evidenceId // empty' "$BODY" 2>/dev/null || true)" >> "$GITHUB_OUTPUT"
STATUS="success"
break
fi
MSG=$(jq -r '.error // empty' "$BODY" 2>/dev/null || true)
if [[ "$CODE" != 000 && "$CODE" != 429 && "$CODE" != 5* ]]; then
echo "::warning::LaneSync rejected the upload (HTTP $CODE): ${MSG:-no details}"
break
fi
if [ "$attempt" -lt 3 ]; then
echo "::warning::Upload attempt $attempt failed (HTTP $CODE) — retrying in $((attempt * 5))s"
sleep $((attempt * 5))
else
echo "::warning::Evidence upload failed after 3 attempts (HTTP $CODE, non-blocking)"
fi
done
echo "status=$STATUS" >> "$GITHUB_OUTPUT"
# ── 5. Step summary ────────────────────────────────────────────────────
- name: Step summary
if: steps.env.outputs.should_upload == 'true'
shell: bash
env:
ENVIRONMENT: ${{ steps.env.outputs.environment }}
VERSION: ${{ steps.env.outputs.version }}
STATUS: ${{ steps.upload.outputs.status || 'skipped' }}
EVIDENCE_ID: ${{ steps.upload.outputs.evidence_id }}
UNIT_TOTAL: ${{ steps.reports.outputs.unit_total }}
UNIT_PASSED: ${{ steps.reports.outputs.unit_passed }}
UNIT_FAILED: ${{ steps.reports.outputs.unit_failed }}
COV_PCT: ${{ steps.reports.outputs.cov_pct }}
run: |
{
echo "## LaneSync Evidence"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| Environment | \`$ENVIRONMENT\` |"
echo "| Version | \`$VERSION\` |"
echo "| Tests | $UNIT_PASSED passed / $UNIT_FAILED failed / $UNIT_TOTAL total |"
echo "| Coverage | $COV_PCT% |"
case "$STATUS" in
success) echo "| Upload | Success${EVIDENCE_ID:+ (evidence \`$EVIDENCE_ID\`)} |" ;;
failed) echo "| Upload | Failed (non-blocking) |" ;;
*) echo "| Upload | Skipped (no credentials) |" ;;
esac
} >> "$GITHUB_STEP_SUMMARY"
rm -rf "${RUNNER_TEMP:-/tmp}/lanesync"
# ── Skip notice ────────────────────────────────────────────────────────
- name: Skip notice
if: steps.env.outputs.should_upload != 'true'
shell: bash
run: |
echo "LaneSync upload skipped — not a dev/release branch or release event."
2
Grant OIDC permission
permissions:
id-token: write # GitHub OIDC for evidence upload
contents: read
LANESYNC_API_KEY repository secret — see Authentication.3
Emit a machine-readable test and coverage report
Keep your existing test command and add the reporter flags below. Write the files to the paths shown so the action finds them.
The full list of recognized file names is in Supported report formats. Tag tests with
| Language | Test report | Coverage |
|---|---|---|
| Node / TypeScript (Jest) | jest --json --outputFile=jest-results.json | --coverage --coverageReporters=cobertura → coverage/cobertura-coverage.xml |
| Python (pytest) | pytest --junitxml=pytest-junit.xml | --cov=. --cov-report=xml:coverage/cobertura.xml |
| Go | go test -json ./... > go-test.json | -coverprofile=coverage.out |
| Java (Maven) | Surefire → target/surefire-reports/TEST-*.xml | JaCoCo → target/site/jacoco/jacoco.xml |
| Java (Gradle) | build/test-results/test/TEST-*.xml | jacocoTestReport → build/reports/jacoco/test/jacocoTestReport.xml |
| C# | dotnet test --logger "junit;LogFilePath=test-results.xml" | Coverlet Cobertura → copy to coverage/cobertura.xml |
| PHP | phpunit --log-junit phpunit-junit.xml | --coverage-clover coverage/clover.xml |
| Ruby | rspec --format json --out rspec-results.json | — |
| C/C++ | ctest --output-junit ../ctest-junit.xml (from build/) | LCOV → coverage/lcov.info |
feat_<issue#> in their names so Quality Insights links them to features. For regression or fix issues, add Existing feature: #<number> in the GitHub issue body so LaneSync keeps the same FEAT-ID (see Quality tracking).4
Add SAST (OpenGrep — free, no account)
- name: SAST scan (OpenGrep)
if: always()
env:
OPENGREP_VERSION: v1.22.0
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
run: |
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
| bash -s -- -v "$OPENGREP_VERSION" || true
export PATH="$HOME/.opengrep/cli/latest:$PATH"
# Scan source only — add your build/dependency dirs (target, build, dist, node_modules, vendor, …)
opengrep scan --config auto --exclude .github --exclude coverage \
--sarif -o sast.sarif . 2>/dev/null || true
sast.sarif. Exclude generated reports and build output: tool-generated HTML (JaCoCo, coverage.py, Istanbul) triggers HTML/JS rules that are not findings in your code.5
Add CVE scanning (Trivy)
- name: CVE scan (Trivy)
if: always()
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
format: json
output: trivy.json
exit-code: '0'
severity: CRITICAL,HIGH,MEDIUM,LOW
package-lock.json, poetry.lock, go.sum, gradle.lockfile, …) to see your dependencies. Commit one, or the CVE report will say no manifests were scanned.6
Upload evidence to LaneSync
Add this as the last step of the job that produced the reports:The action uploads on the development branch (
- name: Upload evidence to LaneSync
uses: ./.github/actions/lanesync-upload
if: always()
with:
api-url: ${{ vars.SDLC_API_URL || 'https://lanesync.dev' }}
api-key: ${{ secrets.LANESYNC_API_KEY }}
dev-branch: "main" # your development branch
release-prefix: "release/" # your release branch prefix
pipeline: "CI — Build & Test" # example — use the check name from your sdlc.yaml
dev), release branches (staging), and published releases (production), and skips pull requests. It needs actions/checkout earlier in the job so the action file exists.Verify
After the next push to your development branch, open the workflow run’s Summary. The LaneSync Evidence table shows detected tests, coverage, and upload status. The “Collect reports” step log lists the exact files it found:Detected: tests=[pytest-junit.xml] coverage=coverage/cobertura.xml(cobertura) sast=yes cve=yes
Integration tests (optional)
# ── Integration tests (uncomment and adapt) ──
# - name: Run integration tests
# run: npm run test:integration
# # Write integration-tests.html for the LaneSync report view
Optional: Teams already running SonarQube Community Edition can add
sonarqube-scan-action separately. LaneSync scorecards read coverage from the report files your workflow uploads, not from Sonar’s dashboard.Related
CI evidence upload
Payload, authentication, supported formats, and troubleshooting.
Quality tracking
How uploaded test cases link to features.