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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,236 @@ jobs:
run: |
sheetsentry rules
sheetsentry audit --help

action-smoke:
# Runs the actual composite action from a separate checkout of its own
# source in a synthetic consumer workspace, starting without SheetSentry
# installed and without any PyPI package to fall back on.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Check out the action source into its own directory
uses: actions/checkout@v4
with:
path: action-source

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Assert SheetSentry is not preinstalled
run: |
if python -m pip show sheetsentry > /dev/null 2>&1; then
echo "sheetsentry is already installed; the smoke test must start without it"
exit 1
fi
echo "sheetsentry not installed (expected)"

- name: Generate synthetic workbooks (clean, SD003 duplicate, SD001 text number)
run: |
pip install --quiet openpyxl
mkdir -p consumer "consumer/sub dir"
python - <<'PY'
from openpyxl import Workbook

wb = Workbook()
ws = wb.active
ws.title = "Data"
ws.append(["id", "value"])
ws.append([1, "ok"])
wb.save("consumer/clean.xlsx")

rows = [[f"A{i}", "item", i] for i in range(1, 25)]
rows[9][0] = "A1" # duplicated SKU -> SD003 error
wb = Workbook()
wb.remove(wb.active)
ws = wb.create_sheet(title="Price")
for row in [["SKU", "Name", "Price"]] + rows:
ws.append(row)
wb.save("consumer/dupes.xlsx")

rows = [[f"A{i}", "item", i * 10] for i in range(1, 12)]
rows[3][2] = "1 250" # price arrived as text -> SD001 warning
wb = Workbook()
wb.remove(wb.active)
ws = wb.create_sheet(title="Price")
for row in [["SKU", "Name", "Price"]] + rows:
ws.append(row)
wb.save("consumer/sub dir/year end.xlsx")
PY

- name: Run the composite action with the default recursive pattern (findings expected)
id: audit_recursive
continue-on-error: true
uses: ./action-source
with:
format: sarif
output: consumer/audit.sarif
fail-on: error

- name: The default invocation must have failed on the SD003 error, not crashed
run: |
echo "outcome: ${STEPS_AUDIT_OUTCOME}, conclusion: ${STEPS_AUDIT_CONCLUSION}"
test "$STEPS_AUDIT_OUTCOME" = "failure"
test "$STEPS_AUDIT_CONCLUSION" = "success"
test -s consumer/audit.sarif
env:
STEPS_AUDIT_OUTCOME: ${{ steps.audit_recursive.outcome }}
STEPS_AUDIT_CONCLUSION: ${{ steps.audit_recursive.conclusion }}

- name: Run the composite action with a literal path containing spaces
uses: ./action-source
with:
files: consumer/sub dir/year end.xlsx
format: json
output: consumer/literal.json
fail-on: never

- name: Run the composite action for a JSON envelope
uses: ./action-source
with:
files: consumer/**/*.xlsx
format: json
output: consumer/audit.json
fail-on: never

- name: Verify installed source metadata and both output documents
run: |
python - <<'PY'
import importlib.metadata as md
import json
import pathlib

dist = md.distribution("sheetsentry")
direct = json.loads(dist.read_text("direct_url.json") or "{}")
url = direct.get("url", "")
print("version:", dist.version)
print("direct_url:", url)
assert "action-source" in url, f"installed from unexpected source: {url}"

# SARIF: one document, one run, every file mapped, rules deduplicated.
sarif = json.loads(pathlib.Path("consumer/audit.sarif").read_text())
assert sarif["version"] == "2.1.0"
assert len(sarif["runs"]) == 1
run = sarif["runs"][0]
assert run["tool"]["driver"]["name"] == "SheetSentry"
result_uris = {
loc["physicalLocation"]["artifactLocation"]["uri"]
for res in run["results"] for loc in res["locations"]
}
assert "consumer/dupes.xlsx" in result_uris, result_uris
assert "consumer/sub%20dir/year%20end.xlsx" in result_uris, result_uris
rule_ids = {rule["id"] for rule in run["tool"]["driver"]["rules"]}
assert {"SD001", "SD003"} <= rule_ids, rule_ids # SD004 (mixed types) may also fire
artifact_uris = {a["location"]["uri"] for a in run["artifacts"]}
assert artifact_uris == {
"consumer/clean.xlsx",
"consumer/dupes.xlsx",
"consumer/sub%20dir/year%20end.xlsx",
}, artifact_uris
print("SARIF OK: one document,", len(run["results"]), "results,", len(artifact_uris), "artifacts")

