Skip to content

feat: module-migration-02 bundle extraction (#332) #926

feat: module-migration-02 bundle extraction (#332)

feat: module-migration-02 bundle extraction (#332) #926

# 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]
paths-ignore:
- "**/*.md"
- "**/*.mdc"
- "docs/**"
push:
branches: [main, dev]
paths-ignore:
- "**/*.md"
- "**/*.mdc"
- "docs/**"
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 }}
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/**'
- 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 }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "code_changed=true" >> "$GITHUB_OUTPUT"
else
echo "code_changed=${{ steps.filter.outputs.code }}" >> "$GITHUB_OUTPUT"
fi
if [ "$EVENT_NAME" = "pull_request" ] && [ "$PR_BASE_REF" = "main" ] && [ "$PR_HEAD_REF" = "dev" ]; then
echo "skip_tests_dev_to_main=true" >> "$GITHUB_OUTPUT"
else
echo "skip_tests_dev_to_main=false" >> "$GITHUB_OUTPUT"
fi
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 cryptography cffi
- name: Verify bundled module checksums and signatures
run: |
BASE_REF=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
fi
if [ -n "$BASE_REF" ]; then
python scripts/verify-modules-signature.py --require-signature --enforce-version-bump --version-check-base "$BASE_REF"
else
python scripts/verify-modules-signature.py --require-signature --enforce-version-bump
fi
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'
- 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 coverage
if: needs.changes.outputs.skip_tests_dev_to_main != 'true'
run: |
python -m pip install --upgrade pip
pip install "hatch" "virtualenv<21" coverage
- name: Cache hatch environments
if: needs.changes.outputs.skip_tests_dev_to_main != 'true'
uses: actions/cache@v4
with:
path: |
~/.local/share/hatch
~/.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 (smart-test-full)
if: needs.changes.outputs.skip_tests_dev_to_main != 'true'
shell: bash
env:
CONTRACT_FIRST_TESTING: "true"
TEST_MODE: "true"
HATCH_TEST_ENV: "py3.12"
SMART_TEST_TIMEOUT_SECONDS: "1800"
PYTEST_ADDOPTS: "-r fEw"
run: |
echo "🧪 Running full test suite (smart-test-full, Python 3.12)..."
echo "ℹ️ HATCH_TEST_ENV=${HATCH_TEST_ENV}"
hatch run smart-test-full
- name: Generate coverage XML for quality gates
if: needs.changes.outputs.skip_tests_dev_to_main != 'true' && env.RUN_UNIT_COVERAGE == 'true'
run: |
hatch -e hatch-test.py3.12 run xml
- 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, tests]
if: 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: |
~/.local/share/hatch
~/.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 || echo "⚠️ Some tests failed (advisory)"
hatch -e hatch-test.py3.11 run xml || true
} 2>&1 | tee "$COMPAT_LOG"
- 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, tests, compat-py311]
if: 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: |
~/.local/share/hatch
~/.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 specfact repro with required CrossHair... (log: $REPRO_LOG)"
hatch run specfact repro --verbose --crosshair-required --budget 120 2>&1 | tee "$REPRO_LOG" || echo "SpecFact repro found issues"
- 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, contract-first-ci]
if: 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 not yet fully implemented"
echo "✅ CLI validation complete (advisory)"
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, tests]
if: 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 hatch
run: |
python -m pip install --upgrade pip
pip install "hatch" "virtualenv<21"
- name: Cache hatch environments
uses: actions/cache@v4
with:
path: |
~/.local/share/hatch
~/.cache/uv
key: ${{ runner.os }}-hatch-typecheck-py312-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-hatch-typecheck-py312-
${{ runner.os }}-hatch-
- 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"
hatch run type-check 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)
runs-on: ubuntu-latest
needs: [changes, tests]
if: 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 "hatch" "virtualenv<21"
- name: Cache hatch environments
uses: actions/cache@v4
with:
path: |
~/.local/share/hatch
~/.cache/uv
key: ${{ runner.os }}-hatch-lint-py312-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-hatch-lint-py312-
${{ runner.os }}-hatch-
- 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"
hatch run lint 2>&1 | tee "$LINT_LOG" || echo "⚠️ Linting incomplete"
- name: Upload lint logs
if: always()
uses: actions/upload-artifact@v4
with:
name: lint-logs
path: logs/lint/
if-no-files-found: ignore
package-validation:
name: Package Validation (uvx/pip)
runs-on: ubuntu-latest
needs: [tests, compat-py311, contract-first-ci, cli-validation, type-checking, linting]
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 cryptography cffi
python - <<'PY'
import cffi
import cryptography
import yaml
print("✅ signing dependencies available:", yaml.__version__, cryptography.__version__, cffi.__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
- 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"