Release dev to main: module install state consistency (#537) #1394
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json | |
| # yamllint disable rule:line-length rule:truthy | |
| name: PR Orchestrator - SpecFact CLI | |
| on: | |
| pull_request: | |
| branches: [main, dev] | |
| push: | |
| branches: [main, dev] | |
| workflow_dispatch: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| env: | |
| PIP_PREFER_BINARY: "1" | |
| jobs: | |
| changes: | |
| name: Detect code changes | |
| runs-on: ubuntu-latest | |
| outputs: | |
| code_changed: ${{ steps.out.outputs.code_changed }} | |
| workflow_changed: ${{ steps.out.outputs.workflow_changed }} | |
| pyproject_changed: ${{ steps.out.outputs.pyproject_changed }} | |
| license_inputs_changed: ${{ steps.out.outputs.license_inputs_changed }} | |
| version_sources_changed: ${{ steps.out.outputs.version_sources_changed }} | |
| skip_tests_dev_to_main: ${{ steps.out.outputs.skip_tests_dev_to_main }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: dorny/paths-filter@v3 | |
| id: filter | |
| with: | |
| filters: | | |
| code: | |
| - '**' | |
| - '!**/*.md' | |
| - '!**/*.mdc' | |
| - '!docs/**' | |
| - '!.github/workflows/**' | |
| pyproject: | |
| - 'pyproject.toml' | |
| license_inputs: | |
| - 'pyproject.toml' | |
| - 'modules/**/module-package.yaml' | |
| - 'src/specfact_cli/modules/**/module-package.yaml' | |
| - 'scripts/check_license_compliance.py' | |
| - 'scripts/license_allowlist.yaml' | |
| - 'scripts/module_pip_dependencies_licenses.yaml' | |
| version_sources: | |
| - 'pyproject.toml' | |
| - 'setup.py' | |
| - 'src/__init__.py' | |
| - 'src/specfact_cli/__init__.py' | |
| workflow: | |
| - '.github/workflows/**' | |
| - 'scripts/run_actionlint.sh' | |
| - 'scripts/yaml-tools.sh' | |
| - id: out | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| PR_BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} | |
| PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| PR_BASE_REF="${PR_BASE_REF:-}" | |
| PR_HEAD_REF="${PR_HEAD_REF:-}" | |
| PR_BASE_SHA="${PR_BASE_SHA:-}" | |
| PR_HEAD_SHA="${PR_HEAD_SHA:-}" | |
| if [ "$EVENT_NAME" = "workflow_dispatch" ]; then | |
| { | |
| echo "code_changed=true" | |
| echo "workflow_changed=true" | |
| echo "pyproject_changed=true" | |
| echo "license_inputs_changed=true" | |
| echo "version_sources_changed=true" | |
| } >> "$GITHUB_OUTPUT" | |
| else | |
| { | |
| echo "code_changed=${{ steps.filter.outputs.code }}" | |
| echo "workflow_changed=${{ steps.filter.outputs.workflow }}" | |
| echo "pyproject_changed=${{ steps.filter.outputs.pyproject }}" | |
| echo "license_inputs_changed=${{ steps.filter.outputs.license_inputs }}" | |
| echo "version_sources_changed=${{ steps.filter.outputs.version_sources }}" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| SKIP_TESTS=false | |
| if [ "$EVENT_NAME" = "pull_request" ] && [ "$PR_BASE_REF" = "main" ] && [ "$PR_HEAD_REF" = "dev" ]; then | |
| PR_BASE_REV="" | |
| PR_HEAD_REV="" | |
| MERGE_BASE="" | |
| if [ -n "$PR_BASE_SHA" ]; then | |
| PR_BASE_REV=$(git rev-parse "${PR_BASE_SHA}^{commit}" 2>/dev/null || true) | |
| fi | |
| if [ -n "$PR_HEAD_SHA" ]; then | |
| PR_HEAD_REV=$(git rev-parse "${PR_HEAD_SHA}^{commit}" 2>/dev/null || true) | |
| fi | |
| if [ -n "$PR_BASE_REV" ] && [ -n "$PR_HEAD_REV" ]; then | |
| MERGE_BASE=$(git merge-base "$PR_BASE_REV" "$PR_HEAD_REV" 2>/dev/null || true) | |
| fi | |
| if [ -n "$PR_BASE_REV" ] && [ -n "$PR_HEAD_REV" ] && [ "$PR_BASE_REV" = "$PR_HEAD_REV" ] && [ "$MERGE_BASE" = "$PR_HEAD_REV" ]; then | |
| SKIP_TESTS=true | |
| echo "✅ Proven release parity between dev and main; skipping duplicate validation." | |
| else | |
| echo "ℹ️ Release parity proof unavailable for dev→main PR; running required validation set." | |
| fi | |
| fi | |
| echo "skip_tests_dev_to_main=${SKIP_TESTS}" >> "$GITHUB_OUTPUT" | |
| verify-module-signatures: | |
| name: Verify Module Signatures | |
| needs: [changes] | |
| if: needs.changes.outputs.code_changed == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| - name: Install verifier dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install pyyaml beartype icontract cryptography cffi | |
| - name: Verify bundled module manifests (PR = relaxed checksum; push = payload checksum + version) | |
| run: | | |
| set -euo pipefail | |
| # shellcheck disable=SC1091 | |
| source scripts/module-verify-policy.sh | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| BASE_REF="origin/${{ github.event.pull_request.base.ref }}" | |
| python scripts/verify-modules-signature.py "${VERIFY_MODULES_PR[@]}" --version-check-base "$BASE_REF" | |
| else | |
| BEFORE="${{ github.event.before }}" | |
| if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then | |
| BEFORE="HEAD~1" | |
| fi | |
| python scripts/verify-modules-signature.py "${VERIFY_MODULES_PUSH_ORCHESTRATOR[@]}" --version-check-base "$BEFORE" | |
| fi | |
| workflow-lint: | |
| name: Workflow Lint | |
| needs: [changes] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Skip when no workflow changes are present | |
| if: needs.changes.outputs.workflow_changed != 'true' && github.event_name != 'workflow_dispatch' | |
| run: | | |
| echo "✅ No workflow changes detected; skipping workflow lint." | |
| - name: Set up Python 3.12 | |
| if: needs.changes.outputs.workflow_changed == 'true' || github.event_name == 'workflow_dispatch' | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Set up Go for actionlint | |
| if: needs.changes.outputs.workflow_changed == 'true' || github.event_name == 'workflow_dispatch' | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version: "1.24" | |
| - name: Install lint dependencies | |
| if: needs.changes.outputs.workflow_changed == 'true' || github.event_name == 'workflow_dispatch' | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install "hatch" "virtualenv<21" | |
| go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.11 | |
| echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | |
| - name: Run workflow lint | |
| if: needs.changes.outputs.workflow_changed == 'true' || github.event_name == 'workflow_dispatch' | |
| run: | | |
| echo "🔍 Running actionlint for workflow changes..." | |
| hatch run lint-workflows | |
| tests: | |
| name: Tests (Python 3.12) | |
| needs: [changes, verify-module-signatures] | |
| if: needs.changes.outputs.code_changed == 'true' | |
| outputs: | |
| run_unit_coverage: ${{ steps.detect-unit.outputs.run_unit_coverage }} | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Skip full run (dev→main PR) | |
| if: needs.changes.outputs.skip_tests_dev_to_main == 'true' | |
| run: | | |
| echo "✅ Dev→main PR: tests already passed on dev; skipping full test run." | |
| - uses: actions/checkout@v4 | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| with: | |
| fetch-depth: 0 | |
| - name: Checkout module bundles repo | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: nold-ai/specfact-cli-modules | |
| path: specfact-cli-modules | |
| ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} | |
| - name: Export module bundles path | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" | |
| - name: Set up Python 3.12 | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install hatch and fallback test dependencies | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install "hatch" "virtualenv<21" coverage "coverage[toml]" pytest pytest-cov pytest-mock pytest-asyncio pytest-xdist pytest-timeout | |
| pip install -e ".[dev]" | |
| - name: Verify version strings are synchronized | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| BASE="" | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| BASE="${{ github.event.pull_request.base.sha }}" | |
| elif [ "${{ github.event_name }}" = "push" ]; then | |
| BEFORE="${{ github.event.before }}" | |
| if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then | |
| BASE="$BEFORE" | |
| fi | |
| fi | |
| if [ -n "$BASE" ]; then | |
| python scripts/check_version_sources.py --changed-vs "$BASE" | |
| else | |
| python scripts/check_version_sources.py | |
| fi | |
| - name: Verify local version is ahead of PyPI | |
| if: >- | |
| needs.changes.outputs.skip_tests_dev_to_main != 'true' && | |
| needs.changes.outputs.version_sources_changed == 'true' | |
| env: | |
| SPECFACT_PYPI_VERSION_CHECK_LENIENT_NETWORK: "1" | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| BASE="" | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| BASE="${{ github.event.pull_request.base.sha }}" | |
| elif [ "${{ github.event_name }}" = "push" ]; then | |
| BEFORE="${{ github.event.before }}" | |
| if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then | |
| BASE="$BEFORE" | |
| fi | |
| fi | |
| if [ -n "$BASE" ]; then | |
| python scripts/check_local_version_ahead_of_pypi.py --skip-when-version-unchanged-vs "$BASE" | |
| else | |
| python scripts/check_local_version_ahead_of_pypi.py | |
| fi | |
| - name: Cache hatch environments | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/uv | |
| key: ${{ runner.os }}-hatch-tests-py312-${{ hashFiles('pyproject.toml', 'src/specfact_cli/modules/*/__init__.py') }} | |
| restore-keys: | | |
| ${{ runner.os }}-hatch-tests-py312- | |
| ${{ runner.os }}-hatch- | |
| - name: Create test output directories | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| shell: bash | |
| run: | | |
| mkdir -p logs/tests/junit logs/tests/coverage logs/tests/workflows | |
| - name: Set run_unit_coverage (or skip for dev→main) | |
| id: detect-unit | |
| shell: bash | |
| run: | | |
| if [ "${{ needs.changes.outputs.skip_tests_dev_to_main }}" = "true" ]; then | |
| echo "run_unit_coverage=false" >> "$GITHUB_OUTPUT" | |
| else | |
| COUNT=$(find tests/unit -name "test_*.py" 2>/dev/null | wc -l) | |
| if [ "$COUNT" -gt 0 ]; then | |
| echo "run_unit_coverage=true" >> "$GITHUB_OUTPUT" | |
| echo "RUN_UNIT_COVERAGE=true" >> "$GITHUB_ENV" | |
| echo "Detected $COUNT unit test files. Will run coverage steps." | |
| else | |
| echo "run_unit_coverage=false" >> "$GITHUB_OUTPUT" | |
| echo "RUN_UNIT_COVERAGE=false" >> "$GITHUB_ENV" | |
| echo "No unit tests detected. Skipping coverage steps." | |
| fi | |
| fi | |
| - name: Run full test suite (direct smart-test-full) | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| shell: bash | |
| env: | |
| CONTRACT_FIRST_TESTING: "true" | |
| TEST_MODE: "true" | |
| SMART_TEST_TIMEOUT_SECONDS: "1800" | |
| PYTEST_ADDOPTS: "-r fEw" | |
| run: | | |
| echo "🧪 Running full test suite (direct smart-test-full, Python 3.12)..." | |
| python tools/smart_test_coverage.py run --level full | |
| - name: Generate coverage XML for quality gates | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' && env.RUN_UNIT_COVERAGE == 'true' | |
| run: | | |
| python -m coverage xml -o logs/tests/coverage/coverage.xml --data-file=logs/tests/coverage/.coverage | |
| - name: Upload test logs | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-logs | |
| path: logs/tests/ | |
| if-no-files-found: ignore | |
| - name: Upload coverage artifacts | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' && env.RUN_UNIT_COVERAGE == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-reports | |
| path: logs/tests/coverage/coverage.xml | |
| if-no-files-found: error | |
| compat-py311: | |
| name: Compatibility (Python 3.11) | |
| runs-on: ubuntu-latest | |
| needs: [changes, verify-module-signatures] | |
| if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Checkout module bundles repo | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: nold-ai/specfact-cli-modules | |
| path: specfact-cli-modules | |
| ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} | |
| - name: Export module bundles path | |
| run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" | |
| - name: Set up Python 3.11 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install hatch | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install "hatch" "virtualenv<21" | |
| - name: Cache hatch environments | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/uv | |
| key: ${{ runner.os }}-hatch-compat-py311-${{ hashFiles('pyproject.toml', 'src/specfact_cli/modules/*/__init__.py') }} | |
| restore-keys: | | |
| ${{ runner.os }}-hatch-compat-py311- | |
| ${{ runner.os }}-hatch- | |
| - name: Run Python 3.11 compatibility tests (hatch-test matrix env) | |
| run: | | |
| echo "🔁 Python 3.11 compatibility checks" | |
| mkdir -p logs/compat-py311 | |
| COMPAT_LOG="logs/compat-py311/compat_$(date -u +%Y%m%d_%H%M%S).log" | |
| { | |
| hatch -e hatch-test.py3.11 run run -- -r fEw tests/unit tests/integration | |
| hatch -e hatch-test.py3.11 run xml || true | |
| } 2>&1 | tee "$COMPAT_LOG" | |
| exit "${PIPESTATUS[0]:-$?}" | |
| - name: Upload compat-py311 logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: compat-py311-logs | |
| path: logs/compat-py311/ | |
| if-no-files-found: ignore | |
| contract-first-ci: | |
| name: Contract-First CI | |
| runs-on: ubuntu-latest | |
| needs: [changes, verify-module-signatures] | |
| if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Checkout module bundles repo | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: nold-ai/specfact-cli-modules | |
| path: specfact-cli-modules | |
| ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} | |
| - name: Export module bundles path | |
| run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install "hatch" "virtualenv<21" | |
| - name: Cache hatch environments | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/uv | |
| key: ${{ runner.os }}-hatch-contract-first-py312-${{ hashFiles('pyproject.toml', 'src/specfact_cli/modules/*/__init__.py') }} | |
| restore-keys: | | |
| ${{ runner.os }}-hatch-contract-first-py312- | |
| ${{ runner.os }}-hatch- | |
| - name: Prepare repro log directory | |
| run: mkdir -p logs/repro | |
| - name: Run contract validation and exploration | |
| id: repro | |
| run: | | |
| echo "🔍 Validating runtime contracts..." | |
| REPRO_LOG="logs/repro/repro_$(date -u +%Y%m%d_%H%M%S).log" | |
| echo "Running contract-first validation with required CrossHair... (log: $REPRO_LOG)" | |
| hatch run contract-test 2>&1 | tee "$REPRO_LOG" | |
| exit "${PIPESTATUS[0]:-$?}" | |
| - name: Upload repro logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: repro-logs | |
| path: logs/repro/ | |
| if-no-files-found: ignore | |
| - name: Upload repro reports | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: repro-reports | |
| path: .specfact/reports/enforcement/ | |
| if-no-files-found: ignore | |
| cli-validation: | |
| name: CLI Command Validation | |
| runs-on: ubuntu-latest | |
| needs: [changes, verify-module-signatures] | |
| if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install CLI | |
| run: | | |
| echo "Installing SpecFact CLI..." | |
| pip install -e . | |
| - name: Validate CLI commands | |
| run: | | |
| echo "🔍 Validating CLI commands..." | |
| specfact --help | |
| echo "✅ CLI validation complete" | |
| quality-gates: | |
| name: Quality Gates (Advisory) | |
| runs-on: ubuntu-latest | |
| needs: [changes, tests] | |
| if: needs.changes.outputs.skip_tests_dev_to_main != 'true' && needs.tests.outputs.run_unit_coverage == 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Download coverage artifacts from Tests | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: coverage-reports | |
| path: logs/tests/coverage | |
| - name: Validate coverage artifact presence | |
| run: | | |
| test -f logs/tests/coverage/coverage.xml || (echo "❌ Missing coverage.xml" && exit 1) | |
| echo "✅ Found coverage.xml" | |
| - name: Run quality gates (advisory) | |
| run: | | |
| mkdir -p logs/quality-gates | |
| QG_LOG="logs/quality-gates/quality-gates_$(date -u +%Y%m%d_%H%M%S).log" | |
| { | |
| echo "🔍 Checking coverage (advisory only)..." | |
| COVERAGE_PERCENT_XML=$(grep -o "line-rate=\"[0-9.]*\"" logs/tests/coverage/coverage.xml | head -1 | sed 's/line-rate=\"//' | sed 's/\"//') | |
| if [ -n "$COVERAGE_PERCENT_XML" ] && [ "$COVERAGE_PERCENT_XML" != "0" ]; then | |
| COVERAGE_PERCENT_INT=$(echo "$COVERAGE_PERCENT_XML * 100" | bc -l | cut -d. -f1) | |
| else | |
| COVERAGE_PERCENT_INT=0 | |
| fi | |
| echo "📊 Line coverage (advisory): ${COVERAGE_PERCENT_INT}%" | |
| if [ "$COVERAGE_PERCENT_INT" -lt 30 ]; then | |
| echo "⚠️ Advisory: coverage below 30% — permitted under contract-first; prioritize contract/scenario gaps." | |
| else | |
| echo "✅ Advisory: coverage acceptable" | |
| fi | |
| } 2>&1 | tee "$QG_LOG" | |
| - name: Upload quality-gates logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: quality-gates-logs | |
| path: logs/quality-gates/ | |
| if-no-files-found: ignore | |
| type-checking: | |
| name: Type Checking (basedpyright) | |
| runs-on: ubuntu-latest | |
| needs: [changes, verify-module-signatures] | |
| if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install type-check dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -e ".[dev]" | |
| - name: Run type checking | |
| run: | | |
| echo "🔍 Running basedpyright type checking..." | |
| mkdir -p logs/type-check | |
| TYPE_CHECK_LOG="logs/type-check/type-check_$(date -u +%Y%m%d_%H%M%S).log" | |
| python -m basedpyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" 2>&1 | tee "$TYPE_CHECK_LOG" | |
| exit "${PIPESTATUS[0]:-$?}" | |
| - name: Upload type-check logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: type-check-logs | |
| path: logs/type-check/ | |
| if-no-files-found: ignore | |
| linting: | |
| name: Linting (ruff, pylint, safe-write guard) | |
| runs-on: ubuntu-latest | |
| needs: [changes, verify-module-signatures] | |
| if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install lint dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -e ".[dev]" | |
| - name: Run linting | |
| run: | | |
| echo "🔍 Running linting checks..." | |
| mkdir -p logs/lint | |
| LINT_LOG="logs/lint/lint_$(date -u +%Y%m%d_%H%M%S).log" | |
| { | |
| set -euo pipefail | |
| ruff format . --check | |
| python -m basedpyright --level error --pythonpath "$(python -c 'import sys; print(sys.executable)')" | |
| ruff check . | |
| pylint src tests tools | |
| python scripts/verify_safe_project_writes.py | |
| } 2>&1 | tee "$LINT_LOG" | |
| exit "${PIPESTATUS[0]:-$?}" | |
| - name: Upload lint logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: lint-logs | |
| path: logs/lint/ | |
| if-no-files-found: ignore | |
| license-check: | |
| name: License Compliance Gate | |
| runs-on: ubuntu-latest | |
| needs: [changes, verify-module-signatures] | |
| if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.license_inputs_changed == 'true' && needs.changes.outputs.skip_tests_dev_to_main != 'true' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -e ".[dev]" | |
| - name: Run license compliance gate | |
| run: | | |
| echo "🔍 Running license compliance gate..." | |
| python scripts/check_license_compliance.py | |
| security-audit: | |
| name: Security Audit (pip-audit) | |
| runs-on: ubuntu-latest | |
| needs: [changes, verify-module-signatures] | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -e ".[dev]" | |
| - name: Run CVE security audit | |
| run: | | |
| echo "🔍 Running CVE security audit..." | |
| python scripts/security_audit_gate.py | |
| package-validation: | |
| name: Package Validation (uvx/pip) | |
| runs-on: ubuntu-latest | |
| needs: [tests, compat-py311, contract-first-ci, cli-validation, type-checking, linting, license-check, security-audit] | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install build tools | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install build twine "hatch" "virtualenv<21" | |
| - name: Build package | |
| run: | | |
| echo "📦 Building SpecFact CLI package..." | |
| hatch build | |
| ls -lh dist/ | |
| - name: Validate package | |
| run: | | |
| echo "🔍 Validating package with twine..." | |
| twine check dist/* | |
| - name: Test installation | |
| run: | | |
| echo "📥 Testing pip installation..." | |
| pip install dist/*.whl | |
| specfact --version || echo "⚠️ CLI not yet fully implemented" | |
| pip uninstall -y specfact-cli | |
| - name: Upload package artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: python-package | |
| path: dist/ | |
| if-no-files-found: error | |
| publish-pypi: | |
| name: Publish to PyPI | |
| runs-on: ubuntu-latest | |
| needs: [package-validation] | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| outputs: | |
| published: ${{ steps.publish.outputs.published }} | |
| version: ${{ steps.publish.outputs.version }} | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| pyproject.toml | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install build twine packaging | |
| # Note: tomllib is part of Python 3.11+ standard library | |
| # This project requires Python >= 3.11, so no additional TOML library needed | |
| - name: Make script executable | |
| run: chmod +x .github/workflows/scripts/check-and-publish-pypi.sh | |
| - name: Check version and publish to PyPI | |
| id: publish | |
| env: | |
| PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} | |
| run: | | |
| ./.github/workflows/scripts/check-and-publish-pypi.sh | |
| - name: Summary | |
| if: always() | |
| run: | | |
| PUBLISHED="${{ steps.publish.outputs.published }}" | |
| VERSION="${{ steps.publish.outputs.version }}" | |
| { | |
| echo "## PyPI Publication Summary" | |
| echo "| Parameter | Value |" | |
| echo "|-----------|--------|" | |
| echo "| Version | $VERSION |" | |
| echo "| Published | $PUBLISHED |" | |
| if [ "$PUBLISHED" = "true" ]; then | |
| echo "| Status | ✅ Published to PyPI |" | |
| else | |
| echo "| Status | ⏭️ Skipped (version not newer) |" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| create-release: | |
| name: Create GitHub Release | |
| runs-on: ubuntu-latest | |
| needs: [publish-pypi] | |
| if: needs.publish-pypi.outputs.published == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install GitHub CLI | |
| run: | | |
| type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) | |
| curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ | |
| && sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ | |
| && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ | |
| && sudo apt update \ | |
| && sudo apt install gh -y | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Make scripts executable | |
| run: | | |
| chmod +x .github/workflows/scripts/generate-release-notes.sh | |
| chmod +x .github/workflows/scripts/create-github-release.sh | |
| chmod +x scripts/sign-module.sh | |
| - name: Sign bundled module manifests (release hardening) | |
| env: | |
| SPECFACT_MODULE_PRIVATE_SIGN_KEY: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY }} | |
| SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE }} | |
| run: | | |
| if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY}" ]; then | |
| echo "❌ Missing required secret: SPECFACT_MODULE_PRIVATE_SIGN_KEY" | |
| exit 1 | |
| fi | |
| if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE}" ]; then | |
| echo "❌ Missing required secret: SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | |
| exit 1 | |
| fi | |
| python -m pip install --upgrade pip | |
| python -m pip install pyyaml beartype icontract cryptography cffi | |
| python - <<'PY' | |
| import beartype | |
| import cffi | |
| import cryptography | |
| import icontract | |
| import yaml | |
| print("✅ signing dependencies available:", yaml.__version__, cryptography.__version__, cffi.__version__, beartype.__version__, icontract.__version__) | |
| PY | |
| BASE_REF="${{ github.event.before }}" | |
| if [ -z "$BASE_REF" ] || [ "$BASE_REF" = "0000000000000000000000000000000000000000" ]; then | |
| BASE_REF="HEAD~1" | |
| fi | |
| git rev-parse --verify "$BASE_REF" >/dev/null 2>&1 || BASE_REF="HEAD~1" | |
| echo "Using module-signing base ref: $BASE_REF" | |
| python scripts/sign-modules.py --changed-only --base-ref "$BASE_REF" --bump-version patch --payload-from-filesystem | |
| - name: Get version from PyPI publish step | |
| id: get_version | |
| run: | | |
| # Use version from publish-pypi job output | |
| VERSION="${{ needs.publish-pypi.outputs.version }}" | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "📦 Version: $VERSION" | |
| - name: Create GitHub Release | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| VERSION="${{ steps.get_version.outputs.version }}" | |
| echo "🚀 Creating GitHub release for version $VERSION..." | |
| ./.github/workflows/scripts/create-github-release.sh "$VERSION" | |
| - name: Release Summary | |
| if: always() | |
| run: | | |
| VERSION="${{ steps.get_version.outputs.version }}" | |
| { | |
| echo "## GitHub Release Summary" | |
| echo "| Parameter | Value |" | |
| echo "|-----------|--------|" | |
| echo "| Version | $VERSION |" | |
| echo "| Status | ✅ Release created |" | |
| echo "| URL | https://github.com/${{ github.repository }}/releases/tag/v${VERSION} |" | |
| } >> "$GITHUB_STEP_SUMMARY" |