# JSON envelope: one object, every file mapped.
envelope = json.loads(pathlib.Path("consumer/audit.json").read_text())
assert [r["file"] for r in envelope["reports"]] == [
"consumer/clean.xlsx",
"consumer/dupes.xlsx",
"consumer/sub dir/year end.xlsx",
], [r["file"] for r in envelope["reports"]]
assert envelope["errors"] == []
print("JSON envelope OK:", len(envelope["reports"]), "reports")

# Literal path with spaces: single-file root shape.
literal = json.loads(pathlib.Path("consumer/literal.json").read_text())
assert literal["file"] == "consumer/sub dir/year end.xlsx", literal["file"]
# The SD001 text-number finding must be present; other legitimate
# rules (SD004 mixed types fires on this same fixture) are allowed
# and only have to agree with the summary counts.
rules = {finding["rule"]: finding["severity"] for finding in literal["findings"]}
assert rules.get("SD001") == "warning", rules
assert literal["summary"]["warning"] == sum(
1 for finding in literal["findings"] if finding["severity"] == "warning"
)
assert literal["summary"]["total"] == len(literal["findings"])
assert literal["summary"]["error"] == 0
print("Literal spaced path OK:", literal["file"])
PY

- name: Review rejects the multi-file JSON envelope with a clear error
run: |
cat > consumer/state.json <<'JSON'
{"version": 1, "items": []}
JSON
set +e
sheetsentry review consumer/audit.json --state consumer/state.json > review-out.txt 2> review-err.txt
code=$?
set -e
cat review-err.txt
test "$code" -eq 2
grep -q "multi-file envelope" review-err.txt

- name: Produce single-file and mixed-unreadable SARIF for the schema check
run: |
printf 'not a workbook' > consumer/broken.xlsx
set +e
sheetsentry audit consumer/dupes.xlsx --format sarif -o consumer/single.sarif
single_rc=$?
sheetsentry audit consumer/clean.xlsx consumer/dupes.xlsx consumer/broken.xlsx --format sarif -o consumer/mixed.sarif
mixed_rc=$?
set -e
test "$single_rc" -eq 1
test "$mixed_rc" -eq 2

- name: Validate every SARIF output against the pinned official OASIS schema
run: |
pip install --quiet jsonschema==4.23.0
python - <<'PY'
import hashlib
import json
import pathlib
import urllib.request

from jsonschema import Draft4Validator

SCHEMA_URL = (
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/"
"adbb670c018335b0f384e6dd8819f4ea055d7ee1/sarif-2.1/schema/"
"sarif-schema-2.1.0.json"
)
SCHEMA_SHA256 = "c3b4bb2d6093897483348925aaa73af03b3e3f4bd4ca38cef26dcb4212a2682e"

schema_path = pathlib.Path("/tmp/sarif-schema-2.1.0.json")
with urllib.request.urlopen(SCHEMA_URL) as response: # TLS verified
schema_path.write_bytes(response.read())
digest = hashlib.sha256(schema_path.read_bytes()).hexdigest()
print("schema sha256:", digest)
assert digest == SCHEMA_SHA256, f"pinned schema hash mismatch: {digest}"
schema = json.loads(schema_path.read_text())
validator = Draft4Validator(schema)

def valid(document_path):
document = json.loads(pathlib.Path(document_path).read_text())
errors = sorted(validator.iter_errors(document), key=str)
for error in errors:
print(f" schema violation in {document_path}: {list(error.absolute_path)}: {error.message}")
return not errors

assert valid("consumer/audit.sarif"), "multi-file SARIF failed the official schema"
assert valid("consumer/single.sarif"), "single-file SARIF failed the official schema"
assert valid("consumer/mixed.sarif"), "mixed unreadable SARIF failed the official schema"

# Mixed run: the unreadable file stays visible as an invocation notification.
mixed = json.loads(pathlib.Path("consumer/mixed.sarif").read_text())
invocation = mixed["runs"][0]["invocations"][0]
assert invocation["executionSuccessful"] is False
assert invocation["toolExecutionNotifications"][0]["level"] == "error"

# Negative control: the pre-D1 layout (notifications on the run itself)
# must be rejected, or this guard is not actually testing anything.
pre_d1 = json.loads(pathlib.Path("consumer/mixed.sarif").read_text())
pre_d1["runs"][0]["toolExecutionNotifications"] = pre_d1["runs"][0]["invocations"][0]["toolExecutionNotifications"]
violations = list(validator.iter_errors(pre_d1))
assert violations, "negative control unexpectedly validated - the guard is blind"
print("negative control rejected pre-D1 output as expected")
PY
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Changelog

All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Fixed

