diff --git a/.github/workflows/reusable-go-quality.yml b/.github/workflows/reusable-go-quality.yml new file mode 100644 index 0000000..3c203d3 --- /dev/null +++ b/.github/workflows/reusable-go-quality.yml @@ -0,0 +1,582 @@ +# Reusable workflow: Go Test Quality +# +# Provides gotestsum-based testing with JUnit XML + coverage output, +# diff coverage via octocov, and test lint (time.Sleep detection). +# +# Usage: +# jobs: +# quality: +# uses: Mininglamp-OSS/.github/.github/workflows/reusable-go-quality.yml@main +# with: +# go-version: '1.25.x' +# services: true +# core-packages: './modules/user/...,./modules/thread/...' + +name: Reusable — Go Test Quality + +on: + workflow_call: + inputs: + go-version: + description: 'Go version to use' + required: true + type: string + services: + description: 'Start MySQL + Redis service containers' + required: false + type: boolean + default: false + coverage-threshold: + description: 'Overall coverage threshold (%)' + required: false + type: number + default: 60 + core-packages: + description: 'Core package paths (comma-separated, e.g. ./modules/user/...,./modules/thread/...)' + required: false + type: string + default: '' + core-coverage-threshold: + description: 'Core packages coverage threshold (%)' + required: false + type: number + default: 75 + coverage-exclude: + description: 'Exclude paths from coverage (comma-separated glob fragments)' + required: false + type: string + default: 'mock,pb.go,_gen.go,vendor,migrations' + +permissions: {} + +jobs: + # ── Test (with MySQL + Redis services) ────────────────────────────── + # GitHub Actions does not support conditional `services:` blocks, so we + # define two mutually-exclusive test jobs gated by `inputs.services`. + test-with-services: + if: inputs.services + name: Test (services) + runs-on: ubuntu-latest + permissions: + contents: read + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: demo + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -pdemo" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: ${{ inputs.go-version }} + + - name: Install gotestsum + run: go install gotest.tools/gotestsum@v1.12.0 + + - name: Install MySQL/Redis client tooling + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq mysql-client redis-tools + + - name: Wait for MySQL + run: | + for _ in $(seq 1 30); do + if mysqladmin ping -h 127.0.0.1 -uroot -pdemo --silent; then + echo "mysql ready" + exit 0 + fi + sleep 2 + done + echo "mysql did not become ready" >&2 + exit 1 + + - name: Run tests (per-package with DB reset) + id: test + run: | + set -euo pipefail + fail=0 + echo "mode: atomic" > coverage.out + pkg_index=0 + total_pass=0 + total_fail=0 + total_skip=0 + + # B2: Capture go list output safely; fail if it errors + pkg_file="$(mktemp)" + if ! go list ./... > "$pkg_file"; then + echo "::error::go list ./... failed" >&2 + exit 1 + fi + if [ ! -s "$pkg_file" ]; then + echo "::error::no Go packages found" >&2 + exit 1 + fi + + while read -r pkg; do + mysql -h 127.0.0.1 -uroot -pdemo -e \ + "DROP DATABASE IF EXISTS test; CREATE DATABASE test CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" + redis-cli -h 127.0.0.1 -p 6379 FLUSHALL >/dev/null + + echo "::group::testing $pkg" + if ! gotestsum \ + --junitfile "junit-${pkg_index}.xml" \ + --format short-verbose \ + -- -race -shuffle=on -count=1 -timeout 5m \ + -coverprofile="coverage-${pkg_index}.out" \ + -covermode=atomic "$pkg"; then + fail=1 + fi + + # Accumulate coverage data (skip mode line from each fragment) + if [ -f "coverage-${pkg_index}.out" ]; then + grep -v '^mode:' "coverage-${pkg_index}.out" >> coverage.out || true + fi + + pkg_index=$((pkg_index + 1)) + echo "::endgroup::" + done < "$pkg_file" + rm -f "$pkg_file" + + # Merge JUnit XMLs using Python (gotestsum has no merge subcommand) + if compgen -G "junit-*.xml" > /dev/null; then + python3 - <<'PYEOF' + import xml.etree.ElementTree as ET, glob, sys + + xmls = sorted(glob.glob('junit-*.xml')) + if not xmls: + sys.exit(0) + + root = ET.parse(xmls[0]).getroot() + if root.tag != 'testsuites': + wrapper = ET.Element('testsuites') + wrapper.append(root) + root = wrapper + + for xml in xmls[1:]: + tree = ET.parse(xml).getroot() + if tree.tag == 'testsuites': + for child in tree: + root.append(child) + else: + root.append(tree) + + tests = sum(int(ts.get('tests', 0)) for ts in root.findall('testsuite')) + failures = sum(int(ts.get('failures', 0)) for ts in root.findall('testsuite')) + skipped = sum(int(ts.get('skipped', 0)) for ts in root.findall('testsuite')) + root.set('tests', str(tests)) + root.set('failures', str(failures)) + root.set('skipped', str(skipped)) + + ET.ElementTree(root).write('junit.xml', xml_declaration=True, encoding='utf-8') + print(f'Merged {len(xmls)} JUnit XMLs: {tests} tests, {failures} failures, {skipped} skipped') + PYEOF + fi + + # Parse test counts from JUnit XML for summary (Nit-1: correct Passed count) + if [ -f junit.xml ]; then + total_tests=$(grep -o 'tests="[0-9]*"' junit.xml | head -1 | grep -o '[0-9]*' || echo 0) + total_fail=$(grep -o 'failures="[0-9]*"' junit.xml | head -1 | grep -o '[0-9]*' || echo 0) + total_skip=$(grep -o 'skipped="[0-9]*"' junit.xml | head -1 | grep -o '[0-9]*' || echo 0) + total_pass=$((total_tests - total_fail - total_skip)) + fi + + { + echo "test_passed=${total_pass}" + echo "test_failed=${total_fail}" + echo "test_skipped=${total_skip}" + echo "test_exit_code=${fail}" + } >> "$GITHUB_OUTPUT" + + exit $fail + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: test-results + path: | + junit.xml + junit-*.xml + coverage.out + retention-days: 14 + + - name: Write PR summary + if: always() + run: | + passed="${{ steps.test.outputs.test_passed }}" + failed="${{ steps.test.outputs.test_failed }}" + skipped="${{ steps.test.outputs.test_skipped }}" + exit_code="${{ steps.test.outputs.test_exit_code }}" + + if [ "$exit_code" = "0" ]; then + verdict="✅ **PASSED**" + else + verdict="❌ **FAILED**" + fi + + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_OUTPUT" + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: test-results + path: | + junit.xml + coverage.out + retention-days: 14 + + - name: Write PR summary + if: always() + run: | + passed="${{ steps.test.outputs.test_passed }}" + failed="${{ steps.test.outputs.test_failed }}" + skipped="${{ steps.test.outputs.test_skipped }}" + outcome="${{ steps.test.outcome }}" + + if [ "$outcome" = "success" ]; then + verdict="✅ **PASSED**" + else + verdict="❌ **FAILED**" + fi + + cat >> "$GITHUB_STEP_SUMMARY" < (filename suffix match) + # bare token (mock) → **//** (directory) + **/**.go (filename substring) + # dotted name (foo.bar) → **//** (directory only) + EXCLUDE_YAML="" + IFS=',' read -ra PATTERNS <<< "$COVERAGE_EXCLUDE" + for p in "${PATTERNS[@]}"; do + p="${p// /}" + [ -z "$p" ] && continue + if [[ "$p" == *.go ]]; then + EXCLUDE_YAML="${EXCLUDE_YAML} - \"**/*${p}\"\n" + elif [[ "$p" != *.* ]]; then + EXCLUDE_YAML="${EXCLUDE_YAML} - \"**/${p}/**\"\n" + EXCLUDE_YAML="${EXCLUDE_YAML} - \"**/*${p}*.go\"\n" + else + EXCLUDE_YAML="${EXCLUDE_YAML} - \"**/${p}/**\"\n" + fi + done + + # Write octocov config with exclude paths + { + echo "# Auto-generated by reusable-go-quality workflow" + echo "coverage:" + echo " paths:" + echo " - coverage.out" + echo " acceptable: \"current >= ${COVERAGE_THRESHOLD} && diff >= 0\"" + if [ -n "$EXCLUDE_YAML" ]; then + echo " exclude:" + printf '%b' "$EXCLUDE_YAML" + fi + echo "comment:" + echo " if: \"is_pull_request && env.IS_FORK != 'true'\"" + echo " hideFooterLink: false" + } > .octocov.yml + + export IS_FORK + + echo "Generated .octocov.yml:" + cat .octocov.yml + + - name: Run octocov + uses: k1LoW/octocov-action@b3b6ee60482a667950f87553abf1df63217235d9 # v1 + + - name: Core packages coverage check + if: inputs.core-packages != '' + env: + CORE_PACKAGES: ${{ inputs.core-packages }} + CORE_THRESHOLD: ${{ inputs.core-coverage-threshold }} + run: | + python3 - <<'PYEOF' + import sys, os, subprocess + + core_pkgs_input = os.environ.get("CORE_PACKAGES", "") + threshold = float(os.environ.get("CORE_THRESHOLD", "75")) + coverage_file = "coverage.out" + + if not core_pkgs_input.strip(): + sys.exit(0) + + # Expand each core package pattern using go list + core_import_paths = set() + for pkg_pattern in core_pkgs_input.split(","): + pkg_pattern = pkg_pattern.strip() + if not pkg_pattern: + continue + result = subprocess.run( + ["go", "list", "-f", "{{.ImportPath}}", pkg_pattern], + capture_output=True, text=True + ) + if result.returncode != 0: + print(f"::error::go list failed for pattern '{pkg_pattern}': {result.stderr.strip()}") + sys.exit(1) + for line in result.stdout.strip().splitlines(): + line = line.strip() + if line: + core_import_paths.add(line) + + if not core_import_paths: + print("::error::no packages matched core-packages patterns") + sys.exit(1) + + # Parse coverage.out: format is "pkg/path/file.go:start.col,end.col stmts count" + total_stmts = 0 + covered_stmts = 0 + + with open(coverage_file) as f: + for line in f: + line = line.strip() + if line.startswith("mode:") or not line: + continue + # e.g. github.com/org/repo/pkg/file.go:10.5,12.10 3 1 + try: + file_part, rest = line.rsplit(":", 1) + # file_part is import_path/filename, extract import path (dir) + import_path = "/".join(file_part.split("/")[:-1]) + parts = rest.split() + # rest = "10.5,12.10 3 1" -> parts = ["10.5,12.10", "3", "1"] + stmts = int(parts[1]) if len(parts) >= 2 else 0 + count = int(parts[2]) if len(parts) >= 3 else 0 + except (ValueError, IndexError): + continue + + if import_path in core_import_paths: + total_stmts += stmts + if count > 0: + covered_stmts += stmts + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "/dev/stderr") + + with open(summary_path, "a") as f: + f.write("## 📊 Core Packages Coverage\n\n") + f.write("| Status | Package | Coverage | Required |\n") + f.write("|--------|---------|----------|----------|\n") + + if total_stmts == 0: + print("::error::no statements found for core packages — check import paths") + with open(summary_path, "a") as f: + f.write("| ⚠️ | (all core packages) | N/A — no statements found | check patterns |\n") + sys.exit(1) + + coverage_pct = covered_stmts / total_stmts * 100 + status = "✅" if coverage_pct >= threshold else "❌" + + with open(summary_path, "a") as f: + f.write(f"| {status} | Core packages ({len(core_import_paths)} pkgs) | {coverage_pct:.1f}% | >={threshold:.0f}% |\n") + + print(f"Core coverage: {coverage_pct:.1f}% ({covered_stmts}/{total_stmts} statements), threshold: {threshold}%") + + if coverage_pct < threshold: + print(f"::error::Core packages coverage {coverage_pct:.1f}% is below threshold {threshold}%") + sys.exit(1) + PYEOF + + # ── Test Lint (time.Sleep detection) ──────────────────────────────── + test-lint: + name: Test Lint + runs-on: ubuntu-latest + permissions: + contents: read + continue-on-error: true + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: ${{ inputs.go-version }} + + - name: Detect time.Sleep in tests + run: | + echo "## 🔍 Test Lint: time.Sleep Detection" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + found=0 + while IFS= read -r -d '' file; do + matches=$(grep -n 'time\.Sleep' "$file" 2>/dev/null || true) + if [ -n "$matches" ]; then + found=1 + echo "::warning file=${file}::time.Sleep detected in test file — consider using a clock abstraction or test helper" + echo "- \`${file}\`:" >> "$GITHUB_STEP_SUMMARY" + while IFS= read -r line; do + echo " - ${line}" >> "$GITHUB_STEP_SUMMARY" + done <<< "$matches" + fi + done < <(find . -name '*_test.go' -not -path './vendor/*' -print0) + + if [ "$found" -eq 0 ]; then + echo "✅ No \`time.Sleep\` found in test files." >> "$GITHUB_STEP_SUMMARY" + else + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "⚠️ \`time.Sleep\` in tests can cause flakiness. Consider using clock abstractions or polling helpers." >> "$GITHUB_STEP_SUMMARY" + fi + + # ── Quality Gate (stable required check for branch protection) ────── + quality-gate: + name: Quality Gate + runs-on: ubuntu-latest + if: always() + needs: [test-with-services, test-standalone, diff-coverage, test-lint] + permissions: + contents: read + steps: + - name: Evaluate results + env: + SERVICES: ${{ inputs.services }} + TEST_RESULT_WITH_SERVICES: ${{ needs.test-with-services.result }} + TEST_RESULT_STANDALONE: ${{ needs.test-standalone.result }} + DIFF_COVERAGE_RESULT: ${{ needs.diff-coverage.result }} + EVENT_NAME: ${{ github.event_name }} + run: | + # Determine which test job ran + if [ "$SERVICES" = "true" ]; then + test_result="$TEST_RESULT_WITH_SERVICES" + else + test_result="$TEST_RESULT_STANDALONE" + fi + + echo "test result: $test_result" + echo "diff-coverage result: $DIFF_COVERAGE_RESULT" + + if [ "$test_result" != "success" ]; then + echo "::error::Test job did not pass (result: $test_result)" + exit 1 + fi + + # diff-coverage only runs on PR; skip check for push events + if [ "$EVENT_NAME" = "pull_request" ]; then + if [ "$DIFF_COVERAGE_RESULT" != "success" ] && [ "$DIFF_COVERAGE_RESULT" != "skipped" ]; then + echo "::error::Diff coverage check did not pass (result: $DIFF_COVERAGE_RESULT)" + exit 1 + fi + fi + + echo "✅ All quality gates passed" diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml new file mode 100644 index 0000000..deaff0b --- /dev/null +++ b/.github/workflows/reusable-node-quality.yml @@ -0,0 +1,314 @@ +# Reusable workflow: Node Test Quality +# +# Provides typecheck, lint, test (optional), and build jobs for Node.js +# projects using pnpm or npm. +# +# Usage: +# jobs: +# quality: +# uses: Mininglamp-OSS/.github/.github/workflows/reusable-node-quality.yml@main +# with: +# package-manager: pnpm +# node-version: '20' +# has-tests: false + +name: Reusable — Node Test Quality + +on: + workflow_call: + inputs: + package-manager: + description: 'Package manager: pnpm or npm' + required: false + type: string + default: 'pnpm' + node-version: + description: 'Node.js version' + required: false + type: string + default: '20' + working-directory: + description: 'Working directory for all commands' + required: false + type: string + default: '.' + has-tests: + description: 'Whether the project has tests to run' + required: false + type: boolean + default: false + +permissions: {} + +jobs: + # ── Type Check ────────────────────────────────────────────────────── + typecheck: + name: Type Check + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + if: inputs.package-manager == 'pnpm' + with: + run_install: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ inputs.node-version }} + cache: ${{ inputs.package-manager == 'pnpm' && 'pnpm' || 'npm' }} + cache-dependency-path: | + ${{ inputs.working-directory }}/pnpm-lock.yaml + ${{ inputs.working-directory }}/package-lock.json + + - name: Install dependencies + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + case "$PACKAGE_MANAGER" in + pnpm|npm) ;; + *) echo "::error::unsupported package-manager: $PACKAGE_MANAGER" >&2; exit 2 ;; + esac + if [ "$PACKAGE_MANAGER" = "pnpm" ]; then + pnpm install --frozen-lockfile + else + npm ci + fi + + - name: Type check + run: npx tsc --noEmit + + # ── Lint ──────────────────────────────────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + if: inputs.package-manager == 'pnpm' + with: + run_install: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ inputs.node-version }} + cache: ${{ inputs.package-manager == 'pnpm' && 'pnpm' || 'npm' }} + cache-dependency-path: | + ${{ inputs.working-directory }}/pnpm-lock.yaml + ${{ inputs.working-directory }}/package-lock.json + + - name: Install dependencies + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + case "$PACKAGE_MANAGER" in + pnpm|npm) ;; + *) echo "::error::unsupported package-manager: $PACKAGE_MANAGER" >&2; exit 2 ;; + esac + if [ "$PACKAGE_MANAGER" = "pnpm" ]; then + pnpm install --frozen-lockfile + else + npm ci + fi + + - name: Lint + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + if [ "$PACKAGE_MANAGER" = "pnpm" ]; then + pnpm lint + else + npm run lint + fi + + # ── Test (only when has-tests is true) ────────────────────────────── + test: + if: inputs.has-tests + name: Test + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + if: inputs.package-manager == 'pnpm' + with: + run_install: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ inputs.node-version }} + cache: ${{ inputs.package-manager == 'pnpm' && 'pnpm' || 'npm' }} + cache-dependency-path: | + ${{ inputs.working-directory }}/pnpm-lock.yaml + ${{ inputs.working-directory }}/package-lock.json + + - name: Install dependencies + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + case "$PACKAGE_MANAGER" in + pnpm|npm) ;; + *) echo "::error::unsupported package-manager: $PACKAGE_MANAGER" >&2; exit 2 ;; + esac + if [ "$PACKAGE_MANAGER" = "pnpm" ]; then + pnpm install --frozen-lockfile + else + npm ci + fi + + - name: Run tests with coverage + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + if [ "$PACKAGE_MANAGER" = "pnpm" ]; then + pnpm test --coverage + else + npm test -- --coverage + fi + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: node-coverage + path: | + ${{ inputs.working-directory }}/coverage/ + retention-days: 7 + + # ── Coverage Report (separate job with write permissions) ─────────── + report-coverage: + if: inputs.has-tests && github.event_name == 'pull_request' + name: Coverage Report + needs: [test] + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Download coverage artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: node-coverage + path: ${{ inputs.working-directory }}/coverage + + - name: Coverage report + uses: davelosert/vitest-coverage-report-action@02f3c2e641286b7fa308cd3e430783103ce6103b # v2 + with: + working-directory: ${{ inputs.working-directory }} + + # ── Build ─────────────────────────────────────────────────────────── + build: + name: Build + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + if: inputs.package-manager == 'pnpm' + with: + run_install: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ inputs.node-version }} + cache: ${{ inputs.package-manager == 'pnpm' && 'pnpm' || 'npm' }} + cache-dependency-path: | + ${{ inputs.working-directory }}/pnpm-lock.yaml + ${{ inputs.working-directory }}/package-lock.json + + - name: Install dependencies + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + case "$PACKAGE_MANAGER" in + pnpm|npm) ;; + *) echo "::error::unsupported package-manager: $PACKAGE_MANAGER" >&2; exit 2 ;; + esac + if [ "$PACKAGE_MANAGER" = "pnpm" ]; then + pnpm install --frozen-lockfile + else + npm ci + fi + + - name: Build + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + if [ "$PACKAGE_MANAGER" = "pnpm" ]; then + pnpm build + else + npm run build + fi + + # ── Quality Gate ──────────────────────────────────────────────────── + quality-gate: + name: Quality Gate + runs-on: ubuntu-latest + if: always() + needs: [typecheck, lint, build, test, report-coverage] + permissions: + contents: read + steps: + - name: Evaluate results + env: + TYPECHECK_RESULT: ${{ needs.typecheck.result }} + LINT_RESULT: ${{ needs.lint.result }} + BUILD_RESULT: ${{ needs.build.result }} + TEST_RESULT: ${{ needs.test.result }} + COVERAGE_REPORT_RESULT: ${{ needs.report-coverage.result }} + run: | + failed=false + + if [ "$TYPECHECK_RESULT" != "success" ]; then + echo "::error::typecheck failed (result: $TYPECHECK_RESULT)" + failed=true + fi + if [ "$LINT_RESULT" != "success" ]; then + echo "::error::lint failed (result: $LINT_RESULT)" + failed=true + fi + if [ "$BUILD_RESULT" != "success" ]; then + echo "::error::build failed (result: $BUILD_RESULT)" + failed=true + fi + # test is optional (has-tests), skipped is ok + if [ "$TEST_RESULT" != "success" ] && [ "$TEST_RESULT" != "skipped" ]; then + echo "::error::test failed (result: $TEST_RESULT)" + failed=true + fi + # report-coverage only runs on PRs with has-tests, skipped is ok + if [ "$COVERAGE_REPORT_RESULT" != "success" ] && [ "$COVERAGE_REPORT_RESULT" != "skipped" ]; then + echo "::error::coverage report failed (result: $COVERAGE_REPORT_RESULT)" + failed=true + fi + + if [ "$failed" = "true" ]; then + exit 1 + fi + echo "✅ All quality gates passed"