diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d274999..12cf190 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,12 +7,22 @@ updates: labels: - "dependencies" groups: + runtime-dependencies: + patterns: + - "cryptography" + - "typing-extensions" dev-dependencies: patterns: - "pytest*" + - "hypothesis" - "ruff" - "mypy" - "tox" + docs-dependencies: + patterns: + - "mkdocs*" + - "pymdown-extensions" + - "pygments" - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..d6b13d5 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,56 @@ +name: Benchmark Baseline + +on: + workflow_dispatch: + inputs: + iterations: + description: "Iterations per operation." + required: true + default: "3" + file_size: + description: "SFTP benchmark file size in bytes." + required: true + default: "1048576" + schedule: + - cron: "37 4 * * 3" + workflow_call: + inputs: + iterations: + required: false + type: string + default: "2" + file_size: + required: false + type: string + default: "262144" + +permissions: + contents: read + +concurrency: + group: benchmark-baseline-${{ github.ref }} + cancel-in-progress: true + +jobs: + local-baseline: + name: Docker local benchmark baseline + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - run: uv pip install --system -e ".[test]" + - name: Run local baseline + run: | + python scripts/benchmark_local_baseline.py \ + --iterations "${{ inputs.iterations || github.event.inputs.iterations || '3' }}" \ + --file-size "${{ inputs.file_size || github.event.inputs.file_size || '1048576' }}" \ + --output benchmark-results/local-baseline.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: spindlex-local-benchmark-${{ github.run_id }} + path: benchmark-results/local-baseline.json + if-no-files-found: error diff --git a/.github/workflows/ci-matrix.yml b/.github/workflows/ci-matrix.yml index 61c2a78..95cfc16 100644 --- a/.github/workflows/ci-matrix.yml +++ b/.github/workflows/ci-matrix.yml @@ -1,28 +1,6 @@ name: Compatibility Matrix on: - pull_request: - branches: [main] - types: [opened, synchronize, reopened, ready_for_review] - paths: - - "spindlex/**" - - "tests/**" - - "scripts/**" - - ".github/ISSUE_TEMPLATE/**" - - "pyproject.toml" - - ".github/workflows/ci-matrix.yml" - - ".github/workflows/integration.yml" - - ".github/workflows/release.yml" - push: - branches: [main] - paths: - - "spindlex/**" - - "tests/**" - - "scripts/**" - - "pyproject.toml" - - ".github/workflows/ci-matrix.yml" - - ".github/workflows/integration.yml" - - ".github/workflows/release.yml" schedule: - cron: "23 3 * * 1" workflow_dispatch: @@ -42,80 +20,154 @@ concurrency: cancel-in-progress: true jobs: - unit-matrix: - name: Ubuntu Python ${{ matrix.python-version }} + ubuntu-python: + name: Ubuntu Python compatibility runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + timeout-minutes: 45 + permissions: + actions: read + contents: read + issues: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + + - name: Python 3.9 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: ${{ matrix.python-version }} - - run: uv pip install --system -e ".[test]" - - name: Run unit compatibility suite - run: | - pytest tests/ \ - -m "not integration and not real_server and not slow and not performance" \ - --timeout=120 \ - --timeout-method=thread \ - --cov=spindlex \ - --cov-report=xml + python-version: "3.9" + - run: | + uv pip install --system -e ".[test]" + pytest tests/ -m "not integration and not real_server and not slow and not performance" --timeout=120 --timeout-method=thread + + - name: Python 3.10 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.10" + - run: | + uv pip install --system -e ".[test]" + pytest tests/ -m "not integration and not real_server and not slow and not performance" --timeout=120 --timeout-method=thread + + - name: Python 3.11 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - run: | + uv pip install --system -e ".[test]" + pytest tests/ -m "not integration and not real_server and not slow and not performance" --timeout=120 --timeout-method=thread --cov=spindlex --cov-report=xml + - name: Upload unit coverage - if: matrix.python-version == '3.11' && github.event_name != 'workflow_call' + if: github.event_name != 'workflow_call' uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 with: files: ./coverage.xml flags: unit token: ${{ secrets.CODECOV_TOKEN }} - cross-platform-smoke: - name: ${{ matrix.os }} Python 3.11 - runs-on: ${{ matrix.os }} + - name: Python 3.12 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - run: | + uv pip install --system -e ".[test]" + pytest tests/ -m "not integration and not real_server and not slow and not performance" --timeout=120 --timeout-method=thread + + - name: Python 3.13 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - run: | + uv pip install --system -e ".[test]" + pytest tests/ -m "not integration and not real_server and not slow and not performance" --timeout=120 --timeout-method=thread + + - name: Track repeated Ubuntu compatibility failures + if: > + failure() && + github.event_name != 'pull_request' && + (github.event_name != 'workflow_call' || inputs.track_failures) + env: + GITHUB_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + python scripts/track_workflow_failure.py ci \ + --repository "${{ github.repository }}" \ + --token "$GITHUB_TOKEN" \ + --workflow-name "${{ github.workflow }}" \ + --workflow-file "ci-matrix.yml" \ + --run-id "${{ github.run_id }}" \ + --run-url "$RUN_URL" \ + --branch "${{ github.ref_name }}" \ + --source-sha "${{ github.sha }}" \ + --event-name "${{ github.event_name }}" \ + --failed-stage "ubuntu-python" + + windows-smoke: + name: Windows Python 3.11 smoke + needs: ubuntu-python + runs-on: windows-latest timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - os: ["windows-latest", "macos-latest"] + permissions: + actions: read + contents: read + issues: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" - - run: uv pip install --system -e ".[test]" - - name: Run cross-platform unit smoke suite - run: | + - run: | + uv pip install --system -e ".[test]" pytest tests/ -m "not integration and not real_server and not slow and not performance" --timeout=120 --timeout-method=thread - failure-tracking: - name: Matrix failure tracking - needs: - - unit-matrix - - cross-platform-smoke - if: > - always() && - github.event_name != 'pull_request' && - (github.event_name != 'workflow_call' || inputs.track_failures) && - (needs.unit-matrix.result == 'failure' || needs.cross-platform-smoke.result == 'failure') - runs-on: ubuntu-latest + - name: Track repeated Windows compatibility failures + if: > + failure() && + github.event_name != 'pull_request' && + (github.event_name != 'workflow_call' || inputs.track_failures) + env: + GITHUB_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + python scripts/track_workflow_failure.py ci \ + --repository "${{ github.repository }}" \ + --token "$GITHUB_TOKEN" \ + --workflow-name "${{ github.workflow }}" \ + --workflow-file "ci-matrix.yml" \ + --run-id "${{ github.run_id }}" \ + --run-url "$RUN_URL" \ + --branch "${{ github.ref_name }}" \ + --source-sha "${{ github.sha }}" \ + --event-name "${{ github.event_name }}" \ + --failed-stage "windows-smoke" + + macos-smoke: + name: macOS Python 3.11 smoke + needs: windows-smoke + runs-on: macos-latest + timeout-minutes: 20 permissions: actions: read contents: read issues: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" - - name: Track repeated compatibility failures + - run: | + uv pip install --system -e ".[test]" + pytest tests/ -m "not integration and not real_server and not slow and not performance" --timeout=120 --timeout-method=thread + + - name: Track repeated macOS compatibility failures env: GITHUB_TOKEN: ${{ github.token }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + if: > + failure() && + github.event_name != 'pull_request' && + (github.event_name != 'workflow_call' || inputs.track_failures) run: | python scripts/track_workflow_failure.py ci \ --repository "${{ github.repository }}" \ @@ -127,4 +179,4 @@ jobs: --branch "${{ github.ref_name }}" \ --source-sha "${{ github.sha }}" \ --event-name "${{ github.event_name }}" \ - --failed-stage "compatibility-matrix" + --failed-stage "macos-smoke" diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 96ec419..914564f 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -6,7 +6,9 @@ on: types: [opened, synchronize, reopened, edited, ready_for_review] permissions: + actions: read contents: read + issues: write pull-requests: read concurrency: @@ -14,155 +16,116 @@ concurrency: cancel-in-progress: true jobs: - pr-validate: - name: pr-validate + pr-quality: + name: PR quality runs-on: ubuntu-latest - outputs: - change_type: ${{ steps.validate.outputs.change_type }} - release_needed: ${{ steps.validate.outputs.release_needed }} - release_type: ${{ steps.validate.outputs.release_type }} + timeout-minutes: 35 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" + + - name: Install project and tooling + run: uv pip install --system -e ".[dev,docs,test]" pip-audit "setuptools<81" "semgrep>=1.136.0" + - id: validate + name: Validate PR metadata run: python scripts/validate_pr_body.py --event-path "$GITHUB_EVENT_PATH" - ruff: - name: ruff - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.11" - - run: uv pip install --system ruff - - run: ruff check spindlex tests - - run: ruff format --check spindlex tests + - name: Lint and format + run: | + ruff check spindlex tests scripts + ruff format --check spindlex tests scripts - mypy: - name: mypy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.11" - - run: uv pip install --system ".[dev]" - - run: mypy spindlex + - name: Type check + run: mypy spindlex - unit-tests: - name: unit-tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.11" - - run: uv pip install --system -e ".[test]" - - run: pytest tests -m "not integration and not real_server and not slow and not performance" + - name: Unit tests + run: pytest tests -m "not integration and not real_server and not slow and not performance" - changelog-check: - name: changelog-check - needs: pr-validate - if: needs.pr-validate.outputs.release_needed == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.11" - - run: python scripts/check_changelog_updated.py --event-path "$GITHUB_EVENT_PATH" + - name: Changelog check + if: steps.validate.outputs.release_needed == 'true' + run: python scripts/check_changelog_updated.py --event-path "$GITHUB_EVENT_PATH" - docs-build: - name: docs-build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.11" - - run: uv pip install --system ".[docs]" - - run: mkdocs build --strict + - name: Docs build + run: mkdocs build --strict - security-fast: - name: security-fast - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.11" - - run: uv pip install --system ".[dev]" pip-audit "setuptools<81" "semgrep>=1.136.0" - - run: bandit -r spindlex -c pyproject.toml - - run: semgrep scan --config p/python --config p/security-audit --metrics=off --severity ERROR --error spindlex - - run: python scripts/export_runtime_requirements.py --output runtime-requirements.txt - - run: pip-audit -r runtime-requirements.txt --cache-dir .pip-audit-cache - - name: Runtime dependency license audit + - name: Security fast checks run: | + bandit -r spindlex -c pyproject.toml + semgrep scan --config p/python --config p/security-audit --metrics=off --severity ERROR --error spindlex + python scripts/export_runtime_requirements.py --output runtime-requirements.txt + pip-audit -r runtime-requirements.txt --cache-dir .pip-audit-cache uv run --with-requirements runtime-requirements.txt \ python scripts/check_dependency_licenses.py \ --exclude pip \ --exclude setuptools \ --exclude wheel \ --fail-on-unknown - - run: | + + - name: Secret scan + run: | docker run --rm \ -v "$PWD:/repo" \ ghcr.io/gitleaks/gitleaks:v8.30.1 \ detect --source /repo --config /repo/.gitleaks.toml --no-git --redact --exit-code 1 - workflow-lint: - name: workflow-lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2 + - name: Workflow lint + uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2 - script-lint: - name: script-lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.11" - - run: python -m py_compile scripts/*.py + - name: Script compile + run: python -m py_compile scripts/*.py + + - name: Summary + run: echo "PR quality checks passed." + + compatibility-matrix: + name: PR compatibility matrix + needs: pr-quality + uses: ./.github/workflows/ci-matrix.yml + secrets: inherit + + integration: + name: PR integration + needs: compatibility-matrix + uses: ./.github/workflows/integration.yml + secrets: inherit + + property-tests: + name: PR property tests + needs: integration + uses: ./.github/workflows/property.yml quality-gate: name: quality-gate - if: always() needs: - - pr-validate - - ruff - - mypy - - unit-tests - - changelog-check - - docs-build - - security-fast - - workflow-lint - - script-lint + - pr-quality + - compatibility-matrix + - integration + - property-tests + if: always() runs-on: ubuntu-latest steps: - - name: Result + - name: Verify sequential PR gates + env: + PR_QUALITY_RESULT: ${{ needs['pr-quality'].result }} + COMPATIBILITY_RESULT: ${{ needs['compatibility-matrix'].result }} + INTEGRATION_RESULT: ${{ needs.integration.result }} + PROPERTY_RESULT: ${{ needs['property-tests'].result }} run: | - if [ "${{ needs.pr-validate.result }}" != "success" ]; then exit 1; fi - if [ "${{ needs.ruff.result }}" != "success" ]; then exit 1; fi - if [ "${{ needs.mypy.result }}" != "success" ]; then exit 1; fi - if [ "${{ needs.unit-tests.result }}" != "success" ]; then exit 1; fi - if [ "${{ needs.changelog-check.result }}" != "success" ] && [ "${{ needs.changelog-check.result }}" != "skipped" ]; then exit 1; fi - if [ "${{ needs.docs-build.result }}" != "success" ]; then exit 1; fi - if [ "${{ needs.security-fast.result }}" != "success" ]; then exit 1; fi - if [ "${{ needs.workflow-lint.result }}" != "success" ]; then exit 1; fi - if [ "${{ needs.script-lint.result }}" != "success" ]; then exit 1; fi - echo "PR gate passed." + if [ "$PR_QUALITY_RESULT" != "success" ] || + [ "$COMPATIBILITY_RESULT" != "success" ] || + [ "$INTEGRATION_RESULT" != "success" ] || + [ "$PROPERTY_RESULT" != "success" ]; then + echo "PR quality: $PR_QUALITY_RESULT" + echo "Compatibility matrix: $COMPATIBILITY_RESULT" + echo "Integration: $INTEGRATION_RESULT" + echo "Property tests: $PROPERTY_RESULT" + exit 1 + fi + + echo "All sequential PR gates passed." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2d5e40a..bfc8cdd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,12 +1,10 @@ name: CodeQL on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] schedule: - cron: '30 1 * * 0' # Every Sunday at 01:30 + workflow_dispatch: + workflow_call: permissions: contents: read diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 9a53c1a..421b917 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -1,28 +1,6 @@ name: Integration on: - pull_request: - branches: [main] - types: [opened, synchronize, reopened, ready_for_review] - paths: - - "spindlex/**" - - "tests/**" - - "scripts/**" - - ".github/ISSUE_TEMPLATE/**" - - "pyproject.toml" - - ".github/workflows/ci-matrix.yml" - - ".github/workflows/integration.yml" - - ".github/workflows/release.yml" - push: - branches: [main] - paths: - - "spindlex/**" - - "tests/**" - - "scripts/**" - - "pyproject.toml" - - ".github/workflows/ci-matrix.yml" - - ".github/workflows/integration.yml" - - ".github/workflows/release.yml" schedule: - cron: "47 3 * * *" workflow_dispatch: @@ -46,6 +24,10 @@ jobs: name: Docker OpenSSH and Dropbear runs-on: ubuntu-latest timeout-minutes: 25 + permissions: + actions: read + contents: read + issues: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 @@ -56,7 +38,7 @@ jobs: - name: Run Docker integration suite run: | pytest tests/integration/ tests/real_server/ tests/misc/test_functional_integration.py \ - -m "integration or real_server" \ + -m "(integration or real_server) and not slow" \ --tb=short \ --timeout=120 \ --timeout-method=thread \ @@ -70,25 +52,11 @@ jobs: flags: integration token: ${{ secrets.CODECOV_TOKEN }} - failure-tracking: - name: Integration failure tracking - needs: docker-protocol - if: > - always() && - github.event_name != 'pull_request' && - (github.event_name != 'workflow_call' || inputs.track_failures) && - needs.docker-protocol.result == 'failure' - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - issues: write - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.11" - name: Track repeated integration failures + if: > + failure() && + github.event_name != 'pull_request' && + (github.event_name != 'workflow_call' || inputs.track_failures) env: GITHUB_TOKEN: ${{ github.token }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/property.yml b/.github/workflows/property.yml new file mode 100644 index 0000000..bbce5d5 --- /dev/null +++ b/.github/workflows/property.yml @@ -0,0 +1,28 @@ +name: Property Tests + +on: + schedule: + - cron: "12 4 * * 2" + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +concurrency: + group: property-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + property-tests: + name: Bounded property tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - run: uv pip install --system -e ".[test]" + - run: pytest tests/protocol/test_property_protocol_boundaries.py --timeout=120 --timeout-method=thread diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e80917c..2c5aabc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,22 +3,6 @@ name: Release on: push: branches: [main] - pull_request: - branches: [main] - types: [opened, synchronize, reopened, ready_for_review] - paths: - - ".github/workflows/release.yml" - - ".github/workflows/ci-matrix.yml" - - ".github/workflows/integration.yml" - - ".github/ISSUE_TEMPLATE/**" - - "scripts/extract_release_notes.py" - - "scripts/plan_release.py" - - "scripts/sync_project_version.py" - - "scripts/track_workflow_failure.py" - - "scripts/validate_pr_body.py" - - "tests/scripts/**" - - "pyproject.toml" - - "spindlex/_version.py" workflow_dispatch: inputs: dry_run: @@ -86,10 +70,43 @@ jobs: echo "- reason: ${{ steps.plan.outputs.reason }}" } >> "$GITHUB_STEP_SUMMARY" + main-codeql: + name: Main push CodeQL + needs: plan + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + actions: read + contents: read + security-events: write + uses: ./.github/workflows/codeql.yml + + main-security: + name: Main push security + needs: main-codeql + if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + actions: read + contents: read + id-token: write + security-events: write + uses: ./.github/workflows/security.yml + compatibility-matrix: name: Release compatibility matrix - needs: plan - if: needs.plan.outputs.release_needed == 'true' + needs: + - plan + - main-codeql + - main-security + if: > + always() && + needs.plan.outputs.release_needed == 'true' && + ( + github.event_name != 'push' || + ( + needs['main-codeql'].result == 'success' && + needs['main-security'].result == 'success' + ) + ) permissions: actions: read contents: read @@ -99,7 +116,9 @@ jobs: integration: name: Release integration - needs: plan + needs: + - plan + - compatibility-matrix if: needs.plan.outputs.release_needed == 'true' permissions: actions: read @@ -108,18 +127,41 @@ jobs: uses: ./.github/workflows/integration.yml secrets: inherit + property-tests: + name: Release property tests + needs: + - plan + - integration + if: needs.plan.outputs.release_needed == 'true' + uses: ./.github/workflows/property.yml + + benchmark-baseline: + name: Release benchmark baseline + needs: + - plan + - property-tests + if: needs.plan.outputs.release_needed == 'true' + uses: ./.github/workflows/benchmark.yml + with: + iterations: "2" + file_size: "262144" + dry-run-release: name: Release dry run needs: - plan - compatibility-matrix - integration + - property-tests + - benchmark-baseline if: > always() && needs.plan.outputs.release_needed == 'true' && needs.plan.outputs.dry_run == 'true' && needs.compatibility-matrix.result == 'success' && - needs.integration.result == 'success' + needs.integration.result == 'success' && + needs.property-tests.result == 'success' && + needs.benchmark-baseline.result == 'success' runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -137,6 +179,8 @@ jobs: run: | python -m build python -m twine check dist/* + - name: Generate integrity artifacts + run: python scripts/generate_release_integrity.py --dist dist --output-dir dist-integrity - name: Validate built wheel import run: | uv run --with dist/*.whl \ @@ -145,23 +189,123 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: release-dry-run-dist-${{ needs.plan.outputs.next_version }} - path: dist/* + path: | + dist/* + dist-integrity/* if-no-files-found: error + create-release-pr: + name: Create release-version PR + needs: + - plan + - compatibility-matrix + - integration + - property-tests + - benchmark-baseline + if: > + always() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.plan.outputs.release_needed == 'true' && + needs.plan.outputs.dry_run == 'false' && + needs.plan.outputs.reason != 'protected release version PR merged' && + needs.compatibility-matrix.result == 'success' && + needs.integration.result == 'success' && + needs.property-tests.result == 'success' && + needs.benchmark-baseline.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Open protected release-version PR + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.plan.outputs.tag }} + NEXT_VERSION: ${{ needs.plan.outputs.next_version }} + SOURCE_PR: ${{ needs.plan.outputs.source_pr }} + SOURCE_PR_URL: ${{ needs.plan.outputs.source_pr_url }} + RELEASE_TYPE: ${{ needs.plan.outputs.release_type }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + echo "::notice::Tag $TAG already exists; no release PR needed." + exit 0 + fi + + branch="release/$TAG" + if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + echo "::notice::Release branch $branch already exists." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$branch" + python scripts/sync_project_version.py --version "$NEXT_VERSION" + python scripts/sync_project_version.py --check + git add pyproject.toml spindlex/_version.py + git commit \ + -m "chore(release): $TAG [publish release]" \ + -m "Source PR: #$SOURCE_PR $SOURCE_PR_URL" + git push origin "$branch" + + cat > release-pr-body.md < always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.plan.outputs.release_needed == 'true' && needs.plan.outputs.dry_run == 'false' && + needs.plan.outputs.reason == 'protected release version PR merged' && needs.compatibility-matrix.result == 'success' && - needs.integration.result == 'success' + needs.integration.result == 'success' && + needs.property-tests.result == 'success' && + needs.benchmark-baseline.result == 'success' runs-on: ubuntu-latest outputs: release_complete: ${{ steps.release.outputs.release_complete }} @@ -186,10 +330,9 @@ jobs: - name: Install build tooling run: uv pip install --system build twine - id: prepare - name: Prepare release commit + name: Validate release commit env: TAG: ${{ needs.plan.outputs.tag }} - NEXT_VERSION: ${{ needs.plan.outputs.next_version }} run: | if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then echo "publish_needed=false" >> "$GITHUB_OUTPUT" @@ -197,21 +340,17 @@ jobs: exit 0 fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - python scripts/sync_project_version.py --version "$NEXT_VERSION" python scripts/sync_project_version.py --check - git add pyproject.toml spindlex/_version.py - git commit -m "chore(release): $TAG [skip release]" - echo "publish_needed=true" >> "$GITHUB_OUTPUT" - name: Build and validate distributions if: steps.prepare.outputs.publish_needed == 'true' run: | python -m build python -m twine check dist/* + - name: Generate integrity artifacts + if: steps.prepare.outputs.publish_needed == 'true' + run: python scripts/generate_release_integrity.py --dist dist --output-dir dist-integrity - name: Validate built wheel import if: steps.prepare.outputs.publish_needed == 'true' run: | @@ -269,8 +408,6 @@ jobs: SOURCE_PR_URL: ${{ needs.plan.outputs.source_pr_url }} RELEASE_TYPE: ${{ needs.plan.outputs.release_type }} run: | - git push origin HEAD:main - git tag -a "$TAG" -m "Release $TAG" git push origin "$TAG" @@ -290,7 +427,9 @@ jobs: gh release create "$TAG" \ --title "$TAG" \ - --notes-file release-notes.md + --notes-file release-notes.md \ + dist/* \ + dist-integrity/* echo "release_complete=true" >> "$GITHUB_OUTPUT" @@ -298,9 +437,14 @@ jobs: name: Release failure tracking needs: - plan + - main-codeql + - main-security - compatibility-matrix - integration + - property-tests + - benchmark-baseline - publish + - create-release-pr if: > always() && github.event_name == 'push' && @@ -332,7 +476,12 @@ jobs: --source-sha "${{ needs.plan.outputs.source_sha }}" \ --release-type "${{ needs.plan.outputs.release_type }}" \ --planned-version "${{ needs.plan.outputs.next_version }}" \ - --compatibility-result "${{ needs.compatibility-matrix.result }}" \ + --codeql-result "${{ needs['main-codeql'].result }}" \ + --security-result "${{ needs['main-security'].result }}" \ + --compatibility-result "${{ needs['compatibility-matrix'].result }}" \ --integration-result "${{ needs.integration.result }}" \ + --property-result "${{ needs['property-tests'].result }}" \ + --benchmark-result "${{ needs['benchmark-baseline'].result }}" \ + --release-pr-result "${{ needs['create-release-pr'].result }}" \ --publish-result "${{ needs.publish.result }}" \ --release-complete "${{ needs.publish.outputs.release_complete }}" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 10f36f9..a5e1b81 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,11 +1,10 @@ name: Security on: - push: - branches: [main] schedule: - cron: "17 2 * * 0" workflow_dispatch: + workflow_call: permissions: actions: read @@ -47,6 +46,8 @@ jobs: dependency-audit: name: Dependency Audit + needs: semgrep + if: always() runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -89,6 +90,8 @@ jobs: gitleaks: name: Gitleaks + needs: dependency-audit + if: always() runs-on: ubuntu-latest permissions: contents: read @@ -116,6 +119,8 @@ jobs: trivy: name: Trivy Filesystem + needs: gitleaks + if: always() runs-on: ubuntu-latest permissions: contents: read @@ -141,7 +146,8 @@ jobs: scorecard: name: OpenSSF Scorecard - if: github.ref == 'refs/heads/main' + needs: trivy + if: always() && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: actions: read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..30e77e8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contributing + +The maintained contributor guide lives in the public documentation: + +[docs/contributing.md](docs/contributing.md) + +Use the GitHub pull request template when opening a PR. It drives the release +planner and must contain exactly one selected change type. diff --git a/README.md b/README.md index 2e363f4..c3fb691 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@
-Quick StartDocumentationSecurityContributing +Quick StartDocumentationSecurityContributing @@ -26,7 +26,7 @@ **SpindleX** is a modern SSH protocol implementation for Python 3.9+. It is designed for high-performance automation and secure file transfers, providing a clean alternative to legacy SSH libraries. > [!NOTE] -> **0.7.x - ChaCha20-Poly1305 & SFTP throughput era.** This release line adds full `chacha20-poly1305@openssh.com` support as the preferred cipher, adaptive SFTP write chunks via `limits@openssh.com` (up to 255 KB), and a hardened async transport. Review [meta/SECURITY.md](meta/SECURITY.md) before deploying in production-facing workflows and pin exact versions. +> **0.7.x - ChaCha20-Poly1305 & SFTP throughput era.** This release line adds full `chacha20-poly1305@openssh.com` support as the preferred cipher, adaptive SFTP write chunks via `limits@openssh.com` (up to 255 KB), and a hardened async transport. Review [SECURITY.md](SECURITY.md), [production usage expectations](docs/production-usage.md), and [compatibility policy](docs/compatibility.md) before deploying in production-facing workflows. ### 🔥 Key Features @@ -139,13 +139,13 @@ SpindleX is optimized for high-throughput environments. The 0.7.x line brings SF - **AEAD Preferred**: `chacha20-poly1305@openssh.com` is the default cipher - authentication is integral, no separate MAC. - **Terrapin Defense**: Strict-KEX (`kex-strict-c-v00@openssh.com`) enabled, sequence numbers reset after NEWKEYS. - **Modern Defaults**: Ed25519, ECDSA, ChaCha20-Poly1305, and AES-CTR only. SHA-1 and CBC mode are excluded. -- **Full Policy**: See [meta/SECURITY.md](meta/SECURITY.md) for vulnerability reporting and security standards. +- **Full Policy**: See [SECURITY.md](SECURITY.md) for vulnerability reporting and [Security Guide](docs/security.md) for operational security guidance. --- ## 🤝 Contributing -Contributions are welcome. See `meta/CONTRIBUTING.md` for guidelines. +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the GitHub entry point and [docs/contributing.md](docs/contributing.md) for the maintained guide. Distributed under the **MIT License**. See `LICENSE` for more information. diff --git a/SECURITY.md b/SECURITY.md index 1aad259..d9ad90c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,6 +23,9 @@ If GitHub Security Advisories are unavailable to you, open a minimal public issue requesting a private security contact and do not include exploit details in that issue. +Maintainer triage, severity, embargo, advisory, CVE, and patch-release handling +are documented in [docs/vulnerability-response.md](docs/vulnerability-response.md). + ## What to Include A useful report should include: @@ -68,6 +71,10 @@ SpindleX follows these security principles: - **Type safety**: Type hints and static checks are used to prevent classes of logic errors. +The broader security model and trust boundaries are documented in +[docs/security.md](docs/security.md) and +[docs/architecture-security.md](docs/architecture-security.md). + ## Minimal Threat Model SpindleX is intended to protect SSH and SFTP sessions from passive observation diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..b2e0144 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,10 @@ +# Support + +SpindleX support expectations are documented in: + +- [Production usage expectations](docs/production-usage.md) +- [Compatibility policy](docs/compatibility.md) +- [Security policy](SECURITY.md) + +Please do not report security vulnerabilities through public GitHub issues. Use +the private reporting path in [SECURITY.md](SECURITY.md). diff --git a/docs/adr/0001-release-policy.md b/docs/adr/0001-release-policy.md new file mode 100644 index 0000000..7c8fe99 --- /dev/null +++ b/docs/adr/0001-release-policy.md @@ -0,0 +1,35 @@ +# 0001 - Release Policy + +Status: Accepted + +## Context + +SpindleX is pre-`1.0.0` and uses PR metadata to decide whether a merge should +produce a beta release. After `1.0.0`, users need a stable SemVer contract and +maintainers need a release process that works without direct commits to +protected `main`. + +## Decision + +Before `1.0.0`, PR type checkboxes continue to drive beta release planning: +`bug` and `feature` produce patch releases, `feature-minor` and `breaking` +produce minor releases, and `docs`, `refactor`, and `test` produce no release. + +Before stable release, version bumps move into a protected release-version PR. +The release PR carries the `pyproject.toml` and `spindlex/_version.py` changes, +runs normal PR gates, and is reviewed before publishing from `main`. + +After `1.0.0`, SpindleX follows SemVer. Breaking changes require a major +release unless they are explicitly outside the documented stable API boundary. + +## Consequences + +Release behavior stays fast during beta, but the stable release path has normal +review and required checks. Contributors must keep PR type metadata accurate +because it controls release planning. + +## Related + +- [Release policy](../release-policy.md) +- [Release runbook](../release-runbook.md) +- [Artifact verification](../release-verification.md) diff --git a/docs/adr/0002-docs-layout.md b/docs/adr/0002-docs-layout.md new file mode 100644 index 0000000..73fd3b3 --- /dev/null +++ b/docs/adr/0002-docs-layout.md @@ -0,0 +1,24 @@ +# 0002 - Documentation Layout + +Status: Accepted + +## Context + +The repository has public MkDocs pages, GitHub-recognized root files, and +internal lifecycle documents. Duplicating the same policy across those locations +causes drift. + +## Decision + +`docs/` is the canonical public documentation source. Root files are short +GitHub entry points. `meta/internal/` is internal planning material and is not +the public roadmap. + +## Consequences + +Most long-form docs live in one place and Read the Docs can publish the complete +user-facing surface. Root and `meta/` files should link instead of duplicating. + +## Related + +- [Documentation ownership](../docs-layout.md) diff --git a/docs/adr/0003-supported-platforms.md b/docs/adr/0003-supported-platforms.md new file mode 100644 index 0000000..6c3460a --- /dev/null +++ b/docs/adr/0003-supported-platforms.md @@ -0,0 +1,26 @@ +# 0003 - Supported Platforms + +Status: Accepted + +## Context + +SpindleX is advertised as OS independent, but compatibility claims must match +CI, package metadata, and integration evidence. + +## Decision + +The v1 support target is Python `3.9` through `3.13`, matching package metadata +and CI. Linux is the primary integration platform. Windows and macOS receive +unit smoke coverage. SSH interoperability claims are limited to tested OpenSSH +and Dropbear Docker-backed integration environments unless additional canary +evidence is recorded. + +## Consequences + +Compatibility docs remain conservative. New compatibility reports become tests, +known incompatibility entries, or canary candidates before they become support +claims. + +## Related + +- [Compatibility](../compatibility.md) diff --git a/docs/adr/0004-api-stability-boundaries.md b/docs/adr/0004-api-stability-boundaries.md new file mode 100644 index 0000000..f0d9112 --- /dev/null +++ b/docs/adr/0004-api-stability-boundaries.md @@ -0,0 +1,27 @@ +# 0004 - API Stability Boundaries + +Status: Accepted + +## Context + +SpindleX exposes user-facing clients, servers, host-key helpers, exceptions, +logging helpers, and command-line tools. It also contains protocol, transport, +and crypto internals that must remain changeable while the implementation +stabilizes. + +## Decision + +The documented APIs under [API stability](../api-stability.md) are the v1 +compatibility surface. Underscore-prefixed modules, undocumented internals, test +helpers, and protocol implementation details are not stable unless a public doc +explicitly says otherwise. + +## Consequences + +Users can rely on documented public imports and command-line tools after v1. +Maintainers can continue to improve internals without treating every import path +as part of the compatibility contract. + +## Related + +- [API stability](../api-stability.md) diff --git a/docs/adr/index.md b/docs/adr/index.md new file mode 100644 index 0000000..cf693fb --- /dev/null +++ b/docs/adr/index.md @@ -0,0 +1,38 @@ +# Architecture Decision Records + +Architecture Decision Records capture project decisions that should remain easy +to find after the implementation details move on. + +ADRs are short. They record context, decision, consequences, and related issues +or docs. They do not replace user documentation or implementation tasks. + +## Records + +- [0001 - Release policy](0001-release-policy.md) +- [0002 - Documentation layout](0002-docs-layout.md) +- [0003 - Supported platforms](0003-supported-platforms.md) +- [0004 - API stability boundaries](0004-api-stability-boundaries.md) + +## Template + +```markdown +# NNNN - Title + +Status: Proposed | Accepted | Superseded + +## Context + +What problem or constraint forced the decision? + +## Decision + +What did we choose? + +## Consequences + +What becomes easier, harder, or intentionally out of scope? + +## Related + +- Issue or documentation links. +``` diff --git a/docs/api-stability.md b/docs/api-stability.md new file mode 100644 index 0000000..76aae8b --- /dev/null +++ b/docs/api-stability.md @@ -0,0 +1,51 @@ +# API Stability + +This page defines the public API surface users can rely on after `1.0.0`. + +## Stable Public APIs + +The stable v1 surface includes documented imports from: + +- `spindlex.SSHClient` +- `spindlex.AsyncSSHClient` +- `spindlex.client` +- `spindlex.hostkeys` +- `spindlex.exceptions` +- documented logging helpers from `spindlex.logging` +- documented key generation and benchmark command-line tools + +The API reference pages are the authoritative list for documented public +objects. + +## Command-Line Tools + +`spindlex-keygen` and `spindlex-benchmark` are public CLI entry points. After +v1, option removal or incompatible output changes require deprecation or a major +release unless the option is documented as experimental. + +## Exceptions + +Public exception classes documented in [Exceptions](api_reference/exceptions.md) +are part of the compatibility surface. Error messages may be clarified in patch +or minor releases, but exception categories should not change incompatibly +without migration guidance. + +## Provisional APIs + +Server-side APIs, lower-level logging/monitoring helpers, and benchmark output +formats may change before v1 unless this page or the API reference explicitly +marks them stable. + +## Internal APIs + +These are not compatibility promises: + +- underscore-prefixed modules, classes, functions, and attributes +- protocol packet internals +- transport state-machine internals +- crypto backend implementation details +- test helpers and scripts not exposed as project CLI entry points +- `meta/internal/` planning material + +Public docs should not present internals as stable unless this page is updated +and an ADR records the decision. diff --git a/docs/architecture-security.md b/docs/architecture-security.md new file mode 100644 index 0000000..0dbd59d --- /dev/null +++ b/docs/architecture-security.md @@ -0,0 +1,48 @@ +# Architecture and Security Boundaries + +This page summarizes the major system boundaries for reviewers. API stability is +defined separately in [API stability](api-stability.md). + +## Data Flow + +1. Client code configures host keys, credentials, algorithms, and connection + options. +2. Transport opens a TCP socket and performs SSH version exchange. +3. Key exchange negotiates KEX, host key, cipher, and MAC or AEAD behavior. +4. Host key policy verifies server identity. +5. Authentication establishes the user session. +6. Channels carry command execution, forwarding, and SFTP subsystem traffic. +7. SFTP clients and servers encode and decode file-operation messages. + +## Trust Boundaries + +- Remote SSH peers are untrusted until host key verification succeeds. +- Credentials and private keys are caller-owned secrets. +- Protocol bytes from the network are untrusted input. +- `cryptography` owns low-level primitive correctness. +- SpindleX owns SSH framing, negotiation, host key policy, authentication flow, + channel behavior, and SFTP message handling. + +## Host Key Verification + +`RejectPolicy` is the safe default. `AutoAddPolicy` is for disposable tests and +controlled development only. Accepting unknown host keys in production changes +the trust model and can hide man-in-the-middle attacks. + +## Sensitive Data Boundaries + +Logging sanitizers redact common credentials, key material, and sensitive +fields. Callers should still avoid logging raw secrets, private keys, or full +environment dumps. + +## Security-Sensitive Interfaces + +Maintainer review is required for changes to: + +- host key policies and storage +- authentication +- key exchange and algorithm negotiation +- packet framing, MAC, AEAD, and rekey behavior +- SFTP read/write integrity +- logging sanitizer rules +- release and artifact integrity workflows diff --git a/docs/changelog.md b/docs/changelog.md index 1dda2b5..39337d9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,33 @@ 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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Summary +This maintainer-facing readiness entry documents the repository lifecycle work +that is pending release assignment. It does not represent a published package +version by itself. + +### Added +* Public project policy docs for production usage, compatibility, API stability, release policy, governance, dependency handling, repository settings, CI policy, release operations, release verification, vulnerability response, architecture/security boundaries, logging operations, and public roadmap tracking. +* Architecture Decision Records for release policy, documentation layout, supported platforms, and API stability boundaries. +* Root GitHub entry points for contributing and support so GitHub, Read the Docs, and repository visitors all route to the maintained documentation source. + +### Changed +* Restructured GitHub Actions so PR validation runs through one sequential orchestrator and expensive reusable workflows do not launch independently on PRs or `main` pushes. +* Moved post-merge CodeQL and full security scanning into the sequential release orchestrator for `main` pushes, preserving security coverage while avoiding parallel free-tier runner contention. +* Documented the protected release-version PR flow, artifact integrity expectations, and maintainer release runbook. +* Replaced the oversized `meta/CONTRIBUTING.md` copy with a short pointer to the maintained public contributor guide. +* Rebuilt the Docker integration stack around locally maintained OpenSSH and Dropbear services so real-server workflow tests can run without relying on a removed public Dropbear image. + +### Fixed +* Limited protected release-publish detection to the canonical first commit-message line so `[publish release]` text in a merge body cannot accidentally enter the release-publish path. +* Restricted protected release-publish mode to merged PRs from the generated `release/vX.Y.Z` branch with source PR metadata, so regular PR titles cannot trigger the publish path. +* Prevented Docker SSH integration tests and local benchmarks from sharing host-key files across OpenSSH, Dropbear, or the developer's real home directory. + +### Verification +* Read the Docs continues to build from `.readthedocs.yaml` through `mkdocs.yml`; newly added public docs are explicitly surfaced in the MkDocs navigation rather than being included by the Read the Docs config directly. + ## [0.7.0] - 2026-05-16 ### Added diff --git a/docs/ci-policy.md b/docs/ci-policy.md new file mode 100644 index 0000000..f633a5b --- /dev/null +++ b/docs/ci-policy.md @@ -0,0 +1,68 @@ +# CI and Required Check Policy + +SpindleX uses GitHub-hosted free-tier runners. Workflows should avoid consuming +unnecessary parallel capacity. PR and release validation run through one +orchestrating workflow at a time so runners are consumed sequentially. + +## PR Checks + +The required merge check is `quality-gate`. It is the final status in the PR +orchestrator and is required by the `main-protected-pr-gate` ruleset. + +The PR gate runs in phases: + +1. PR metadata validation. +2. Lint, type check, unit tests, docs build, security-fast, workflow lint, and + script compile checks. +3. Compatibility matrix. +4. Docker OpenSSH/Dropbear integration. +5. Bounded property tests. +6. Aggregate `quality-gate`. + +Compatibility, integration, and property workflows are reusable workflows. They +do not trigger independently on PRs or `main` pushes, which prevents duplicate +runs and skipped release-only jobs from cluttering PR status. + +## Advisory Checks + +Scheduled security, CodeQL, compatibility, integration, property tests, and +benchmarks may be advisory during beta unless they are called by the PR or +release orchestrator. CodeQL and the full security scan are called by the +`main` push release orchestrator so post-merge code is scanned without launching +parallel workflows. + +## Release-Blocking Checks + +Before publishing release artifacts, these must pass: + +- release planning +- compatibility matrix +- Docker OpenSSH/Dropbear integration +- distribution build and `twine check` +- wheel import/version validation +- PyPI install verification +- artifact integrity generation when promoted from advisory to blocking + +## Free-Tier Runner Policy + +Workflows use concurrency groups and avoid overlapping expensive jobs. Release +validation runs jobs one after another: + +1. plan +2. CodeQL +3. full security scan +4. compatibility matrix +5. integration +6. property tests +7. benchmark baseline +8. build and artifact verification +9. publish + +The compatibility matrix itself is also serialized: Ubuntu versions run in one +job, then Windows, then macOS. + +## Promotion Rules + +During beta, new heavy checks can start as manual or scheduled. Before v1 RC, +maintainers decide whether each check is required, advisory, or release-blocking +and update this page plus repository settings when needed. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..96c7a5f --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,69 @@ +# Compatibility + +This page defines the supported and tested environments for SpindleX v1 +planning. + +## Python Versions + +SpindleX supports Python `3.9` through `3.13`. + +This matches package metadata and the Linux compatibility matrix. Python `3.11` +is the primary CI and release tooling version. + +## Operating Systems + +SpindleX is intended to be OS independent at the library layer. + +| Platform | Current validation | +| --- | --- | +| Linux | full unit matrix and Docker-backed SSH/SFTP integration | +| macOS | Python 3.11 unit smoke coverage | +| Windows | Python 3.11 unit smoke coverage | + +OS independent does not mean every operating-system SSH setup is validated. It +means the runtime library avoids platform-specific assumptions where practical. + +## SSH Server Compatibility + +Current integration validation uses Docker-backed OpenSSH and Dropbear services. +Compatibility claims should name the server family and version or image when +recorded. + +Known-compatible reports should include: + +- SpindleX version +- Python version +- operating system +- server family and version +- authentication method +- affected API or CLI +- minimal reproduction or confirming test + +## Unsupported Features and Known Incompatibilities + +Use this format for known incompatibilities: + +```text +Server/version: +Affected SpindleX version: +Feature: +Symptom: +Workaround: +Tracking issue: +Validation needed: +``` + +Current unsupported or limited areas: + +- universal compatibility with legacy or appliance-specific SSH behavior +- production use of unknown-host auto-acceptance policies +- unsupported or disabled legacy algorithms listed in [Algorithms](algorithms.md) + +## Report Handling + +Repeated compatibility reports should become one of: + +- a regression test +- a Docker or canary validation target +- a known incompatibility entry +- an update to the public support boundary diff --git a/docs/dependency-policy.md b/docs/dependency-policy.md new file mode 100644 index 0000000..a884c37 --- /dev/null +++ b/docs/dependency-policy.md @@ -0,0 +1,49 @@ +# Dependency Monitoring and Update Policy + +## Scope + +This policy covers runtime dependencies, development dependencies, documentation +tooling, and GitHub Actions. + +## Runtime Dependencies + +Runtime dependencies receive the highest scrutiny because they ship to users. +Runtime vulnerability fixes should include: + +- advisory and affected version range +- patched version +- release impact +- evidence from unit, integration, and security checks + +High or critical runtime vulnerabilities with a practical exploit path should +block release until fixed or explicitly accepted by a maintainer. + +## Development and Documentation Dependencies + +Development and documentation updates may be grouped when they do not affect +runtime artifacts. They still need CI evidence because docs and release tooling +are part of the public project surface. + +## GitHub Actions + +Actions should remain pinned by SHA in workflows. Dependabot may propose action +updates monthly. Updates require actionlint and the relevant workflow checks. + +## Dependabot Cadence + +Dependabot runs: + +- weekly for Python dependencies +- monthly for GitHub Actions + +Runtime-impacting security updates may be merged outside the normal cadence. + +## Emergency Path + +For high-impact security issues: + +1. Confirm the dependency is reachable in supported usage. +2. Patch or constrain the dependency. +3. Run security, unit, integration, and release dry-run checks. +4. Publish a patch release if the vulnerable version is public. +5. Update the advisory or vulnerability report. diff --git a/docs/docs-layout.md b/docs/docs-layout.md new file mode 100644 index 0000000..e3b4b9d --- /dev/null +++ b/docs/docs-layout.md @@ -0,0 +1,40 @@ +# Documentation Ownership + +This page defines where public, GitHub-facing, and internal project material +lives. + +## Canonical Public Documentation + +`docs/` is the canonical public documentation source. Pages under `docs/` are +published by MkDocs and Read the Docs, and should be the destination for long +form user, contributor, compatibility, security, release, and maintainer +guidance. + +When content belongs in public docs, update `docs/` first and link to that page +from other entry points. + +## GitHub Entry Points + +Root files exist because GitHub and packaging tools recognize them: + +- `README.md` introduces the project and links to maintained docs. +- `SECURITY.md` provides the private vulnerability reporting path. +- `CONTRIBUTING.md` points contributors to the maintained guide. +- `CODE_OF_CONDUCT.md` defines community conduct expectations. +- `SUPPORT.md` points users to support, compatibility, and security guidance. + +Root files should stay short unless GitHub needs the content directly. + +## Internal Planning + +`meta/internal/` contains planning material, lifecycle notes, and internal +roadmaps. It is useful maintainer context, but it is not the public roadmap or a +user-facing compatibility promise. + +`meta/` files outside `meta/internal/` are compatibility pointers for old links. +They should not duplicate maintained public guidance. + +## Change Rule + +If two pages appear to conflict, the public page under `docs/` wins unless the +root file is a GitHub-specific policy entry point such as `SECURITY.md`. diff --git a/docs/governance.md b/docs/governance.md new file mode 100644 index 0000000..b762674 --- /dev/null +++ b/docs/governance.md @@ -0,0 +1,43 @@ +# Governance and Maintainer Model + +SpindleX is a small production-facing open-source project. Governance should be +clear without creating support commitments that exceed maintainer capacity. + +## Maintainers + +Repository maintainers are listed in `pyproject.toml` and enforced for reviews +through `.github/CODEOWNERS`. + +Maintainers are responsible for: + +- reviewing security-sensitive changes +- maintaining release and compatibility policy +- triaging issues and pull requests +- deciding when ADRs are needed +- handling private vulnerability reports + +## Decision Rules + +Most changes can be decided in PR review. Use an ADR when a decision affects: + +- release policy +- public API stability +- supported platforms +- security posture +- documentation ownership +- long-term maintainer workflow + +## Review Expectations + +- Runtime changes require tests or a documented reason tests are not practical. +- Security-sensitive changes require maintainer review. +- Release automation and workflow changes require evidence from script tests, + actionlint, or dry-run behavior where practical. +- Public docs should link related policy pages instead of duplicating them. + +## Escalation + +- Security vulnerabilities use GitHub Security Advisories. +- Release failures use release-blocked issue templates and the release runbook. +- Repeated CI failures become tracked issues. +- Community moderation follows `CODE_OF_CONDUCT.md`. diff --git a/docs/index.md b/docs/index.md index cfc8d85..f23ac61 100644 --- a/docs/index.md +++ b/docs/index.md @@ -68,3 +68,5 @@ pip install spindlex * [**API Reference**](api_reference/index.md) - Detailed technical documentation. * [**Performance**](performance.md) - Benchmarks and optimization tips. * [**Security**](security.md) - Our security model and best practices. +* [**Production Usage**](production-usage.md) - Supported production-facing usage expectations. +* [**Compatibility**](compatibility.md) - Supported Python, OS, and SSH server compatibility boundaries. diff --git a/docs/logging-operations.md b/docs/logging-operations.md new file mode 100644 index 0000000..c15cfe9 --- /dev/null +++ b/docs/logging-operations.md @@ -0,0 +1,68 @@ +# Logging and Debug Observability + +This page defines production-safe logging expectations for users and +maintainers. + +## Logger Names + +SpindleX loggers use the `spindlex` namespace. Module loggers should remain +under that namespace so sanitizing filters and application log configuration can +target the whole library. + +Important categories: + +- `spindlex.*` for runtime library events +- `spindlex.security` for security-relevant events +- `spindlex.performance` for timing and throughput metrics + +## Levels + +- `ERROR`: operation failed and the caller likely needs to handle it. +- `WARNING`: degraded behavior, unsafe user configuration, retryable issue, or + compatibility warning. +- `INFO`: lifecycle events useful during normal operations. +- `DEBUG`: protocol diagnostics and detailed troubleshooting data. + +## Redaction Guarantees + +Sanitizers are expected to redact common passwords, tokens, private-key blocks, +and sensitive key/value fields. They are defense in depth, not permission to log +raw secrets. + +Maintainers adding logs must avoid including: + +- passwords +- private keys +- raw authorization tokens +- full known-host files +- unredacted environment dumps + +## Safe Debug Fields + +Safe diagnostic fields include: + +- algorithm names +- packet/message type names +- byte counts +- channel identifiers +- elapsed time +- server version family when already visible on the wire + +Avoid logging full payloads, command arguments that may contain secrets, or +private filesystem paths unless the caller explicitly controls the log. + +## Incident Debugging + +For production incidents: + +1. Enable `DEBUG` only for the narrow process or logger needed. +2. Reproduce with sanitized logs. +3. Capture Python, SpindleX, OS, and SSH server versions. +4. Include correlation context from the application when available. +5. Remove logs after triage according to local retention policy. + +## Metrics + +Useful operational metrics include connect time, authentication time, command +latency, SFTP throughput, retry counts, error counts, and packet-level profiler +summaries when `SPINDLEX_PROFILE=1` is enabled. diff --git a/docs/performance.md b/docs/performance.md index ae9a6a8..ec56f66 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -16,16 +16,21 @@ SpindleX is engineered for speed, security, and protocol efficiency. Key perform ### Benchmark Scripts -SpindleX ships two benchmark scripts in `scripts/`: +SpindleX ships repeatable benchmark scripts in `scripts/`: - **`benchmark_ciphers.py`** - compares upload throughput across all supported ciphers (ChaCha20-Poly1305, AES-256/192/128-CTR) against other SSH libraries on a live server configured in `.env`. - **`benchmark_production.py`** - full protocol correctness and performance sweep: verifies every cipher, MAC, KEX, and host-key algorithm negotiates and transfers data correctly, then reports timing. +- **`benchmark_local_baseline.py`** - zero-cost Docker-backed baseline against local OpenSSH and Dropbear services. It writes JSON artifacts for handshake, command execution, SFTP upload/download, and sync/async paths. ```bash python scripts/benchmark_ciphers.py python scripts/benchmark_production.py +python scripts/benchmark_local_baseline.py --output benchmark-results/local.json ``` +The local baseline records environment metadata and should be used for comparing +project changes over time, not for universal performance claims. + ### Packet-Level Profiler Set `SPINDLEX_PROFILE=1` to enable the built-in `PacketProfiler`, which records per-packet build / encrypt / socket-write latencies for every packet sent through the transport: diff --git a/docs/production-usage.md b/docs/production-usage.md new file mode 100644 index 0000000..0ba603f --- /dev/null +++ b/docs/production-usage.md @@ -0,0 +1,50 @@ +# Production Usage Expectations + +SpindleX v1 is intended for production-facing SSH and SFTP automation when the +application owner can validate server compatibility, manage credentials safely, +and keep host key verification enabled. + +## Supported Usage Patterns + +- SSH command execution against known servers. +- SFTP upload, download, and directory automation against tested servers. +- Synchronous and asynchronous client workflows. +- Server-side use for controlled environments where the application owner can + review the current server feature set. + +## User Responsibilities + +- Pin exact SpindleX versions in production deployments. +- Keep host key verification enabled and manage trusted host keys explicitly. +- Store credentials and private keys outside source control. +- Run integration tests against the actual SSH servers used in production. +- Review release notes before upgrading. +- Monitor compatibility notes for OpenSSH, Dropbear, and unsupported features. + +## Unsupported or Out of Scope + +- Treating `AutoAddPolicy` as a safe production host-key policy. +- Relying on undocumented internal modules as stable APIs. +- Using SpindleX as a complete replacement for deployment-specific security + review. +- Expecting universal compatibility with every SSH server, appliance, or legacy + algorithm. +- Relying on benchmark numbers without validating local network and server + conditions. + +## Production-Impacting Bugs + +Maintainers treat the following as production-impacting when reproducible: + +- Host key verification bypasses or unsafe defaults. +- Authentication regressions for supported key/password flows. +- SFTP data corruption, truncation, or unreported write failure. +- Protocol deadlocks, resource leaks, or unbounded malformed-input handling. +- Compatibility regressions against documented tested environments. + +## Related + +- [Security Guide](security.md) +- [Compatibility](compatibility.md) +- [API stability](api-stability.md) +- [Release policy](release-policy.md) diff --git a/docs/release-policy.md b/docs/release-policy.md new file mode 100644 index 0000000..601c195 --- /dev/null +++ b/docs/release-policy.md @@ -0,0 +1,54 @@ +# Versioning, Deprecation, and Support Policy + +## Before 1.0.0 + +SpindleX is in beta. The `0.x` line may still make compatibility-affecting +changes when needed for protocol correctness, security, or v1 readiness. + +PR type metadata controls beta release planning: + +| PR type | Beta release behavior | +| --- | --- | +| `bug` | patch release | +| `feature` | patch release | +| `feature-minor` | minor release | +| `breaking` | minor release | +| `docs` | no release | +| `refactor` | no release | +| `test` | no release | + +## After 1.0.0 + +SpindleX follows SemVer: + +- Patch releases fix bugs, security issues, documentation drift, and compatible + behavior defects. +- Minor releases add compatible functionality and may introduce deprecations. +- Major releases are required for breaking changes to the stable public API. + +## Support Window + +The default support policy is latest stable minor only. Security fixes target +the latest supported release unless maintainers explicitly announce an extended +support window. + +## Deprecation Expectations + +Deprecations should include: + +- the affected API or behavior +- the replacement path +- release-note coverage +- migration guidance when the change is user visible + +## Release Notes + +Release notes should call out compatibility, security, and performance impact +when relevant. Material `0.x -> 1.0` changes require migration notes before the +stable release. + +## Related + +- [Release runbook](release-runbook.md) +- [API stability](api-stability.md) +- [ADR 0001](adr/0001-release-policy.md) diff --git a/docs/release-runbook.md b/docs/release-runbook.md new file mode 100644 index 0000000..1c40b58 --- /dev/null +++ b/docs/release-runbook.md @@ -0,0 +1,57 @@ +# Maintainer Release Runbook + +This runbook explains the beta release flow and the target v1 activation path. + +## Current Beta Flow + +1. A PR targets `main` and selects exactly one change type in the template. +2. The PR gate validates metadata, code, docs, security-fast, workflows, and + scripts. +3. After merge, release planning reads the merged PR metadata. +4. If a release is needed, compatibility, integration, property, and benchmark + gates run sequentially to avoid unnecessary free-tier runner contention. +5. The workflow opens a protected release-version PR instead of pushing a + version bump directly to `main`. +6. After a maintainer merges the release PR, publishing builds wheel and sdist + artifacts, validates them, creates integrity artifacts, creates a tag and + GitHub Release, publishes to PyPI through trusted publishing, and verifies + install from PyPI. + +## Protected Release-Version PR Flow + +The release-version PR flow avoids direct version-bump commits to protected +`main`: + +1. A feature/fix PR merges to `main`. +2. Release planning computes the next version. +3. A workflow opens `release/vX.Y.Z`. +4. The release PR updates `pyproject.toml` and `spindlex/_version.py`. +5. Normal PR gates run. +6. A maintainer reviews and merges the release PR. +7. Publishing runs from the protected `main` release commit. + +## Dry Runs + +Use workflow dispatch with `dry_run=true` to validate release planning, build, +and artifact checks without publishing. + +## Fix Forward + +If PyPI upload succeeds but later GitHub release steps fail, do not delete public +artifacts automatically. Verify artifact state and fix forward with a patch +release if the public package is broken. + +## Failure Issues + +Release failures should use the release-blocked template and include workflow +run URL, planned version, failed stage, and artifact state. + +## v1 Activation Checklist + +- Public API stability reviewed. +- Compatibility docs match CI and integration evidence. +- Artifact verification strategy documented and implemented at least + non-blocking. +- Protected release-version PR flow active. +- Required checks match repository settings. +- Security and vulnerability response docs linked from README/docs. diff --git a/docs/release-verification.md b/docs/release-verification.md new file mode 100644 index 0000000..d56930d --- /dev/null +++ b/docs/release-verification.md @@ -0,0 +1,35 @@ +# Artifact Integrity and Release Verification + +SpindleX releases should provide enough evidence for maintainers and users to +verify what was published. + +## Integrity Signals + +The release process should provide: + +- wheel and sdist built from the release commit +- `twine check` output +- wheel import/version validation +- PyPI install verification +- annotated Git tag +- release notes linked to changelog +- SHA-256 hashes for release artifacts +- SBOM artifact where tooling is practical +- GitHub artifact attestations when available + +## User Verification + +Users can verify a release by: + +1. Installing an exact version, for example `spindlex==0.7.0`. +2. Checking `python -c "import spindlex; print(spindlex.__version__)"`. +3. Comparing wheel or sdist hashes from the GitHub Release when published. +4. Reviewing the tag and release notes. + +## Maintainer Policy + +During beta, SBOM, hash, and attestation generation may start as non-blocking. +Before v1 RC, maintainers decide which integrity failures block release. + +PyPI trusted publishing should remain the preferred publishing path. Long-lived +PyPI tokens should not be required for the normal release process. diff --git a/docs/repository-settings.md b/docs/repository-settings.md new file mode 100644 index 0000000..c7c4f50 --- /dev/null +++ b/docs/repository-settings.md @@ -0,0 +1,46 @@ +# Repository Settings Checklist + +Some GitHub settings cannot be fully represented in source control. Maintainers +should keep this checklist aligned with repository reality before v1. + +## Current Repository State + +Last audited for the v1 readiness work: + +- default branch: `main` +- issues: enabled +- discussions: enabled +- wiki: disabled +- security policy: enabled +- homepage URL: `https://spindlex.readthedocs.io/` +- branch protection API: no classic branch protection +- ruleset: active `main-protected-pr-gate` +- required check: `quality-gate` +- CODEOWNERS review: required +- resolved review threads: required +- linear history: required +- merge methods: squash and rebase +- GitHub Actions: enabled, allowed actions set to all +- SHA pinning enforcement: disabled +- Dependabot alerts: enabled +- secret scanning: enabled +- environments: none + +## Expected v1 Settings + +- Keep `main-protected-pr-gate` active. +- Keep `quality-gate` required for merge. +- Keep CODEOWNERS review required. +- Keep wiki disabled so public docs live in MkDocs. +- Keep repository homepage set to the Read the Docs URL. +- Keep secret scanning enabled. +- Keep private vulnerability reporting enabled. +- Define release environments if PyPI trusted publishing requires environment + protection. +- Keep Actions pinned by SHA in source even if repository-level enforcement is + unavailable. + +## Manual Follow-Up + +When a setting cannot be changed in a PR, record the expected value here and +confirm it in the relevant maintainer checklist before closing the work. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..a24f2ac --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,56 @@ +# Public Roadmap + +This roadmap summarizes public lifecycle expectations without exposing internal +planning noise or committing to exact timelines. + +## Beta Stabilization + +Current beta work focuses on: + +- protocol correctness and malformed-input handling +- SFTP reliability and performance +- security documentation and release trust +- CI reliability on free GitHub-hosted runners +- conservative compatibility claims + +## v1.0 Release Candidate + +The `1.0.0` release candidate starts only after the readiness checklist is +complete or explicitly accepted by maintainers: + +- public production usage, compatibility, and API stability docs +- release, artifact verification, and support policy docs +- CI/release checks that match the documented policy +- property tests and repeatable local benchmark baseline +- security and vulnerability response documentation + +## After v1 + +After stable release, work shifts toward: + +- SemVer discipline +- compatibility matrix maintenance +- repeated user reports becoming docs, tests, or compatibility entries +- benchmark history and release verification evidence +- focused protocol and SFTP improvements + +## Future Ideas + +The following are non-committed ideas: + +- broader canary environments +- additional package distribution channels +- long-running fuzzing outside the normal PR gate +- expanded server-side API stability +- additional SSH server family compatibility targets + +## How Feedback Becomes Work + +User feedback is triaged into the smallest durable artifact: + +- documentation update +- regression test +- compatibility entry +- benchmark scenario +- roadmap issue +- security advisory path when private handling is required diff --git a/docs/security.md b/docs/security.md index 7b88391..2521bb7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -100,7 +100,10 @@ For a full reference of all supported KEX, host key, cipher, and MAC algorithms ## Security Policy -For information on how to report vulnerabilities or our disclosure policy, please see our [Responsible Disclosure Policy](https://github.com/stratza/spindlex/blob/main/meta/SECURITY.md). +For information on how to report vulnerabilities or our disclosure policy, see +the repository [Security Policy](https://github.com/stratza/spindlex/blob/main/SECURITY.md). +Maintainer response workflow details are in +[Vulnerability Response](vulnerability-response.md). ## Security Scanning and Blocker Policy @@ -116,8 +119,10 @@ The repository uses layered automated checks: PR gates block high-confidence fast findings such as Bandit failures, Semgrep `ERROR` findings, vulnerable runtime dependencies, and committed secrets. The -full security workflow runs on `main`, on schedule, and on demand. Supported -tools upload SARIF to GitHub code scanning. +full CodeQL and security workflows run on schedule, on demand, and through the +sequential `main` push release orchestrator so post-merge code is scanned +without competing with PR or release runner capacity. Supported tools upload +SARIF to GitHub code scanning. ### Release-Blocking Findings diff --git a/docs/vulnerability-response.md b/docs/vulnerability-response.md new file mode 100644 index 0000000..2ef61bb --- /dev/null +++ b/docs/vulnerability-response.md @@ -0,0 +1,46 @@ +# Vulnerability Response Runbook + +Public reporting guidance lives in `SECURITY.md`. This page documents the +maintainer workflow after a private report arrives. + +## Triage + +1. Acknowledge receipt within 48 hours where practical. +2. Reproduce or bound the report. +3. Identify affected versions, supported versions, and attacker capabilities. +4. Assign severity. +5. Decide whether an embargo is needed. + +## Severity Rubric + +| Severity | Examples | +| --- | --- | +| Critical | credential disclosure, host key verification bypass, remote code execution in supported usage | +| High | SFTP data corruption with security impact, exploitable dependency in runtime path | +| Medium | denial of service, malformed input crash, unsafe diagnostic leakage | +| Low | hardening issue, incomplete warning, low-impact dependency advisory | + +## GHSA and CVE Handling + +Use GitHub Security Advisories for private coordination. Request a CVE when the +issue affects released versions and has meaningful downstream security impact. + +## Patch Release Flow + +1. Prepare a private or minimal public fix branch. +2. Add regression coverage when safe. +3. Run security, unit, integration, and release dry-run checks. +4. Publish a patch release through the documented release process. +5. Publish advisory details after fixed artifacts are available. + +## Reporter Communication + +Keep communication concise: + +- acknowledgement +- affected scope +- expected fix path +- disclosure timing +- credit preference when disclosure happens + +Do not request exploit details in public issues. diff --git a/meta/CONTRIBUTING.md b/meta/CONTRIBUTING.md index 5c66786..78fbf3f 100644 --- a/meta/CONTRIBUTING.md +++ b/meta/CONTRIBUTING.md @@ -1,459 +1,7 @@ -# Contributing to SpindleX +# Contributing -Thank you for your interest in contributing to SpindleX! This document provides guidelines and information for contributors. +The maintained contributor guide lives in public documentation: -## Code of Conduct +[docs/contributing.md](../docs/contributing.md) -By participating in this project, you agree to abide by the project's Code of Conduct. Please be respectful and constructive in all interactions. - -## Getting Started - -### Development Environment Setup - -1. **Fork and Clone** - ```bash - git clone https://github.com/stratza/spindlex.git - cd spindlex - ``` - -2. **Create Virtual Environment** - ```bash - python -m venv venv - source venv/bin/activate # On Windows: venv\Scripts\activate - ``` - -3. **Install Development Dependencies** - ```bash - pip install -e ".[dev,docs,test]" - ``` - -4. **Install Pre-commit Hooks** - ```bash - pre-commit install - ``` - -### Running Tests - -```bash -# Run the fast local test suite -python -m pytest tests -m "not integration and not real_server and not slow and not performance" - -# Run with coverage -python -m pytest --cov=spindlex --cov-report=html - -# Run specific test categories -python -m pytest -m unit -python -m pytest -m integration -python -m pytest -m performance - -# Run tests for specific modules -python -m pytest tests/protocol/test_protocol_utils.py -``` - -### Code Quality - -I maintain high code quality standards: - -```bash -# Lint code -ruff check spindlex tests - -# Check formatting -ruff format --check spindlex tests - -# Format code -ruff check --fix spindlex tests -ruff format spindlex tests - -# Type checking -mypy spindlex - -# Security scanning -bandit -r spindlex -c pyproject.toml - -# Build docs -mkdocs build --strict -``` - -## Contributing Guidelines - -### Reporting Issues - -When reporting issues, please include: - -- **Clear Description**: What you expected vs. what happened -- **Reproduction Steps**: Minimal code to reproduce the issue -- **Environment**: Python version, OS, library version -- **Error Messages**: Full stack traces when applicable - -Use the project's issue templates: -- Bug Report -- Feature Request -- Security Issue (use GitHub Security Advisory) - -### Submitting Changes - -The default development flow is: - -1. Create a short-lived branch from an up-to-date `main`. -2. Open a pull request back to `main`. -3. Fill the PR template completely. -4. Select exactly one `Type of Change` token. -5. Wait for the required `quality-gate` check to pass. -6. Resolve review conversations before merge. - -`main` is the protected integration branch. Maintainers should configure branch -protection to require pull requests, conversation resolution, and the -`quality-gate` status check before merge. Direct pushes to `main` should be -reserved for emergency recovery by repository administrators. - -1. **Create Feature Branch** - ```bash - git switch main - git pull --ff-only - git switch -c feature/your-feature-name - ``` - -2. **Make Changes** - - Follow coding standards (see below) - - Add tests for new functionality - - Update documentation as needed - -3. **Test Changes** - ```bash - ruff check spindlex tests - ruff format --check spindlex tests - mypy spindlex - python -m pytest tests -m "not integration and not real_server and not slow and not performance" - mkdocs build --strict - ``` - -4. **Commit Changes** - ```bash - git add . - git commit -m "feat: add new feature description" - ``` - -5. **Push and Create PR** - ```bash - git push origin feature/your-feature-name - ``` - - In the PR body, select one `Type of Change`: - - - `bug`: patch release after merge - - `feature`: feature or stabilization work for the current beta minor line; patch release before `1.0.0` - - `feature-minor`: intentional beta minor-line feature; minor release before `1.0.0` - - `breaking`: breaking beta change; minor release before `1.0.0` - - `docs`: no release - - `refactor`: no release - - `test`: no release - - Release-impact types (`bug`, `feature`, `breaking`) must include test - evidence in the PR body. - -### Commit Message Format - -I use conventional commits: - -``` -(): - -[optional body] - -[optional footer] -``` - -Types: -- `feat`: New feature -- `fix`: Bug fix -- `docs`: Documentation changes -- `style`: Code style changes (formatting, etc.) -- `refactor`: Code refactoring -- `test`: Adding or updating tests -- `chore`: Maintenance tasks - -Examples: -``` -feat(client): add support for Ed25519 keys -fix(transport): handle connection timeout properly -docs(readme): update installation instructions -test(crypto): add tests for key generation -``` - -## Coding Standards - -### Python Style - -I follow PEP 8 with some modifications: - -- **Line Length**: 88 characters -- **Imports**: Use Ruff import sorting -- **Type Hints**: Required for all public APIs -- **Docstrings**: Google style docstrings - -### Code Structure - -```python -"""Module docstring describing the module's purpose.""" - -import standard_library -import third_party_library - -from spindlex import local_imports - - -class ExampleClass: - """Class docstring. - - Args: - param1: Description of parameter. - param2: Description of parameter. - - Attributes: - attr1: Description of attribute. - """ - - def __init__(self, param1: str, param2: int) -> None: - """Initialize the class. - - Args: - param1: Description. - param2: Description. - """ - self.attr1 = param1 - self._private_attr = param2 - - def public_method(self, arg: str) -> bool: - """Public method with proper docstring. - - Args: - arg: Description of argument. - - Returns: - Description of return value. - - Raises: - ValueError: When arg is invalid. - """ - if not arg: - raise ValueError("arg cannot be empty") - return True - - def _private_method(self) -> None: - """Private method (single underscore).""" - pass -``` - -### Testing Standards - -- **Test Coverage**: Aim for >90% coverage -- **Test Types**: Unit, integration, and performance tests -- **Test Structure**: Use pytest fixtures and parametrization -- **Mocking**: Use unittest.mock for external dependencies - -```python -import pytest -from unittest.mock import Mock, patch - -from spindlex.client.ssh_client import SSHClient - - -class TestSSHClient: - """Test cases for SSHClient.""" - - @pytest.fixture - def client(self): - """Provide a test client instance.""" - return SSHClient() - - def test_connect_success(self, client): - """Test successful connection.""" - # Test implementation - pass - - @pytest.mark.parametrize("username,password,expected", [ - ("user1", "pass1", True), - ("user2", "pass2", False), - ]) - def test_authentication(self, client, username, password, expected): - """Test authentication with various credentials.""" - # Test implementation - pass - - @patch('spindlex.transport.transport.socket') - def test_connection_failure(self, mock_socket, client): - """Test connection failure handling.""" - mock_socket.side_effect = ConnectionError("Connection failed") - # Test implementation - pass -``` - -### Documentation Standards - -- **API Documentation**: All public APIs must have docstrings -- **Type Hints**: Required for all function signatures -- **Examples**: Include usage examples in docstrings -- **MkDocs / mkdocstrings**: Public APIs should have docstrings that render clearly in the generated documentation - -```python -def connect( - self, - hostname: str, - port: int = 22, - username: Optional[str] = None, - password: Optional[str] = None, - pkey: Optional[PKey] = None, - timeout: Optional[float] = None -) -> None: - """Connect to SSH server. - - Establishes an SSH connection to the specified server with the given - authentication credentials. - - Args: - hostname: Server hostname or IP address. - port: SSH port number (default: 22). - username: Username for authentication. - password: Password for authentication (if using password auth). - pkey: Private key for authentication (if using key auth). - timeout: Connection timeout in seconds. - - Raises: - AuthenticationException: If authentication fails. - TransportException: If connection cannot be established. - - Example: - >>> client = SSHClient() - >>> client.connect('example.com', username='user', password='pass') - >>> # Use the connection - >>> client.close() - """ -``` - -## Security Guidelines - -### Security-First Development - -- **Input Validation**: Validate all inputs -- **Constant-Time Operations**: Use constant-time comparisons for secrets -- **Memory Safety**: Clear sensitive data from memory -- **Logging**: Never log sensitive information - -### Cryptographic Standards - -- **Modern Algorithms**: Use only modern, secure algorithms -- **Key Sizes**: Enforce minimum key sizes -- **Random Generation**: Use cryptographically secure random generators -- **Timing Attacks**: Protect against timing-based attacks - -### Security Review Process - -1. **Self Review**: Check your code for security issues -2. **Automated Scanning**: Run bandit and other security tools -3. **Peer Review**: Have security-conscious developers review -4. **Security Team Review**: For cryptographic or security-critical changes - -## Performance Guidelines - -### Performance Considerations - -- **Efficiency**: Optimize hot paths and frequently called functions -- **Memory Usage**: Minimize memory allocations and leaks -- **Async Support**: Consider async alternatives for I/O operations -- **Benchmarking**: Add benchmarks for performance-critical code - -### Benchmarking - -```python -import time -from spindlex.crypto.pkey import Ed25519Key - -def benchmark_key_generation(): - """Benchmark key generation performance.""" - iterations = 100 - start_time = time.perf_counter() - - for _ in range(iterations): - Ed25519Key.generate() - - end_time = time.perf_counter() - avg_time = (end_time - start_time) / iterations - - print(f"Average key generation time: {avg_time:.4f}s") - assert avg_time < 0.1 # Should be fast -``` - -## Documentation - -### Types of Documentation - -1. **API Documentation**: Auto-generated from docstrings -2. **User Guide**: How-to guides and tutorials -3. **Examples**: Practical code examples -4. **Security Guide**: Security best practices - -### Building Documentation - -```bash -# Install documentation dependencies -pip install -e .[docs] - -# Build documentation -mkdocs build --strict - -# View documentation -open site/index.html -``` - -### Writing Documentation - -- **Clear Language**: Use simple, clear language -- **Code Examples**: Include working code examples -- **Cross-References**: Link to related documentation -- **Updates**: Keep documentation in sync with code changes - -## Community - -### Communication Channels - -- **GitHub Issues**: Bug reports and feature requests -- **GitHub Discussions**: General questions and discussions -- **Security Issue**: Use the GitHub Security Advisory system for security-related concerns. - -### Getting Help - -- **Documentation**: Check the documentation first -- **Search Issues**: Look for existing issues -- **Ask Questions**: Use GitHub Discussions for questions -- **Stack Overflow**: Tag questions with `spindlex` - -## Recognition - -Contributors are recognized in: -- **CONTRIBUTORS.md**: List of all contributors -- **Release Notes**: Major contributions mentioned -- **Documentation**: Author attribution where appropriate - -## Legal - -### Contributor License Agreement - -By contributing to SpindleX, you agree that: - -1. Your contributions are your original work -2. You have the right to submit the contributions -3. Your contributions are licensed under the MIT license -4. You grant the project creator the right to use your contributions - -### Copyright - -- **New Files**: Include MIT license header -- **Existing Files**: Maintain existing copyright notices -- **Third-Party Code**: Clearly mark and attribute third-party code - -## Thank You - -Thank you for contributing to SpindleX! Your contributions help make secure SSH communication accessible to Python developers worldwide. - -For questions about contributing, please open a GitHub Discussion or contact me. +This `meta/` entry point is retained only for older links. diff --git a/mkdocs.yml b/mkdocs.yml index f225fda..0c32cf3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,6 +94,28 @@ nav: - SFTP Recipes: cookbook/sftp_recipes.md - Automation: cookbook/automation.md - Deployment: deployment.md + - Project: + - Documentation Ownership: docs-layout.md + - Production Usage: production-usage.md + - Compatibility: compatibility.md + - API Stability: api-stability.md + - Release Policy: release-policy.md + - Public Roadmap: roadmap.md + - Governance: governance.md + - Dependency Policy: dependency-policy.md + - Repository Settings: repository-settings.md + - CI Policy: ci-policy.md + - Release Runbook: release-runbook.md + - Release Verification: release-verification.md + - Vulnerability Response: vulnerability-response.md + - Architecture and Security: architecture-security.md + - Logging Operations: logging-operations.md + - ADRs: + - Overview: adr/index.md + - Release Policy: adr/0001-release-policy.md + - Documentation Layout: adr/0002-docs-layout.md + - Supported Platforms: adr/0003-supported-platforms.md + - API Stability Boundaries: adr/0004-api-stability-boundaries.md - API Reference: - Overview: api_reference/index.md - Client: api_reference/client.md diff --git a/pyproject.toml b/pyproject.toml index 840ab58..82676f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dev = [ "mypy>=1.0.0", "pre-commit>=3.0.0", "pytest>=7.0.0", + "hypothesis>=6.100.0", "pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0", "pytest-xdist>=2.0", @@ -77,6 +78,7 @@ docs = [ ] test = [ "pytest>=7.0.0", + "hypothesis>=6.100.0", "pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0", "pytest-xdist>=2.0", diff --git a/scripts/benchmark_local_baseline.py b/scripts/benchmark_local_baseline.py new file mode 100644 index 0000000..fb7e6cb --- /dev/null +++ b/scripts/benchmark_local_baseline.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Run a repeatable local SSH/SFTP benchmark baseline and write JSON results.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import platform +import socket +import statistics +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +import spindlex # noqa: E402 +from spindlex import AsyncSSHClient, SSHClient # noqa: E402 +from spindlex.hostkeys.policy import AutoAddPolicy # noqa: E402 +from spindlex.hostkeys.storage import HostKeyStorage # noqa: E402 + + +@dataclass(frozen=True) +class Target: + name: str + host: str + port: int + username: str = "testuser" + password: str = "password123" # noqa: S105 - Docker test fixture password. + + +def run(command: list[str], *, cwd: Path = REPO_ROOT) -> str: + completed = subprocess.run( # noqa: S603 - command list is fixed by callers. + command, + cwd=cwd, + text=True, + capture_output=True, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise RuntimeError(f"Command failed ({' '.join(command)}): {detail}") + return completed.stdout.strip() + + +def docker_compose_command() -> list[str]: + try: + run(["docker", "compose", "version"]) + return ["docker", "compose"] + except Exception: + run(["docker-compose", "--version"]) + return ["docker-compose"] + + +def wait_for_port(host: str, port: int, timeout: float = 120.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=2.0): + return + except OSError: + time.sleep(2.0) + raise TimeoutError(f"Timed out waiting for {host}:{port}") + + +def compose_port(compose: list[str], service: str, internal_port: int) -> int: + output = run( + compose + + [ + "-f", + "tests/integration/docker-compose.yml", + "port", + service, + str(internal_port), + ] + ) + return int(output.rsplit(":", 1)[1]) + + +def start_targets(skip_docker: bool) -> list[Target]: + if skip_docker: + return [ + Target( + name="external", + host=os.environ.get("SSH_HOST", "127.0.0.1"), + port=int(os.environ.get("SSH_PORT", "22")), + username=os.environ.get("SSH_USER", "testuser"), + password=os.environ.get("SSH_PASSWORD", "password123"), + ) + ] + + compose = docker_compose_command() + run(compose + ["-f", "tests/integration/docker-compose.yml", "up", "-d", "--build"]) + openssh_port = compose_port(compose, "openssh-server", 22) + dropbear_port = compose_port(compose, "dropbear-server", 22) + targets = [ + Target("openssh", "127.0.0.1", openssh_port), + Target("dropbear", "127.0.0.1", dropbear_port), + ] + for target in targets: + wait_for_port(target.host, target.port) + time.sleep(10) + return targets + + +def timed(label: str, iterations: int, operation: Callable[[], Any]) -> dict[str, Any]: + times: list[float] = [] + for _ in range(iterations): + start = time.perf_counter() + operation() + times.append(time.perf_counter() - start) + return { + "operation": label, + "iterations": iterations, + "times_seconds": times, + "median_seconds": statistics.median(times), + "mean_seconds": statistics.mean(times), + "min_seconds": min(times), + "max_seconds": max(times), + "stdev_seconds": statistics.stdev(times) if len(times) > 1 else 0.0, + } + + +def host_key_storage(target: Target) -> HostKeyStorage: + path = Path(tempfile.gettempdir()) / ( + f"spindlex-benchmark-known-hosts-{target.name}-{os.getpid()}" + ) + return HostKeyStorage(str(path)) + + +def connect(target: Target) -> SSHClient: + client = SSHClient() + client.set_host_key_storage(host_key_storage(target)) + client.set_missing_host_key_policy(AutoAddPolicy(accept_risk=True)) + client.connect( + target.host, + port=target.port, + username=target.username, + password=target.password, + timeout=30, + ) + return client + + +async def async_connect_exec(target: Target) -> None: + client = AsyncSSHClient() + client.set_host_key_storage(host_key_storage(target)) + client.set_missing_host_key_policy(AutoAddPolicy(accept_risk=True)) + try: + await client.connect( + target.host, + port=target.port, + username=target.username, + password=target.password, + timeout=30, + ) + _stdin, stdout, stderr = await client.exec_command("printf async-ok") + await stdout.read() + await stderr.read() + finally: + await client.close() + + +def benchmark_target(target: Target, iterations: int, file_size: int) -> dict[str, Any]: + payload = b"x" * file_size + results: list[dict[str, Any]] = [] + + def handshake() -> None: + client = connect(target) + client.close() + + results.append(timed("sync_handshake", iterations, handshake)) + + client = connect(target) + try: + results.append( + timed( + "sync_exec", + iterations, + lambda: client.exec_command("printf spindlex-baseline")[1].read(), + ) + ) + + with tempfile.TemporaryDirectory() as tmp: + local_upload = Path(tmp) / "upload.bin" + local_download = Path(tmp) / "download.bin" + local_upload.write_bytes(payload) + remote_path = f"/tmp/spindlex-baseline-{int(time.time())}.bin" + sftp = client.open_sftp() + try: + results.append( + timed( + "sync_sftp_upload", + iterations, + lambda: sftp.put(str(local_upload), remote_path), + ) + ) + results.append( + timed( + "sync_sftp_download", + iterations, + lambda: sftp.get(remote_path, str(local_download)), + ) + ) + finally: + try: + sftp.remove(remote_path) + except Exception: + pass + sftp.close() + finally: + client.close() + + results.append( + timed( + "async_connect_exec", + iterations, + lambda: asyncio.run(async_connect_exec(target)), + ) + ) + + return { + "target": target.name, + "host": target.host, + "port": target.port, + "file_size_bytes": file_size, + "results": results, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--iterations", type=int, default=3) + parser.add_argument("--file-size", type=int, default=1024 * 1024) + parser.add_argument("--skip-docker", action="store_true") + args = parser.parse_args(argv) + + targets = start_targets(args.skip_docker) + report = { + "schema_version": 1, + "generated_at_unix": int(time.time()), + "environment": { + "python": platform.python_version(), + "platform": platform.platform(), + "spindlex": spindlex.__version__, + }, + "iterations": args.iterations, + "targets": [ + benchmark_target(target, args.iterations, args.file_size) + for target in targets + ], + } + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(report, indent=2, sort_keys=True), encoding="utf-8" + ) + print(f"Wrote benchmark baseline to {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_release_integrity.py b/scripts/generate_release_integrity.py new file mode 100644 index 0000000..e5a1188 --- /dev/null +++ b/scripts/generate_release_integrity.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Generate release artifact hashes and a minimal SBOM artifact.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +from pathlib import Path + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def generate(dist: Path, output_dir: Path) -> list[dict[str, str]]: + artifacts = sorted(path for path in dist.iterdir() if path.is_file()) + output_dir.mkdir(parents=True, exist_ok=True) + + records = [ + { + "file": artifact.name, + "sha256": sha256(artifact), + "size_bytes": str(artifact.stat().st_size), + } + for artifact in artifacts + ] + + sums = "\n".join(f"{record['sha256']} {record['file']}" for record in records) + (output_dir / "SHA256SUMS").write_text(sums + "\n", encoding="utf-8") + + sbom = { + "schema": "spindlex-release-sbom-v1", + "tool": "scripts/generate_release_integrity.py", + "python": platform.python_version(), + "artifacts": records, + } + (output_dir / "sbom.spindlex.json").write_text( + json.dumps(sbom, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return records + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dist", type=Path, default=Path("dist")) + parser.add_argument("--output-dir", type=Path, default=Path("dist-integrity")) + args = parser.parse_args(argv) + + records = generate(args.dist, args.output_dir) + print(json.dumps(records, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plan_release.py b/scripts/plan_release.py index aa9abb5..5521555 100644 --- a/scripts/plan_release.py +++ b/scripts/plan_release.py @@ -21,6 +21,11 @@ RELEASE_BUMPS = {"patch", "minor", "major"} NO_RELEASE_TYPES = {"docs", "refactor", "test", "none"} DIRECT_RELEASE_TYPES = RELEASE_BUMPS | NO_RELEASE_TYPES +PUBLISH_RELEASE_PATTERN = re.compile( + r"^chore\(release\): v(?P\d+\.\d+\.\d+) \[publish release\](?:\s+\(#\d+\))?" +) +RELEASE_BRANCH_PATTERN = re.compile(r"^release/v(?P\d+\.\d+\.\d+)$") +SOURCE_PR_PATTERN = re.compile(r"Source PR:\s+#(?P\d+)\s+(?P\S+)") @dataclass(frozen=True) @@ -113,6 +118,34 @@ def _merge_message_pr_number(message: str) -> int | None: return int(match.group("number")) if match else None +def _protected_release_commit_version(message: str) -> str | None: + first_line = message.splitlines()[0] if message else "" + match = PUBLISH_RELEASE_PATTERN.search(first_line) + return match.group("version") if match else None + + +def _is_trusted_protected_release_pr( + pr: dict[str, Any] | None, *, version: str, source_sha: str +) -> bool: + if not pr or not pr.get("merged_at"): + return False + if str(pr.get("merge_commit_sha") or "") != source_sha: + return False + + head = pr.get("head") if isinstance(pr.get("head"), dict) else {} + base = pr.get("base") if isinstance(pr.get("base"), dict) else {} + head_ref = str(head.get("ref") or "") + base_ref = str(base.get("ref") or "") + branch_match = RELEASE_BRANCH_PATTERN.fullmatch(head_ref) + if branch_match is None or branch_match.group("version") != version: + return False + if base_ref and base_ref != "main": + return False + + body = str(pr.get("body") or "") + return SOURCE_PR_PATTERN.search(body) is not None + + def _fallback_merged_pull_request_from_message( *, repository: str, message: str, sha: str, token: str ) -> dict[str, Any] | None: @@ -159,6 +192,44 @@ def _plan_from_release_type( ) +def _plan_from_protected_release_commit( + *, + current_version: str, + source_sha: str, + message: str, + pr_body: str = "", +) -> ReleasePlan: + first_line = message.splitlines()[0] if message else "" + match = PUBLISH_RELEASE_PATTERN.search(first_line) + if match is None: + raise ValueError("Release commit message does not include a publish version.") + version = match.group("version") + validate_version(version) + if version != current_version: + raise ValueError( + "Release commit version does not match pyproject.toml: " + f"{version!r} != {current_version!r}" + ) + source_match = SOURCE_PR_PATTERN.search(message) or SOURCE_PR_PATTERN.search( + pr_body + ) + source_pr = source_match.group("number") if source_match else "" + source_pr_url = source_match.group("url") if source_match else "" + return ReleasePlan( + release_needed="true", + release_type="patch", + change_type="release", + current_version=current_version, + next_version=current_version, + tag=f"v{current_version}", + dry_run="false", + source_pr=source_pr, + source_pr_url=source_pr_url, + source_sha=source_sha, + reason="protected release version PR merged", + ) + + def _plan_from_pr_body( *, body: str, @@ -265,6 +336,16 @@ def create_plan(event_path: Path) -> ReleasePlan: sha=source_sha, token=token, ) + protected_version = _protected_release_commit_version(message) + if protected_version and _is_trusted_protected_release_pr( + pr, version=protected_version, source_sha=source_sha + ): + return _plan_from_protected_release_commit( + current_version=current_version, + source_sha=source_sha, + message=message, + pr_body=str(pr.get("body") or ""), + ) if pr is None: return _plan_from_release_type( release_type="none", diff --git a/scripts/track_workflow_failure.py b/scripts/track_workflow_failure.py index 96b19cc..5b907e4 100644 --- a/scripts/track_workflow_failure.py +++ b/scripts/track_workflow_failure.py @@ -317,8 +317,13 @@ def release_record( def handle_release(args: argparse.Namespace) -> int: client = GitHubClient(args.repository, args.token) results = { + "codeql": args.codeql_result, + "security": args.security_result, "compatibility-matrix": args.compatibility_result, "integration": args.integration_result, + "property-tests": args.property_result, + "benchmark-baseline": args.benchmark_result, + "release-pr": args.release_pr_result, "publish": args.publish_result, } @@ -326,6 +331,16 @@ def handle_release(args: argparse.Namespace) -> int: print("Release failure tracking skipped for cancelled workflow.") return 0 + if args.codeql_result != "success": + record = release_record(args, "release-blocked", "codeql") + ensure_issue(client, record) + return 0 + + if args.security_result != "success": + record = release_record(args, "release-blocked", "security") + ensure_issue(client, record) + return 0 + if args.compatibility_result != "success": record = release_record(args, "release-blocked", "compatibility-matrix") ensure_issue(client, record) @@ -336,6 +351,21 @@ def handle_release(args: argparse.Namespace) -> int: ensure_issue(client, record) return 0 + if args.property_result != "success": + record = release_record(args, "release-blocked", "property-tests") + ensure_issue(client, record) + return 0 + + if args.benchmark_result != "success": + record = release_record(args, "release-blocked", "benchmark-baseline") + ensure_issue(client, record) + return 0 + + if args.release_pr_result == "failure": + record = release_record(args, "release-blocked", "release-pr") + ensure_issue(client, record) + return 0 + if args.publish_result == "success" and args.release_complete == "true": record = release_record(args, "release-blocked", "release") closed = close_matching_issues(client, record) @@ -419,8 +449,13 @@ def build_parser() -> argparse.ArgumentParser: release.add_argument("--source-sha", required=True) release.add_argument("--release-type", required=True) release.add_argument("--planned-version", required=True) + release.add_argument("--codeql-result", required=True) + release.add_argument("--security-result", required=True) release.add_argument("--compatibility-result", required=True) release.add_argument("--integration-result", required=True) + release.add_argument("--property-result", required=True) + release.add_argument("--benchmark-result", required=True) + release.add_argument("--release-pr-result", required=True) release.add_argument("--publish-result", required=True) release.add_argument("--release-complete", default="") release.set_defaults(func=handle_release) diff --git a/tests/conftest.py b/tests/conftest.py index b658c93..bdd291b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import os import socket import time +from dataclasses import dataclass import pytest @@ -24,6 +25,27 @@ _EXTERNAL_SERVER_AVAILABLE = bool(SSH_HOST and SSH_USER) +@dataclass(frozen=True) +class SSHServer: + host: str + port: int + user: str + password: str + server_type: str + + def __iter__(self): + yield self.host + yield self.port + yield self.user + yield self.password + + def __len__(self): + return 4 + + def __getitem__(self, index): + return (self.host, self.port, self.user, self.password)[index] + + def pytest_configure(config): config.addinivalue_line( "markers", "real_server: requires a live SSH server (Docker or .env)" @@ -34,6 +56,17 @@ def pytest_configure(config): ) +@pytest.fixture(autouse=True) +def isolated_real_ssh_home(monkeypatch, tmp_path, request): + """Keep Docker SSH host keys out of the user's real home directory.""" + if request.node.get_closest_marker( + "real_server" + ) or request.node.get_closest_marker("integration"): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + + # --------------------------------------------------------------------------- # Docker / External Server Fixtures # --------------------------------------------------------------------------- @@ -58,7 +91,7 @@ def docker_compose_file(pytestconfig): @pytest.fixture(scope="session", params=_get_ssh_server_params()) -def ssh_server(request, docker_ip=None, docker_services=None, pytestconfig=None): +def ssh_server(request): """ Ensure an SSH server is available. Supports OpenSSH and Dropbear via Docker. @@ -68,13 +101,15 @@ def ssh_server(request, docker_ip=None, docker_services=None, pytestconfig=None) if _EXTERNAL_SERVER_AVAILABLE: # If external server is provided, we only test against it once # (and assume it's compatible with OpenSSH tests) - return SSH_HOST, SSH_PORT, SSH_USER, SSH_PASSWORD + return SSHServer(SSH_HOST, SSH_PORT, SSH_USER, SSH_PASSWORD, "external") + docker_ip = request.getfixturevalue("docker_ip") + docker_services = request.getfixturevalue("docker_services") if not docker_services: pytest.skip("No SSH server configured and Docker not available") service_name = "openssh-server" if server_type == "openssh" else "dropbear-server" - internal_port = 2222 if server_type == "openssh" else 22 + internal_port = 22 port = docker_services.port_for(service_name, internal_port) @@ -89,7 +124,7 @@ def check(): docker_services.wait_until_responsive(timeout=120.0, pause=2.0, check=check) time.sleep(15) - return docker_ip, port, "testuser", "password123" + return SSHServer(docker_ip, port, "testuser", "password123", server_type) @pytest.fixture @@ -102,5 +137,6 @@ def ssh_client(ssh_server): client = SSHClient() client.set_missing_host_key_policy(AutoAddPolicy(accept_risk=True)) client.connect(host, port=port, username=user, password=password) + client._test_server_type = getattr(ssh_server, "server_type", "external") yield client client.close() diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml index 9dd52e2..8e0453f 100644 --- a/tests/integration/docker-compose.yml +++ b/tests/integration/docker-compose.yml @@ -1,39 +1,26 @@ -version: '3.8' - services: openssh-server: - image: linuxserver/openssh-server:latest - container_name: spindlex-integration-server - environment: - - PUID=1000 - - PGID=1000 - - TZ=Europe/London - - USER_PASSWORD=password123 - - USER_NAME=testuser - - PASSWORD_ACCESS=true + build: + context: . + dockerfile: openssh.Dockerfile ports: - - 2222 - volumes: - - ./config:/config + - 22 restart: unless-stopped healthcheck: - test: ["CMD", "nc", "-z", "localhost", "2222"] + test: ["CMD", "nc", "-z", "127.0.0.1", "22"] interval: 5s timeout: 5s retries: 5 dropbear-server: - image: luvletter2333/dropbear:latest - container_name: spindlex-dropbear-server - environment: - - USERNAME=testuser - - PASSWORD=password123 + build: + context: . + dockerfile: dropbear.Dockerfile ports: - - 2223:22 + - 22 healthcheck: - test: ["CMD", "nc", "-z", "localhost", "22"] + test: ["CMD", "nc", "-z", "127.0.0.1", "22"] interval: 5s timeout: 5s retries: 5 restart: unless-stopped - diff --git a/tests/integration/dropbear.Dockerfile b/tests/integration/dropbear.Dockerfile new file mode 100644 index 0000000..608f89c --- /dev/null +++ b/tests/integration/dropbear.Dockerfile @@ -0,0 +1,9 @@ +FROM alpine:3.20 + +RUN apk add --no-cache busybox-extras dropbear openssh-sftp-server \ + && adduser -D -s /bin/sh testuser \ + && echo "testuser:password123" | chpasswd + +EXPOSE 22 + +CMD ["/usr/sbin/dropbear", "-F", "-E", "-R", "-p", "0.0.0.0:22"] diff --git a/tests/integration/openssh.Dockerfile b/tests/integration/openssh.Dockerfile new file mode 100644 index 0000000..3abf8cc --- /dev/null +++ b/tests/integration/openssh.Dockerfile @@ -0,0 +1,23 @@ +FROM alpine:3.20 + +RUN apk add --no-cache busybox-extras openssh-server openssh-sftp-server \ + && adduser -D -s /bin/sh testuser \ + && echo "testuser:password123" | chpasswd \ + && ssh-keygen -A \ + && mkdir -p /run/sshd \ + && printf '%s\n' \ + "Port 22" \ + "ListenAddress 0.0.0.0" \ + "PasswordAuthentication yes" \ + "PermitEmptyPasswords no" \ + "KbdInteractiveAuthentication no" \ + "UsePAM no" \ + "AllowTcpForwarding yes" \ + "GatewayPorts no" \ + "X11Forwarding no" \ + "Subsystem sftp internal-sftp" \ + > /etc/ssh/sshd_config + +EXPOSE 22 + +CMD ["/usr/sbin/sshd", "-D", "-e"] diff --git a/tests/integration/test_ssh_integration.py b/tests/integration/test_ssh_integration.py index dac2277..82f8e4b 100644 --- a/tests/integration/test_ssh_integration.py +++ b/tests/integration/test_ssh_integration.py @@ -32,16 +32,18 @@ def docker_compose_file(pytestconfig): @pytest.fixture(scope="session") -def ssh_server(docker_ip=None, docker_services=None): +def ssh_server(request): """Ensure that SSH server is up and responsive.""" if EXTERNAL_HOST: # Use external server if configured return EXTERNAL_HOST, EXTERNAL_PORT + docker_ip = request.getfixturevalue("docker_ip") + docker_services = request.getfixturevalue("docker_services") if not docker_services: pytest.skip("No SSH server configured and Docker not available") - port = docker_services.port_for("openssh-server", 2222) + port = docker_services.port_for("openssh-server", 22) def check(): try: diff --git a/tests/protocol/test_property_protocol_boundaries.py b/tests/protocol/test_property_protocol_boundaries.py new file mode 100644 index 0000000..686d526 --- /dev/null +++ b/tests/protocol/test_property_protocol_boundaries.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from spindlex.exceptions import ProtocolException +from spindlex.protocol.messages import ChannelDataMessage, IgnoreMessage, Message +from spindlex.protocol.sftp_messages import ( + SFTPDataMessage, + SFTPReadMessage, + SFTPStatusMessage, + SFTPWriteMessage, +) +from spindlex.protocol.utils import ( + read_byte, + read_mpint, + read_string, + read_uint32, + read_uint64, + write_byte, + write_mpint, + write_string, + write_uint32, + write_uint64, +) + +BOUNDED = settings( + max_examples=75, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@given(st.integers(min_value=0, max_value=0xFFFFFFFF)) +@BOUNDED +def test_uint32_round_trip(value: int) -> None: + encoded = write_uint32(value) + + decoded, offset = read_uint32(encoded, 0) + + assert decoded == value + assert offset == len(encoded) + + +@given(st.integers(min_value=0, max_value=0xFFFFFFFFFFFFFFFF)) +@BOUNDED +def test_uint64_round_trip(value: int) -> None: + encoded = write_uint64(value) + + decoded, offset = read_uint64(encoded, 0) + + assert decoded == value + assert offset == len(encoded) + + +@given(st.integers(min_value=0, max_value=255)) +@BOUNDED +def test_byte_round_trip(value: int) -> None: + encoded = write_byte(value) + + decoded, offset = read_byte(encoded, 0) + + assert decoded == value + assert offset == len(encoded) + + +@given(st.binary(max_size=512)) +@BOUNDED +def test_string_round_trip(payload: bytes) -> None: + encoded = write_string(payload) + + decoded, offset = read_string(encoded, 0) + + assert decoded == payload + assert offset == len(encoded) + + +@given(st.integers(min_value=-(2**63), max_value=2**63 - 1)) +@BOUNDED +def test_mpint_round_trip(value: int) -> None: + encoded = write_mpint(value) + + decoded, offset = read_mpint(encoded, 0) + + assert decoded == value + assert offset == len(encoded) + + +@given( + st.integers(min_value=192, max_value=255), + st.binary(max_size=512), +) +@BOUNDED +def test_generic_ssh_extension_message_round_trip( + msg_type: int, payload: bytes +) -> None: + message = Message(msg_type) + message._data.extend(payload) + + unpacked = Message.unpack(message.pack()) + + assert unpacked.msg_type == msg_type + assert bytes(unpacked._data) == payload + + +@given( + st.integers(min_value=0, max_value=0xFFFFFFFF), + st.binary(max_size=512), +) +@BOUNDED +def test_channel_data_message_round_trip(channel: int, payload: bytes) -> None: + unpacked = Message.unpack(ChannelDataMessage(channel, payload).pack()) + + assert isinstance(unpacked, ChannelDataMessage) + assert unpacked.recipient_channel == channel + assert unpacked.data == payload + + +@given(st.binary(max_size=512)) +@BOUNDED +def test_ignore_message_round_trip(payload: bytes) -> None: + unpacked = Message.unpack(IgnoreMessage(payload).pack()) + + assert isinstance(unpacked, IgnoreMessage) + assert unpacked.data == payload + + +@given( + st.integers(min_value=0, max_value=0xFFFFFFFF), + st.binary(max_size=512), +) +@BOUNDED +def test_sftp_data_message_round_trip(request_id: int, payload: bytes) -> None: + unpacked = SFTPDataMessage.unpack(SFTPDataMessage(request_id, payload).pack()) + + assert isinstance(unpacked, SFTPDataMessage) + assert unpacked.request_id == request_id + assert unpacked.data == payload + + +@given( + st.integers(min_value=0, max_value=0xFFFFFFFF), + st.binary(min_size=1, max_size=64), + st.integers(min_value=0, max_value=0xFFFFFFFFFFFFFFFF), + st.binary(max_size=512), +) +@BOUNDED +def test_sftp_write_message_round_trip( + request_id: int, handle: bytes, offset: int, payload: bytes +) -> None: + unpacked = SFTPWriteMessage.unpack( + SFTPWriteMessage(request_id, handle, offset, payload).pack() + ) + + assert isinstance(unpacked, SFTPWriteMessage) + assert unpacked.request_id == request_id + assert unpacked.handle == handle + assert unpacked.offset == offset + assert unpacked.data == payload + + +@given( + st.integers(min_value=0, max_value=0xFFFFFFFF), + st.binary(min_size=1, max_size=64), + st.integers(min_value=0, max_value=0xFFFFFFFFFFFFFFFF), + st.integers(min_value=0, max_value=0xFFFFFFFF), +) +@BOUNDED +def test_sftp_read_message_round_trip( + request_id: int, handle: bytes, offset: int, length: int +) -> None: + unpacked = SFTPReadMessage.unpack( + SFTPReadMessage(request_id, handle, offset, length).pack() + ) + + assert isinstance(unpacked, SFTPReadMessage) + assert unpacked.request_id == request_id + assert unpacked.handle == handle + assert unpacked.offset == offset + assert unpacked.length == length + + +@given(st.binary(max_size=16)) +@BOUNDED +def test_truncated_sftp_status_rejects_without_partial_object(payload: bytes) -> None: + with pytest.raises(ProtocolException): + SFTPStatusMessage.unpack(payload) + + +@given(st.binary(max_size=3)) +@BOUNDED +def test_truncated_uint32_rejects_as_protocol_error(payload: bytes) -> None: + with pytest.raises(ProtocolException): + read_uint32(payload, 0) diff --git a/tests/real_server/test_async_ssh_client.py b/tests/real_server/test_async_ssh_client.py index c396b86..9bcb660 100644 --- a/tests/real_server/test_async_ssh_client.py +++ b/tests/real_server/test_async_ssh_client.py @@ -31,6 +31,9 @@ async def test_async_ssh_client_exec(ssh_server): @pytest.mark.asyncio async def test_async_ssh_client_concurrent_exec(ssh_server): + if getattr(ssh_server, "server_type", "") == "dropbear": + pytest.skip("Dropbear does not support concurrent session channels here.") + host, port, user, password = ssh_server async with AsyncSSHClient() as client: client.set_missing_host_key_policy(AutoAddPolicy(accept_risk=True)) diff --git a/tests/real_server/test_channel.py b/tests/real_server/test_channel.py index c7a4905..6597908 100644 --- a/tests/real_server/test_channel.py +++ b/tests/real_server/test_channel.py @@ -68,6 +68,13 @@ def test_channel_close_behavior(ssh_client): def test_multiple_channels_real(ssh_client): + if getattr(ssh_client, "_test_server_type", "") == "dropbear": + pytest.skip("Dropbear does not support concurrent session channels here.") + + remote_version = getattr(ssh_client.get_transport(), "_remote_version", "") + if "dropbear" in remote_version.lower(): + pytest.skip("Dropbear does not support concurrent session channels here.") + transport = ssh_client.get_transport() c1 = transport.open_channel("session") c2 = transport.open_channel("session") diff --git a/tests/real_server/test_coverage_boost.py b/tests/real_server/test_coverage_boost.py index 1805641..bd8f85d 100644 --- a/tests/real_server/test_coverage_boost.py +++ b/tests/real_server/test_coverage_boost.py @@ -120,6 +120,7 @@ async def test_async_sftp_comprehensive(self, ssh_server): def test_public_key_auth_logic(self, ssh_server): host, port, user, _ = ssh_server from spindlex.auth.publickey import PublicKeyAuth + from spindlex.protocol.messages import UserAuthFailureMessage from spindlex.transport.transport import Transport sock = socket.create_connection((host, port)) @@ -128,9 +129,8 @@ def test_public_key_auth_logic(self, ssh_server): t.start_client() key = Ed25519Key.generate() auth = PublicKeyAuth(t) - try: + with patch.object(t, "_expect_message") as mock_expect: + mock_expect.return_value = UserAuthFailureMessage(["password"], False) auth.authenticate(user, key) - except Exception: - pass finally: t.close() diff --git a/tests/real_server/test_real_server.py b/tests/real_server/test_real_server.py index a8f6704..4b550f8 100644 --- a/tests/real_server/test_real_server.py +++ b/tests/real_server/test_real_server.py @@ -395,6 +395,9 @@ async def run(): asyncio.run(run()) def test_async_concurrent_commands(self, ssh_server): + if getattr(ssh_server, "server_type", "") == "dropbear": + pytest.skip("Dropbear does not support concurrent session channels here.") + host, port, user, password = ssh_server async def run(): @@ -624,19 +627,28 @@ async def upload_and_remove(name, data): class TestLocalPortForwarding: def test_local_forward_and_use(self, ssh_client): - """Open a local port forward to the SSH server's own port 22.""" + """Open a local port forward to the SSH server's own SSH daemon.""" import socket as sock_mod transport = ssh_client.get_transport() fwd_mgr = transport.get_port_forwarding_manager() - local_port = 14722 + with sock_mod.socket(sock_mod.AF_INET, sock_mod.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + local_port = probe.getsockname()[1] + + _, stdout, _ = ssh_client.exec_command( + "if nc -z 127.0.0.1 22; then echo 22; " + "elif nc -z 127.0.0.1 2222; then echo 2222; " + "else echo 22; fi" + ) + remote_port = int(stdout.read().decode().strip()) tunnel_id = fwd_mgr.create_local_tunnel( local_host="127.0.0.1", local_port=local_port, remote_host="127.0.0.1", - remote_port=22, + remote_port=remote_port, ) try: @@ -688,6 +700,13 @@ def test_channel_exit_status(self, ssh_client): assert status == 42 def test_multiple_channels(self, ssh_client): + if getattr(ssh_client, "_test_server_type", "") == "dropbear": + pytest.skip("Dropbear does not support concurrent session channels here.") + + remote_version = getattr(ssh_client.get_transport(), "_remote_version", "") + if "dropbear" in remote_version.lower(): + pytest.skip("Dropbear does not support concurrent session channels here.") + channels = [] for i in range(3): stdin, stdout, stderr = ssh_client.exec_command(f"echo channel_{i}") diff --git a/tests/scripts/test_generate_release_integrity.py b/tests/scripts/test_generate_release_integrity.py new file mode 100644 index 0000000..4a5034d --- /dev/null +++ b/tests/scripts/test_generate_release_integrity.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parents[2] / "scripts" + + +def load_script(name: str): + path = SCRIPT_DIR / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +generate_release_integrity = load_script("generate_release_integrity") + + +def test_generate_release_integrity_writes_hashes_and_sbom(tmp_path: Path) -> None: + dist = tmp_path / "dist" + output = tmp_path / "integrity" + dist.mkdir() + (dist / "example.whl").write_bytes(b"wheel") + (dist / "example.tar.gz").write_bytes(b"sdist") + + records = generate_release_integrity.generate(dist, output) + + assert [record["file"] for record in records] == [ + "example.tar.gz", + "example.whl", + ] + assert (output / "SHA256SUMS").exists() + assert (output / "sbom.spindlex.json").exists() diff --git a/tests/scripts/test_release_helpers.py b/tests/scripts/test_release_helpers.py index 36a1fdb..550090c 100644 --- a/tests/scripts/test_release_helpers.py +++ b/tests/scripts/test_release_helpers.py @@ -43,6 +43,23 @@ def pr_body(change_type: str) -> str: """ +def trusted_release_pr(version: str, source_sha: str, source_pr: int = 199) -> dict: + return { + "number": 200, + "html_url": "https://github.com/stratza/spindlex/pull/200", + "title": f"chore(release): v{version} [publish release]", + "body": ( + f"Protected release-version PR for v{version}.\n\n" + f"Source PR: #{source_pr} " + f"https://github.com/stratza/spindlex/pull/{source_pr}" + ), + "merged_at": "2026-05-23T19:35:34Z", + "merge_commit_sha": source_sha, + "head": {"ref": f"release/v{version}"}, + "base": {"ref": "main"}, + } + + def test_bump_version_by_release_type(): assert plan_release.bump_version("1.2.3", "patch") == "1.2.4" assert plan_release.bump_version("1.2.3", "minor") == "1.3.0" @@ -181,6 +198,134 @@ def test_push_skip_release_commit_plans_no_release(monkeypatch, tmp_path): assert plan.reason == "head commit contains [skip release]" +def test_push_publish_release_commit_plans_publish(monkeypatch, tmp_path): + monkeypatch.setenv("GITHUB_EVENT_NAME", "push") + monkeypatch.setenv("GITHUB_SHA", "release-sha") + monkeypatch.setenv("GITHUB_REPOSITORY", "stratza/spindlex") + monkeypatch.setenv("GITHUB_TOKEN", "token") + current = sync_project_version.read_pyproject_version() + monkeypatch.setattr( + plan_release, + "_last_merged_pull_request", + lambda *a, **k: trusted_release_pr(current, "release-sha"), + ) + event_path = write_event( + tmp_path, + {"head_commit": {"message": f"chore(release): v{current} [publish release]"}}, + ) + + plan = plan_release.create_plan(event_path) + + assert plan.release_needed == "true" + assert plan.next_version == current + assert plan.tag == f"v{current}" + assert plan.source_pr == "199" + assert plan.reason == "protected release version PR merged" + + +def test_push_publish_release_commit_accepts_squash_suffix_and_source_pr( + monkeypatch, tmp_path +): + monkeypatch.setenv("GITHUB_EVENT_NAME", "push") + monkeypatch.setenv("GITHUB_SHA", "release-sha") + monkeypatch.setenv("GITHUB_REPOSITORY", "stratza/spindlex") + monkeypatch.setenv("GITHUB_TOKEN", "token") + current = sync_project_version.read_pyproject_version() + monkeypatch.setattr( + plan_release, + "_last_merged_pull_request", + lambda *a, **k: trusted_release_pr(current, "release-sha"), + ) + event_path = write_event( + tmp_path, + { + "head_commit": { + "message": ( + f"chore(release): v{current} [publish release] (#200)\n\n" + "Source PR: #199 https://github.com/stratza/spindlex/pull/199" + ) + }, + }, + ) + + plan = plan_release.create_plan(event_path) + + assert plan.release_needed == "true" + assert plan.source_pr == "199" + assert plan.source_pr_url == "https://github.com/stratza/spindlex/pull/199" + + +def test_push_publish_title_from_regular_pr_uses_pr_body(monkeypatch, tmp_path): + monkeypatch.setenv("GITHUB_EVENT_NAME", "push") + monkeypatch.setenv("GITHUB_SHA", "merge-sha") + monkeypatch.setenv("GITHUB_REPOSITORY", "stratza/spindlex") + monkeypatch.setenv("GITHUB_TOKEN", "token") + current = sync_project_version.read_pyproject_version() + monkeypatch.setattr( + plan_release, + "_last_merged_pull_request", + lambda *a, **k: { + "number": 202, + "html_url": "https://github.com/stratza/spindlex/pull/202", + "merged_at": "2026-05-23T19:35:34Z", + "merge_commit_sha": "merge-sha", + "head": {"ref": "feature/spoof-release-title"}, + "base": {"ref": "main"}, + "body": pr_body("feature"), + }, + ) + event_path = write_event( + tmp_path, + { + "head_commit": { + "message": f"chore(release): v{current} [publish release] (#202)" + } + }, + ) + + plan = plan_release.create_plan(event_path) + + assert plan.reason == "release planned from PR type feature" + assert plan.source_pr == "202" + + +def test_push_publish_token_in_body_does_not_enter_release_publish_path( + monkeypatch, tmp_path +): + monkeypatch.setenv("GITHUB_EVENT_NAME", "push") + monkeypatch.setenv("GITHUB_SHA", "merge-sha") + monkeypatch.setenv("GITHUB_REPOSITORY", "stratza/spindlex") + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(plan_release, "_last_merged_pull_request", lambda *a, **k: None) + monkeypatch.setattr( + plan_release, + "_pull_request_by_number", + lambda *a, **k: { + "number": 201, + "html_url": "https://github.com/stratza/spindlex/pull/201", + "merged_at": "2026-05-23T18:53:12Z", + "merge_commit_sha": "merge-sha", + "body": pr_body("feature"), + }, + ) + event_path = write_event( + tmp_path, + { + "head_commit": { + "message": ( + "feat: merge release prep (#201)\n\n" + "Body text mentions [publish release] but is not a release commit." + ) + }, + }, + ) + + plan = plan_release.create_plan(event_path) + + assert plan.reason == "release planned from PR type feature" + assert plan.source_pr == "201" + + def test_push_falls_back_to_merge_commit_pr_number(monkeypatch, tmp_path): monkeypatch.setenv("GITHUB_EVENT_NAME", "push") monkeypatch.setenv("GITHUB_SHA", "merge-sha") diff --git a/tests/scripts/test_track_workflow_failure.py b/tests/scripts/test_track_workflow_failure.py index aa79651..0c77de4 100644 --- a/tests/scripts/test_track_workflow_failure.py +++ b/tests/scripts/test_track_workflow_failure.py @@ -159,8 +159,13 @@ def release_args(**overrides): "source_sha": "abc123", "release_type": "patch", "planned_version": "0.6.7", + "codeql_result": "success", + "security_result": "success", "compatibility_result": "success", "integration_result": "success", + "property_result": "success", + "benchmark_result": "success", + "release_pr_result": "skipped", "publish_result": "success", "release_complete": "true", } @@ -193,3 +198,29 @@ def test_handle_release_does_not_close_when_publish_skipped_existing_tag(monkeyp assert result == 0 assert client.closed == [] assert client.comments == [] + + +def test_handle_release_tracks_codeql_failure_before_release_gates(monkeypatch): + monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) + client = FakeClient() + monkeypatch.setattr(track_workflow_failure, "GitHubClient", lambda *_: client) + + result = track_workflow_failure.handle_release( + release_args(codeql_result="failure", compatibility_result="skipped") + ) + + assert result == 0 + assert "codeql" in client.created[0]["body"] + + +def test_handle_release_tracks_security_failure_before_release_gates(monkeypatch): + monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) + client = FakeClient() + monkeypatch.setattr(track_workflow_failure, "GitHubClient", lambda *_: client) + + result = track_workflow_failure.handle_release( + release_args(security_result="failure", compatibility_result="skipped") + ) + + assert result == 0 + assert "security" in client.created[0]["body"]