- The composite GitHub Action installs SheetSentry from the action's own
checked-out source (`github.action_path`, passed through a step environment
variable and a quoted shell argument) instead of `pip install sheetsentry`,
which targeted a PyPI package that does not exist and always failed.
- Aggregate SARIF (multi-file or failed audits) is valid against the official
OASIS SARIF 2.1.0 schema: tool execution notifications moved from the run
level into `run.invocations[0]` with an `executionSuccessful` flag (the run
object forbids additional properties). CI validates every generated SARIF
document against the pinned official schema, including a negative control
that must reject the old layout.
- Auditing several files with `--format json` or `--format sarif` now writes
one valid document instead of concatenated per-file documents. The JSON
multi-file shape is an envelope `{"reports": [...], "errors": [...]}` whose
entries keep the single-file payload; `errors` lists unreadable files. The
multi-file SARIF document has one run with deduplicated rule descriptors,
results carrying the original workbook path (directories included, spaces
URI-encoded) and an `artifacts` list that includes clean workbooks;
unreadable files become tool execution notifications. A single-file audit
keeps the original shapes. Exit priority is unchanged: clean 0, findings at
or above the threshold 1, unreadable input 2 even with `--fail-on never`.
- `review` given a multi-file JSON envelope now exits 2 with an explanation
instead of failing on reconciliation.
- The Action `files` input accepts one literal path containing spaces (used
as-is when it names exactly one existing file); glob/list behavior is
otherwise unchanged.

### Changed

- SARIF `informationUri` now points at the real source repository
(https://github.com/HlinorAI/sheetsentry).

### Added

- CI runs an `action-smoke` job that executes the real composite action from a
separate checkout in a synthetic consumer workspace: asserts SheetSentry is
not preinstalled, generates workbooks including a filename with spaces and
distinct per-file findings, exercises the default recursive pattern, a
literal spaced path and a JSON envelope, validates the SARIF report, the
envelope mapping and the installed-source metadata (`direct_url.json`). The
3 OS x 4 Python test matrix is unchanged.
- `README.md` documents source-only installation (private repo, Python
3.10+), the composite Action (same-repo and cross-repo private patterns
with a contents-read token placeholder), the `files` input semantics, and
the multi-file output shapes with the single-file `review` limitation.

### History note

No dependency, version, or CLI flag changes in this update; the 0.1.0
package remains unpublished.
49 changes: 49 additions & 0 deletions PROJECT_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# PROJECT_PLAN — SheetSentry

Checks and code review for business-critical `.xlsx` files crossing a company
boundary: 17 audit rules, 8 diff rules, a finding-review workflow, and SARIF
output for GitHub code scanning. One runtime dependency (`openpyxl`);
everything else is the standard library. Python 3.10+.

## Current state (2026-10-01)

- Private repository, version `0.1.0`, **not published** to PyPI.
- Full test suite: 96 tests, CI matrix 3 OS x 4 Python versions.
- Composite GitHub Action (`action.yml`) runs the CLI on the caller's
workbooks and installs SheetSentry **from the action's own checked-out
source** (`github.action_path`) — not from PyPI, where no package exists.

## Current work (SHEETSENTRY-001)

- `action.yml`: installation step now installs the exact checked-out action
source via a step environment variable; no PyPI lookup.
- `ci.yml`: an `action-smoke` job runs the real composite action from a
separate checkout in a synthetic consumer workspace, starting from a
clean environment (asserts SheetSentry is not preinstalled), validates the
SARIF report and the installed-source metadata (`direct_url.json`).
- `README.md`: Install / In CI sections document source installation only and
the Python 3.10+ / private-repo prerequisites.
- No version bump, no dependency changes, no release in this change.

## Multi-file output (SHEETSENTRY-002)

- `audit` over several files emits one JSON envelope `{"reports": [...],
"errors": [...]}` (per-file payloads keep the single-file shape; unreadable
files are listed under `errors`) and one SARIF 2.1.0 document with a single
run: deduplicated rules, results carrying the original workbook path with
spaces URI-encoded, `artifacts` including clean workbooks, unreadable files
as tool execution notifications. Single-file audits keep the original
shapes. Exit priority: 0 clean / 1 findings / 2 unreadable input (even with
`--fail-on never`).
- `review` is single-file by design; a multi-file envelope exits 2 with an
explanation instead of mixing finding IDs across files.
- The Action `files` input takes a glob, a list, or one literal path
(spaces supported when it names exactly one existing file).

## Governance

This repository is governed by the portfolio agent contract and coordination
workflow, which live outside this repository (shared workspace, not published
here). Product decisions and Git operations follow that contract: bounded
implementation tasks, independent review, explicit Owner acceptance, Git-only
remote stages.
Loading
Loading