diff --git a/.github/workflows/release-manifest-contract.yml b/.github/workflows/release-manifest-contract.yml index dc8567ae6..4aa353904 100644 --- a/.github/workflows/release-manifest-contract.yml +++ b/.github/workflows/release-manifest-contract.yml @@ -5,6 +5,8 @@ on: paths: - ".github/actionlint.yaml" - ".github/workflows/release-manifest-contract.yml" + - ".github/workflows/sentrux-quality-gate.yml" + - "tests/workflow_sentrux_shell_test.py" - ".github/workflows/release-coordinator.yml" - ".github/workflows/release-comprehensive.yml" - ".github/workflows/publish-npm.yml" @@ -60,6 +62,8 @@ on: paths: - ".github/actionlint.yaml" - ".github/workflows/release-manifest-contract.yml" + - ".github/workflows/sentrux-quality-gate.yml" + - "tests/workflow_sentrux_shell_test.py" - ".github/workflows/release-coordinator.yml" - ".github/workflows/release-comprehensive.yml" - ".github/workflows/publish-npm.yml" @@ -216,7 +220,7 @@ jobs: - name: actionlint (static workflow analysis) run: | set -euo pipefail - actionlint .github/workflows/publish-crates.yml .github/workflows/lsp-public-ci.yml .github/workflows/publish-lsp-crates.yml .github/workflows/lsp-producer.yml .github/workflows/lsp-publish.yml .github/workflows/release-coordinator.yml .github/workflows/release-sign.yml .github/workflows/release-comprehensive.yml .github/workflows/publish-npm.yml .github/workflows/publish-pypi.yml .github/workflows/publish-bun.yml .github/workflows/publish-tauri.yml .github/workflows/release-manifest-contract.yml + actionlint .github/workflows/sentrux-quality-gate.yml .github/workflows/publish-crates.yml .github/workflows/lsp-public-ci.yml .github/workflows/publish-lsp-crates.yml .github/workflows/lsp-producer.yml .github/workflows/lsp-publish.yml .github/workflows/release-coordinator.yml .github/workflows/release-sign.yml .github/workflows/release-comprehensive.yml .github/workflows/publish-npm.yml .github/workflows/publish-pypi.yml .github/workflows/publish-bun.yml .github/workflows/publish-tauri.yml .github/workflows/release-manifest-contract.yml - name: Parse workflow YAML run: | @@ -231,6 +235,9 @@ jobs: - name: Run coordinator state-machine contract tests run: python -m unittest -v tests.release_coordinator_test + - name: Exercise Sentrux shell output handling + run: python -m unittest -v tests.workflow_sentrux_shell_test + - name: Run workflow static contract tests run: python -m unittest -v tests.workflow_release_coordinator_contract_test diff --git a/.github/workflows/sentrux-quality-gate.yml b/.github/workflows/sentrux-quality-gate.yml index 31357199a..487a16be9 100644 --- a/.github/workflows/sentrux-quality-gate.yml +++ b/.github/workflows/sentrux-quality-gate.yml @@ -31,23 +31,25 @@ jobs: # Extract quality signal QUALITY=$(echo "$OUTPUT" | grep "Quality:" | awk '{print $2}') - echo "quality=$QUALITY" >> $GITHUB_OUTPUT + echo "quality=$QUALITY" >> "$GITHUB_OUTPUT" # Count violations VIOLATIONS=$(echo "$OUTPUT" | grep "violation(s) found" | awk '{print $2}') - echo "violations=$VIOLATIONS" >> $GITHUB_OUTPUT + echo "violations=$VIOLATIONS" >> "$GITHUB_OUTPUT" # Determine status if echo "$OUTPUT" | grep -q "āœ—.*violation"; then - echo "status=fail" >> $GITHUB_OUTPUT + echo "status=fail" >> "$GITHUB_OUTPUT" else - echo "status=pass" >> $GITHUB_OUTPUT + echo "status=pass" >> "$GITHUB_OUTPUT" fi # Save full output for comment - echo "output<> $GITHUB_OUTPUT - echo "$OUTPUT" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + { + echo "output<> "$GITHUB_OUTPUT" - name: Fetch Baseline (PR only) if: github.event_name == 'pull_request' @@ -59,10 +61,10 @@ jobs: python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('content','').strip())" 2>/dev/null || echo "0") if [ "$BASELINE" = "0" ] || [ -z "$BASELINE" ]; then - echo "baseline=0" >> $GITHUB_OUTPUT + echo "baseline=0" >> "$GITHUB_OUTPUT" echo "No baseline found" else - echo "baseline=$BASELINE" >> $GITHUB_OUTPUT + echo "baseline=$BASELINE" >> "$GITHUB_OUTPUT" echo "Baseline: $BASELINE" fi @@ -74,16 +76,16 @@ jobs: BASELINE=${{ steps.baseline.outputs.baseline }} if [ "$BASELINE" = "0" ] || [ -z "$BASELINE" ]; then - echo "delta=unknown" >> $GITHUB_OUTPUT - echo "degraded=false" >> $GITHUB_OUTPUT + echo "delta=unknown" >> "$GITHUB_OUTPUT" + echo "degraded=false" >> "$GITHUB_OUTPUT" else DELTA=$((CURRENT - BASELINE)) - echo "delta=$DELTA" >> $GITHUB_OUTPUT + echo "delta=$DELTA" >> "$GITHUB_OUTPUT" - if [ $DELTA -lt -100 ]; then - echo "degraded=true" >> $GITHUB_OUTPUT + if [ "$DELTA" -lt -100 ]; then + echo "degraded=true" >> "$GITHUB_OUTPUT" else - echo "degraded=false" >> $GITHUB_OUTPUT + echo "degraded=false" >> "$GITHUB_OUTPUT" fi fi @@ -133,33 +135,33 @@ jobs: COMMENT="## Sentrux Quality Gate $STATUS_ICON -**Quality Signal:** $QUALITY / 10000 -**Violations:** $VIOLATIONS -**Status:** $STATUS -" + **Quality Signal:** $QUALITY / 10000 + **Violations:** $VIOLATIONS + **Status:** $STATUS + " if [ "$DELTA" != "unknown" ]; then COMMENT="${COMMENT}**Delta:** $DELTA_ICON $DELTA points (baseline: $BASELINE) -" + " if [ "$DEGRADED" = "true" ]; then COMMENT="${COMMENT} -šŸ”“ **Quality degraded by more than 100 points!** -" + šŸ”“ **Quality degraded by more than 100 points!** + " fi else COMMENT="${COMMENT}**Delta:** No baseline available -" + " fi COMMENT="${COMMENT} -
-Full Report +
+ Full Report -\`\`\` -${{ steps.sentrux.outputs.output }} -\`\`\` -
-" + \`\`\` + ${{ steps.sentrux.outputs.output }} + \`\`\` +
+ " # Post comment curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \ diff --git a/tests/workflow_sentrux_shell_test.py b/tests/workflow_sentrux_shell_test.py new file mode 100644 index 000000000..ba01b3320 --- /dev/null +++ b/tests/workflow_sentrux_shell_test.py @@ -0,0 +1,78 @@ +"""Run Sentrux workflow output handling in real Bash, without external services.""" + +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + + +WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/sentrux-quality-gate.yml" + + +def run_block(step): + """Extract one literal run block, rejecting content outside its indentation.""" + body = WORKFLOW.read_text().split(f" - name: {step}\n", 1)[1] + body = body.split("\n - name:", 1)[0].split(" run: |\n", 1)[1] + lines = body.splitlines() + if any(line.strip() and not line.startswith(" ") for line in lines): + raise ValueError(f"Run block contains unindented content: {step}") + return "\n".join(line[10:] if line.startswith(" ") else line for line in lines) + "\n" + + +class SentruxShellTests(unittest.TestCase): + def output_file(self, script, **variables): + """Execute a pure workflow fragment against a real file with spaces.""" + with tempfile.TemporaryDirectory(prefix="sentrux workflow ") as folder: + output = Path(folder) / "step output.txt" + output.write_text("existing=value\n") + environment = dict(os.environ, GITHUB_OUTPUT=str(output), **variables) + subprocess.run(["bash", "-e", "-c", script], env=environment, + check=True, capture_output=True, text=True, timeout=10) + return output.read_text() + + def test_report_extraction_appends_exact_multiline_output(self): + # Exercise the real parsing/redirect fragment; do not replace the Sentrux binary. + script = run_block("Run Sentrux Check").split("# Extract quality signal\n", 1)[1] + for report, quality, count, status in [ + ("Quality: 9600\nāœ“ 0 violation(s) found", "9600", "0", "pass"), + ("Quality: 8000\nāœ— 2 violation(s) found\nDetail: a * literal [path]", "8000", "2", "fail"), + ]: + with self.subTest(status=status): + self.assertEqual( + self.output_file(script, OUTPUT=report), + f"existing=value\nquality={quality}\nviolations={count}\nstatus={status}\n" + f"output<\nFull Report\n", script) + + +if __name__ == "__main__": + unittest.main()