diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..00c83fa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 +max_line_length = 100 + +[*.py] +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..569a9ba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Disable EOL conversion for release builds and blob_exact workspaces. +* -text + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.pdf binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6cbcd39 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# No active CODEOWNERS entries are enabled yet. +# Add real maintainer handles or teams before requiring CODEOWNERS reviews. + +# Optional examples: +# /src/crewplane/ @crewplaneai/maintainers +# /.github/ @crewplaneai/maintainers +# /docs/architecture/adr/ @crewplaneai/maintainers diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml new file mode 100644 index 0000000..8c78881 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -0,0 +1,16 @@ +title: "[Idea] " +labels: ["discussion: idea"] +body: + - type: textarea + id: idea + attributes: + label: Idea + description: What should Crewplane make easier? + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context + description: What are you trying to orchestrate today? diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a1e1c6b..c139ae4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,56 +1,136 @@ name: Bug report -description: Report a reproducible problem -title: "[bug]: " -labels: - - bug +description: Report a reproducible bug. +title: "fix: " +labels: ["type: bug"] body: - type: markdown attributes: value: | - Thanks for filing a bug. Please provide enough detail for us to reproduce it. - - type: input + Thanks for reporting a bug. Please include enough detail for a maintainer to reproduce it without your private environment. + Sanitize commands, workflows, and logs. Never include credentials, proprietary prompts, customer data, or private provider payloads. + + - type: textarea id: summary attributes: label: Summary - placeholder: One-sentence description of the problem + description: What happened? + placeholder: The workflow failed when... validations: required: true + - type: textarea - id: steps + id: reproduction attributes: - label: Steps to reproduce + label: Reproduction steps + description: Minimal steps, command, workflow file, or fixture. placeholder: | 1. Run ... - 2. Use workflow ... - 3. Observe ... + 2. Observe ... validations: required: true + - type: textarea id: expected attributes: label: Expected behavior validations: required: true + - type: textarea id: actual attributes: label: Actual behavior validations: required: true + + - type: input + id: version + attributes: + label: Version or commit + placeholder: v0.1.0 or commit SHA + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating system + multiple: true + options: + - macOS + - Linux + - Windows + - WSL + - Other + validations: + required: true + + - type: input + id: shell + attributes: + label: Shell + placeholder: zsh 5.9, Bash 5.2, etc. + validations: + required: true + + - type: input + id: python + attributes: + label: Python version + placeholder: "3.13.7" + validations: + required: true + + - type: dropdown + id: install_method + attributes: + label: Installation method + options: + - uv tool + - pip or pipx + - npm + - Homebrew + - install.sh + - Source checkout + - Other + validations: + required: true + - type: textarea - id: environment + id: provider_invoker attributes: - label: Environment - placeholder: | - OS: - Shell: - Python: - Install method: - AI CLI(s): + label: Provider CLI or invoker + description: List each provider CLI and version, or state that you used the mock invoker. Do not include credentials or private provider payloads. + placeholder: mock invoker, Codex CLI 1.2.3, etc. + validations: + required: true + + - type: dropdown + id: live_mode + attributes: + label: Live mode + description: How was the optional tmux dashboard handled? + options: + - Enabled with tmux + - Disabled with --no-live + - Requested but unavailable + - Not applicable validations: required: true + - type: textarea id: logs attributes: - label: Logs / traceback - render: shell + label: Sanitized logs + description: Remove secrets, tokens, usernames, home directories, customer data, and provider payloads. + render: text + + - type: checkboxes + id: safety + attributes: + label: Safety checks + options: + - label: I removed secrets and private data from this report. + required: true + - label: I searched existing issues first. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3ba13e0..e06996b 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1,8 @@ blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/crewplaneai/crewplane/security/advisories/new + about: Please report vulnerabilities privately through GitHub Security Advisories. + - name: Questions / usage help + url: https://github.com/crewplaneai/crewplane/discussions + about: Use Discussions for questions, ideas, and usage help. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 0000000..bf862d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,24 @@ +name: Documentation issue +description: Report missing, confusing, or incorrect docs. +title: "docs: " +labels: ["type: docs"] +body: + - type: textarea + id: page + attributes: + label: Page or file + placeholder: docs/guides/running-workflows.md + validations: + required: true + + - type: textarea + id: issue + attributes: + label: What is wrong or missing? + validations: + required: true + + - type: textarea + id: suggestion + attributes: + label: Suggested change diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 2510d83..f2d47cc 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,33 +1,50 @@ name: Feature request -description: Suggest an improvement or new capability -title: "[feature]: " -labels: - - enhancement +description: Propose a focused improvement. +title: "feat: " +labels: ["type: feature"] body: - - type: markdown - attributes: - value: | - Thanks for suggesting an improvement. Please explain the user problem first. - type: textarea id: problem attributes: - label: Problem / use case - placeholder: What are you trying to do that is hard today? + label: Problem + description: What problem does this solve? validations: required: true + - type: textarea id: proposal attributes: label: Proposed solution - placeholder: Describe the behavior or UX you want + description: Describe the smallest useful solution. validations: required: true + - type: textarea id: alternatives attributes: label: Alternatives considered - - type: textarea - id: additional_context + + - type: dropdown + id: area + attributes: + label: Area + options: + - CLI UX + - Workflow authoring and composition + - Config and provider integrations + - Runtime execution + - Artifacts, resume, and support bundles + - Observability and tmux UI + - Docs + - Packaging / installation + - Security + - Other + + - type: checkboxes + id: contribution attributes: - label: Additional context - placeholder: Links, examples, prior art, screenshots, etc. + label: Contribution + options: + - label: I would be willing to submit a PR. + - label: This may require an ADR. + - label: This may be a breaking change. diff --git a/.github/ISSUE_TEMPLATE/integration_request.yml b/.github/ISSUE_TEMPLATE/integration_request.yml new file mode 100644 index 0000000..c2c3a80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/integration_request.yml @@ -0,0 +1,43 @@ +name: AI CLI integration request +description: Request support for another AI CLI or runtime adapter. +title: "feat: add integration for " +labels: ["type: integration"] +body: + - type: input + id: cli + attributes: + label: CLI/tool name + placeholder: Codex CLI, Claude Code, Gemini CLI, etc. + validations: + required: true + + - type: input + id: homepage + attributes: + label: Official docs or repo URL + validations: + required: true + + - type: textarea + id: commands + attributes: + label: Relevant commands/options + description: Link to official docs. Do not paste proprietary docs. + validations: + required: true + + - type: textarea + id: usecase + attributes: + label: Use case + description: What workflow would this unlock? + validations: + required: true + + - type: checkboxes + id: safety + attributes: + label: Safety + options: + - label: The integration can be tested without real provider credentials. + - label: The integration does not require bypassing the tool's terms of service. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8230961 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,8 @@ +# GitHub Copilot Instructions + +Use `AGENTS.md` as the canonical repository instructions. + +Copilot-specific note: +- Keep this file thin. Put repo-wide instructions in `AGENTS.md`. +- Use `DEVELOPMENT.md` for setup, validation, release, and automation guidance. +- Do not duplicate project rules here; update `AGENTS.md` instead. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fc2d210 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,45 @@ +version: 2 + +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/Vancouver" + labels: ["dependencies", "status: needs-triage", "area: packaging"] + open-pull-requests-limit: 5 + cooldown: + default-days: 3 + groups: + python-patch-and-minor: + applies-to: version-updates + patterns: ["*"] + update-types: ["patch", "minor"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:30" + timezone: "America/Vancouver" + labels: ["dependencies", "status: needs-triage", "area: ci"] + open-pull-requests-limit: 5 + groups: + github-actions: + patterns: ["*"] + update-types: ["patch", "minor"] + + - package-ecosystem: "pre-commit" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "10:00" + timezone: "America/Vancouver" + labels: ["dependencies", "status: needs-triage", "area: ci"] + open-pull-requests-limit: 5 + cooldown: + default-days: 3 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..c73f0f7 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,37 @@ +"area: ci": + - changed-files: + - any-glob-to-any-file: + - ".github/**" + - ".pre-commit-config.yaml" + +"area: docs": + - changed-files: + - any-glob-to-any-file: + - "docs/**" + - "*.md" + +"area: agents": + - changed-files: + - any-glob-to-any-file: + - ".agents/**" + - "AGENTS.md" + - "CLAUDE.md" + - "GEMINI.md" + +"area: core": + - changed-files: + - any-glob-to-any-file: + - "src/crewplane/**" + +"area: tests": + - changed-files: + - any-glob-to-any-file: + - "tests/**" + +"area: packaging": + - changed-files: + - any-glob-to-any-file: + - "packaging/**" + - "pyproject.toml" + - "uv.lock" + - "Makefile" diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..6e30936 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,157 @@ +[ + { + "name": "status: needs-triage", + "color": "fbca04", + "description": "Needs maintainer triage" + }, + { + "name": "status: blocked", + "color": "b60205", + "description": "Blocked by missing info or dependency" + }, + { + "name": "status: needs-repro", + "color": "d93f0b", + "description": "Needs a minimal reproduction" + }, + { + "name": "status: accepted", + "color": "0e8a16", + "description": "Accepted for implementation" + }, + { + "name": "status: stale", + "color": "cfd3d7", + "description": "Inactive for a while" + }, + { + "name": "type: bug", + "color": "d73a4a", + "description": "Bug report" + }, + { + "name": "type: feature", + "color": "a2eeef", + "description": "Feature request" + }, + { + "name": "type: docs", + "color": "0075ca", + "description": "Documentation" + }, + { + "name": "type: security", + "color": "ee0701", + "description": "Security-related change" + }, + { + "name": "type: integration", + "color": "5319e7", + "description": "AI CLI/runtime integration" + }, + { + "name": "type: chore", + "color": "c5def5", + "description": "Maintenance" + }, + { + "name": "area: ci", + "color": "0366d6", + "description": "CI, release, GitHub automation" + }, + { + "name": "area: docs", + "color": "0075ca", + "description": "Docs" + }, + { + "name": "area: agents", + "color": "5319e7", + "description": "Agent skills or workflows" + }, + { + "name": "area: core", + "color": "1d76db", + "description": "Core runtime" + }, + { + "name": "area: tests", + "color": "0e8a16", + "description": "Tests" + }, + { + "name": "area: packaging", + "color": "c2e0c6", + "description": "Packaging and dependency management" + }, + { + "name": "size: xs", + "color": "c2e0c6", + "description": "Very small pull request" + }, + { + "name": "size: s", + "color": "bfdadc", + "description": "Small pull request" + }, + { + "name": "size: m", + "color": "fbca04", + "description": "Medium pull request" + }, + { + "name": "size: l", + "color": "d93f0b", + "description": "Large pull request" + }, + { + "name": "size: xl", + "color": "b60205", + "description": "Very large pull request" + }, + { + "name": "priority: high", + "color": "b60205", + "description": "High priority" + }, + { + "name": "priority: low", + "color": "cfd3d7", + "description": "Low priority" + }, + { + "name": "good first issue", + "color": "7057ff", + "description": "Good for newcomers" + }, + { + "name": "help wanted", + "color": "008672", + "description": "Maintainer would welcome help" + }, + { + "name": "breaking-change", + "color": "b60205", + "description": "Breaking change" + }, + { + "name": "skip-changelog", + "color": "ededed", + "description": "Exclude from release notes" + }, + { + "name": "dependencies", + "color": "0366d6", + "description": "Dependency update" + }, + { + "name": "automerge", + "color": "0e8a16", + "description": "Eligible for safe automerge" + }, + { + "name": "discussion: idea", + "color": "bfdadc", + "description": "Idea discussion" + } +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..48cbe2e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,124 @@ +name: ci + +on: + push: + branches: ["master"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + with: + version: "0.10.9" + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install dependencies + run: uv sync --locked --extra dev + + - name: Check repository hygiene + run: uvx pre-commit==4.6.0 run --all-files --show-diff-on-failure + + - name: Check GitHub Actions workflows + run: make actionlint + + - name: Check formatting + run: make format-check + + - name: Lint + run: make lint + + test: + name: test (${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python-version: ["3.13", "3.14"] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + with: + version: "0.10.9" + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install dependencies + run: uv sync --locked --extra dev + + - name: Run tests + run: uv run --extra dev python -m pytest -q + + - name: CLI smoke test + run: uv run --extra dev crewplane --help + + package: + name: package + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [lint, test] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + with: + version: "0.10.9" + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Build and smoke-test package + run: make install-smoke-pip + + - name: Inspect dist + run: | + ls -la dist + python -m zipfile --test dist/*.whl + uv run --extra dev twine check dist/* + + - name: Upload dist artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: dist + path: dist/* + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..29044f3 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: codeql + +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + schedule: + - cron: "23 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: codeql + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + with: + languages: python + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..d694e42 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,38 @@ +name: dependency-review + +on: + pull_request: + branches: ["master"] + paths: + - "pyproject.toml" + - "uv.lock" + - "packaging/npm/package*.json" + - ".github/workflows/**" + +permissions: + contents: read + pull-requests: write + +concurrency: + group: dependency-review-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + name: dependency-review + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Dependency Review + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5 + with: + fail-on-severity: high + allow-licenses: > + 0BSD, Apache-2.0, BSD-2-Clause, BSD-3-Clause, CC0-1.0, + ISC, MIT, MPL-2.0, PSF-2.0, Python-2.0, + Unicode-3.0, Unicode-DFS-2016, Unlicense, Zlib + comment-summary-in-pr: always diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000..e441a7e --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,35 @@ +name: issue-triage + +on: # zizmor: ignore[dangerous-triggers] metadata only; no checkout or PR code + issues: + types: [opened, reopened] + pull_request_target: + types: [opened, reopened] + +permissions: + issues: write + pull-requests: write + +concurrency: + group: issue-triage-${{ github.event.pull_request.number || github.event.issue.number || github.ref }} + cancel-in-progress: true + +jobs: + triage: + name: triage + if: github.event_name != 'pull_request_target' || github.event.pull_request.user.login != 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Add triage labels + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const item = context.payload.issue || context.payload.pull_request; + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + labels: ["status: needs-triage"] + }); diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..f4041fe --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,49 @@ +name: nightly + +on: + schedule: + - cron: "11 11 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: true + +jobs: + cross-platform: + name: ${{ matrix.os }} py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.13", "3.14"] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + with: + version: "0.10.9" + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install dependencies + run: uv sync --locked --extra dev + + - name: Run tests + run: uv run --extra dev python -m pytest -q + + - name: CLI smoke test + run: uv run --extra dev crewplane --help diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 0000000..dedaf51 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,25 @@ +name: pr-labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-labeler-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + label: + name: label + if: github.event.pull_request.user.login != 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + sync-labels: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2df3a4f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,136 @@ +name: release + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag just created by make release from the current master commit" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: github-release-publication + cancel-in-progress: false + queue: max + +jobs: + verify: + name: verify + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + release_commit: ${{ steps.release-source.outputs.release_commit }} + env: + TAG_NAME: ${{ inputs.tag }} + steps: + - name: Reject non-master dispatch + if: github.ref != 'refs/heads/master' + run: | + echo "GitHub Release publication must be dispatched from master." >&2 + exit 1 + + - name: Check out release tag + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + persist-credentials: false + + - name: Resolve and verify current release source + id: release-source + run: | + release_commit="$(git rev-parse --verify 'HEAD^{commit}')" + if [ "$release_commit" != "$GITHUB_SHA" ]; then + echo "Release tag must point to the dispatched master commit." >&2 + exit 1 + fi + echo "release_commit=$release_commit" >> "$GITHUB_OUTPUT" + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + with: + version: "0.10.9" + enable-cache: false + + - name: Install dependencies + run: uv sync --locked --extra dev + + - name: Prepare release artifacts + run: | + uv run --locked --extra dev \ + python scripts/release.py release-artifacts + + - name: Verify GitHub release inputs + run: | + uv run --locked --extra dev \ + python scripts/release.py github-release-plan \ + --expected-tag "$TAG_NAME" + + - name: Upload verified release bundle + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: release-bundle + path: | + dist/* + .release/npm/*.tgz + .release/release-manifest.json + include-hidden-files: true + if-no-files-found: error + overwrite: true + retention-days: 30 + + github-release: + name: github-release + needs: [verify] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + env: + TAG_NAME: ${{ inputs.tag }} + GH_REPO: ${{ github.repository }} + steps: + - name: Check out verified release commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ needs.verify.outputs.release_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify release commit remains on master + run: | + git fetch --quiet --no-tags origin refs/heads/master + git merge-base --is-ancestor HEAD FETCH_HEAD + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + with: + version: "0.10.9" + enable-cache: false + + - name: Install dependencies + run: uv sync --locked --extra dev + + - name: Download verified release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: release-bundle + path: . + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: scripts/publish_github_release.sh dist diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..f1fa710 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,35 @@ +name: scorecard + +on: + branch_protection_rule: + schedule: + - cron: "31 8 * * 1" + push: + branches: ["master"] + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: scorecard-${{ github.ref }} + cancel-in-progress: true + +jobs: + scorecard: + name: OpenSSF Scorecard + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Run analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: false + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + with: + sarif_file: results.sarif diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml new file mode 100644 index 0000000..12dd859 --- /dev/null +++ b/.github/workflows/sync-labels.yml @@ -0,0 +1,85 @@ +name: sync-labels + +on: + push: + branches: ["master"] + paths: + - ".github/labels.json" + - ".github/workflows/sync-labels.yml" + workflow_dispatch: + inputs: + prune: + description: "Delete repository labels not present in .github/labels.json" + required: false + default: "false" + type: choice + options: ["false", "true"] + +permissions: + issues: write + contents: read + +concurrency: + group: sync-labels-${{ github.ref }} + cancel-in-progress: false + +jobs: + sync-labels: + name: sync-labels + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Reject non-master runs + if: github.ref != 'refs/heads/master' + run: | + echo "Label synchronization must run from master." >&2 + exit 1 + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Sync labels + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PRUNE: ${{ inputs.prune || 'false' }} + with: + script: | + const fs = require("fs"); + const desired = JSON.parse(fs.readFileSync(".github/labels.json", "utf8")); + const desiredNames = new Set(desired.map(label => label.name)); + const {owner, repo} = context.repo; + + for (const label of desired) { + try { + await github.rest.issues.updateLabel({ + owner, + repo, + name: label.name, + color: label.color, + description: label.description + }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.issues.createLabel({ + owner, + repo, + name: label.name, + color: label.color, + description: label.description + }); + } + } + + if (process.env.PRUNE === "true") { + const existing = await github.paginate(github.rest.issues.listLabelsForRepo, { + owner, + repo, + per_page: 100 + }); + for (const label of existing) { + if (!desiredNames.has(label.name)) { + await github.rest.issues.deleteLabel({owner, repo, name: label.name}); + } + } + } diff --git a/.github/workflows/testpypi.yml b/.github/workflows/testpypi.yml new file mode 100644 index 0000000..fa821a0 --- /dev/null +++ b/.github/workflows/testpypi.yml @@ -0,0 +1,79 @@ +name: testpypi + +on: + workflow_dispatch: + inputs: + reason: + description: "Why are you publishing a TestPyPI build?" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: testpypi-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + with: + version: "0.10.9" + + - name: Install dependencies + run: uv sync --locked --extra dev + + - name: Run fast tests + run: make test + + - name: Build package + run: uv build + + - name: Verify wheel metadata + run: uv run --extra dev twine check dist/* + + - name: Upload dist artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: dist + path: dist/* + if-no-files-found: error + retention-days: 7 + + publish-testpypi: + name: publish-testpypi + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: testpypi + url: https://test.pypi.org/project/crewplane/ + permissions: + id-token: write + contents: read + steps: + - name: Download dist artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: dist + path: dist + + - name: Publish to TestPyPI via Trusted Publishing + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/vulnerability-scan.yml b/.github/workflows/vulnerability-scan.yml new file mode 100644 index 0000000..da219fb --- /dev/null +++ b/.github/workflows/vulnerability-scan.yml @@ -0,0 +1,45 @@ +name: vulnerability-scan + +on: + schedule: + - cron: "49 9 * * 1" + workflow_dispatch: + pull_request: + paths: + - "pyproject.toml" + - "uv.lock" + - "Makefile" + - "scripts/dependency_audit.py" + - ".github/workflows/vulnerability-scan.yml" + +permissions: + contents: read + +concurrency: + group: vulnerability-scan-${{ github.ref }} + cancel-in-progress: true + +jobs: + pip-audit: + name: pip-audit + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + with: + version: "0.10.9" + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Audit dependencies + run: make dependency-audit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8156070 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +default_install_hook_types: [pre-commit, pre-push] +default_stages: [pre-commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + hooks: + - id: check-added-large-files + args: ["--maxkb=1024", "--enforce-all"] + exclude: ^(?:\.github/crewplane-splash\.png|docs/images/concepts/(?:control-plane|different-design|why-crewplane)\.png)$ + - id: check-case-conflict + - id: check-json + - id: check-merge-conflict + - id: check-shebang-scripts-are-executable + - id: check-symlinks + - id: check-toml + - id: check-vcs-permalinks + - id: check-yaml + args: ["--unsafe"] + - id: destroyed-symlinks + - id: detect-private-key + - id: end-of-file-fixer + exclude: ^src/crewplane/py\.typed$ + - id: mixed-line-ending + args: ["--fix=lf"] + - id: trailing-whitespace + exclude: ^docs/.*\.md$ + + - repo: local + hooks: + - id: make-check + name: make check + entry: make check + language: system + pass_filenames: false + stages: [pre-push] diff --git a/AGENTS.md b/AGENTS.md index e3ff12a..be49654 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ Use this file for repo-wide agent behavior. Use [DEVELOPMENT.md](./DEVELOPMENT.m `crewplane` is a Python 3.13+ Typer CLI for running multi-agent workflows defined in Markdown. The core architectural rule is blackboard-style orchestration: providers do not coordinate through shared in-memory state; they communicate through artifacts written under `.crewplane/`. +Crewplane supports Linux, macOS, and WSL. Native Windows is not supported. + When changing behavior, preserve these properties: - CLI-first provider integration. The project is built around external AI CLIs, not vendor SDKs. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9467f5c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,50 @@ +# Code of Conduct + +This project follows the Contributor Covenant Code of Conduct, version 2.1. + +## Our pledge + +We pledge to make participation in this project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +## Our standards + +Examples of behavior that contributes to a positive environment include: + +- demonstrating empathy and kindness toward other people; +- being respectful of differing opinions, viewpoints, and experiences; +- giving and gracefully accepting constructive feedback; +- accepting responsibility and apologizing to those affected by mistakes; +- focusing on what is best for the overall community. + +Examples of unacceptable behavior include: + +- sexualized language or imagery, and sexual attention or advances; +- trolling, insulting or derogatory comments, and personal or political attacks; +- public or private harassment; +- publishing others' private information without explicit permission; +- conduct that could reasonably be considered inappropriate in a professional setting. + +## Enforcement responsibilities + +Maintainers are responsible for clarifying and enforcing standards of acceptable behavior and may remove, edit, or reject comments, commits, code, issues, pull requests, and other contributions that are not aligned with this Code of Conduct. + +## Scope + +This Code of Conduct applies within all project spaces and when an individual is officially representing the project in public spaces. + +## Reporting + +Report unacceptable behavior privately through a +[GitHub Security Advisory](https://github.com/crewplaneai/crewplane/security/advisories/new). +If the report concerns a maintainer or needs independent escalation, use +[GitHub's Report Abuse form](https://support.github.com/contact/report-abuse). + +All complaints will be reviewed and investigated promptly and fairly. Maintainers are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement + +Maintainers may take any action they deem appropriate, including warnings, temporary bans, permanent bans, or reporting to platform moderators. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e434eb..69e7f9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,9 @@ Thanks for contributing to `crewplane`. +See [GOVERNANCE.md](GOVERNANCE.md) for project roles and decision making, and +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for participation expectations. + ## Local Setup ```bash @@ -9,8 +12,29 @@ make setup make check ``` -Use Python 3.13 or newer. `uv` is optional but recommended; the Makefile falls -back to `python -m ...` commands when possible. +Use Python 3.13 or newer. The Makefile falls back to `python -m ...` for project +checks when possible; install `uv` to run the full automation checks below. + +Crewplane supports Python 3.13+ on Linux, macOS, and WSL when the configured +provider CLIs are available. Native Windows is not supported; use WSL on Windows +hosts. Pull-request CI runs on Linux for Python 3.13 and 3.14; nightly CI also +covers macOS. See +[Supported Platforms](DEVELOPMENT.md#supported-platforms) for the current +platform policy and tmux live UI notes. + +## Pull Requests + +- Use a Conventional Commit-style PR title, such as + `fix(runtime): handle failed provider output`. +- Keep PRs focused enough for a maintainer to review in one pass. +- Run `make check` before opening a PR when the change touches code, workflows, + config, or docs. +- For GitHub Actions or community-file changes, also run: + +```bash +make actionlint +uvx pre-commit==4.6.0 run --all-files --show-diff-on-failure +``` ## Development Rules @@ -47,9 +71,16 @@ make release ``` `make release-prepare` synchronizes generated release metadata from -`pyproject.toml`, refreshes `uv.lock`, builds local PyPI and npm artifacts, -writes release manifests, and prepares the Homebrew formula candidate. It fails -if the target version already exists on PyPI or npm. +`pyproject.toml`, refreshes `uv.lock`, builds local PyPI and npm artifacts plus +the offline runtime wheelhouse, writes release manifests, and prepares the +Homebrew formula candidate. It fails if the target version already exists on +PyPI or npm. + +The exact Hatchling build-system pin keeps the manual release and its immediately +following GitHub Release rebuild on the same backend version. The manually +dispatched `release-artifacts` command rebuilds the registry artifacts needed for +verification but skips the offline wheelhouse, which stays local to release +preparation and explicit wheelhouse checks. `make release-check` is state-aware. For unpublished versions it verifies generated metadata and runs lint, format-check, tests, package checks, and @@ -70,3 +101,36 @@ non-TTY npm two-factor flows, use `NPM_PUBLISH_OTP` and `NPM_DIST_TAG_OTP` so `npm publish` and `npm dist-tag add` each receive a fresh OTP. Homebrew tap publishing is still a separate maintainer step: copy the prepared formula into the tap, run audit/test there, and push the tap update. + +Immediately after the manual `make release` flow creates and pushes the tag, +dispatch `.github/workflows/release.yml` from `master` and provide that tag as +the required `tag` input, before `master` advances. The workflow rejects any +other dispatch ref. At the start of verification, it checks out the requested +tag, resolves its commit, and requires it to match the master commit that +dispatched the workflow exactly. Historical-tag backfills and new dispatches +after `master` advances are unsupported. It rebuilds from that tagged source +with `scripts/release.py release-artifacts`, verifies PyPI, npm, Homebrew, +manifest, local artifact, and tag state with +`scripts/release.py github-release-plan`, and transfers the verified artifacts +and manifest to the write-authorized job. The publishing job checks out the +exact verified commit, verifies fresh external state before mutating GitHub, +repeats the plan, and supplies the verified predecessor tag explicitly when +GitHub generates release notes. Every plan evaluation re-fetches +`origin/master` and requires the release commit to remain reachable from it. The +publisher then reloads and re-verifies draft assets immediately before +publication. The GitHub Release contains `dist/*`; the npm tarball is an +integrity input, not a GitHub Release asset. It verifies exact asset names, +sizes, and GitHub SHA-256 digests before and after publication, rejects +unexpected draft assets, and never mutates an already-published mismatch. The +release title must match the tag, and generated notes carry a hidden, versioned +automation marker; existing drafts without both are rejected before asset +mutation. +Prereleases can never be GitHub `Latest`. A stable release is `Latest` only when +it is the highest published stable version on PyPI. A repository-wide +non-dropping queue serializes publication workflows around those checks and +mutations. PyPI and npm remain the production publishing sources of truth; the +workflow does not publish production packages. +`.github/workflows/testpypi.yml` is the separate TestPyPI Trusted Publishing +workflow. Maintainers may intentionally dispatch it from any selected ref; it is +not restricted to `master`. It fails if the package version already exists on +TestPyPI. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1551903..aa7864f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -10,7 +10,19 @@ Use this document for local setup, repository layout, repeatable development wor - Python 3.13+ - `pip` (required) -- `uv` (optional, used automatically by `Makefile` if available) +- `uv` (recommended; required for the repository-automation checks below) + +## Supported Platforms + +Crewplane supports Python 3.13 and newer on Linux, macOS, and WSL when the +configured provider CLIs are available on that platform. Native Windows is not +supported; use WSL on Windows hosts. + +Pull-request CI runs on Linux for Python 3.13 and 3.14. Nightly CI runs on +Linux and macOS for Python 3.13 and 3.14. + +The tmux live dashboard requires `tmux` and is intended for Unix-like +environments. WSL supports the same tmux live mode as Linux. ## Setup @@ -25,15 +37,112 @@ make setup ```bash make test # project-env pytest -q -make lint # project-env ruff check src tests -make format # project-env ruff format src tests -make format-check # project-env ruff format --check src tests +make lint # project-env ruff check src tests scripts +make format # project-env ruff import fixes + format src tests scripts +make format-check # project-env ruff format --check src tests scripts make check # lint + format-check + tests make help # list package and release targets make clean # remove caches and build artifacts make uninstall # uninstall package from current environment ``` +## Repository Automation + +GitHub Actions, issue templates, label automation, and community files are +tailored for the public `crewplane` repository. Use the same entry points +locally and in CI: + +```bash +make setup +make check +make actionlint +uvx pre-commit==4.6.0 run --all-files --show-diff-on-failure +``` + +The Makefile falls back to `python -m ...` for project checks when `uv` is not +installed. The full repository-automation check uses `uvx` for pinned +pre-commit execution. + +Current CI policy: + +- Default branch: `master`. +- The supported platform matrix is defined in + [Supported Platforms](#supported-platforms). +- Production publishing is local-only. Maintainers run `make release` to + publish PyPI, publish npm with the `latest` dist-tag, and create/push the + final Git tag. GitHub Actions does not publish production PyPI or npm + packages and does not need PyPI or npm credentials. +- `.github/workflows/release.yml` provides manually dispatched GitHub Release + automation as the second phase of the same release operation: + + 1. **Create the production release locally.** The manual `make release` flow + is the production source of truth. + 2. **Dispatch GitHub publication from the same commit.** Immediately after + `make release` creates and pushes the final tag, dispatch the workflow from + `master` with the required `tag` input, before `master` advances. The + workflow rejects dispatches outside `refs/heads/master`. It checks out the + requested tag, resolves its commit, and requires it to match the master + commit that dispatched the workflow exactly. Historical-tag backfills and + new dispatches after `master` advances are unsupported. + 3. **Verify the tagged source.** The workflow rebuilds the artifacts and + verifies their manifest, registry state, Homebrew formula metadata, and tag + state through `scripts/release.py`. It transfers the verified sdist, wheel, + npm tarball, and manifest to the write-authorized job. + 4. **Publish the GitHub Release.** The publisher checks out the exact verified + commit and verifies fresh registry and tag state before mutating GitHub. It + repeats the plan after draft-asset verification immediately before + publication and supplies the verified predecessor tag when GitHub generates + release notes. The GitHub Release itself contains only `dist/*`. + +- GitHub Release publication fails closed: + + - Every plan evaluation re-fetches `origin/master` and requires the release + commit to remain reachable from it. + - Draft and published assets are checked by exact name, size, and GitHub's + immutable upload-time SHA-256 digest. API failures, truncated responses, and + unexpected assets fail closed. + - Release titles must exactly match their tags, and generated notes carry a + hidden, versioned automation marker. Existing drafts without both are + rejected before any asset mutation. + - New releases are built as drafts, verified, published, and verified again. + Prereleases can never be GitHub `Latest`; a stable release is `Latest` only + when it is the highest published stable version on PyPI. + - A repository-wide non-dropping concurrency queue serializes publication + workflows around those checks and mutations. PyPI and npm remain the + production publishing sources of truth. + +- Release artifact builds are reproducible: + + - `pyproject.toml` uses an exact Hatchling build-system pin so the manual + release and its immediately following GitHub Release rebuild use the same + backend version. + - The manually dispatched job rebuilds only the registry artifacts needed for + verification. `make release-prepare` owns the offline runtime wheelhouse used + by maintainer checks. +- The release workflow verifies the Homebrew formula metadata in this repo, but + does not update the Homebrew tap; tap publishing remains a separate maintainer + step. +- `.github/workflows/testpypi.yml` remains the separate TestPyPI Trusted + Publishing workflow. Maintainers may intentionally dispatch it from any + selected ref; it is not restricted to `master`. It fails if the package + version already exists on TestPyPI. Trusted Publishing is free and uses + short-lived OIDC credentials instead of a stored package token. +- Workflow actions are pinned to immutable commits, with version comments kept + beside each pin for dependency automation and review. Workflow `uv` + installations are also version-pinned. + +Operational notes: + +- `pull_request_target` label workflows do not check out or execute pull-request + code. +- Label definitions synchronize on `master` when `.github/labels.json` changes; + manual dispatch can intentionally prune labels that are no longer declared. +- Before running `.github/workflows/testpypi.yml`, create the `testpypi` GitHub + environment under `Settings -> Environments`, then register a free pending + publisher at with project + `crewplane`, owner `crewplaneai`, repository `crewplane`, workflow + `testpypi.yml`, and environment `testpypi`. No repository secret is required. + ## Cleanup and Deletion Use these commands when you need to remove generated files or reset local state: diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..e37a18a --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,39 @@ +# Governance + +This repository is currently founder-led. + +## Roles + +### Maintainer + +The maintainer has final responsibility for roadmap, releases, security response, and merge decisions. + +### Contributor + +A contributor submits issues, docs, tests, code, examples, workflows, or reviews. + +### Trusted contributor + +A trusted contributor may be granted triage or review permissions after sustained, high-quality contributions. + +## Decision making + +Most decisions are made through issues, PRs, and ADRs. + +An ADR is expected for changes that affect: + +- public CLI behavior; +- workflow manifest semantics; +- plugin/adapter boundaries; +- execution model or security model; +- storage, logs, or replay/idempotency behavior; +- compatibility commitments; +- licensing, governance, or release policy. + +## Maintainer boundaries + +The maintainer may close issues that are stale, unclear, out of scope, unsafe, or not actionable. + +## Commercial direction + +The repository is Apache-2.0 licensed. Commercial offerings may exist around hosting, enterprise support, managed integrations, or proprietary extensions. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..1833865 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,24 @@ +# Maintainers + +Current maintainer: + +- The owner of `crewplaneai/crewplane`. + +## Maintainer duties + +- triage new issues; +- review pull requests; +- keep CI green; +- publish releases; +- respond to security reports; +- update roadmap and docs; +- maintain repository automation. + +## Adding maintainers + +Trusted contributors may become maintainers after repeated high-quality contributions, security-conscious behavior, and alignment with the project direction. + +## Contributors + +Contributors are recognized through the Git history and release notes. +Contribution does not require maintainer or repository permissions. diff --git a/Makefile b/Makefile index c1a9797..2f70f13 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,8 @@ endif RUN_RELEASE = $(RUN_PYTHON) scripts/release.py -.PHONY: help setup uninstall test lint format format-check check clean \ +.PHONY: help setup uninstall test lint format format-check check actionlint \ + dependency-audit clean \ package-build package-check package-wheelhouse changelog-check \ install-smoke-pip install-smoke-uv install-smoke-pipx install-smoke \ install-script-smoke npm-pack npm-smoke brew-smoke install-check \ @@ -47,6 +48,8 @@ help: ' format Run ruff import fixes and formatter' \ ' format-check Check formatting' \ ' check Run lint, format-check, and tests' \ + ' actionlint Validate GitHub Actions workflows' \ + ' dependency-audit Audit locked and build-system Python dependencies' \ ' clean Remove caches, build output, and release scratch files' \ ' uninstall Uninstall the package from the active environment' \ '' \ @@ -97,6 +100,12 @@ format-check: check: lint format-check test +actionlint: + scripts/actionlint.sh + +dependency-audit: + $(PYTHON) scripts/dependency_audit.py + package-build: $(RUN_RELEASE) package-build diff --git a/README.md b/README.md index 6abd70f..2a6c8e9 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Crewplane splash +> **Public alpha.** Workflow and config compatibility may break before 1.0. + --- ## Why Crewplane? @@ -269,8 +271,10 @@ for exact flags, config keys, workflow syntax, and artifact formats, use the Interested in contributing? Start with [Contributing and local development](https://github.com/crewplaneai/crewplane/blob/master/DEVELOPMENT.md). +Questions and workflow ideas are welcome in +[GitHub Discussions](https://github.com/crewplaneai/crewplane/discussions). Have a coding-agent workflow you don't want to leave to a free-running loop? -Open a [Discussion](https://github.com/crewplaneai/crewplane/discussions) and describe it — good examples can become Crewplane templates. +Describe it there — good examples can become Crewplane templates. --- diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..3269416 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,33 @@ +# Support + +Community support is provided on a best-effort basis through GitHub Issues and +Discussions. + +## Where to ask + +- **Bug reports:** use the bug report issue template. +- **Feature requests:** use the feature request issue template. +- **Questions and ideas:** use GitHub Discussions. +- **Security issues:** use GitHub Security Advisories; do not open public issues. + +## What to include + +For CLI or workflow issues, include: + +- operating system and shell; +- Python version; +- package version or commit SHA; +- install method; +- command run; +- sanitized log output; +- whether tmux, external AI CLIs, or mock invokers were used. + +## Out of scope + +The project cannot provide support for: + +- debugging proprietary prompts; +- running paid provider accounts; +- reverse engineering third-party AI CLI internals; +- bypassing external tool terms of service; +- unsafe automation that executes untrusted code. diff --git a/docs/architecture/adr/0014-artifact-backed-node-boundary-resume.md b/docs/architecture/adr/0014-artifact-backed-node-boundary-resume.md index c47f707..070d917 100644 --- a/docs/architecture/adr/0014-artifact-backed-node-boundary-resume.md +++ b/docs/architecture/adr/0014-artifact-backed-node-boundary-resume.md @@ -36,10 +36,15 @@ directories. Current run state is written under: Cancelled run manifests record explicit reasons for UI stops, external cancellation, and stale-lock recovery. -Resume hydration copies only consolidated result artifacts and required findings -artifacts into the fresh results directory. Raw stage outputs, invocation logs, -review scratch state, review-loop status, symlinks, and hardlinks are never -hydrated. +Resume hydration copies validated consolidated result, required findings, and +generated-file artifacts. Workspace-enabled resume may also copy only lineage +artifacts named by validated node-state descriptors, including the selected +canonical review output and `review-state/review-loop-status.json` when needed +to reconstruct lineage. Each copied file must be contained, regular, safely +mapped into the fresh run, and match its recorded hash and size; hydrated +workspace state is rewritten through the resume schema to preserve provenance +and fresh-run identity. Arbitrary or unselected stage output, logs, scratch +state, live workspaces, cached refs, symlinks, and hardlinks remain ineligible. ## Rationale Filesystem artifacts are the product's audit boundary. Reusing only validated @@ -53,10 +58,10 @@ runs auditable and leave failed or cancelled runs intact for postmortems. more directories than mutating the failed run in place. - Node-boundary resume avoids provider-specific replay and hidden in-memory state, but any partially completed node must run again. -- Hydrating only consolidated results and required findings keeps downstream - template lookups equivalent to a fresh upstream completion, but raw stage - outputs, logs, review scratch state, and review-loop status remain only in the - source run. +- Restricting hydration to stable result artifacts and descriptor-named + workspace lineage keeps downstream templates and source reconstruction + equivalent to a fresh upstream completion. Unlisted stage output, logs, and + scratch state remain only in the source run. - Success-first duplicate detection keeps ADR 0009 whole-workflow idempotency authoritative, even when a newer failed or cancelled same-context run exists. - Filesystem-only v1 allows strict local path, symlink, hardlink, and lock @@ -76,10 +81,10 @@ runs auditable and leave failed or cancelled runs intact for postmortems. - Add fine-grained successful-run caching. Rejected to preserve ADR 0009's coarse workflow-level idempotency and avoid a dependency-aware drift engine in this change. -- Hydrate raw stage directories, provider logs, review scratch state, or - review-loop status. Rejected because those files are not the stable - downstream contract and may contain provider-specific or partial execution - state. +- Hydrate whole stage directories or arbitrary provider logs and review state. + Rejected because those files are not the stable downstream contract and may + contain provider-specific or partial execution state. Workspace lineage is + limited to descriptor-named, integrity-checked files. - Implement provider-native replay or intra-node resume. Rejected because it crosses the invoker adapter boundary and would require provider-specific semantics inside runtime scheduling. @@ -121,3 +126,6 @@ runs auditable and leave failed or cancelled runs intact for postmortems. fresh runs. Resume hydration copies ordinary node-boundary artifacts plus workspace state/bundle descriptors into the new run layout; it does not reuse old live workspace directories or cached refs as truth. +- **2026-07-11**: Clarified that descriptor-named workspace lineage may include + the selected canonical review output and review-loop status without allowing + arbitrary stage hydration. diff --git a/docs/architecture/adr/0016-node-scoped-git-workspace-isolation.md b/docs/architecture/adr/0016-node-scoped-git-workspace-isolation.md index f5e8622..07e97ab 100644 --- a/docs/architecture/adr/0016-node-scoped-git-workspace-isolation.md +++ b/docs/architecture/adr/0016-node-scoped-git-workspace-isolation.md @@ -616,6 +616,11 @@ project source if there is no verified boundary. Runtime may reset a retained checkout back to the verified source commit; if reset verification fails, it falls back to a fresh managed checkout and records the fallback. +[ADR 0014](0014-artifact-backed-node-boundary-resume.md) owns resume artifact +eligibility. Workspace-enabled resume adds only descriptor-named, +integrity-checked lineage artifacts; it never reuses live workspaces or cached +refs as truth. + Large repository behavior: - The first node for a logical worktree creates a full checkout. @@ -1152,7 +1157,7 @@ are reserved by the design: repo-relative `\{\{file:...\}\}` tokens become workspace-file locators backed by Git blob bytes, literal path resolution, and per-invocation source identities. -- Extends ADR 0014 with workspace lineage artifact hydration, +- Extends ADR 0014 with descriptor-bound workspace lineage artifact hydration, invocation-source descriptor hydration, duplicate-skip verification, and resume validation. - Refines ADR 0001 for mandatory invocation `cwd`, optional invoker workspace diff --git a/docs/architecture/workspace-isolation-implementation/07-result-bundles-state.md b/docs/architecture/workspace-isolation-implementation/07-result-bundles-state.md index 3bf531a..42c360b 100644 --- a/docs/architecture/workspace-isolation-implementation/07-result-bundles-state.md +++ b/docs/architecture/workspace-isolation-implementation/07-result-bundles-state.md @@ -89,6 +89,14 @@ Downstream rehydration rules: 6. Verify the downstream plan expects `blob_exact`. 7. Create downstream mutable workspace from the verified result commit. +Branch-export validation verifies the same bundle chain in an ephemeral bare +repository seeded with a depth-one copy of the already-validated project base +commit, tree, and blobs. Project history before that base is not copied, and +the verifier does not use object alternates. Bundle imports and verification +refs exist only in that temporary repository. Real fulfillment imports the +verified lineage afterward; dry-run stops before importing objects or creating +refs in the source repository. + For chained lineage, the workspace manager imports bundles in dependency order. Node-sourced state preserves upstream source descriptors so a depth-2 chain imports the first node bundle before the second node bundle when materializing a diff --git a/docs/architecture/workspace-isolation-implementation/08-lifecycle-locking-cleanup.md b/docs/architecture/workspace-isolation-implementation/08-lifecycle-locking-cleanup.md index 862878e..511ebc6 100644 --- a/docs/architecture/workspace-isolation-implementation/08-lifecycle-locking-cleanup.md +++ b/docs/architecture/workspace-isolation-implementation/08-lifecycle-locking-cleanup.md @@ -202,7 +202,8 @@ Runtime-owned Git commands use a sanitized environment: - reject local/worktree config includes rather than following them - use plumbing commands for runtime-owned commits -The inherited exact unset list includes: +The common inherited exact unset list for Crewplane-owned Git commands and +managed provider/setup child processes includes: - `GIT_DIR` - `GIT_WORK_TREE` @@ -218,6 +219,7 @@ The inherited exact unset list includes: - `GIT_CONFIG_NOSYSTEM` - `GIT_CONFIG_COUNT` - `GIT_CONFIG_PARAMETERS` +- `GIT_TEMPLATE_DIR` - `GIT_ATTR_NOSYSTEM` - `GIT_ATTR_SOURCE` - `GIT_LITERAL_PATHSPECS` @@ -227,6 +229,13 @@ The inherited exact unset list includes: - `GIT_ASKPASS` - `SSH_ASKPASS` +Crewplane-owned Git commands additionally unset these transport controls so +trusted local source and bundle fetches are not blocked by ambient process +policy: + +- `GIT_PROTOCOL_FROM_USER` +- `GIT_ALLOW_PROTOCOL` + The runtime helper expands prefix variables into exact names before process launch: @@ -242,6 +251,10 @@ outside the effective workspace. Runtime may set `GIT_CEILING_DIRECTORIES` to the workspace checkout root's parent so Git can discover metadata inside the workspace but cannot climb above it. +Provider and setup child processes preserve inherited +`GIT_PROTOCOL_FROM_USER` and `GIT_ALLOW_PROTOCOL` values. These variables are +caller-owned transport restrictions rather than repository-discovery state. + Runtime does not promise to suppress hooks, filters, or pathspec behavior in provider-run Git commands after the provider starts. Instead, v1 rejects final `HEAD` movement, unsupported config, unsupported attributes, and protected-state diff --git a/docs/guides/running-workflows.md b/docs/guides/running-workflows.md index f9d7876..426b924 100644 --- a/docs/guides/running-workflows.md +++ b/docs/guides/running-workflows.md @@ -174,8 +174,9 @@ Expected advisory phrases include: - `Resume advisory: would_execute_full_run` - `Resume advisory: would_skip` -- `Resume advisory: would_resume node(s) from `, for example - `from 20260629-202539` +- `Resume advisory: would_resume node(s) from (nodes: )`, + which reports the exact dependency-closed node identifiers selected for + hydration ## Run The Workflow diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 21eb663..a2ce733 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -35,7 +35,7 @@ and `.crewplane/execution-results//` as needed. | `CLI '' not found in PATH for provider ''` | A workflow references an agent whose CLI executable is unavailable. | Confirm the command works directly or switch back to `mock`. | | `Identical context detected` | A same-signature successful run was reused. | Use `crewplane run --force` for a fresh run. | | `Resume advisory: would_skip` | Dry-run predicts duplicate skip. | Run with `--force` to bypass. | -| `Resume advisory: would_resume node(s) from ` | Dry-run predicts resume hydration from a failed or cancelled run. | Inspect `resumed_nodes` and `.crewplane/execution-stages///resume-source.json` after a run. | +| `Resume advisory: would_resume node(s) from (nodes: )` | Dry-run predicts resume hydration of the listed dependency-closed nodes from a failed or cancelled run. | Inspect `resumed_nodes` and `.crewplane/execution-stages///resume-source.json` after a run. | | `Resuming workflow '' from validated node boundary(s)` | A run hydrated completed nodes from prior artifacts. | Inspect `.crewplane/execution-stages//manifests/run.json`. | | `Run lock unavailable: ` | The same-context run lock could not be acquired. | [Run lock unavailable](#run-lock-unavailable). | | `tmux not found; continuing without live dashboard.` | Execution can continue without the live dashboard. | Use `--no-live` or install/configure tmux. | diff --git a/pyproject.toml b/pyproject.toml index ea01693..066e526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ Issues = "https://github.com/crewplaneai/crewplane/issues" Documentation = "https://github.com/crewplaneai/crewplane/blob/master/docs/index.md" [build-system] -requires = ["hatchling"] +requires = ["hatchling==1.30.1"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] @@ -62,7 +62,7 @@ only-include = [ target-version = "py313" [tool.ruff.lint] -select = ["E", "F", "I", "B", "UP", "SIM", "ARG"] +select = ["E", "F", "I", "B", "UP", "SIM", "ARG", "T10"] ignore = ["E501", "B008"] dummy-variable-rgx = "^$" diff --git a/scripts/actionlint.sh b/scripts/actionlint.sh new file mode 100755 index 0000000..11d1ded --- /dev/null +++ b/scripts/actionlint.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly ACTIONLINT_VERSION="1.7.9" +# actionlint 1.7.9 predates GitHub's supported concurrency.queue property. +readonly ACTIONLINT_QUEUE_IGNORE='unexpected key "queue" for "concurrency" section' + +actionlint_release_metadata() { + case "$1-$2" in + Darwin-x86_64) + printf '%s %s\n' \ + "actionlint_${ACTIONLINT_VERSION}_darwin_amd64.tar.gz" \ + "f89a910e90e536f60df7c504160247db01dd67cab6f08c064c1c397b76c91a79" + ;; + Darwin-arm64) + printf '%s %s\n' \ + "actionlint_${ACTIONLINT_VERSION}_darwin_arm64.tar.gz" \ + "855e49e823fc68c6371fd6967e359cde11912d8d44fed343283c8e6e943bd789" + ;; + Linux-x86_64 | Linux-amd64) + printf '%s %s\n' \ + "actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + "233b280d05e100837f4af1433c7b40a5dcb306e3aa68fb4f17f8a7f45a7df7b4" + ;; + Linux-aarch64 | Linux-arm64) + printf '%s %s\n' \ + "actionlint_${ACTIONLINT_VERSION}_linux_arm64.tar.gz" \ + "6b82a3b8c808bf1bcd39a95aced22fc1a026eef08ede410f81e274af8deadbbc" + ;; + *) + echo "actionlint bootstrap does not support platform $1-$2." >&2 + return 1 + ;; + esac +} + +require_tool() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "actionlint bootstrap requires '$1'." >&2 + return 1 + fi +} + +verify_actionlint_checksum() { + local archive_path="$1" + local expected_checksum="$2" + local checksum_output + + if command -v sha256sum >/dev/null 2>&1; then + checksum_output="$(sha256sum "$archive_path")" + elif command -v shasum >/dev/null 2>&1; then + checksum_output="$(shasum -a 256 "$archive_path")" + else + echo "actionlint bootstrap requires 'sha256sum' or 'shasum'." >&2 + return 1 + fi + + local actual_checksum="${checksum_output%% *}" + if [[ "$actual_checksum" != "$expected_checksum" ]]; then + echo "actionlint archive checksum mismatch." >&2 + return 1 + fi +} + +main() { + if command -v actionlint >/dev/null 2>&1 \ + && [[ "$(actionlint -version 2>/dev/null | head -n 1)" == "$ACTIONLINT_VERSION" ]]; then + exec actionlint -color -ignore "$ACTIONLINT_QUEUE_IGNORE" "$@" + fi + + local release_metadata + release_metadata="$(actionlint_release_metadata "$(uname -s)" "$(uname -m)")" + local archive + local expected_checksum + read -r archive expected_checksum <<<"$release_metadata" + + require_tool curl + require_tool tar + require_tool mktemp + + local url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" + local temp_dir + ( + temp_dir="$(mktemp -d)" + trap 'rm -rf "$temp_dir"' EXIT + + curl \ + --connect-timeout 10 \ + --fail \ + --location \ + --max-time 120 \ + --silent \ + --show-error \ + "$url" \ + --output "$temp_dir/$archive" + verify_actionlint_checksum "$temp_dir/$archive" "$expected_checksum" + tar -xzf "$temp_dir/$archive" -C "$temp_dir" actionlint + "$temp_dir/actionlint" -color -ignore "$ACTIONLINT_QUEUE_IGNORE" "$@" + ) +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/dependency_audit.py b/scripts/dependency_audit.py new file mode 100644 index 0000000..07e1e3b --- /dev/null +++ b/scripts/dependency_audit.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import tomllib +from collections.abc import Sequence +from pathlib import Path + +AUDIT_PYTHON_VERSION = "3.13" +AUDIT_TIMEOUT_SECONDS = 300 +PIP_AUDIT_VERSION = "2.10.1" +UV_EXPORT_TIMEOUT_SECONDS = 120 + + +def audit_dependencies(root: Path) -> None: + build_requirements = read_build_requirements(root / "pyproject.toml") + with tempfile.TemporaryDirectory(prefix="crewplane-dependency-audit-") as temp_dir: + temp_path = Path(temp_dir) + locked_path = temp_path / "locked-project-dev.txt" + build_path = temp_path / "build-system.txt" + export_locked_requirements(root, locked_path) + write_requirements(build_path, build_requirements) + + print("Auditing locked project and development dependencies...", flush=True) + audit_requirements(root, locked_path) + print("Auditing build-system dependencies...", flush=True) + audit_requirements(root, build_path) + + +def read_build_requirements(pyproject_path: Path) -> tuple[str, ...]: + with pyproject_path.open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) + build_system = pyproject.get("build-system") + requirements = ( + build_system.get("requires") if isinstance(build_system, dict) else None + ) + if ( + not isinstance(requirements, list) + or not requirements + or not all( + isinstance(requirement, str) and requirement.strip() + for requirement in requirements + ) + ): + raise ValueError( + "pyproject.toml [build-system].requires must be a non-empty list " + "of non-empty strings" + ) + return tuple(requirement.strip() for requirement in requirements) + + +def export_locked_requirements(root: Path, output_path: Path) -> None: + subprocess.run( + [ + "uv", + "export", + "--locked", + "--extra", + "dev", + "--no-emit-project", + "--format", + "requirements-txt", + "--output-file", + str(output_path), + ], + cwd=root, + check=True, + timeout=UV_EXPORT_TIMEOUT_SECONDS, + ) + + +def write_requirements(output_path: Path, requirements: Sequence[str]) -> None: + output_path.write_text( + "".join(f"{requirement}\n" for requirement in requirements), + encoding="utf-8", + ) + + +def audit_requirements(root: Path, requirements_path: Path) -> None: + subprocess.run( + [ + "uvx", + "--python", + AUDIT_PYTHON_VERSION, + f"pip-audit=={PIP_AUDIT_VERSION}", + "-r", + str(requirements_path), + "--strict", + ], + cwd=root, + check=True, + timeout=AUDIT_TIMEOUT_SECONDS, + ) + + +def main() -> int: + try: + audit_dependencies(Path.cwd()) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f"Dependency audit failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_github_release.sh b/scripts/publish_github_release.sh new file mode 100755 index 0000000..eec1a16 --- /dev/null +++ b/scripts/publish_github_release.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash +set -euo pipefail + +RELEASE_AUTOMATION_MARKER='' + +sha256_file() { + local path="$1" + local digest + if command -v sha256sum >/dev/null 2>&1; then + read -r digest _ < <(sha256sum -- "$path") + elif command -v shasum >/dev/null 2>&1; then + read -r digest _ < <(shasum -a 256 -- "$path") + else + echo "sha256sum or shasum is required to verify release assets." >&2 + return 1 + fi + if [[ ! "$digest" =~ ^[a-f0-9]{64}$ ]]; then + echo "Could not calculate a valid SHA-256 digest for $path." >&2 + return 1 + fi + printf '%s\n' "$digest" +} + +validate_boolean() { + local name="$1" + local value="$2" + if [[ "$value" != "true" && "$value" != "false" ]]; then + echo "$name must be 'true' or 'false'." >&2 + return 1 + fi +} + +build_expected_assets() { + local records_file="$1" + local names_file="$2" + local unsorted_records="$3" + local unsorted_names="$4" + shift 4 + local release_artifacts=("$@") + + : >"$unsorted_records" + : >"$unsorted_names" + local artifact + for artifact in "${release_artifacts[@]}"; do + if [[ ! -f "$artifact" || -L "$artifact" ]]; then + echo "Release artifact must be a regular, non-symlink file: $artifact" >&2 + return 1 + fi + local name="${artifact##*/}" + if printf '%s' "$name" | LC_ALL=C grep -q '[[:cntrl:]]'; then + echo "Release asset names may not contain control characters: $name" >&2 + return 1 + fi + local size + size="$(wc -c <"$artifact")" + size="${size//[[:space:]]/}" + local digest + digest="$(sha256_file "$artifact")" + printf '%s\t%s\tsha256:%s\n' \ + "$name" "$size" "$digest" >>"$unsorted_records" + printf '%s\n' "$name" >>"$unsorted_names" + done + LC_ALL=C sort "$unsorted_records" >"$records_file" + LC_ALL=C sort "$unsorted_names" >"$names_file" + + if [[ "$(wc -l <"$names_file")" -ne "${#release_artifacts[@]}" ]]; then + echo "Could not account for every local release artifact." >&2 + return 1 + fi +} + +query_release_snapshot() { + local repository="$1" + local tag_name="$2" + local snapshot_file="$3" + local owner="${repository%%/*}" + local name="${repository#*/}" + if [[ ! "$repository" =~ ^[^/]+/[^/]+$ ]]; then + echo "GH_REPO must use the owner/repository form." >&2 + return 1 + fi + + if ! gh api graphql \ + -f owner="$owner" \ + -f name="$name" \ + -f tag="$tag_name" \ + -f query='query($owner: String!, $name: String!, $tag: String!) { + repository(owner: $owner, name: $name) { + release(tagName: $tag) { + id + name + description + isDraft + isPrerelease + isLatest + releaseAssets(first: 100) { + totalCount + nodes { name size digest } + } + } + } + }' \ + --jq ' + .data.repository as $repository | + if $repository == null then + error("repository not found") + elif $repository.release == null then + ["release", "absent"] | @tsv + else + $repository.release as $release | + if ($release.name | type) != "string" or + ($release.description | type) != "string" then + error("release title or description is missing") + else + (["release", "present", $release.id, $release.name, + (($release.description | + startswith("'"$RELEASE_AUTOMATION_MARKER"'")) | tostring), + ($release.isDraft | tostring), + ($release.isPrerelease | tostring), + ($release.isLatest | tostring), + ($release.releaseAssets.totalCount | tostring), + ($release.releaseAssets.nodes | length | tostring)] | @tsv), + ($release.releaseAssets.nodes[] | + ["asset", .name, (.size | tostring), (.digest // "")] | @tsv) + end + end + ' >"$snapshot_file"; then + echo "Could not query GitHub Release $tag_name; refusing to mutate it." >&2 + return 1 + fi +} + +load_release_snapshot() { + local snapshot_file="$1" + local assets_file="$2" + local unsorted_assets_file="$3" + : >"$unsorted_assets_file" + + local first_line + if ! IFS= read -r first_line <"$snapshot_file"; then + echo "GitHub returned an empty release response." >&2 + return 1 + fi + if [[ "$first_line" == $'release\tabsent' ]]; then + if [[ "$(wc -l <"$snapshot_file")" -ne 1 ]]; then + echo "GitHub returned malformed metadata for an absent release." >&2 + return 1 + fi + release_exists=false + release_title="" + release_has_automation_marker="" + release_is_draft="" + release_is_prerelease="" + release_is_latest="" + : >"$assets_file" + return 0 + fi + + local record + local presence + local release_id + local total_count + local returned_count + local extra + IFS=$'\t' read -r record presence release_id release_title \ + release_has_automation_marker release_is_draft release_is_prerelease \ + release_is_latest total_count returned_count extra <<<"$first_line" + if [[ "$record" != "release" || "$presence" != "present" || -z "$release_id" || -n "$extra" ]]; then + echo "GitHub returned malformed release metadata." >&2 + return 1 + fi + validate_boolean \ + "GitHub release automation marker state" \ + "$release_has_automation_marker" + validate_boolean "GitHub draft state" "$release_is_draft" + validate_boolean "GitHub prerelease state" "$release_is_prerelease" + validate_boolean "GitHub Latest state" "$release_is_latest" + if [[ ! "$total_count" =~ ^[0-9]+$ || ! "$returned_count" =~ ^[0-9]+$ ]]; then + echo "GitHub returned invalid release asset counts." >&2 + return 1 + fi + + local parsed_count=0 + while IFS=$'\t' read -r record asset_name asset_size asset_digest extra; do + if [[ "$record" != "asset" || -z "$asset_name" || -n "$extra" ]]; then + echo "GitHub returned malformed release asset metadata." >&2 + return 1 + fi + if [[ ! "$asset_size" =~ ^[0-9]+$ ]]; then + echo "GitHub returned incomplete size or digest metadata for $asset_name." >&2 + return 1 + fi + if [[ ! "$asset_digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then + if [[ "$release_is_draft" != "true" || -n "$asset_digest" ]]; then + echo "GitHub returned incomplete size or digest metadata for $asset_name." >&2 + return 1 + fi + fi + printf '%s\t%s\t%s\n' \ + "$asset_name" "$asset_size" "$asset_digest" \ + >>"$unsorted_assets_file" + parsed_count=$((parsed_count + 1)) + done < <(tail -n +2 "$snapshot_file") + + if [[ "$total_count" -ne "$returned_count" || "$returned_count" -ne "$parsed_count" ]]; then + echo "GitHub release asset query was truncated or internally inconsistent." >&2 + return 1 + fi + LC_ALL=C sort "$unsorted_assets_file" >"$assets_file" + release_exists=true +} + +refresh_release_state() { + query_release_snapshot "$repository" "$tag_name" "$snapshot_file" + load_release_snapshot "$snapshot_file" "$release_assets_file" \ + "$unsorted_release_assets_file" +} + +verify_loaded_assets() { + local label="$1" + if ! cmp -s "$expected_assets_file" "$release_assets_file"; then + echo "$label assets do not match the verified dist artifacts:" >&2 + diff -u "$expected_assets_file" "$release_assets_file" >&2 || true + return 1 + fi +} + +verify_loaded_release_metadata() { + local label="$1" + if [[ "$release_title" != "$tag_name" ]]; then + echo "$label title does not match the release tag." >&2 + return 1 + fi + if [[ "$release_has_automation_marker" != "true" ]]; then + echo "$label notes are missing the release automation marker." >&2 + return 1 + fi +} + +verify_loaded_published_release() { + local label="$1" + if [[ "$release_exists" != "true" || "$release_is_draft" != "false" ]]; then + echo "$label is missing or still a draft." >&2 + return 1 + fi + verify_loaded_release_metadata "$label" + verify_loaded_assets "$label" + if [[ "$release_is_prerelease" != "$is_prerelease" ]]; then + echo "$label prerelease state does not match the verified version." >&2 + return 1 + fi + if [[ "$release_is_latest" != "$is_latest" ]]; then + echo "$label Latest state does not match the verified release plan." >&2 + return 1 + fi +} + +verify_loaded_draft_release() { + local label="$1" + if [[ "$release_exists" != "true" || "$release_is_draft" != "true" ]]; then + echo "$label is missing or is no longer a draft." >&2 + return 1 + fi + verify_loaded_release_metadata "$label" + verify_loaded_assets "$label" +} + +refresh_verified_release_plan() { + local plan_file="$1" + if ! uv run --locked --extra dev \ + python scripts/release.py github-release-plan \ + --expected-tag "$tag_name" >"$plan_file"; then + echo "Could not verify the release plan; refusing to mutate GitHub." >&2 + return 1 + fi + + local line_count + line_count="$(wc -l <"$plan_file")" + line_count="${line_count//[[:space:]]/}" + local prerelease_line + local latest_line + local notes_start_tag_line + IFS= read -r prerelease_line <"$plan_file" + latest_line="$(sed -n '2p' "$plan_file")" + notes_start_tag_line="$(sed -n '3p' "$plan_file")" + if [[ "$line_count" != "3" \ + || ! "$prerelease_line" =~ ^prerelease=(true|false)$ \ + || ! "$latest_line" =~ ^latest=(true|false)$ \ + || ! "$notes_start_tag_line" =~ ^notes_start_tag=(|v[0-9A-Za-z._!-]+)$ ]]; then + echo "The verified release plan returned malformed output." >&2 + return 1 + fi + is_prerelease="${prerelease_line#prerelease=}" + is_latest="${latest_line#latest=}" + notes_start_tag="${notes_start_tag_line#notes_start_tag=}" + if [[ "$is_prerelease" == "true" && "$is_latest" == "true" ]]; then + echo "The verified release plan marked a prerelease as GitHub Latest." >&2 + return 1 + fi +} + +publish_github_release() ( + local dist_dir="$1" + local tag_name="${TAG_NAME:?TAG_NAME is required}" + local repository="${GH_REPO:?GH_REPO is required}" + + shopt -s nullglob dotglob + local release_artifacts=("$dist_dir"/*) + if [[ "${#release_artifacts[@]}" -eq 0 ]]; then + echo "No verified release artifacts were found in $dist_dir." >&2 + return 1 + fi + + local temp_dir + temp_dir="$(mktemp -d)" + trap 'rm -rf "$temp_dir"' EXIT + local expected_assets_file="$temp_dir/expected-assets.tsv" + local expected_names_file="$temp_dir/expected-names.txt" + local unsorted_expected_assets_file="$temp_dir/expected-assets.unsorted.tsv" + local unsorted_expected_names_file="$temp_dir/expected-names.unsorted.txt" + local snapshot_file="$temp_dir/release-snapshot.tsv" + local release_assets_file="$temp_dir/release-assets.tsv" + local unsorted_release_assets_file="$temp_dir/release-assets.unsorted.tsv" + local plan_file="$temp_dir/release-plan.txt" + build_expected_assets \ + "$expected_assets_file" \ + "$expected_names_file" \ + "$unsorted_expected_assets_file" \ + "$unsorted_expected_names_file" \ + "${release_artifacts[@]}" + + local is_prerelease + local is_latest + local notes_start_tag + refresh_verified_release_plan "$plan_file" + local release_exists + local release_title + local release_has_automation_marker + local release_is_draft + local release_is_prerelease + local release_is_latest + refresh_release_state + + if [[ "$release_exists" == "true" && "$release_is_draft" == "false" ]]; then + verify_loaded_published_release "Published release" + echo "Verified existing published GitHub Release $tag_name; leaving it unchanged." + return 0 + fi + + if [[ "$release_exists" == "true" ]]; then + verify_loaded_release_metadata "Existing draft" + local release_names_file="$temp_dir/release-names.txt" + cut -f1 "$release_assets_file" >"$release_names_file" + local unexpected_assets + unexpected_assets="$(comm -13 "$expected_names_file" "$release_names_file")" + if [[ -n "$unexpected_assets" ]]; then + echo "Refusing to publish a draft with unexpected assets:" >&2 + printf '%s\n' "$unexpected_assets" >&2 + return 1 + fi + + gh release upload "$tag_name" "${release_artifacts[@]}" \ + --repo "$repository" \ + --clobber + refresh_release_state + verify_loaded_draft_release "Draft" + else + local release_notes_flags=() + if [[ -n "$notes_start_tag" ]]; then + release_notes_flags=(--notes-start-tag "$notes_start_tag") + fi + gh release create "$tag_name" "${release_artifacts[@]}" \ + --repo "$repository" \ + --draft \ + --verify-tag \ + --generate-notes \ + --notes "$RELEASE_AUTOMATION_MARKER" \ + "${release_notes_flags[@]}" \ + --title "$tag_name" \ + --prerelease=false \ + --latest=false + refresh_release_state + verify_loaded_draft_release "New draft" + fi + + refresh_verified_release_plan "$plan_file" + refresh_release_state + verify_loaded_draft_release "Final draft" + local release_flags=(--prerelease=false --latest=false) + if [[ "$is_prerelease" == "true" ]]; then + release_flags=(--prerelease --latest=false) + elif [[ "$is_latest" == "true" ]]; then + release_flags=(--prerelease=false --latest) + fi + + gh release edit "$tag_name" \ + --repo "$repository" \ + --tag "$tag_name" \ + --draft=false \ + --verify-tag \ + "${release_flags[@]}" + refresh_release_state + verify_loaded_published_release "Published release" + echo "Published GitHub Release $tag_name." +) + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + if [[ "$#" -ne 1 ]]; then + echo "Usage: $0 DIST_DIR" >&2 + exit 2 + fi + publish_github_release "$1" +fi diff --git a/scripts/release.py b/scripts/release.py index c5efa90..ac63342 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 from __future__ import annotations import argparse @@ -31,6 +30,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: subparsers = parser.add_subparsers(dest="command", required=True) for command in ( "prepare", + "release-artifacts", "check", "confirm", "package-build", @@ -48,6 +48,15 @@ def parse_args(argv: list[str]) -> argparse.Namespace: "changelog-check", ): subparsers.add_parser(command) + for command in ( + "verify-complete", + "github-release-plan", + ): + verify_parser = subparsers.add_parser(command) + verify_parser.add_argument( + "--expected-tag", + help="The tag expected by the release workflow. Must match context.version.tag.", + ) for command in ("publish-pypi", "publish-npm", "finalize"): command_parser = subparsers.add_parser(command) command_parser.add_argument("--execute", action="store_true") @@ -69,8 +78,16 @@ def dispatch(args: argparse.Namespace, root: Path, runner: CommandRunner) -> int if command == "prepare": build.prepare_release(root, runner) return 0 + if command == "release-artifacts": + build.release_artifacts(root, runner) + return 0 if command == "check": return release_check(root, runner) + if command == "verify-complete": + return publish.verify_complete_release(root, runner, args.expected_tag) + if command == "github-release-plan": + publish.print_github_release_plan(root, runner, args.expected_tag) + return 0 if command == "confirm": publish.confirm_release(root) return 0 diff --git a/scripts/release/build.py b/scripts/release/build.py index eb3c57e..c2a2f1e 100644 --- a/scripts/release/build.py +++ b/scripts/release/build.py @@ -41,6 +41,7 @@ def prepare_release(root: Path, runner: CommandRunner) -> None: sync_generated_metadata(context, runner) clean_release_outputs(context) artifacts = build_release_artifacts(context, runner) + build_wheelhouse(context, runner) sync_homebrew_formula_metadata(context, artifacts["pypi_sdist"].sha256) manifest = write_release_manifest(context, artifacts) fail_if_generated_metadata_stale(context, manifest) @@ -48,6 +49,16 @@ def prepare_release(root: Path, runner: CommandRunner) -> None: print_homebrew_instructions(context) +def release_artifacts(root: Path, runner: CommandRunner) -> None: + context = read_release_context(root) + fail_if_generated_metadata_stale(context, None) + clean_release_outputs(context) + artifacts = build_release_artifacts(context, runner) + manifest = write_release_manifest(context, artifacts) + fail_if_generated_metadata_stale(context, manifest) + print("Release artifacts rebuilt.") + + def clean_release_outputs(context: ReleaseContext) -> None: for relative in ("dist", "build", ".release", ".release-manifests"): shutil.rmtree(context.root / relative, ignore_errors=True) @@ -62,7 +73,6 @@ def build_release_artifacts( build_python_artifacts(context, runner) run_twine_check(context, runner) npm_artifact = build_npm_artifact(context, runner) - build_wheelhouse(context, runner) sdist = context.root / "dist" / context.sdist_filename wheel = context.root / "dist" / context.wheel_filename ensure_file(sdist) diff --git a/scripts/release/publish.py b/scripts/release/publish.py index 90509f3..73b01ef 100644 --- a/scripts/release/publish.py +++ b/scripts/release/publish.py @@ -8,8 +8,11 @@ from dataclasses import dataclass from pathlib import Path +from packaging.version import InvalidVersion, Version + from . import smoke from .state import ( + ORIGIN_MASTER_ANCESTRY_ERROR, CommandRunner, DerivedReleaseState, NpmRelease, @@ -22,13 +25,17 @@ derive_release_state, fail_if_generated_metadata_stale, inspect_git_state, + inspect_release_tag_state, is_tag_only_missing_error, + manifest_context_issues, + print_state, query_npm_release, query_pypi_release, read_formula_state, read_manifest, read_release_context, require_publish_git_state, + verified_release_notes_start_tag, verify_formula_state_for_release, verify_git_tag_state, verify_local_manifest_artifacts, @@ -45,6 +52,13 @@ class NpmOtp: dist_tag: str +@dataclass(frozen=True) +class GitHubReleasePlan: + prerelease: bool + latest: bool + notes_start_tag: str + + def confirm_release(root: Path) -> None: context = read_release_context(root) prompt = ( @@ -196,16 +210,171 @@ def finalize_release(root: Path, runner: CommandRunner, execute: bool) -> int: ) -def verify_completed_release(root: Path, runner: CommandRunner) -> DerivedReleaseState: +def verify_completed_release( + root: Path, + runner: CommandRunner, + expected_tag: str | None = None, +) -> DerivedReleaseState: context = read_release_context(root) manifest = read_manifest(root) pypi = query_pypi_release(context) npm = query_npm_release(context) formula = read_formula_state(context) - git = inspect_git_state(context, runner) + git = inspect_release_tag_state(context, runner, expected_tag=expected_tag) return derive_release_state(context, pypi, npm, formula, git, manifest) +def verify_complete_release( + root: Path, runner: CommandRunner, expected_tag: str | None = None +) -> int: + state = verify_completed_release(root, runner, expected_tag=expected_tag) + return report_verification_result(state) + + +def verified_github_release_plan( + root: Path, + runner: CommandRunner, + expected_tag: str | None = None, +) -> GitHubReleasePlan: + context = read_release_context(root) + manifest = read_manifest(root) + manifest_issues = manifest_context_issues(context, manifest) + expected_artifact_keys = {"pypi_sdist", "pypi_wheel", "npm_tarball"} + if set(manifest.artifacts) != expected_artifact_keys: + manifest_issues.append( + "release manifest must contain exactly the sdist, wheel, and npm tarball" + ) + fail_github_release_verification(manifest_issues) + + local_issues = github_release_bundle_issues(context, manifest) + local_issues.extend( + verify_local_manifest_artifacts( + context, + manifest, + ("pypi_sdist", "pypi_wheel", "npm_tarball"), + ) + ) + fail_github_release_verification(local_issues) + + pypi = query_pypi_release(context) + npm = query_npm_release(context) + formula = read_formula_state(context) + git = inspect_release_tag_state(context, runner, expected_tag=expected_tag) + issues: list[str] = [] + issues.extend(verify_pypi_artifacts(context, pypi, manifest)) + issues.extend(verify_npm_artifact(context, npm, manifest)) + issues.extend(verify_formula_state_for_release(context, formula, manifest)) + issues.extend(verify_git_tag_state(git)) + issues.extend(npm_latest_progression_issues(context, npm)) + latest, latest_issues = github_latest_eligibility(context, pypi) + issues.extend(latest_issues) + fail_github_release_verification(issues) + notes_start_tag = verified_release_notes_start_tag(context, runner) + return GitHubReleasePlan( + prerelease=context.version.is_prerelease, + latest=latest, + notes_start_tag=notes_start_tag, + ) + + +def github_release_bundle_issues( + context: ReleaseContext, + manifest: ReleaseManifest, +) -> list[str]: + expected_paths = { + "pypi_sdist": Path("dist") / context.sdist_filename, + "pypi_wheel": Path("dist") / context.wheel_filename, + "npm_tarball": Path(".release") / "npm" / context.npm_filename, + } + issues: list[str] = [] + for key, expected_path in expected_paths.items(): + if Path(manifest.artifact(key).path) != expected_path: + issues.append(f"release manifest artifact {key} has an unexpected path") + + expected_directories = { + Path("dist"): {context.sdist_filename, context.wheel_filename}, + Path(".release") / "npm": {context.npm_filename}, + } + for relative_directory, expected_names in expected_directories.items(): + directory = context.root / relative_directory + if not directory.is_dir() or directory.is_symlink(): + issues.append( + f"release bundle directory is missing or invalid: {relative_directory}" + ) + continue + entries = list(directory.iterdir()) + actual_names = {entry.name for entry in entries} + if actual_names != expected_names: + issues.append( + f"release bundle directory has unexpected contents: {relative_directory}" + ) + for entry in entries: + if entry.name in expected_names and ( + not entry.is_file() or entry.is_symlink() + ): + relative_path = entry.relative_to(context.root) + issues.append( + f"release bundle artifact must be a regular file: {relative_path}" + ) + return issues + + +def npm_latest_progression_issues( + context: ReleaseContext, npm: NpmRelease +) -> list[str]: + try: + latest = Version(npm.latest) + except InvalidVersion: + return ["npm latest dist-tag is missing or invalid"] + target = Version(context.version.npm) + if latest == target and npm.latest != context.version.npm: + return ["npm latest dist-tag does not exactly match the target release"] + if latest < target: + return ["npm latest dist-tag points to an older release"] + return [] + + +def github_latest_eligibility( + context: ReleaseContext, + pypi: PypiRelease, +) -> tuple[bool, list[str]]: + if context.version.is_prerelease: + return False, [] + try: + latest_stable = Version(pypi.latest_stable) + except InvalidVersion: + return False, ["PyPI has no verifiable stable release for GitHub Latest"] + target = Version(context.version.python) + if latest_stable < target: + return False, ["PyPI reports a stable release older than the target version"] + return latest_stable == target, [] + + +def fail_github_release_verification(issues: list[str]) -> None: + if issues: + raise ReleaseError( + "GitHub release verification failed:\n " + "\n ".join(issues) + ) + + +def print_github_release_plan( + root: Path, + runner: CommandRunner, + expected_tag: str | None = None, +) -> None: + plan = verified_github_release_plan(root, runner, expected_tag=expected_tag) + print(f"prerelease={str(plan.prerelease).lower()}") + print(f"latest={str(plan.latest).lower()}") + print(f"notes_start_tag={plan.notes_start_tag}") + + +def report_verification_result(state: DerivedReleaseState) -> int: + print_state(state) + if state.status == ReleaseStatus.COMPLETE: + return 0 + return 1 + + def is_registry_recovery(pypi: PypiRelease, npm: NpmRelease) -> bool: return pypi.exists or npm.exists @@ -384,6 +553,8 @@ def otp_arg(value: str) -> list[str]: def create_and_push_tag(context: ReleaseContext, runner: CommandRunner) -> None: git = inspect_git_state(context, runner) + if not git.head_reachable_from_origin_master: + raise ReleaseError(ORIGIN_MASTER_ANCESTRY_ERROR) if not git.tag_commit: runner.run( [ diff --git a/scripts/release/state_checks.py b/scripts/release/state_checks.py index 8458a26..82f47df 100644 --- a/scripts/release/state_checks.py +++ b/scripts/release/state_checks.py @@ -7,6 +7,7 @@ from typing import Any from packaging.markers import InvalidMarker, Marker +from packaging.version import InvalidVersion, Version from .state_types import ( ArtifactIdentity, @@ -104,6 +105,7 @@ def inspect_git_state(context: ReleaseContext, runner: CommandRunner) -> GitStat branch = git_output(runner, root, ["git", "branch", "--show-current"]) default_branch = remote_default_branch(runner, root) head_commit = git_output(runner, root, ["git", "rev-parse", "HEAD"]) + head_reachable = head_reachable_from_origin_master(runner, root) dirty = bool(git_output(runner, root, ["git", "status", "--porcelain=v1"])) ahead, behind = upstream_counts(runner, root) tag_commit = git_tag_commit(runner, root, context.version.tag) @@ -112,6 +114,7 @@ def inspect_git_state(context: ReleaseContext, runner: CommandRunner) -> GitStat branch=branch, default_branch=default_branch, head_commit=head_commit, + head_reachable_from_origin_master=head_reachable, upstream_ahead=ahead, upstream_behind=behind, dirty=dirty, @@ -120,6 +123,50 @@ def inspect_git_state(context: ReleaseContext, runner: CommandRunner) -> GitStat ) +def inspect_release_tag_state( + context: ReleaseContext, runner: CommandRunner, expected_tag: str | None = None +) -> GitState: + root = context.root + tag = expected_tag or context.version.tag + if expected_tag is not None and expected_tag != context.version.tag: + raise ReleaseError( + f"expected workflow tag {expected_tag!r} but context declares {context.version.tag!r}" + ) + head_commit = git_output(runner, root, ["git", "rev-parse", "HEAD"]) + head_reachable = head_reachable_from_origin_master(runner, root) + tag_commit = git_tag_commit(runner, root, tag) + remote_tag_commit = remote_git_tag_commit(runner, root, tag) + return GitState( + branch="", + default_branch="", + head_commit=head_commit, + head_reachable_from_origin_master=head_reachable, + upstream_ahead=0, + upstream_behind=0, + dirty=False, + tag_commit=tag_commit, + remote_tag_commit=remote_tag_commit, + ) + + +def head_reachable_from_origin_master(runner: CommandRunner, root: Path) -> bool: + runner.run( + ["git", "fetch", "--quiet", "--no-tags", "origin", "refs/heads/master"], + cwd=root, + timeout=120, + ) + result = runner.run( + ["git", "merge-base", "--is-ancestor", "HEAD", "FETCH_HEAD"], + cwd=root, + timeout=60, + capture_output=True, + check=False, + ) + if result.returncode in {0, 1}: + return result.returncode == 0 + raise ReleaseError("could not verify release commit ancestry against origin/master") + + def git_output(runner: CommandRunner, root: Path, command: Sequence[str]) -> str: result = runner.run(command, cwd=root, timeout=60, capture_output=True, check=True) return result.stdout.strip() @@ -193,6 +240,98 @@ def remote_git_tag_commit(runner: CommandRunner, root: Path, tag: str) -> str: return direct +def verified_release_notes_start_tag( + context: ReleaseContext, + runner: CommandRunner, +) -> str: + parent_commit = _release_commit_first_parent(context.root, runner) + if not parent_commit: + return "" + predecessor = _nearest_reachable_version_tag( + context.root, + runner, + parent_commit, + ) + if not predecessor: + return "" + _verify_release_notes_predecessor(context, runner, predecessor) + return predecessor + + +def _release_commit_first_parent(root: Path, runner: CommandRunner) -> str: + ancestry = git_output( + runner, + root, + ["git", "rev-list", "--parents", "-n", "1", "HEAD"], + ).split() + if not ancestry: + raise ReleaseError("could not determine the release commit ancestry") + return ancestry[1] if len(ancestry) > 1 else "" + + +def _nearest_reachable_version_tag( + root: Path, + runner: CommandRunner, + parent_commit: str, +) -> str: + reachable_tags = frozenset( + git_output( + runner, + root, + ["git", "tag", "--merged", parent_commit, "--list", "v*"], + ).splitlines() + ) + if not reachable_tags: + return "" + predecessor = git_output( + runner, + root, + ["git", "describe", "--tags", "--abbrev=0", "--match", "v*", parent_commit], + ) + if predecessor not in reachable_tags: + raise ReleaseError("could not verify the release notes predecessor tag") + return predecessor + + +def _verify_release_notes_predecessor( + context: ReleaseContext, + runner: CommandRunner, + predecessor: str, +) -> None: + try: + predecessor_version = Version(predecessor.removeprefix("v")) + except InvalidVersion as error: + raise ReleaseError( + f"release notes predecessor is not a valid version tag: {predecessor!r}" + ) from error + if not predecessor.startswith("v") or predecessor_version >= Version( + context.version.python + ): + raise ReleaseError( + f"release notes predecessor is not older than {context.version.tag}: {predecessor!r}" + ) + + local_commit = git_tag_commit(runner, context.root, predecessor) + remote_commit = remote_git_tag_commit(runner, context.root, predecessor) + if not local_commit or remote_commit != local_commit: + raise ReleaseError( + f"release notes predecessor tag does not match origin: {predecessor!r}" + ) + ancestry = runner.run( + ["git", "merge-base", "--is-ancestor", local_commit, "HEAD"], + cwd=context.root, + timeout=60, + capture_output=True, + check=False, + ) + if ancestry.returncode == 1: + raise ReleaseError( + f"release notes predecessor tag is not reachable from HEAD: {predecessor!r}" + ) + if ancestry.returncode != 0: + raise ReleaseError("could not verify release notes predecessor ancestry") + + def verify_generated_metadata( context: ReleaseContext, manifest: ReleaseManifest | None ) -> list[str]: @@ -581,6 +720,9 @@ def verify_pypi_artifacts( for filename in expected_names: if filename not in release.files: issues.append(f"PyPI is missing expected file {filename}") + unexpected_names = sorted(release.files.keys() - expected_names) + if unexpected_names: + issues.append(f"PyPI has unexpected files: {', '.join(unexpected_names)}") return issues diff --git a/scripts/release/state_state.py b/scripts/release/state_state.py index 98834be..0bb46f6 100644 --- a/scripts/release/state_state.py +++ b/scripts/release/state_state.py @@ -18,6 +18,8 @@ ReleaseStatus, ) +ORIGIN_MASTER_ANCESTRY_ERROR = "release commit is not reachable from origin/master" + def derive_release_state( context: ReleaseContext, @@ -39,6 +41,12 @@ def derive_release_state( "run make release-prepare to regenerate the release manifest and synced metadata", ), ) + if git is not None and not git.head_reachable_from_origin_master: + return DerivedReleaseState( + ReleaseStatus.BLOCKED, + (ORIGIN_MASTER_ANCESTRY_ERROR,), + ("Create releases only from commits reachable from origin/master.",), + ) artifact_issues: list[str] = [] if pypi.exists or npm.exists: @@ -149,13 +157,16 @@ def verify_formula_state_for_release( def verify_git_tag_state(git: GitState | None) -> list[str]: if git is None: return ["Git tag state could not be inspected"] + issues: list[str] = [] + if not git.head_reachable_from_origin_master: + issues.append(ORIGIN_MASTER_ANCESTRY_ERROR) if git.tag_commit and git.tag_commit != git.head_commit: - return ["Git tag points at a different commit"] + issues.append("Git tag points at a different commit") if git.remote_tag_commit and git.remote_tag_commit != git.head_commit: - return ["remote Git tag points at a different commit"] + issues.append("remote Git tag points at a different commit") if not git.tag_commit or not git.remote_tag_commit: - return ["Git tag is missing locally or on origin"] - return [] + issues.append("Git tag is missing locally or on origin") + return issues def is_tag_only_missing_error(issues: list[str]) -> bool: @@ -183,6 +194,8 @@ def publishing_git_issues( allow_local_changes: bool = False, ) -> list[str]: issues: list[str] = [] + if not git.head_reachable_from_origin_master: + issues.append(ORIGIN_MASTER_ANCESTRY_ERROR) if not allow_local_changes: if git.dirty: issues.append("worktree is dirty") diff --git a/scripts/release/state_types.py b/scripts/release/state_types.py index b38db4a..cb30999 100644 --- a/scripts/release/state_types.py +++ b/scripts/release/state_types.py @@ -124,6 +124,10 @@ class ReleaseVersion: npm: str tag: str + @property + def is_prerelease(self) -> bool: + return Version(self.python).is_prerelease + @classmethod def from_project(cls, project_version: str) -> ReleaseVersion: try: @@ -214,6 +218,7 @@ class PypiRelease: exists: bool version_key: str files: dict[str, PypiFile] + latest_stable: str = "" @dataclass(frozen=True) @@ -245,6 +250,7 @@ class GitState: branch: str default_branch: str head_commit: str + head_reachable_from_origin_master: bool upstream_ahead: int upstream_behind: int dirty: bool @@ -417,6 +423,17 @@ def query_pypi_release(context: ReleaseContext) -> PypiRelease: releases = data.get("releases") if not isinstance(releases, dict): raise ReleaseError("PyPI response is missing releases") + stable_versions: list[Version] = [] + for candidate, files in releases.items(): + if not isinstance(files, list) or not files: + continue + try: + parsed = Version(str(candidate)) + except InvalidVersion: + continue + if not parsed.is_prerelease: + stable_versions.append(parsed) + latest_stable = str(max(stable_versions)) if stable_versions else "" version_key = "" files_payload: list[Any] = [] for candidate, files in releases.items(): @@ -431,7 +448,12 @@ def query_pypi_release(context: ReleaseContext) -> PypiRelease: files_payload = files break if not version_key: - return PypiRelease(exists=False, version_key="", files={}) + return PypiRelease( + exists=False, + version_key="", + files={}, + latest_stable=latest_stable, + ) files_by_name: dict[str, PypiFile] = {} for file_payload in files_payload: if not isinstance(file_payload, dict): @@ -447,7 +469,12 @@ def query_pypi_release(context: ReleaseContext) -> PypiRelease: size=int(file_payload.get("size", 0)), sha256=str(digests["sha256"]), ) - return PypiRelease(exists=True, version_key=version_key, files=files_by_name) + return PypiRelease( + exists=True, + version_key=version_key, + files=files_by_name, + latest_stable=latest_stable, + ) def query_npm_release(context: ReleaseContext) -> NpmRelease: diff --git a/src/crewplane/adapters/invokers/cli_invoker/capabilities.py b/src/crewplane/adapters/invokers/cli_invoker/capabilities.py index 9f6d144..64948da 100644 --- a/src/crewplane/adapters/invokers/cli_invoker/capabilities.py +++ b/src/crewplane/adapters/invokers/cli_invoker/capabilities.py @@ -139,11 +139,11 @@ def _build_argv( prompt: str, structured_output_file: Path | None, ) -> list[str]: - """Build the provider CLI argv and validate the executable. + """Build the provider CLI argv and resolve the executable when available. The first argument identifies the process to launch, so it is resolved - before appending provider flags. This keeps command validation and log - headers tied to the actual executable while leaving user-supplied + before appending provider flags. This keeps log headers tied to the actual + executable when PATH resolution succeeds while leaving user-supplied arguments untouched. """ cmd = config.get_command() @@ -170,9 +170,11 @@ def _build_argv( def _resolved_cli_executable(executable: str) -> str: """Return an executable path suitable for subprocess invocation. - Bare executable names are resolved through `PATH` and validated. Absolute - paths are validated directly. Relative path-like commands are preserved so - subprocess can resolve them relative to the configured working directory. + Bare executable names are resolved through `PATH` when available. Missing + bare names are preserved so injected command runners and subprocess launch + handle the execution boundary consistently. Absolute paths are validated + directly. Relative path-like commands are preserved so subprocess can + resolve them relative to the configured working directory. """ executable_path = Path(executable) if executable_path.is_absolute(): @@ -181,7 +183,7 @@ def _resolved_cli_executable(executable: str) -> str: return executable resolved = shutil.which(executable) if resolved is None: - raise FileNotFoundError(f"CLI executable '{executable}' was not found.") + return executable return _resolved_existing_executable(Path(resolved)) diff --git a/src/crewplane/artifacts/resume/generated_files.py b/src/crewplane/artifacts/resume/generated_files.py index b0a0375..568e9e6 100644 --- a/src/crewplane/artifacts/resume/generated_files.py +++ b/src/crewplane/artifacts/resume/generated_files.py @@ -5,6 +5,7 @@ from crewplane.architecture.ports import ArtifactStorePort from crewplane.core.execution_state import ArtifactDescriptor +from crewplane.core.file_hashing import file_size_and_sha256 from ..atomic import atomic_write_bytes from ..naming import build_generated_file_result_dir_name @@ -52,11 +53,20 @@ def copy_generated_file_descriptor( raise ValueError(f"Generated file artifact size changed for node '{node_id}'.") target_path = output.results_dir / descriptor.relative_path atomic_write_bytes(target_path, payload) + target_size, target_sha256 = file_size_and_sha256(target_path) + if target_size != descriptor.size_bytes: + raise ValueError( + f"Hydrated generated file artifact size changed for node '{node_id}'." + ) + if target_sha256 != descriptor.sha256: + raise ValueError( + f"Hydrated generated file artifact hash changed for node '{node_id}'." + ) return ArtifactDescriptor( kind=descriptor.kind, relative_path=descriptor.relative_path, - size_bytes=target_path.stat().st_size, - sha256=hashlib.sha256(target_path.read_bytes()).hexdigest(), + size_bytes=target_size, + sha256=descriptor.sha256, ) diff --git a/src/crewplane/artifacts/resume/hydration.py b/src/crewplane/artifacts/resume/hydration.py index a4d1d62..f7bc684 100644 --- a/src/crewplane/artifacts/resume/hydration.py +++ b/src/crewplane/artifacts/resume/hydration.py @@ -14,6 +14,7 @@ NodeState, ResumeOrigin, ) +from crewplane.core.file_hashing import file_size_and_sha256 from crewplane.core.preflight.models import ( PreflightExecutionNode, PreflightExecutionPlan, @@ -107,12 +108,21 @@ def _copy_descriptors( raise ValueError(f"Resume artifact size changed for node '{node.id}'.") target_path = output.results_dir / descriptor.relative_path atomic_write_bytes(target_path, payload) + target_size, target_sha256 = file_size_and_sha256(target_path) + if target_size != descriptor.size_bytes: + raise ValueError( + f"Hydrated resume artifact size changed for node '{node.id}'." + ) + if target_sha256 != descriptor.sha256: + raise ValueError( + f"Hydrated resume artifact hash changed for node '{node.id}'." + ) hydrated.append( ArtifactDescriptor( kind=descriptor.kind, relative_path=descriptor.relative_path, - size_bytes=target_path.stat().st_size, - sha256=hashlib.sha256(target_path.read_bytes()).hexdigest(), + size_bytes=target_size, + sha256=descriptor.sha256, ) ) return hydrated @@ -160,6 +170,9 @@ def _copy_workspace_artifacts( payload, expected_artifacts, ) + source_descriptor = expected_artifacts[run_relative_path] + expected_size = source_descriptor["size_bytes"] + expected_sha256 = source_descriptor["sha256"] if relative.name.startswith("workspace-state") and relative.suffix == ".json": payload = _hydrated_workspace_state_payload( payload, @@ -168,7 +181,14 @@ def _copy_workspace_artifacts( node.id, hydrated_at, ) + expected_size = len(payload) + expected_sha256 = hashlib.sha256(payload).hexdigest() atomic_write_bytes(target_path, payload) + actual_size, actual_sha256 = file_size_and_sha256(target_path) + if actual_size != expected_size or actual_sha256 != expected_sha256: + raise ValueError( + f"Hydrated workspace resume artifact changed for node '{node.id}'." + ) def _stage_relative_workspace_artifact_path( diff --git a/src/crewplane/cli/run/resume.py b/src/crewplane/cli/run/resume.py index f0e2ac5..943200b 100644 --- a/src/crewplane/cli/run/resume.py +++ b/src/crewplane/cli/run/resume.py @@ -200,9 +200,11 @@ def print_dry_run_resume_advisory( source_run_id = ( source_run.manifest.run_id if source_run is not None else "unknown" ) + resumed_nodes = ", ".join(resume_plan.resumed_node_ids) console.print( "Resume advisory: would_resume " - f"{len(resume_plan.resumed_node_ids)} node(s) from {source_run_id}" + f"{len(resume_plan.resumed_node_ids)} node(s) from {source_run_id} " + f"(nodes: {resumed_nodes})" ) case "execute_full": console.print("Resume advisory: would_execute_full_run") diff --git a/src/crewplane/core/workspace/git_policy.py b/src/crewplane/core/workspace/git_policy.py index f24ac9c..b123e59 100644 --- a/src/crewplane/core/workspace/git_policy.py +++ b/src/crewplane/core/workspace/git_policy.py @@ -43,6 +43,10 @@ "GIT_ASKPASS", "SSH_ASKPASS", ) +RUNTIME_OWNED_GIT_TRANSPORT_ENV_UNSET = ( + "GIT_PROTOCOL_FROM_USER", + "GIT_ALLOW_PROTOCOL", +) WORKSPACE_GIT_ENV_PREFIXES_UNSET = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_") WORKSPACE_GIT_BASE_ENVIRONMENT = ( ("GIT_CONFIG_NOSYSTEM", "1"), @@ -158,7 +162,11 @@ def sanitized_workspace_git_environment( ceiling_directories: Path | None = None, ) -> dict[str, str]: env = dict(os.environ) - for key in workspace_git_environment_unset_keys(env): + unset_keys = ( + *workspace_git_environment_unset_keys(env), + *RUNTIME_OWNED_GIT_TRANSPORT_ENV_UNSET, + ) + for key in unset_keys: env.pop(key, None) env.update( workspace_git_base_environment( diff --git a/src/crewplane/runtime/execution/provider_call/workspace.py b/src/crewplane/runtime/execution/provider_call/workspace.py index edf5c0d..bd2a78d 100644 --- a/src/crewplane/runtime/execution/provider_call/workspace.py +++ b/src/crewplane/runtime/execution/provider_call/workspace.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from dataclasses import replace +from dataclasses import dataclass, replace from crewplane.architecture.contracts import InvocationContext from crewplane.runtime.workspace import ( @@ -15,6 +15,21 @@ from ..runtime_context import DeferredAsyncCleanupRegistry PREPARATION_CANCELLATION_TIMEOUT_SECONDS = 0.5 +PREPARATION_CANCELLATION_MESSAGE = ( + "Provider invocation was cancelled during workspace preparation." +) + + +@dataclass(frozen=True) +class _WorkspacePreparationResult: + prepared_workspace: PreparedWorkspace + cancellation_finalized: bool + + +class _WorkspaceCancellationFinalizationError(RuntimeError): + def __init__(self, error: Exception) -> None: + super().__init__(str(error)) + self.error = error async def prepare_workspace_with_cancellation( @@ -25,13 +40,14 @@ async def prepare_workspace_with_cancellation( setup_cancellation = WorkspaceSetupCancellation() prepare_task = asyncio.create_task( asyncio.to_thread( - prepare_invocation_workspace, - replace(workspace_request, setup_cancellation=setup_cancellation), + _prepare_workspace, + workspace_request, invocation_context, + setup_cancellation, ) ) try: - return await asyncio.shield(prepare_task) + preparation = await asyncio.shield(prepare_task) except asyncio.CancelledError as cancel: await _handle_preparation_cancellation( prepare_task, @@ -40,43 +56,75 @@ async def prepare_workspace_with_cancellation( cleanup_registry, ) raise + if preparation.cancellation_finalized: + raise RuntimeError("Workspace preparation finalized unexpected cancellation.") + return preparation.prepared_workspace + + +def _prepare_workspace( + workspace_request: WorkspaceInvocationRequest, + invocation_context: InvocationContext, + setup_cancellation: WorkspaceSetupCancellation, +) -> _WorkspacePreparationResult: + prepared_workspace = prepare_invocation_workspace( + replace(workspace_request, setup_cancellation=setup_cancellation), + invocation_context, + ) + if not setup_cancellation.is_cancelled(): + return _WorkspacePreparationResult(prepared_workspace, False) + try: + _mark_prepared_workspace_cancelled_now(prepared_workspace) + except Exception as error: + raise _WorkspaceCancellationFinalizationError(error) from error + return _WorkspacePreparationResult(prepared_workspace, True) async def _handle_preparation_cancellation( - prepare_task: asyncio.Task[PreparedWorkspace], + prepare_task: asyncio.Task[_WorkspacePreparationResult], setup_cancellation: WorkspaceSetupCancellation, cancel: asyncio.CancelledError, cleanup_registry: DeferredAsyncCleanupRegistry, ) -> None: await asyncio.to_thread(setup_cancellation.cancel) try: - prepared_workspace = await asyncio.wait_for( + preparation = await asyncio.wait_for( asyncio.shield(prepare_task), PREPARATION_CANCELLATION_TIMEOUT_SECONDS, ) except TimeoutError: _schedule_preparation_cancellation_cleanup(prepare_task, cleanup_registry) return + except _WorkspaceCancellationFinalizationError as cleanup_error: + note_cleanup_failure( + cancel, + "Workspace preparation cancellation handling", + cleanup_error.error, + ) + return except Exception as exc: raise cancel from exc + if preparation.cancellation_finalized: + return await _mark_prepared_workspace_cancelled( - prepared_workspace, + preparation.prepared_workspace, cleanup_registry, cancel, ) def _schedule_preparation_cancellation_cleanup( - prepare_task: asyncio.Task[PreparedWorkspace], + prepare_task: asyncio.Task[_WorkspacePreparationResult], cleanup_registry: DeferredAsyncCleanupRegistry, ) -> None: async def cleanup_when_ready() -> None: try: - prepared_workspace = await asyncio.shield(prepare_task) + preparation = await asyncio.shield(prepare_task) except asyncio.CancelledError: return + if preparation.cancellation_finalized: + return await _mark_prepared_workspace_cancelled( - prepared_workspace, + preparation.prepared_workspace, cleanup_registry, ) @@ -90,9 +138,8 @@ async def _mark_prepared_workspace_cancelled( ) -> None: mark_task = asyncio.create_task( asyncio.to_thread( - prepared_workspace.mark_cancelled, - "Provider invocation was cancelled during workspace preparation.", - workspace_child_environment_applied(prepared_workspace, False), + _mark_prepared_workspace_cancelled_now, + prepared_workspace, ) ) try: @@ -113,6 +160,15 @@ async def _mark_prepared_workspace_cancelled( raise +def _mark_prepared_workspace_cancelled_now( + prepared_workspace: PreparedWorkspace, +) -> None: + prepared_workspace.mark_cancelled( + PREPARATION_CANCELLATION_MESSAGE, + workspace_child_environment_applied(prepared_workspace, False), + ) + + def _schedule_mark_cancelled_completion( mark_task: asyncio.Task[None], cleanup_registry: DeferredAsyncCleanupRegistry, diff --git a/src/crewplane/runtime/workspace/branch_export/checkpoint.py b/src/crewplane/runtime/workspace/branch_export/checkpoint.py index cd15791..c927820 100644 --- a/src/crewplane/runtime/workspace/branch_export/checkpoint.py +++ b/src/crewplane/runtime/workspace/branch_export/checkpoint.py @@ -96,6 +96,7 @@ def _ensure_branch_export_source_available( source_ref: WorktreeSourceRef, ) -> None: try: + verify_source_commit_available(source, source_ref) ensure_source_commit_available(source, source_ref) except RuntimeError as exc: _raise_branch_export_result_mismatch(exc) diff --git a/src/crewplane/runtime/workspace/worktree/lineage.py b/src/crewplane/runtime/workspace/worktree/lineage.py index 735118a..5795a06 100644 --- a/src/crewplane/runtime/workspace/worktree/lineage.py +++ b/src/crewplane/runtime/workspace/worktree/lineage.py @@ -12,7 +12,7 @@ ) from ..cleanup_notes import note_cleanup_failure -from ..git import GitCommand, git +from ..git import GitCommand, git, git_error from ..locks import git_metadata_lock from .protected_refs import PROTECTED_REF_PREFIX from .refs import checked_ref, safe_file_component, safe_ref_component @@ -30,17 +30,48 @@ def verify_source_commit_available( source: WorkspaceSourceSnapshot, source_ref: WorktreeSourceRef, ) -> None: - with TemporaryDirectory(prefix="crewplane-lineage-verify-") as temp_dir: - git_dir = Path(temp_dir) / "verify.git" - git(Path(temp_dir)).run( - "init", - "--bare", - f"--object-format={source.object_format}", - git_dir.as_posix(), - ) - command = git(git_dir) - _fetch_commit_for_verification(command, source, source.run_base_commit) - _verify_source_commit_available(source, command, source_ref, set()) + pending_sources = [source_ref] + visited_sources: set[int] = set() + while pending_sources: + pending_source = pending_sources.pop() + if id(pending_source) in visited_sources: + continue + visited_sources.add(id(pending_source)) + if not _source_requires_bundle(pending_source) and not ( + pending_source.source_kind == "project" + and pending_source.source_commit == source.run_base_commit + ): + raise RuntimeError( + "Workspace lineage source commit is unavailable from recorded " + "lineage and requires a recorded bundle." + ) + pending_sources.extend(pending_source.upstream_sources) + + with TemporaryDirectory(prefix="crewplane-lineage-verify-") as temp_dir_name: + temp_dir = Path(temp_dir_name) + try: + git(temp_dir).run( + "init", + "--bare", + f"--object-format={source.object_format}", + ) + command = git(temp_dir) + _fetch_commit_for_verification(command, source, source.run_base_commit) + _verify_source_commit_available(source, command, source_ref, set()) + except subprocess.CalledProcessError as exc: + detail = ( + git_error(exc) + if isinstance(exc.stderr, bytes) and exc.stderr.strip() + else "Git did not provide diagnostic output." + ) + safe_detail = detail.replace( + temp_dir.as_posix(), + "", + ) + raise RuntimeError( + "Workspace lineage source verification failed while validating " + f"recorded Git artifacts: {safe_detail}" + ) from exc def _ensure_source_commit_available( @@ -391,6 +422,7 @@ def _import_source_bundle( with git_metadata_lock(Path(source.common_git_dir)): command.run( "fetch", + "--no-auto-maintenance", bundle_path.as_posix(), f"{source_ref.bundle_ref}:{import_ref}", ) @@ -404,6 +436,7 @@ def _fetch_bundle_for_verification( import_ref = _import_ref_for_source_commit(source_ref.source_commit) command.run( "fetch", + "--no-auto-maintenance", bundle_path.as_posix(), f"{source_ref.bundle_ref}:{import_ref}", ) @@ -416,6 +449,9 @@ def _fetch_commit_for_verification( ) -> None: command.run( "fetch", + "--no-auto-maintenance", + "--depth=1", + "--no-tags", Path(source.git_top_level).as_posix(), f"{commit}:{_import_ref_for_source_commit(commit)}", ) diff --git a/tests/helpers/workspace_service.py b/tests/helpers/workspace_service.py index cd8c238..2c01243 100644 --- a/tests/helpers/workspace_service.py +++ b/tests/helpers/workspace_service.py @@ -26,6 +26,13 @@ from crewplane.runtime.workspace.service import MaterializationLimiter from crewplane.version import SCHEMA_VERSION +GIT_TEST_CONFIG_ARGS = ( + "-c", + "maintenance.auto=false", + "-c", + "gc.auto=0", +) + def create_git_repo(tmp_path: Path, object_format: str = "sha1") -> Path: repo = tmp_path / "repo" @@ -46,7 +53,7 @@ def create_git_repo(tmp_path: Path, object_format: str = "sha1") -> Path: def run_git_text(repo: Path, *args: str) -> str: result = subprocess.run( - ["git", "-C", repo.as_posix(), *args], + ["git", *GIT_TEST_CONFIG_ARGS, "-C", repo.as_posix(), *args], check=True, capture_output=True, ) @@ -55,7 +62,15 @@ def run_git_text(repo: Path, *args: str) -> str: def git_commit_exists(repo: Path, object_id: str) -> bool: result = subprocess.run( - ["git", "-C", repo.as_posix(), "cat-file", "-e", f"{object_id}^{{commit}}"], + [ + "git", + *GIT_TEST_CONFIG_ARGS, + "-C", + repo.as_posix(), + "cat-file", + "-e", + f"{object_id}^{{commit}}", + ], check=False, capture_output=True, ) diff --git a/tests/integration/adapters/test_invoker_cli.py b/tests/integration/adapters/test_invoker_cli.py index 87da002..3742094 100644 --- a/tests/integration/adapters/test_invoker_cli.py +++ b/tests/integration/adapters/test_invoker_cli.py @@ -87,6 +87,19 @@ def test_invocation_plan_resolves_cli_executable_before_workspace_cwd( self.assertEqual(plan.cmd[0], expected_executable) + def test_invocation_plan_preserves_missing_bare_cli_executable( + self, + ) -> None: + with patch.dict(os.environ, {"PATH": ""}): + plan = build_cli_invocation_plan( + AgentConfig(cli_cmd=["missing-provider"]), + model=None, + prompt="prompt", + output_file=Path("output.md"), + ) + + self.assertEqual(plan.cmd[0], "missing-provider") + def test_invocation_plan_preserves_relative_path_cli_executable( self, ) -> None: diff --git a/tests/integration/runtime/agent/test_invocation_command.py b/tests/integration/runtime/agent/test_invocation_command.py index 98b8b5f..f4d30be 100644 --- a/tests/integration/runtime/agent/test_invocation_command.py +++ b/tests/integration/runtime/agent/test_invocation_command.py @@ -23,6 +23,7 @@ run_command_once, run_invocation_attempt, ) +from crewplane.runtime.agent.workspace_environment import workspace_child_environment from crewplane.version import SCHEMA_VERSION @@ -113,6 +114,36 @@ async def test_run_command_once_applies_cwd_and_child_environment(self) -> None: self.assertEqual(lines[1], "applied") self.assertEqual(lines[2], "None") + async def test_workspace_child_environment_preserves_git_transport_controls( + self, + ) -> None: + inherited = { + "GIT_PROTOCOL_FROM_USER": "0", + "GIT_ALLOW_PROTOCOL": "https", + } + with patch.dict(os.environ, inherited): + result = await run_command_once( + cmd=[ + sys.executable, + "-c", + ( + "import os; " + "print(os.getenv('GIT_PROTOCOL_FROM_USER')); " + "print(os.getenv('GIT_ALLOW_PROTOCOL'))" + ), + ], + stdin_data=None, + log_file=None, + append_log=False, + log_header=None, + cwd=Path.cwd(), + invocation_context=None, + idle_timeout_seconds=None, + child_environment=workspace_child_environment(Path.cwd()), + ) + + self.assertEqual(result.stdout_text.strip().splitlines(), ["0", "https"]) + async def test_run_command_once_records_child_environment_after_spawn(self) -> None: record_calls = 0 diff --git a/tests/unit/artifacts/test_resume_hydration.py b/tests/unit/artifacts/test_resume_hydration.py index 76f652c..77dda71 100644 --- a/tests/unit/artifacts/test_resume_hydration.py +++ b/tests/unit/artifacts/test_resume_hydration.py @@ -1,15 +1,17 @@ from __future__ import annotations import json +from pathlib import Path import pytest +import crewplane.artifacts.resume.generated_files as generated_files_module +import crewplane.artifacts.resume.hydration as hydration_module from crewplane.artifacts.manager import OutputManager from crewplane.artifacts.naming import ( build_generated_file_result_dir_name, build_node_state_filename, ) -from crewplane.artifacts.resume.hydration import hydrate_resume_frontier from crewplane.artifacts.resume.validation import ( ValidatedResumeFrontier, validate_resume_frontier, @@ -70,7 +72,7 @@ def test_hydrate_resume_frontier_copies_only_required_artifacts(tmp_path) -> Non frontier, plan = validated_frontier(tmp_path) output = OutputManager("Workflow", base_dir=tmp_path) - resumed = hydrate_resume_frontier(frontier, plan, output) + resumed = hydration_module.hydrate_resume_frontier(frontier, plan, output) assert resumed == ("a",) assert (output.results_dir / "a-result.md").read_text( @@ -99,7 +101,28 @@ def test_hydrate_rechecks_source_hash_after_validation(tmp_path) -> None: output = OutputManager("Workflow", base_dir=tmp_path) with pytest.raises(ValueError, match="hash changed"): - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) + + +def test_hydrate_rechecks_target_after_descriptor_bound_copy( + tmp_path, + monkeypatch, +) -> None: + frontier, plan = validated_frontier(tmp_path, include_findings=False) + output = OutputManager("Workflow", base_dir=tmp_path) + + def corrupt_target_write(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload + b"corruption") + + monkeypatch.setattr( + hydration_module, + "atomic_write_bytes", + corrupt_target_write, + ) + + with pytest.raises(ValueError, match="Hydrated resume artifact size changed"): + hydration_module.hydrate_resume_frontier(frontier, plan, output) def test_hydrate_resume_frontier_records_findings_hash_when_required(tmp_path) -> None: @@ -110,7 +133,7 @@ def test_hydrate_resume_frontier_records_findings_hash_when_required(tmp_path) - ) output = OutputManager("Workflow", base_dir=tmp_path) - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) resume_source = json.loads( (output.stages_dir / "a" / "resume-source.json").read_text(encoding="utf-8") @@ -150,7 +173,7 @@ def test_hydrate_resume_frontier_copies_generated_file_sidecars(tmp_path) -> Non frontier = validate_resume_frontier(source, plan) output = OutputManager("Workflow", base_dir=tmp_path) - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) hydrated_file = output.results_dir / "generated-files/a/alpha/src/app.txt" assert hydrated_file.read_text(encoding="utf-8") == "generated content" @@ -161,6 +184,50 @@ def test_hydrate_resume_frontier_copies_generated_file_sidecars(tmp_path) -> Non ) +def test_hydrate_rechecks_generated_file_target_after_copy( + tmp_path, + monkeypatch, +) -> None: + manifest = make_run_manifest("source", "workflow--source", status="failed") + write_run_manifest(tmp_path, manifest) + source = find_same_context_runs( + tmp_path, + WORKFLOW_IDENTITY, + WORKFLOW_NAME, + WORKFLOW_SIGNATURE, + )[0] + output_descriptor = write_result(source.results_dir, "a-result.md", "a output") + generated_descriptor = write_generated_file( + source.results_dir, + "generated-files/a/alpha/src/app.txt", + "generated content", + ) + write_node_state( + source.run_dir, + make_node_state(source.manifest, "a", [output_descriptor]).model_copy( + update={"generated_files": [generated_descriptor]} + ), + ) + frontier = validate_resume_frontier(source, make_plan()) + output = OutputManager("Workflow", base_dir=tmp_path) + + def corrupt_target_write(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload + b"corruption") + + monkeypatch.setattr( + generated_files_module, + "atomic_write_bytes", + corrupt_target_write, + ) + + with pytest.raises( + ValueError, + match="Hydrated generated file artifact size changed", + ): + hydration_module.hydrate_resume_frontier(frontier, make_plan(), output) + + def test_hydrate_resume_frontier_copies_generated_file_from_bounded_node_directory( tmp_path, ) -> None: @@ -191,7 +258,7 @@ def test_hydrate_resume_frontier_copies_generated_file_from_bounded_node_directo frontier = validate_resume_frontier(source, plan) output = OutputManager("Workflow", base_dir=tmp_path) - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) hydrated_file = output.results_dir / relative_path assert hydrated_file.read_text(encoding="utf-8") == "generated content" @@ -239,7 +306,7 @@ def test_hydrate_resume_frontier_rewrites_workspace_state_run_identity( frontier = validate_resume_frontier(source, plan) output = OutputManager("Workflow", base_dir=tmp_path) - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) state = json.loads( (output.stages_dir / "a" / "workspace-state.json").read_text(encoding="utf-8") @@ -273,6 +340,91 @@ def test_hydrate_resume_frontier_rewrites_workspace_state_run_identity( assert workspace["states"][0]["workspace"]["materialization"] == "snapshot_checkout" +def test_hydrate_rechecks_rewritten_workspace_state_before_node_success( + tmp_path, + monkeypatch, +) -> None: + manifest = make_run_manifest("source", "workflow--source", status="failed") + write_run_manifest(tmp_path, manifest) + source = find_same_context_runs( + tmp_path, + WORKFLOW_IDENTITY, + WORKFLOW_NAME, + WORKFLOW_SIGNATURE, + )[0] + descriptor = write_result(source.results_dir, "a-result.md", "a output") + write_node_state( + source.run_dir, + make_node_state(source.manifest, "a", [descriptor]), + ) + plan = _workspace_snapshot_plan() + _write_snapshot_workspace_state(source, plan) + frontier = validate_resume_frontier(source, plan) + output = OutputManager("Workflow", base_dir=tmp_path) + write_bytes = hydration_module.atomic_write_bytes + + def corrupt_workspace_state(path: Path, payload: bytes) -> None: + write_bytes(path, payload) + if path.name == "workspace-state.json": + path.write_bytes(b"x" + payload[1:]) + + monkeypatch.setattr( + hydration_module, + "atomic_write_bytes", + corrupt_workspace_state, + ) + + with pytest.raises( + ValueError, + match="Hydrated workspace resume artifact changed", + ): + hydration_module.hydrate_resume_frontier(frontier, plan, output) + + node_state_path = ( + output.stages_dir / "manifests" / "nodes" / build_node_state_filename("a") + ) + assert not node_state_path.exists() + + +def test_workspace_target_verification_does_not_add_full_file_read( + tmp_path, + monkeypatch, +) -> None: + manifest = make_run_manifest("source", "workflow--source", status="failed") + write_run_manifest(tmp_path, manifest) + source = find_same_context_runs( + tmp_path, + WORKFLOW_IDENTITY, + WORKFLOW_NAME, + WORKFLOW_SIGNATURE, + )[0] + descriptor = write_result(source.results_dir, "a-result.md", "a output") + write_node_state( + source.run_dir, + make_node_state(source.manifest, "a", [descriptor]), + ) + plan = _workspace_snapshot_plan() + _write_snapshot_workspace_state(source, plan) + frontier = validate_resume_frontier(source, plan) + output = OutputManager("Workflow", base_dir=tmp_path) + target_path = output.stages_dir / "a" / "workspace-state.json" + read_bytes = Path.read_bytes + target_read_count = 0 + + def track_full_target_read(path: Path) -> bytes: + nonlocal target_read_count + if path == target_path: + target_read_count += 1 + return read_bytes(path) + + monkeypatch.setattr(Path, "read_bytes", track_full_target_read) + + hydration_module.hydrate_resume_frontier(frontier, plan, output) + + assert target_path.is_file() + assert target_read_count <= 1 + + def test_hydrate_resume_frontier_strips_source_branch_export( tmp_path, ) -> None: @@ -302,7 +454,7 @@ def test_hydrate_resume_frontier_strips_source_branch_export( frontier = validate_resume_frontier(source, plan) output = OutputManager("Workflow", base_dir=tmp_path) - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) state = json.loads( (output.stages_dir / "a" / "workspace-state.json").read_text(encoding="utf-8") @@ -344,7 +496,7 @@ def test_hydrate_resume_frontier_skips_bool_workspace_artifact_size_bytes( output = OutputManager("Workflow", base_dir=tmp_path) with pytest.raises(RuntimeError, match="no workspace-state artifact"): - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) def test_hydrate_resume_frontier_preserves_review_loop_canonical_lineage( @@ -387,7 +539,7 @@ def test_hydrate_resume_frontier_preserves_review_loop_canonical_lineage( frontier = ValidatedResumeFrontier(source, {"a": node_state}) output = OutputManager("Workflow", base_dir=tmp_path) - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) assert ( required_lineage_state_path(output, "a").name @@ -441,7 +593,7 @@ def test_hydrate_resume_frontier_rechecks_review_loop_artifact_hash( output = OutputManager("Workflow", base_dir=tmp_path) with pytest.raises(ValueError, match="Workspace resume artifact hash changed"): - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) def test_hydrate_resume_frontier_copies_workspace_setup_artifacts( @@ -489,7 +641,7 @@ def test_hydrate_resume_frontier_copies_workspace_setup_artifacts( frontier = ValidatedResumeFrontier(source, {"a": node_state}) output = OutputManager("Workflow", base_dir=tmp_path) - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) hydrated_setup_dir = output.stages_dir / "a" / "workspace-setup" assert (hydrated_setup_dir / metadata_path.name).is_file() @@ -521,7 +673,7 @@ def test_hydrate_resume_frontier_ignores_undeclared_workspace_artifacts( extra_state.write_bytes(b"\xff") output = OutputManager("Workflow", base_dir=tmp_path) - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) assert (output.stages_dir / "a" / "workspace-state.json").is_file() assert not (output.stages_dir / "a" / "workspace-state-extra.json").exists() @@ -556,7 +708,7 @@ def test_hydrated_parallel_workspace_state_can_be_resumed_without_raw_outputs( frontier = validate_resume_frontier(source, plan) output = OutputManager("Workflow", base_dir=tmp_path) - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) output.write_run_manifest( make_run_manifest(output.run_id, output.run_key_name, status="failed") ) @@ -604,7 +756,7 @@ def test_hydrate_resume_frontier_rechecks_workspace_artifact_hash( output = OutputManager("Workflow", base_dir=tmp_path) with pytest.raises(ValueError, match="Workspace resume artifact hash changed"): - hydrate_resume_frontier(frontier, plan, output) + hydration_module.hydrate_resume_frontier(frontier, plan, output) def _workspace_snapshot_plan(): diff --git a/tests/unit/cli/test_workspace_source_policy_git.py b/tests/unit/cli/test_workspace_source_policy_git.py index bc0be0d..063d714 100644 --- a/tests/unit/cli/test_workspace_source_policy_git.py +++ b/tests/unit/cli/test_workspace_source_policy_git.py @@ -393,7 +393,9 @@ def test_workspace_source_policy_allows_disabled_sparse_checkout_config( _require_workspace_git() _create_clean_repo(tmp_path) run_git_text(tmp_path, "config", "core.sparseCheckout", config_value) - (tmp_path / ".git" / "info" / "sparse-checkout").write_text( + sparse_checkout_path = tmp_path / ".git" / "info" / "sparse-checkout" + sparse_checkout_path.parent.mkdir(parents=True, exist_ok=True) + sparse_checkout_path.write_text( "README.md\n", encoding="utf-8", ) diff --git a/tests/unit/core/test_workspace_git_policy.py b/tests/unit/core/test_workspace_git_policy.py index 740af76..bbc8887 100644 --- a/tests/unit/core/test_workspace_git_policy.py +++ b/tests/unit/core/test_workspace_git_policy.py @@ -75,6 +75,21 @@ def test_sanitized_workspace_git_environment_removes_dynamic_config_keys( assert "GIT_CONFIG_VALUE_9" not in env +@pytest.mark.parametrize( + "key", + ["GIT_PROTOCOL_FROM_USER", "GIT_ALLOW_PROTOCOL"], +) +def test_sanitized_workspace_git_environment_removes_runtime_transport_controls( + key: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(key, "restricted") + + env = sanitized_workspace_git_environment() + + assert key not in env + + def test_workspace_git_base_environment_supports_known_variants( tmp_path: Path, ) -> None: @@ -133,12 +148,16 @@ def test_workspace_child_environment_uses_shared_unset_and_ceiling_policy( monkeypatch.setenv("GIT_TEMPLATE_DIR", "/tmp/template") monkeypatch.setenv("GIT_CONFIG_KEY_4", "core.fsmonitor") monkeypatch.setenv("GIT_CONFIG_VALUE_4", "true") + monkeypatch.setenv("GIT_PROTOCOL_FROM_USER", "0") + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "https") child = workspace_child_environment(tmp_path / "checkout") assert "GIT_TEMPLATE_DIR" in child.unset assert "GIT_CONFIG_KEY_4" in child.unset assert "GIT_CONFIG_VALUE_4" in child.unset + assert "GIT_PROTOCOL_FROM_USER" not in child.unset + assert "GIT_ALLOW_PROTOCOL" not in child.unset assert child.set["GIT_CONFIG_NOSYSTEM"] == "1" assert child.set["GIT_CEILING_DIRECTORIES"] == tmp_path.as_posix() assert "GIT_OPTIONAL_LOCKS" not in child.set diff --git a/tests/unit/observability/fixtures/compact_render/selected_output_zero_height/expected-right.txt b/tests/unit/observability/fixtures/compact_render/selected_output_zero_height/expected-right.txt index 8b13789..e69de29 100644 --- a/tests/unit/observability/fixtures/compact_render/selected_output_zero_height/expected-right.txt +++ b/tests/unit/observability/fixtures/compact_render/selected_output_zero_height/expected-right.txt @@ -1 +0,0 @@ - diff --git a/tests/unit/packaging/test_actionlint_script.py b/tests/unit/packaging/test_actionlint_script.py new file mode 100644 index 0000000..555bb57 --- /dev/null +++ b/tests/unit/packaging/test_actionlint_script.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +ACTIONLINT_SCRIPT = ROOT / "scripts" / "actionlint.sh" + + +def run_script_function( + function_name: str, + arguments: tuple[str, ...], + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "/bin/bash", + "-c", + 'source "$1"; shift; "$@"', + "actionlint-test", + str(ACTIONLINT_SCRIPT), + function_name, + *arguments, + ], + check=False, + capture_output=True, + env=env, + text=True, + ) + + +@pytest.mark.parametrize( + ("system", "machine", "archive", "checksum"), + [ + ( + "Darwin", + "x86_64", + "actionlint_1.7.9_darwin_amd64.tar.gz", + "f89a910e90e536f60df7c504160247db01dd67cab6f08c064c1c397b76c91a79", + ), + ( + "Darwin", + "arm64", + "actionlint_1.7.9_darwin_arm64.tar.gz", + "855e49e823fc68c6371fd6967e359cde11912d8d44fed343283c8e6e943bd789", + ), + ( + "Linux", + "x86_64", + "actionlint_1.7.9_linux_amd64.tar.gz", + "233b280d05e100837f4af1433c7b40a5dcb306e3aa68fb4f17f8a7f45a7df7b4", + ), + ( + "Linux", + "aarch64", + "actionlint_1.7.9_linux_arm64.tar.gz", + "6b82a3b8c808bf1bcd39a95aced22fc1a026eef08ede410f81e274af8deadbbc", + ), + ], +) +def test_release_metadata_matches_official_actionlint_assets( + system: str, + machine: str, + archive: str, + checksum: str, +) -> None: + result = run_script_function("actionlint_release_metadata", (system, machine)) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == f"{archive} {checksum}" + + +def test_release_metadata_rejects_unsupported_platform() -> None: + result = run_script_function( + "actionlint_release_metadata", ("Windows_NT", "x86_64") + ) + + assert result.returncode != 0 + assert "does not support platform Windows_NT-x86_64" in result.stderr + + +def test_installed_actionlint_ignores_only_the_new_concurrency_queue_key( + tmp_path: Path, +) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + call_log = tmp_path / "actionlint-call.log" + actionlint = fake_bin / "actionlint" + actionlint.write_text( + "#!/bin/sh\n" + 'if [ "$1" = "-version" ]; then printf "1.7.9\\n"; exit 0; fi\n' + 'printf "%s\\n" "$@" > "$ACTIONLINT_CALL_LOG"\n', + encoding="utf-8", + ) + actionlint.chmod(0o755) + env = os.environ.copy() + env.update( + { + "ACTIONLINT_CALL_LOG": str(call_log), + "PATH": f"{fake_bin}{os.pathsep}/usr/bin:/bin", + } + ) + + result = run_script_function("main", (".github/workflows/release.yml",), env) + + assert result.returncode == 0, result.stderr + assert call_log.read_text(encoding="utf-8").splitlines() == [ + "-color", + "-ignore", + 'unexpected key "queue" for "concurrency" section', + ".github/workflows/release.yml", + ] + + +@pytest.mark.parametrize( + ("tool_name", "expected_arguments"), + [ + ("sha256sum", "{archive}"), + ("shasum", "-a 256 {archive}"), + ], +) +def test_checksum_verification_uses_available_platform_tool( + tmp_path: Path, + tool_name: str, + expected_arguments: str, +) -> None: + expected_checksum = "a" * 64 + archive = tmp_path / "actionlint.tar.gz" + archive.write_bytes(b"release archive") + tool_log = tmp_path / "checksum-tool.log" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + checksum_tool = fake_bin / tool_name + checksum_tool.write_text( + "#!/bin/sh\n" + 'printf "%s\\n" "$*" > "$CHECKSUM_TOOL_LOG"\n' + 'printf "%s archive\\n" "$EXPECTED_CHECKSUM"\n', + encoding="utf-8", + ) + checksum_tool.chmod(0o755) + env = os.environ.copy() + env.update( + { + "CHECKSUM_TOOL_LOG": str(tool_log), + "EXPECTED_CHECKSUM": expected_checksum, + "PATH": str(fake_bin), + } + ) + + result = run_script_function( + "verify_actionlint_checksum", + (str(archive), expected_checksum), + env, + ) + + assert result.returncode == 0, result.stderr + assert tool_log.read_text(encoding="utf-8").strip() == expected_arguments.format( + archive=archive + ) + + +def test_checksum_verification_fails_without_supported_tool(tmp_path: Path) -> None: + archive = tmp_path / "actionlint.tar.gz" + archive.write_bytes(b"release archive") + env = os.environ.copy() + env["PATH"] = str(tmp_path / "empty-bin") + + result = run_script_function( + "verify_actionlint_checksum", (str(archive), "a" * 64), env + ) + + assert result.returncode != 0 + assert "requires 'sha256sum' or 'shasum'" in result.stderr + + +def test_bootstrap_cleans_up_download_after_success(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + download_dir = tmp_path / "actionlint-download" + expected_checksum = ( + "233b280d05e100837f4af1433c7b40a5dcb306e3aa68fb4f17f8a7f45a7df7b4" + ) + fake_tools = { + "uname": """#!/bin/sh +if [ "$1" = "-s" ]; then printf 'Linux\\n'; else printf 'x86_64\\n'; fi +""", + "mktemp": """#!/bin/sh +/bin/mkdir -p "$FAKE_TEMP_DIR" +printf '%s\\n' "$FAKE_TEMP_DIR" +""", + "curl": """#!/bin/sh +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output" ]; then shift; output="$1"; fi + shift +done +printf 'archive' > "$output" +""", + "sha256sum": """#!/bin/sh +printf '%s %s\\n' "$EXPECTED_CHECKSUM" "$1" +""", + "tar": """#!/bin/sh +while [ "$#" -gt 0 ]; do + if [ "$1" = "-C" ]; then shift; destination="$1"; fi + shift +done +printf '#!/bin/sh\\nexit 0\\n' > "$destination/actionlint" +/bin/chmod +x "$destination/actionlint" +""", + "rm": """#!/bin/sh +/bin/rm "$@" +""", + } + for name, content in fake_tools.items(): + tool = fake_bin / name + tool.write_text(content, encoding="utf-8") + tool.chmod(0o755) + env = os.environ.copy() + env.update( + { + "EXPECTED_CHECKSUM": expected_checksum, + "FAKE_TEMP_DIR": str(download_dir), + "PATH": str(fake_bin), + } + ) + + result = run_script_function("main", (), env) + + assert result.returncode == 0, result.stderr + assert not download_dir.exists() diff --git a/tests/unit/packaging/test_dependency_audit_script.py b/tests/unit/packaging/test_dependency_audit_script.py new file mode 100644 index 0000000..db1232a --- /dev/null +++ b/tests/unit/packaging/test_dependency_audit_script.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = ROOT / "scripts" / "dependency_audit.py" + + +def load_dependency_audit_script() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "dependency_audit_script_under_test", SCRIPT_PATH + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_audits_locked_and_build_dependencies_as_separate_inputs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + dependency_audit = load_dependency_audit_script() + (tmp_path / "pyproject.toml").write_text( + "[build-system]\n" + 'requires = ["hatchling==1.30.1", "packaging>=24"]\n' + 'build-backend = "hatchling.build"\n', + encoding="utf-8", + ) + locked_requirements = "click==8.3.1 --hash=sha256:abc123\n" + commands: list[list[str]] = [] + timeouts: list[int] = [] + audit_inputs: list[tuple[Path, str]] = [] + + def fake_run( + command: list[str], + cwd: Path, + check: bool, + timeout: int, + ) -> subprocess.CompletedProcess[str]: + commands.append(command) + timeouts.append(timeout) + assert cwd == tmp_path + assert check is True + if command[0] == "uv": + output_path = Path(command[command.index("--output-file") + 1]) + output_path.write_text(locked_requirements, encoding="utf-8") + else: + requirements_path = Path(command[command.index("-r") + 1]) + audit_inputs.append( + ( + requirements_path, + requirements_path.read_text(encoding="utf-8"), + ) + ) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(dependency_audit.subprocess, "run", fake_run) + + dependency_audit.audit_dependencies(tmp_path) + + assert [command[0] for command in commands] == ["uv", "uvx", "uvx"] + assert timeouts == [120, 300, 300] + assert commands[0][1:-1] == [ + "export", + "--locked", + "--extra", + "dev", + "--no-emit-project", + "--format", + "requirements-txt", + "--output-file", + ] + assert all( + command[1:5] + == [ + "--python", + "3.13", + "pip-audit==2.10.1", + "-r", + ] + and command[-1] == "--strict" + for command in commands[1:] + ) + assert audit_inputs[0][1] == locked_requirements + assert audit_inputs[1][1] == "hatchling==1.30.1\npackaging>=24\n" + assert audit_inputs[0][0] != audit_inputs[1][0] + assert all(not path.exists() for path, _contents in audit_inputs) + + +@pytest.mark.parametrize( + "pyproject_text", + [ + '[project]\nname = "crewplane"\n', + '[build-system]\nrequires = "hatchling==1.30.1"\n', + "[build-system]\nrequires = []\n", + '[build-system]\nrequires = [""]\n', + ], +) +def test_rejects_missing_or_invalid_build_requirements( + tmp_path: Path, + pyproject_text: str, +) -> None: + dependency_audit = load_dependency_audit_script() + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text(pyproject_text, encoding="utf-8") + + with pytest.raises( + ValueError, + match=r"pyproject\.toml \[build-system\]\.requires must be a non-empty list", + ): + dependency_audit.read_build_requirements(pyproject_path) + + +def test_workflow_and_makefile_use_the_dependency_audit_script() -> None: + workflow = (ROOT / ".github/workflows/vulnerability-scan.yml").read_text( + encoding="utf-8" + ) + makefile = (ROOT / "Makefile").read_text(encoding="utf-8") + + assert ' - "scripts/dependency_audit.py"' in workflow + assert ' - "Makefile"' in workflow + assert "run: make dependency-audit" in workflow + assert "uv export --locked" not in workflow + assert "\ndependency-audit:\n\t$(PYTHON) scripts/dependency_audit.py\n" in makefile + + +def test_main_reports_subprocess_timeout_without_a_traceback( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + dependency_audit = load_dependency_audit_script() + + def time_out(root: Path) -> None: + assert root == Path.cwd() + raise subprocess.TimeoutExpired(["uvx", "pip-audit"], 300) + + monkeypatch.setattr(dependency_audit, "audit_dependencies", time_out) + + assert dependency_audit.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "timed out after 300 seconds" in captured.err + assert "Traceback" not in captured.err diff --git a/tests/unit/packaging/test_github_release_script.py b/tests/unit/packaging/test_github_release_script.py new file mode 100644 index 0000000..bc64584 --- /dev/null +++ b/tests/unit/packaging/test_github_release_script.py @@ -0,0 +1,690 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +GITHUB_RELEASE_SCRIPT = ROOT / "scripts" / "publish_github_release.sh" +EXPECTED_ASSETS = ( + "crewplane-1.2.3-py3-none-any.whl", + "crewplane-1.2.3.tar.gz", +) +RELEASE_AUTOMATION_MARKER = "" +GENERATED_RELEASE_NOTES = f"{RELEASE_AUTOMATION_MARKER}\n\nGenerated release notes" +MUTATING_COMMANDS = ("release\tupload", "release\tedit", "release\tcreate") + + +def asset_record(name: str, content: str | None = None) -> dict[str, object]: + payload = (content if content is not None else name).encode() + return { + "name": name, + "size": len(payload), + "digest": f"sha256:{hashlib.sha256(payload).hexdigest()}", + "state": "uploaded", + } + + +def exact_assets() -> list[dict[str, object]]: + return [asset_record(name) for name in EXPECTED_ASSETS] + + +def release_state( + *, + exists: bool = True, + draft: bool = False, + prerelease: bool = False, + latest: bool = True, + assets: list[dict[str, object]] | None = None, + **faults: object, +) -> dict[str, object]: + return { + "exists": exists, + "title": "v1.2.3", + "body": GENERATED_RELEASE_NOTES, + "draft": draft, + "prerelease": prerelease, + "latest": latest, + "assets": exact_assets() if assets is None else assets, + **faults, + } + + +def write_fake_gh(path: Path) -> None: + path.write_text( + r"""#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import os +import sys +from pathlib import Path + +args = sys.argv[1:] +state_path = Path(os.environ["FAKE_GH_STATE"]) +log_path = Path(os.environ["GH_CALL_LOG"]) +with log_path.open("a", encoding="utf-8") as stream: + stream.write("\t".join(args) + "\n") +state = json.loads(state_path.read_text(encoding="utf-8")) +release_automation_marker = os.environ["RELEASE_AUTOMATION_MARKER"] + + +def save() -> None: + state_path.write_text(json.dumps(state), encoding="utf-8") + + +def boolean_flag(name: str, default: bool) -> bool: + if name in args: + return True + if f"{name}=false" in args: + return False + return default + + +def option_value(name: str, default: str = "") -> str: + if name not in args: + return default + index = args.index(name) + if index + 1 >= len(args): + return default + return args[index + 1] + + +def artifact_paths() -> list[Path]: + paths: list[Path] = [] + for value in args[3:]: + if value.startswith("--"): + break + paths.append(Path(value)) + return paths + + +def uploaded_asset(path: Path) -> dict[str, object]: + payload = path.read_bytes() + return { + "name": path.name, + "size": len(payload), + "digest": f"sha256:{hashlib.sha256(payload).hexdigest()}", + "state": "uploaded", + } + + +def corrupt_asset() -> None: + if state["assets"]: + state["assets"][0]["digest"] = "sha256:" + "0" * 64 + + +if args[:2] == ["api", "graphql"]: + state["query_count"] = int(state.get("query_count", 0)) + 1 + save() + if state.get("query_failure"): + print("simulated GraphQL failure", file=sys.stderr) + raise SystemExit(1) + if not state["exists"]: + print("release\tabsent") + raise SystemExit(0) + assets = list(state["assets"]) + returned_limit = state.get("returned_limit") + if returned_limit is not None: + assets = assets[: int(returned_limit)] + total_count = int(state.get("total_count", len(state["assets"]))) + print( + "\t".join( + ( + "release", + "present", + "R_test", + str(state["title"]), + str(str(state["body"]).startswith(release_automation_marker)).lower(), + str(state["draft"]).lower(), + str(state["prerelease"]).lower(), + str(state["latest"]).lower(), + str(total_count), + str(len(assets)), + ) + ) + ) + for asset in assets: + print( + "\t".join( + ( + "asset", + str(asset["name"]), + str(asset["size"]), + str(asset["digest"]), + ) + ) + ) + raise SystemExit(0) + +if args[:2] == ["release", "create"]: + notes = option_value("--notes") + state.update( + { + "exists": True, + "title": option_value("--title", args[2]), + "body": f"{notes}\n\nGenerated release notes", + "draft": True, + "prerelease": boolean_flag("--prerelease", False), + "latest": boolean_flag("--latest", False), + "assets": [uploaded_asset(path) for path in artifact_paths()], + } + ) + if state.get("corrupt_after_create"): + corrupt_asset() + save() + raise SystemExit(0) + +if args[:2] == ["release", "upload"]: + by_name = {asset["name"]: asset for asset in state["assets"]} + for path in artifact_paths(): + asset = uploaded_asset(path) + by_name[asset["name"]] = asset + state["assets"] = list(by_name.values()) + if state.get("corrupt_after_upload"): + corrupt_asset() + save() + raise SystemExit(0) + +if args[:2] == ["release", "edit"]: + state["title"] = option_value("--title", str(state["title"])) + state["body"] = option_value("--notes", str(state["body"])) + state["draft"] = boolean_flag("--draft", state["draft"]) + state["prerelease"] = boolean_flag("--prerelease", state["prerelease"]) + state["latest"] = boolean_flag("--latest", state["latest"]) + if state.get("corrupt_after_edit") == "asset": + corrupt_asset() + elif state.get("corrupt_after_edit") == "latest": + state["latest"] = not state["latest"] + elif state.get("corrupt_after_edit") == "title": + state["title"] = "Wrong title" + elif state.get("corrupt_after_edit") == "body": + state["body"] = "Unmarked release notes" + save() + raise SystemExit(0) + +print(f"unexpected gh invocation: {args}", file=sys.stderr) +raise SystemExit(2) +""", + encoding="utf-8", + ) + path.chmod(0o755) + + +def write_fake_uv(path: Path) -> None: + path.write_text( + r"""#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +expected = [ + "run", + "--locked", + "--extra", + "dev", + "python", + "scripts/release.py", + "github-release-plan", + "--expected-tag", + os.environ["TAG_NAME"], +] +if sys.argv[1:] != expected: + print(f"unexpected uv invocation: {sys.argv[1:]}", file=sys.stderr) + raise SystemExit(2) + +state_path = Path(os.environ["FAKE_GH_STATE"]) +state = json.loads(state_path.read_text(encoding="utf-8")) +if state.get("plan_failure"): + print("simulated release plan failure", file=sys.stderr) + raise SystemExit(1) + +plan_count = int(state.get("plan_count", 0)) +plans = state.get("plans") +if plans: + plan = plans[min(plan_count, len(plans) - 1)] + prerelease = str(plan["prerelease"]).lower() + latest = str(plan["latest"]).lower() + notes_start_tag = str(plan.get("notes_start_tag", "")) +else: + prerelease = os.environ["FAKE_PLAN_PRERELEASE"] + latest = os.environ["FAKE_PLAN_LATEST"] + notes_start_tag = os.environ["FAKE_PLAN_NOTES_START_TAG"] +state["plan_count"] = plan_count + 1 +if state.get("corrupt_during_second_plan") and plan_count == 1 and state["assets"]: + state["assets"][0]["digest"] = "sha256:" + "0" * 64 +state_path.write_text(json.dumps(state), encoding="utf-8") +print(f"prerelease={prerelease}") +print(f"latest={latest}") +print(f"notes_start_tag={notes_start_tag}") +""", + encoding="utf-8", + ) + path.chmod(0o755) + + +def run_release( + tmp_path: Path, + initial_state: dict[str, object], + *, + expected_prerelease: str = "false", + expected_latest: str = "true", + expected_notes_start_tag: str = "", + repository: str = "crewplaneai/crewplane", +) -> tuple[ + subprocess.CompletedProcess[str], + tuple[str, ...], + dict[str, object], +]: + dist = tmp_path / "dist" + dist.mkdir() + for name in EXPECTED_ASSETS: + (dist / name).write_text(name, encoding="utf-8") + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + write_fake_gh(fake_bin / "gh") + write_fake_uv(fake_bin / "uv") + call_log = tmp_path / "gh-calls.log" + call_log.touch() + state_path = tmp_path / "gh-state.json" + state_path.write_text(json.dumps(initial_state), encoding="utf-8") + env = os.environ.copy() + env.update( + { + "FAKE_PLAN_LATEST": expected_latest, + "FAKE_PLAN_NOTES_START_TAG": expected_notes_start_tag, + "FAKE_PLAN_PRERELEASE": expected_prerelease, + "FAKE_GH_STATE": str(state_path), + "GH_CALL_LOG": str(call_log), + "GH_REPO": repository, + "PATH": f"{fake_bin}{os.pathsep}{env['PATH']}", + "RELEASE_AUTOMATION_MARKER": RELEASE_AUTOMATION_MARKER, + "TAG_NAME": "v1.2.3", + } + ) + + result = subprocess.run( + [str(GITHUB_RELEASE_SCRIPT), str(dist)], + cwd=ROOT, + check=False, + capture_output=True, + env=env, + text=True, + ) + calls = tuple(call_log.read_text(encoding="utf-8").splitlines()) + final_state = json.loads(state_path.read_text(encoding="utf-8")) + return result, calls, final_state + + +def assert_no_release_mutations(calls: tuple[str, ...]) -> None: + assert not any(call.startswith(MUTATING_COMMANDS) for call in calls) + + +@pytest.mark.parametrize( + ("prerelease", "latest"), + (("false", "true"), ("false", "false"), ("true", "false")), +) +def test_matching_published_release_is_verified_without_mutation( + tmp_path: Path, + prerelease: str, + latest: str, +) -> None: + result, calls, _state = run_release( + tmp_path, + release_state(prerelease=prerelease == "true", latest=latest == "true"), + expected_prerelease=prerelease, + expected_latest=latest, + ) + + assert result.returncode == 0, result.stderr + assert ( + result.stdout + == "Verified existing published GitHub Release v1.2.3; leaving it unchanged.\n" + ) + assert calls[0].startswith("api\tgraphql") + assert_no_release_mutations(calls) + + +@pytest.mark.parametrize( + ("state", "expected_error"), + ( + ( + release_state(assets=exact_assets()[:-1]), + "assets do not match", + ), + ( + release_state(assets=[*exact_assets(), asset_record("unexpected.txt")]), + "assets do not match", + ), + ( + release_state( + assets=[ + {**exact_assets()[0], "size": 999}, + exact_assets()[1], + ] + ), + "assets do not match", + ), + ( + release_state( + assets=[ + {**exact_assets()[0], "digest": "sha256:" + "0" * 64}, + exact_assets()[1], + ] + ), + "assets do not match", + ), + ( + release_state( + assets=[ + {**exact_assets()[0], "digest": ""}, + exact_assets()[1], + ] + ), + "incomplete size or digest metadata", + ), + (release_state(prerelease=True), "prerelease state does not match"), + (release_state(latest=False), "Latest state does not match"), + (release_state(title="Wrong title"), "title does not match"), + ( + release_state(body="Unmarked release notes"), + "notes are missing the release automation marker", + ), + ), +) +def test_published_release_mismatch_fails_without_mutation( + tmp_path: Path, + state: dict[str, object], + expected_error: str, +) -> None: + result, calls, _state = run_release(tmp_path, state) + + assert result.returncode != 0 + assert expected_error in result.stderr + assert_no_release_mutations(calls) + + +@pytest.mark.parametrize( + ("prerelease", "latest", "expected_error"), + ( + ("invalid", "false", "malformed output"), + ("false", "invalid", "malformed output"), + ("true", "true", "marked a prerelease as GitHub Latest"), + ), +) +def test_invalid_release_plan_fails_before_querying_github( + tmp_path: Path, + prerelease: str, + latest: str, + expected_error: str, +) -> None: + result, calls, _state = run_release( + tmp_path, + release_state(), + expected_prerelease=prerelease, + expected_latest=latest, + ) + + assert result.returncode != 0 + assert expected_error in result.stderr + assert calls == () + + +@pytest.mark.parametrize( + "notes_start_tag", + ("--not-a-tag", "v1.1.0\nunexpected=true"), +) +def test_invalid_release_notes_start_tag_fails_before_querying_github( + tmp_path: Path, + notes_start_tag: str, +) -> None: + result, calls, _state = run_release( + tmp_path, + release_state(), + expected_notes_start_tag=notes_start_tag, + ) + + assert result.returncode != 0 + assert "malformed output" in result.stderr + assert calls == () + + +def test_github_query_failure_never_creates_a_release(tmp_path: Path) -> None: + result, calls, _state = run_release( + tmp_path, + release_state(exists=False, query_failure=True), + ) + + assert result.returncode != 0 + assert "refusing to mutate" in result.stderr + assert_no_release_mutations(calls) + + +@pytest.mark.parametrize( + ("prerelease", "latest", "expected_edit_flags"), + ( + ("false", "true", ("--prerelease=false", "--latest")), + ("false", "false", ("--prerelease=false", "--latest=false")), + ("true", "false", ("--prerelease", "--latest=false")), + ), +) +def test_absent_release_is_created_as_verified_draft_then_published( + tmp_path: Path, + prerelease: str, + latest: str, + expected_edit_flags: tuple[str, str], +) -> None: + result, calls, final_state = run_release( + tmp_path, + release_state(exists=False, assets=[]), + expected_prerelease=prerelease, + expected_latest=latest, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "Published GitHub Release v1.2.3.\n" + create = next(call for call in calls if call.startswith("release\tcreate")) + edit = next(call for call in calls if call.startswith("release\tedit")) + assert "\t--draft\t" in create + assert "\t--notes-start-tag\t" not in create + assert f"\t--notes\t{RELEASE_AUTOMATION_MARKER}\t" in create + assert "\t--title\tv1.2.3\t" in create + assert all(f"\t{flag}" in edit for flag in expected_edit_flags) + assert final_state["draft"] is False + assert final_state["prerelease"] is (prerelease == "true") + assert final_state["latest"] is (latest == "true") + assert final_state["title"] == "v1.2.3" + assert final_state["body"] == GENERATED_RELEASE_NOTES + assert final_state["assets"] == exact_assets() + + +def test_delayed_release_uses_verified_notes_predecessor(tmp_path: Path) -> None: + result, calls, final_state = run_release( + tmp_path, + release_state(exists=False, assets=[]), + expected_notes_start_tag="v1.1.0", + ) + + assert result.returncode == 0, result.stderr + create = next(call for call in calls if call.startswith("release\tcreate")) + assert "\t--notes-start-tag\tv1.1.0\t" in create + assert final_state["draft"] is False + + +@pytest.mark.parametrize( + "initial_assets", + ( + [], + exact_assets()[:-1], + [asset_record(EXPECTED_ASSETS[0], "wrong bytes")], + [{**exact_assets()[0], "digest": ""}], + ), +) +def test_existing_draft_assets_are_clobbered_and_verified_before_publish( + tmp_path: Path, + initial_assets: list[dict[str, object]], +) -> None: + result, calls, final_state = run_release( + tmp_path, + release_state(draft=True, latest=False, assets=initial_assets), + ) + + assert result.returncode == 0, result.stderr + upload_index = next( + index for index, call in enumerate(calls) if call.startswith("release\tupload") + ) + edit_index = next( + index for index, call in enumerate(calls) if call.startswith("release\tedit") + ) + assert upload_index < edit_index + assert final_state["assets"] == exact_assets() + assert final_state["draft"] is False + + +@pytest.mark.parametrize( + ("metadata", "expected_error"), + ( + ({"title": "Wrong title"}, "title does not match the release tag"), + ( + {"body": "Manual release notes"}, + "notes are missing the release automation marker", + ), + ), +) +def test_unverified_draft_metadata_fails_before_mutation( + tmp_path: Path, + metadata: dict[str, str], + expected_error: str, +) -> None: + result, calls, final_state = run_release( + tmp_path, + release_state(draft=True, latest=False, **metadata), + ) + + assert result.returncode != 0 + assert expected_error in result.stderr + assert_no_release_mutations(calls) + assert final_state["draft"] is True + + +def test_draft_with_unexpected_asset_fails_without_mutation(tmp_path: Path) -> None: + result, calls, _state = run_release( + tmp_path, + release_state( + draft=True, + latest=False, + assets=[*exact_assets(), asset_record("unexpected.txt")], + ), + ) + + assert result.returncode != 0 + assert "unexpected assets" in result.stderr + assert_no_release_mutations(calls) + + +def test_corrupt_upload_prevents_draft_publication(tmp_path: Path) -> None: + result, calls, final_state = run_release( + tmp_path, + release_state( + draft=True, + latest=False, + assets=[], + corrupt_after_upload=True, + ), + ) + + assert result.returncode != 0 + assert "Draft assets do not match" in result.stderr + assert any(call.startswith("release\tupload") for call in calls) + assert not any(call.startswith("release\tedit") for call in calls) + assert final_state["draft"] is True + + +@pytest.mark.parametrize("corruption", ("asset", "latest", "title", "body")) +def test_post_publish_mismatch_is_detected( + tmp_path: Path, + corruption: str, +) -> None: + result, calls, final_state = run_release( + tmp_path, + release_state( + exists=False, + assets=[], + corrupt_after_edit=corruption, + ), + ) + + assert result.returncode != 0 + assert any(call.startswith("release\tedit") for call in calls) + assert final_state["draft"] is False + assert "Published release" in result.stderr + + +def test_truncated_asset_query_fails_without_mutation(tmp_path: Path) -> None: + result, calls, _state = run_release( + tmp_path, + release_state(total_count=3, returned_limit=1), + ) + + assert result.returncode != 0 + assert "truncated or internally inconsistent" in result.stderr + assert_no_release_mutations(calls) + + +def test_latest_plan_is_refreshed_immediately_before_publication( + tmp_path: Path, +) -> None: + result, calls, final_state = run_release( + tmp_path, + release_state( + exists=False, + assets=[], + plans=[ + {"prerelease": False, "latest": True}, + {"prerelease": False, "latest": False}, + ], + ), + ) + + assert result.returncode == 0, result.stderr + edit = next(call for call in calls if call.startswith("release\tedit")) + assert "\t--latest=false" in edit + assert final_state["latest"] is False + assert final_state["plan_count"] == 2 + + +def test_draft_mutation_during_second_plan_prevents_publication( + tmp_path: Path, +) -> None: + result, calls, final_state = run_release( + tmp_path, + release_state( + exists=False, + assets=[], + corrupt_during_second_plan=True, + ), + ) + + assert result.returncode != 0 + assert "Final draft assets do not match" in result.stderr + assert not any(call.startswith("release\tedit") for call in calls) + assert final_state["draft"] is True + + +def test_same_owner_and_repository_name_is_valid(tmp_path: Path) -> None: + result, _calls, _state = run_release( + tmp_path, + release_state(), + repository="crewplane/crewplane", + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/unit/packaging/test_release_surfaces.py b/tests/unit/packaging/test_release_surfaces.py index 5eabf0b..2097d29 100644 --- a/tests/unit/packaging/test_release_surfaces.py +++ b/tests/unit/packaging/test_release_surfaces.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +import yaml from packaging.version import Version ROOT = Path(__file__).resolve().parents[3] @@ -18,6 +19,12 @@ CLI_COMMAND = "crewplane" IMPORT_PACKAGE = "crewplane" REPOSITORY_URL = "https://github.com/crewplaneai/crewplane" +GRANDFATHERED_LARGE_FILE_LIMITS = { + ".github/crewplane-splash.png": 1_093_755, + "docs/images/concepts/control-plane.png": 1_664_884, + "docs/images/concepts/different-design.png": 1_466_376, + "docs/images/concepts/why-crewplane.png": 1_511_410, +} def repo_path(*parts: str) -> Path: @@ -67,6 +74,10 @@ def test_python_distribution_metadata_reserves_crewplane_name() -> None: assert urls["Issues"] == f"{REPOSITORY_URL}/issues" assert urls["Documentation"] == f"{REPOSITORY_URL}/blob/master/docs/index.md" + build_system = pyproject["build-system"] + assert build_system["requires"] == ["hatchling==1.30.1"] + assert build_system["build-backend"] == "hatchling.build" + wheel_config = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] assert wheel_config["packages"] == [f"src/{IMPORT_PACKAGE}"] @@ -141,8 +152,490 @@ def test_release_script_exposes_stateful_commands() -> None: capture_output=True, text=True, ) - for command in ("prepare", "check", "publish-pypi", "publish-npm", "finalize"): + for command in ( + "prepare", + "release-artifacts", + "check", + "verify-complete", + "github-release-plan", + "publish-pypi", + "publish-npm", + "finalize", + ): assert command in result.stdout + assert "recover-release-artifacts" not in result.stdout + assert "verify-backfill" not in result.stdout + + +def test_ci_package_job_smoke_tests_wheel_before_inspection_and_upload() -> None: + workflow = yaml.load( + read_text(".github", "workflows", "ci.yml"), Loader=yaml.BaseLoader + ) + package_steps = workflow["jobs"]["package"]["steps"] + step_positions = { + step.get("name"): index for index, step in enumerate(package_steps) + } + smoke_index = step_positions["Build and smoke-test package"] + inspect_index = step_positions["Inspect dist"] + upload_index = step_positions["Upload dist artifact"] + + assert package_steps[smoke_index]["run"] == "make install-smoke-pip" + assert smoke_index < inspect_index < upload_index + assert all(step.get("run") != "uv build" for step in package_steps) + + +def test_production_release_workflow_reuses_release_tool_without_pypi_publish() -> None: + workflow = read_text(".github", "workflows", "release.yml") + workflow_config = yaml.load(workflow, Loader=yaml.BaseLoader) + release_script = read_text("scripts", "publish_github_release.sh") + + dispatch = workflow_config["on"]["workflow_dispatch"] + assert "push" not in workflow_config["on"] + assert dispatch["inputs"]["tag"]["required"] == "true" + assert dispatch["inputs"]["tag"]["type"] == "string" + assert dispatch["inputs"]["tag"]["description"] == ( + "Tag just created by make release from the current master commit" + ) + verify_steps = workflow_config["jobs"]["verify"]["steps"] + master_guard = verify_steps[0] + assert master_guard["name"] == "Reject non-master dispatch" + assert master_guard["if"] == "github.ref != 'refs/heads/master'" + assert "exit 1" in master_guard["run"] + release_source_guard = verify_steps[2] + assert release_source_guard["name"] == "Resolve and verify current release source" + assert ( + "release_commit=\"$(git rev-parse --verify 'HEAD^{commit}')\"" + in release_source_guard["run"] + ) + assert ( + 'if [ "$release_commit" != "$GITHUB_SHA" ]; then' + in (release_source_guard["run"]) + ) + assert ( + "Release tag must point to the dispatched master commit." + in (release_source_guard["run"]) + ) + assert workflow.count("TAG_NAME: ${{ inputs.tag }}") == 2 + assert workflow.count("fetch-depth: 0") == 2 + assert workflow.count("git fetch --quiet --no-tags origin refs/heads/master") == 1 + assert workflow.count("git merge-base --is-ancestor") == 1 + assert "ref: refs/tags/${{ inputs.tag }}" in workflow + assert ( + "release_commit: ${{ steps.release-source.outputs.release_commit }}" in workflow + ) + assert "ref: ${{ needs.verify.outputs.release_commit }}" in workflow + assert 'echo "release_commit=$release_commit" >> "$GITHUB_OUTPUT"' in workflow + assert "group: github-release-publication" in workflow + assert "cancel-in-progress: false" in workflow + assert "queue: max" in workflow + assert workflow.count("uses: actions/checkout@") == 2 + assert "github.event.inputs" not in workflow + assert "github.ref_name" not in workflow + assert "path: tooling" not in workflow + assert "path: source" not in workflow + assert "working-directory:" not in workflow + assert "python scripts/release.py release-artifacts" in workflow + assert workflow.count("python scripts/release.py github-release-plan") == 1 + assert "python scripts/release.py github-release-plan" in release_script + assert "github-release-metadata" not in workflow + assert workflow.count("needs.verify.outputs.release_commit") == 1 + assert "steps.release-plan.outputs" not in workflow + assert "name: release-bundle" in workflow + assert "dist/*" in workflow + assert ".release/npm/*.tgz" in workflow + assert ".release/release-manifest.json" in workflow + assert "include-hidden-files: true" in workflow + assert "overwrite: true" in workflow + assert "scripts/publish_github_release.sh dist" in workflow + assert "release_flags=(--prerelease --latest=false)" in release_script + assert "release_flags=(--prerelease=false --latest)" in release_script + assert "release_flags=(--prerelease=false --latest=false)" in release_script + assert '"${release_flags[@]}"' in release_script + assert '--expected-tag "$TAG_NAME"' in workflow + assert "recover-release-artifacts" not in workflow + assert "verify-backfill" not in workflow + assert "IS_BACKFILL" not in workflow + assert 'gh release create "$tag_name" "${release_artifacts[@]}"' in release_script + assert "release(tagName: $tag)" in release_script + assert "nodes { name size digest }" in release_script + assert "totalCount" in release_script + assert 'gh release upload "$tag_name" "${release_artifacts[@]}"' in (release_script) + assert "--clobber" in release_script + assert 'release_artifacts=("$dist_dir"/*)' in release_script + assert 'comm -13 "$expected_names_file" "$release_names_file"' in (release_script) + assert 'cmp -s "$expected_assets_file" "$release_assets_file"' in release_script + assert "Refusing to publish a draft with unexpected assets" in release_script + assert "assets do not match the verified dist artifacts" in release_script + assert "prerelease state does not match" in release_script + assert "Latest state does not match" in release_script + assert "Verified existing published GitHub Release" in release_script + assert "query was truncated or internally inconsistent" in release_script + assert "refusing to mutate it" in release_script + assert 'gh release edit "$tag_name"' in release_script + assert '--tag "$tag_name"' in release_script + assert "--draft=false" in release_script + assert "--verify-tag" in release_script + assert "GH_REPO: ${{ github.repository }}" in workflow + assert '--repo "$repository"' in release_script + assert workflow.count("contents: write") == 1 + assert "uv build" not in workflow + assert "urllib.request" not in workflow + assert "pypa/gh-action-pypi-publish" not in workflow + assert "id-token: write" not in workflow + + +def test_release_drafter_was_removed() -> None: + assert not repo_path(".github", "release-drafter.yml").exists() + assert not repo_path(".github", "workflows", "release-drafter.yml").exists() + + +def test_github_workflow_actions_are_pinned_to_commits() -> None: + workflows = sorted(repo_path(".github", "workflows").glob("*.yml")) + for workflow in workflows: + for line_number, line in enumerate( + workflow.read_text(encoding="utf-8").splitlines(), start=1 + ): + if "uses:" not in line: + continue + action = line.split("uses:", 1)[1].split("#", 1)[0].strip() + assert re.fullmatch(r"[^@\s]+@[0-9a-f]{40}", action), ( + f"{workflow.relative_to(ROOT)}:{line_number} is not commit-pinned" + ) + + +def test_github_workflow_uv_installs_are_version_pinned() -> None: + workflows = sorted(repo_path(".github", "workflows").glob("*.yml")) + for workflow in workflows: + lines = workflow.read_text(encoding="utf-8").splitlines() + for line_number, line in enumerate(lines, start=1): + if "astral-sh/setup-uv@" not in line: + continue + step = "\n".join(lines[line_number - 1 : line_number + 6]) + assert 'version: "0.10.9"' in step, ( + f"{workflow.relative_to(ROOT)}:{line_number} does not pin uv" + ) + + +def test_large_file_hook_enforces_limit_with_narrow_grandfathering() -> None: + config = yaml.safe_load(read_text(".pre-commit-config.yaml")) + hooks = [ + hook + for repository in config["repos"] + for hook in repository["hooks"] + if hook["id"] == "check-added-large-files" + ] + assert len(hooks) == 1 + hook = hooks[0] + assert hook["args"] == ["--maxkb=1024", "--enforce-all"] + + exclusion = re.compile(hook["exclude"]) + tracked = subprocess.run( + ["git", "ls-files", "-z"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.split("\0") + tracked_paths = {path for path in tracked if path} + oversized = { + path + for path in tracked_paths + if (ROOT / path).is_file() and (ROOT / path).stat().st_size > 1024**2 + } + excluded = {path for path in tracked_paths if exclusion.search(path)} + + grandfathered = set(GRANDFATHERED_LARGE_FILE_LIMITS) + assert oversized == grandfathered + assert excluded == grandfathered + for path, size_limit in GRANDFATHERED_LARGE_FILE_LIMITS.items(): + assert (ROOT / path).stat().st_size <= size_limit + assert "pre-commit==4.6.0 run --all-files" in read_text( + ".github", "workflows", "ci.yml" + ) + + +def test_pre_commit_hooks_are_immutable_and_dependabot_managed() -> None: + pre_commit_text = read_text(".pre-commit-config.yaml") + pre_commit = yaml.safe_load(pre_commit_text) + hook_repository = next( + repository + for repository in pre_commit["repos"] + if repository["repo"] == "https://github.com/pre-commit/pre-commit-hooks" + ) + + hook_revision = hook_repository["rev"] + assert isinstance(hook_revision, str) + assert re.fullmatch(r"[0-9a-f]{40}", hook_revision) + assert re.search( + rf"^\s+rev:\s+{re.escape(hook_revision)}\s+" + r"# frozen: v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\s*$", + pre_commit_text, + re.MULTILINE, + ) + + dependabot = yaml.safe_load(read_text(".github", "dependabot.yml")) + pre_commit_updates = [ + update + for update in dependabot["updates"] + if update["package-ecosystem"] == "pre-commit" + ] + assert len(pre_commit_updates) == 1 + assert pre_commit_updates[0]["directory"] == "/" + assert {"dependencies", "status: needs-triage", "area: ci"} <= set( + pre_commit_updates[0]["labels"] + ) + + +def test_ruff_lint_rejects_debugger_calls() -> None: + ruff_lint = load_pyproject()["tool"]["ruff"]["lint"] + + assert "T10" in ruff_lint["select"] + + +def test_label_automation_uses_declared_labels() -> None: + labels = json.loads(read_text(".github", "labels.json")) + label_names = [label["name"] for label in labels] + assert len(label_names) == len(set(label_names)) + declared_labels = set(label_names) + + template_labels: set[str] = set() + template_paths = [ + *repo_path(".github", "ISSUE_TEMPLATE").glob("*.yml"), + *repo_path(".github", "DISCUSSION_TEMPLATE").glob("*.yml"), + ] + for template_path in template_paths: + template = yaml.safe_load(template_path.read_text(encoding="utf-8")) + template_labels.update(template.get("labels", [])) + + path_labels = yaml.safe_load(read_text(".github", "labeler.yml")) + referenced_labels = { + "status: needs-triage", + "dependencies", + *template_labels, + *path_labels, + } + assert referenced_labels <= declared_labels + packaging_globs = path_labels["area: packaging"][0]["changed-files"][0][ + "any-glob-to-any-file" + ] + assert "packaging/**" in packaging_globs + + triage = read_text(".github", "workflows", "issue-triage.yml") + assert 'item.user?.login === "dependabot[bot]"' not in triage + assert 'item.user?.type === "Bot"' not in triage + assert "github.event_name != 'pull_request_target'" in triage + assert "github.event.pull_request.user.login != 'dependabot[bot]'" in triage + + pr_labeler = read_text(".github", "workflows", "pr-labeler.yml") + assert "github.event.pull_request.user.login != 'dependabot[bot]'" in pr_labeler + + dependabot = yaml.safe_load(read_text(".github", "dependabot.yml")) + for update in dependabot["updates"]: + assert {"dependencies", "status: needs-triage"} <= set(update["labels"]) + + sync = read_text(".github", "workflows", "sync-labels.yml") + assert 'branches: ["master"]' in sync + assert "inputs.prune || 'false'" in sync + + +def test_manual_label_sync_fails_outside_master() -> None: + workflow = yaml.safe_load(read_text(".github", "workflows", "sync-labels.yml")) + steps = workflow["jobs"]["sync-labels"]["steps"] + guard = steps[0] + + assert guard["name"] == "Reject non-master runs" + assert guard["if"] == "github.ref != 'refs/heads/master'" + assert "exit 1" in guard["run"] + assert steps[1]["uses"].startswith("actions/checkout@") + + +def test_questions_and_usage_help_are_routed_to_discussions() -> None: + assert not repo_path(".github", "ISSUE_TEMPLATE", "question.yml").exists() + + issue_config = yaml.safe_load(read_text(".github", "ISSUE_TEMPLATE", "config.yml")) + discussions_links = [ + link + for link in issue_config["contact_links"] + if link["url"] == f"{REPOSITORY_URL}/discussions" + ] + assert len(discussions_links) == 1 + assert "questions" in discussions_links[0]["about"].lower() + assert "usage help" in discussions_links[0]["about"].lower() + + labels = json.loads(read_text(".github", "labels.json")) + assert "type: question" not in {label["name"] for label in labels} + assert "**Questions and ideas:** use GitHub Discussions." in read_text("SUPPORT.md") + + +def test_bug_report_requires_support_environment_details() -> None: + bug_report = yaml.safe_load( + read_text(".github", "ISSUE_TEMPLATE", "bug_report.yml") + ) + fields = {field["id"]: field for field in bug_report["body"] if "id" in field} + required_fields = { + "os": "Operating system", + "shell": "Shell", + "python": "Python version", + "install_method": "Installation method", + "provider_invoker": "Provider CLI or invoker", + "live_mode": "Live mode", + } + + for field_id, label in required_fields.items(): + assert fields[field_id]["attributes"]["label"] == label + assert fields[field_id]["validations"]["required"] is True + + privacy_guidance = fields["logs"]["attributes"]["description"].lower() + for protected_detail in ("secrets", "tokens", "customer data", "provider payloads"): + assert protected_detail in privacy_guidance + safety_checks = fields["safety"]["attributes"]["options"] + assert all(option["required"] is True for option in safety_checks) + + +def test_release_docs_describe_single_production_publish_path() -> None: + development = read_text("DEVELOPMENT.md") + contributing = read_text("CONTRIBUTING.md") + normalized_development = " ".join(development.split()) + normalized_contributing = " ".join(contributing.split()) + + assert "Production publishing is local-only." in development + assert "Maintainers run `make release`" in normalized_development + assert "does not publish production PyPI or npm packages" in normalized_development + assert "does not need PyPI or npm credentials" in normalized_development + assert "manually dispatched GitHub Release automation" in normalized_development + assert "required `tag` input" in normalized_development + assert "rejects dispatches outside `refs/heads/master`" in normalized_development + assert "manual `make release` flow is the production source of truth" in ( + normalized_development + ) + assert "second phase of the same release operation" in normalized_development + assert ( + "requires it to match the master commit that dispatched the workflow exactly" + in normalized_development + ) + assert ( + "Historical-tag backfills and new dispatches after `master` advances are unsupported" + in normalized_development + ) + assert "exact Hatchling build-system pin" in normalized_development + assert "offline runtime wheelhouse" in normalized_development + assert "GitHub Release itself contains only `dist/*`" in (normalized_development) + assert "New releases are built as drafts, verified, published" in ( + normalized_development + ) + assert "exact name, size, and GitHub's immutable upload-time SHA-256 digest" in ( + normalized_development + ) + assert "highest published stable version on PyPI" in normalized_development + assert "does not update the Homebrew tap" in development + assert "TestPyPI Trusted Publishing workflow" in normalized_development + assert "dispatch it from any selected ref" in normalized_development + assert "not restricted to `master`" in normalized_development + assert "delayed GitHub Release" not in normalized_development + assert "stale attempts for older releases" not in normalized_development + assert "PyPI Trusted Publishing through GitHub OIDC" not in development + assert "scripts/release.py release-artifacts" in contributing + assert "scripts/release.py github-release-plan" in contributing + assert "recover-release-artifacts" not in contributing + assert "verify-backfill" not in contributing + assert "exact Hatchling build-system pin" in normalized_contributing + assert "offline runtime wheelhouse" in normalized_contributing + assert "exact asset names, sizes, and GitHub SHA-256 digests" in ( + normalized_contributing + ) + assert "never mutates an already-published mismatch" in (normalized_contributing) + assert "highest published stable version on PyPI" in normalized_contributing + assert "checks out the requested tag" in normalized_contributing + assert ( + "requires it to match the master commit that dispatched the workflow exactly" + in normalized_contributing + ) + assert ( + "Historical-tag backfills and new dispatches after `master` advances are unsupported" + in normalized_contributing + ) + assert "checks out the exact verified commit" in normalized_contributing + assert "current `master` tip" not in normalized_contributing + assert "reloads and re-verifies draft assets immediately before publication" in ( + normalized_contributing + ) + assert "does not publish production packages" in normalized_contributing + assert "dispatch it from any selected ref" in normalized_contributing + assert "not restricted to `master`" in normalized_contributing + assert "delayed GitHub Release" not in normalized_contributing + assert "stale attempts for older releases" not in normalized_contributing + + +def test_repository_automation_matches_supported_platform_and_publish_policy() -> None: + development = read_text("DEVELOPMENT.md") + contributing = read_text("CONTRIBUTING.md") + nightly_text = read_text(".github", "workflows", "nightly.yml") + nightly = yaml.safe_load(nightly_text) + testpypi = read_text(".github", "workflows", "testpypi.yml") + + for document in (development, contributing): + normalized_document = " ".join(document.split()) + assert "Linux, macOS, and WSL" in normalized_document + assert "Native Windows is not supported" in normalized_document + assert ( + "supports Python 3.13+ on Linux, macOS, Windows" not in normalized_document + ) + + assert nightly["jobs"]["cross-platform"]["strategy"]["matrix"]["os"] == [ + "ubuntu-latest", + "macos-latest", + ] + assert "windows-latest" not in nightly_text + assert "skip-existing" not in testpypi + + +def test_private_reporting_surfaces_use_github_security_advisories() -> None: + issue_config = yaml.safe_load(read_text(".github", "ISSUE_TEMPLATE", "config.yml")) + advisory_url = f"{REPOSITORY_URL}/security/advisories/new" + security_links = [ + link for link in issue_config["contact_links"] if link["url"] == advisory_url + ] + + assert len(security_links) == 1 + assert "privately" in security_links[0]["about"].lower() + assert "GitHub Security Advisories" in read_text("SECURITY.md") + assert "GitHub Security Advisories" in read_text("SUPPORT.md") + assert advisory_url in read_text("CODE_OF_CONDUCT.md") + + +def test_repository_hosting_policy_uses_current_workflow_surfaces() -> None: + assert not repo_path(".github", "branch-protection.json").exists() + assert not repo_path(".github", "settings.yml").exists() + + testpypi = yaml.safe_load(read_text(".github", "workflows", "testpypi.yml")) + publisher = testpypi["jobs"]["publish-testpypi"] + assert publisher["environment"]["name"] == "testpypi" + assert publisher["permissions"] == {"id-token": "write", "contents": "read"} + + +def test_repository_gitattributes_preserve_blob_exact_workspace_compatibility() -> None: + attributes = read_text(".gitattributes") + policy_lines = { + line.strip() + for line in attributes.splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + + assert "* -text" in policy_lines + assert "text=" not in attributes + assert " eol=" not in attributes + assert " crlf=" not in attributes + assert "working-tree-encoding" not in attributes + + +def test_dependency_review_covers_python_and_npm_manifests() -> None: + workflow = yaml.load( + read_text(".github", "workflows", "dependency-review.yml"), + Loader=yaml.BaseLoader, + ) + paths = set(workflow["on"]["pull_request"]["paths"]) + + assert {"pyproject.toml", "uv.lock", "packaging/npm/package*.json"} <= paths def test_install_script_uses_uv_and_supports_local_artifact_smoke() -> None: diff --git a/tests/unit/packaging/test_release_tool_fixtures.py b/tests/unit/packaging/test_release_tool_fixtures.py index 1babfdd..385ef0c 100644 --- a/tests/unit/packaging/test_release_tool_fixtures.py +++ b/tests/unit/packaging/test_release_tool_fixtures.py @@ -228,29 +228,30 @@ def append_uv_lock_package( def release_state_fixture( root: Path, + version: str = "1.0.0-alpha.1", ) -> tuple[ state.ReleaseContext, state.ReleaseManifest, state.FormulaState, state.GitState ]: - write_minimal_repo(root, "1.0.0-alpha.1") + write_minimal_repo(root, version) context = state.read_release_context(root) artifacts = { "pypi_sdist": state.ArtifactIdentity( "pypi_sdist", - "dist/crewplane-1.0.0a1.tar.gz", + f"dist/{context.sdist_filename}", context.sdist_filename, 10, "a" * 64, ), "pypi_wheel": state.ArtifactIdentity( "pypi_wheel", - "dist/crewplane-1.0.0a1-py3-none-any.whl", + f"dist/{context.wheel_filename}", context.wheel_filename, 20, "b" * 64, ), "npm_tarball": state.ArtifactIdentity( "npm_tarball", - ".release/npm/crewplane-1.0.0-alpha.1.tgz", + f".release/npm/{context.npm_filename}", context.npm_filename, 30, "c" * 64, @@ -287,6 +288,7 @@ def release_state_fixture( branch="master", default_branch="master", head_commit="abc", + head_reachable_from_origin_master=True, upstream_ahead=0, upstream_behind=0, dirty=False, @@ -297,7 +299,9 @@ def release_state_fixture( def matching_pypi( - context: state.ReleaseContext, manifest: state.ReleaseManifest + context: state.ReleaseContext, + manifest: state.ReleaseManifest, + latest_stable: str | None = None, ) -> state.PypiRelease: sdist = manifest.artifact("pypi_sdist") wheel = manifest.artifact("pypi_wheel") @@ -308,6 +312,11 @@ def matching_pypi( sdist.filename: state.PypiFile(sdist.filename, sdist.size, sdist.sha256), wheel.filename: state.PypiFile(wheel.filename, wheel.size, wheel.sha256), }, + latest_stable=( + latest_stable + if latest_stable is not None + else ("" if context.version.is_prerelease else context.version.python) + ), ) diff --git a/tests/unit/packaging/test_release_tool_publish.py b/tests/unit/packaging/test_release_tool_publish.py index e9a0572..ad9ead3 100644 --- a/tests/unit/packaging/test_release_tool_publish.py +++ b/tests/unit/packaging/test_release_tool_publish.py @@ -35,6 +35,438 @@ def test_publish_commands_without_execute_are_non_publishing_failures( assert not runner.commands +def test_verify_complete_release_returns_zero_for_complete_state( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + context, manifest, formula, git = release_state_fixture(tmp_path) + monkeypatch.setattr(publish, "read_release_context", constant(context)) + monkeypatch.setattr(publish, "read_manifest", constant(manifest)) + monkeypatch.setattr( + publish, "query_pypi_release", constant(matching_pypi(context, manifest)) + ) + monkeypatch.setattr( + publish, + "query_npm_release", + constant(matching_npm(context, manifest, latest=context.version.npm)), + ) + monkeypatch.setattr(publish, "read_formula_state", constant(formula)) + monkeypatch.setattr(publish, "inspect_release_tag_state", constant(git)) + + assert publish.verify_complete_release(tmp_path, FakeRunner()) == 0 + assert ( + publish.verify_complete_release( + tmp_path, FakeRunner(), expected_tag=context.version.tag + ) + == 0 + ) + assert "Release state: complete" in capsys.readouterr().out + + +def test_verify_complete_release_fails_on_expected_tag_mismatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + context, manifest, formula, git = release_state_fixture(tmp_path) + monkeypatch.setattr(publish, "read_release_context", constant(context)) + monkeypatch.setattr(publish, "read_manifest", constant(manifest)) + monkeypatch.setattr( + publish, "query_pypi_release", constant(matching_pypi(context, manifest)) + ) + monkeypatch.setattr( + publish, + "query_npm_release", + constant(matching_npm(context, manifest, latest=context.version.npm)), + ) + monkeypatch.setattr(publish, "read_formula_state", constant(formula)) + + with pytest.raises( + state.ReleaseError, match="expected workflow tag .* but context declares" + ): + publish.verify_complete_release( + tmp_path, FakeRunner(), expected_tag="v0.0.0-mismatch" + ) + + +@pytest.mark.parametrize( + ("version", "npm_latest", "pypi_latest_stable", "expected"), + ( + ( + "1.2.3", + "1.2.3", + "1.2.3", + "prerelease=false\nlatest=true\nnotes_start_tag=v1.0.0\n", + ), + ( + "1.2.3", + "2.0.0", + "2.0.0", + "prerelease=false\nlatest=false\nnotes_start_tag=v1.0.0\n", + ), + ( + "1.2.3", + "2.0.0-alpha.1", + "1.2.3", + "prerelease=false\nlatest=true\nnotes_start_tag=v1.0.0\n", + ), + ( + "1.2.3-alpha.4", + "1.2.3-alpha.4", + "1.1.0", + "prerelease=true\nlatest=false\nnotes_start_tag=v1.0.0\n", + ), + ( + "1.2.3.dev5", + "1.2.3-dev.5", + "1.1.0", + "prerelease=true\nlatest=false\nnotes_start_tag=v1.0.0\n", + ), + ( + "1.2.3.post6", + "1.2.3-post.6", + "1.2.3.post6", + "prerelease=false\nlatest=true\nnotes_start_tag=v1.0.0\n", + ), + ), +) +def test_github_release_plan_uses_fresh_registry_state( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + version: str, + npm_latest: str, + pypi_latest_stable: str, + expected: str, +) -> None: + context, manifest, formula, git = release_state_fixture(tmp_path, version) + monkeypatch.setattr(publish, "read_release_context", constant(context)) + monkeypatch.setattr(publish, "read_manifest", constant(manifest)) + monkeypatch.setattr( + publish, + "query_pypi_release", + constant( + matching_pypi( + context, + manifest, + latest_stable=pypi_latest_stable, + ) + ), + ) + monkeypatch.setattr( + publish, + "query_npm_release", + constant(matching_npm(context, manifest, latest=npm_latest)), + ) + monkeypatch.setattr(publish, "read_formula_state", constant(formula)) + monkeypatch.setattr(publish, "inspect_release_tag_state", constant(git)) + monkeypatch.setattr(publish, "github_release_bundle_issues", constant([])) + monkeypatch.setattr(publish, "verify_local_manifest_artifacts", constant([])) + monkeypatch.setattr( + publish, + "verified_release_notes_start_tag", + constant("v1.0.0"), + ) + + publish.print_github_release_plan( + tmp_path, + FakeRunner(), + expected_tag=context.version.tag, + ) + + assert capsys.readouterr().out == expected + + +@pytest.mark.parametrize( + ("failed_check", "expected_error"), + ( + ("manifest", "manifest mismatch"), + ("bundle", "bundle mismatch"), + ("local", "local artifact mismatch"), + ("pypi", "PyPI mismatch"), + ("npm", "npm mismatch"), + ("formula", "formula mismatch"), + ("tag", "tag mismatch"), + ), +) +def test_github_release_plan_fails_closed_on_verification_issue( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failed_check: str, + expected_error: str, +) -> None: + context, manifest, formula, git = release_state_fixture(tmp_path, "1.2.3") + monkeypatch.setattr(publish, "read_release_context", constant(context)) + monkeypatch.setattr(publish, "read_manifest", constant(manifest)) + monkeypatch.setattr( + publish, "query_pypi_release", constant(matching_pypi(context, manifest)) + ) + monkeypatch.setattr( + publish, + "query_npm_release", + constant(matching_npm(context, manifest, latest=context.version.npm)), + ) + monkeypatch.setattr(publish, "read_formula_state", constant(formula)) + monkeypatch.setattr(publish, "inspect_release_tag_state", constant(git)) + monkeypatch.setattr(publish, "manifest_context_issues", constant([])) + monkeypatch.setattr(publish, "github_release_bundle_issues", constant([])) + monkeypatch.setattr(publish, "verify_local_manifest_artifacts", constant([])) + monkeypatch.setattr(publish, "verify_pypi_artifacts", constant([])) + monkeypatch.setattr(publish, "verify_npm_artifact", constant([])) + monkeypatch.setattr(publish, "verify_formula_state_for_release", constant([])) + monkeypatch.setattr(publish, "verify_git_tag_state", constant([])) + monkeypatch.setattr( + publish, + "verified_release_notes_start_tag", + constant("v1.0.0"), + ) + + check_name = { + "manifest": "manifest_context_issues", + "bundle": "github_release_bundle_issues", + "local": "verify_local_manifest_artifacts", + "pypi": "verify_pypi_artifacts", + "npm": "verify_npm_artifact", + "formula": "verify_formula_state_for_release", + "tag": "verify_git_tag_state", + }[failed_check] + monkeypatch.setattr(publish, check_name, constant([expected_error])) + + with pytest.raises(state.ReleaseError, match=expected_error): + publish.verified_github_release_plan( + tmp_path, + FakeRunner(), + expected_tag=context.version.tag, + ) + + +@pytest.mark.parametrize( + ("npm_latest", "expected_error"), + ( + ("", "missing or invalid"), + ("not-a-version", "missing or invalid"), + ("1.2.3.0", "does not exactly match"), + ("1.2.2", "older release"), + ), +) +def test_github_release_plan_rejects_invalid_or_older_npm_latest( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + npm_latest: str, + expected_error: str, +) -> None: + context, manifest, formula, git = release_state_fixture(tmp_path, "1.2.3") + monkeypatch.setattr(publish, "read_release_context", constant(context)) + monkeypatch.setattr(publish, "read_manifest", constant(manifest)) + monkeypatch.setattr( + publish, "query_pypi_release", constant(matching_pypi(context, manifest)) + ) + monkeypatch.setattr( + publish, + "query_npm_release", + constant(matching_npm(context, manifest, latest=npm_latest)), + ) + monkeypatch.setattr(publish, "read_formula_state", constant(formula)) + monkeypatch.setattr(publish, "inspect_release_tag_state", constant(git)) + monkeypatch.setattr(publish, "github_release_bundle_issues", constant([])) + monkeypatch.setattr(publish, "verify_local_manifest_artifacts", constant([])) + + with pytest.raises(state.ReleaseError, match=expected_error): + publish.verified_github_release_plan(tmp_path, FakeRunner()) + + +def test_github_release_bundle_requires_exact_generated_layout( + tmp_path: Path, +) -> None: + context, manifest, _formula, _git = release_state_fixture(tmp_path, "1.2.3") + for artifact in manifest.artifacts.values(): + path = tmp_path / artifact.path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"artifact") + + assert publish.github_release_bundle_issues(context, manifest) == [] + + (tmp_path / "dist" / "unexpected.txt").write_text("unexpected", encoding="utf-8") + + assert publish.github_release_bundle_issues(context, manifest) == [ + "release bundle directory has unexpected contents: dist" + ] + + +@pytest.mark.parametrize( + "case", + ( + "missing-pypi", + "missing-npm-latest", + "stale-manifest", + "mismatched-tag", + "missing-remote-tag", + "not-on-origin-master", + ), +) +def test_verify_complete_release_returns_nonzero_for_incomplete_state( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + case: str, +) -> None: + context, manifest, formula, git = release_state_fixture(tmp_path) + pypi = matching_pypi(context, manifest) + npm = matching_npm(context, manifest, latest=context.version.npm) + release_manifest = manifest + release_git = git + if case == "missing-pypi": + pypi = state.PypiRelease(False, "", {}) + elif case == "missing-npm-latest": + npm = matching_npm(context, manifest, latest="previous") + elif case == "stale-manifest": + release_manifest = state.ReleaseManifest( + package_name=manifest.package_name, + project_version="0.0.0", + python_version=manifest.python_version, + npm_version=manifest.npm_version, + git_tag=manifest.git_tag, + artifacts=manifest.artifacts, + ) + elif case == "mismatched-tag": + release_git = state.GitState( + branch="", + default_branch="", + head_commit=git.head_commit, + head_reachable_from_origin_master=True, + upstream_ahead=0, + upstream_behind=0, + dirty=False, + tag_commit="different", + remote_tag_commit=git.remote_tag_commit, + ) + elif case == "missing-remote-tag": + release_git = state.GitState( + branch="", + default_branch="", + head_commit=git.head_commit, + head_reachable_from_origin_master=True, + upstream_ahead=0, + upstream_behind=0, + dirty=False, + tag_commit=git.tag_commit, + remote_tag_commit="", + ) + elif case == "not-on-origin-master": + release_git = state.GitState( + branch="", + default_branch="", + head_commit=git.head_commit, + head_reachable_from_origin_master=False, + upstream_ahead=0, + upstream_behind=0, + dirty=False, + tag_commit=git.tag_commit, + remote_tag_commit=git.remote_tag_commit, + ) + + monkeypatch.setattr(publish, "read_release_context", constant(context)) + monkeypatch.setattr(publish, "read_manifest", constant(release_manifest)) + monkeypatch.setattr(publish, "query_pypi_release", constant(pypi)) + monkeypatch.setattr(publish, "query_npm_release", constant(npm)) + monkeypatch.setattr(publish, "read_formula_state", constant(formula)) + monkeypatch.setattr(publish, "inspect_release_tag_state", constant(release_git)) + + assert publish.verify_complete_release(tmp_path, FakeRunner()) == 1 + + +def test_verify_completed_release_allows_detached_tag_checkout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + context, manifest, formula, _git = release_state_fixture(tmp_path) + + class DetachedTagRunner: + def __init__(self) -> None: + self.commands: list[tuple[str, ...]] = [] + + def run( + self, + command, + cwd: Path, + env=None, + timeout=None, + capture_output: bool = True, + check: bool = True, + ) -> state.CommandResult: + del cwd, env, timeout, capture_output, check + command_tuple = tuple(command) + self.commands.append(command_tuple) + if command_tuple == ("git", "rev-parse", "HEAD"): + return state.CommandResult(command_tuple, 0, "abc\n", "") + if command_tuple == ( + "git", + "fetch", + "--quiet", + "--no-tags", + "origin", + "refs/heads/master", + ): + return state.CommandResult(command_tuple, 0, "", "") + if command_tuple == ( + "git", + "merge-base", + "--is-ancestor", + "HEAD", + "FETCH_HEAD", + ): + return state.CommandResult(command_tuple, 0, "", "") + if command_tuple == ( + "git", + "rev-parse", + "-q", + "--verify", + f"refs/tags/{context.version.tag}^{{}}", + ): + return state.CommandResult(command_tuple, 0, "abc\n", "") + if command_tuple == ( + "git", + "ls-remote", + "--tags", + "origin", + f"refs/tags/{context.version.tag}*", + ): + return state.CommandResult( + command_tuple, 0, f"abc\trefs/tags/{context.version.tag}^{{}}\n", "" + ) + raise AssertionError(f"unexpected git command: {command_tuple}") + + monkeypatch.setattr(publish, "read_release_context", constant(context)) + monkeypatch.setattr(publish, "read_manifest", constant(manifest)) + monkeypatch.setattr( + publish, "query_pypi_release", constant(matching_pypi(context, manifest)) + ) + monkeypatch.setattr( + publish, + "query_npm_release", + constant(matching_npm(context, manifest, latest=context.version.npm)), + ) + monkeypatch.setattr(publish, "read_formula_state", constant(formula)) + + runner = DetachedTagRunner() + release_state = publish.verify_completed_release(tmp_path, runner) + + assert release_state.status == state.ReleaseStatus.COMPLETE + assert ("git", "rev-list", "--left-right", "--count", "@{u}...HEAD") not in ( + runner.commands + ) + assert ( + "git", + "fetch", + "--quiet", + "--no-tags", + "origin", + "refs/heads/master", + ) in runner.commands + assert ( + "git", + "merge-base", + "--is-ancestor", + "HEAD", + "FETCH_HEAD", + ) in runner.commands + + def test_publish_auth_checks_fail_before_upload( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/packaging/test_release_tool_state.py b/tests/unit/packaging/test_release_tool_state.py index 058fca1..db23bb9 100644 --- a/tests/unit/packaging/test_release_tool_state.py +++ b/tests/unit/packaging/test_release_tool_state.py @@ -17,6 +17,8 @@ FakeRunner, append_uv_lock_package, constant, + no_op, + release_state_fixture, write_minimal_repo, ) @@ -206,3 +208,125 @@ def test_prepare_refuses_existing_remote_versions( with pytest.raises(state.ReleaseError, match="already exists on PyPI"): build.prepare_release(tmp_path, FakeRunner()) + + +def test_prepare_builds_offline_wheelhouse( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + context, manifest, _formula, _git = release_state_fixture(tmp_path) + missing_releases = ( + state.PypiRelease(False, "", {}), + state.NpmRelease(False, "", "", "", "", "", "", "", ""), + ) + calls: list[str] = [] + + def record_wheelhouse(_context: object, _runner: object) -> None: + del _context, _runner + calls.append("wheelhouse") + + monkeypatch.setattr(build, "read_release_context", constant(context)) + monkeypatch.setattr(build, "query_registry_state", constant(missing_releases)) + monkeypatch.setattr(build, "sync_generated_metadata", no_op) + monkeypatch.setattr(build, "clean_release_outputs", no_op) + monkeypatch.setattr( + build, "build_release_artifacts", constant(dict(manifest.artifacts)) + ) + monkeypatch.setattr(build, "sync_homebrew_formula_metadata", no_op) + monkeypatch.setattr(build, "write_release_manifest", constant(manifest)) + monkeypatch.setattr(build, "fail_if_generated_metadata_stale", no_op) + monkeypatch.setattr(build, "build_wheelhouse", record_wheelhouse) + + build.prepare_release(tmp_path, FakeRunner()) + + assert calls == ["wheelhouse"] + + +def test_release_artifact_build_does_not_download_wheelhouse( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + context, manifest, _formula, _git = release_state_fixture(tmp_path) + + def fail_wheelhouse_build(_context: object, _runner: object) -> None: + del _context, _runner + raise AssertionError("post-tag artifact builds must not download a wheelhouse") + + def identity( + _path: Path, _root: Path, artifact_type: str + ) -> state.ArtifactIdentity: + del _path, _root + return manifest.artifact(artifact_type) + + monkeypatch.setattr(build, "build_python_artifacts", no_op) + monkeypatch.setattr(build, "run_twine_check", no_op) + monkeypatch.setattr( + build, + "build_npm_artifact", + constant(tmp_path / ".release" / "npm" / context.npm_filename), + ) + monkeypatch.setattr(build, "build_wheelhouse", fail_wheelhouse_build) + monkeypatch.setattr(build, "ensure_file", no_op) + monkeypatch.setattr(build, "artifact_identity", identity) + + artifacts = build.build_release_artifacts(context, FakeRunner()) + + assert artifacts == manifest.artifacts + + +def test_release_artifacts_rebuilds_from_committed_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + context, manifest, _formula, _git = release_state_fixture(tmp_path) + calls: list[tuple[str, bool | None]] = [] + + def stale_check( + context_arg: state.ReleaseContext, manifest_arg: state.ReleaseManifest | None + ) -> None: + assert context_arg == context + calls.append(("stale-check", manifest_arg is None)) + + def fail_registry_query(_context: state.ReleaseContext) -> object: + del _context + raise AssertionError("release-artifacts must not query registries") + + def fail_metadata_sync(_context: state.ReleaseContext, _runner: FakeRunner) -> None: + del _context, _runner + raise AssertionError("release-artifacts must not sync generated metadata") + + def clean_outputs(context_arg: state.ReleaseContext) -> None: + assert context_arg == context + calls.append(("clean", None)) + + def build_artifacts( + context_arg: state.ReleaseContext, runner_arg: FakeRunner + ) -> dict[str, state.ArtifactIdentity]: + assert context_arg == context + assert isinstance(runner_arg, FakeRunner) + calls.append(("build", None)) + return dict(manifest.artifacts) + + def write_manifest( + context_arg: state.ReleaseContext, + artifacts: dict[str, state.ArtifactIdentity], + ) -> state.ReleaseManifest: + assert context_arg == context + assert artifacts == manifest.artifacts + calls.append(("manifest", None)) + return manifest + + monkeypatch.setattr(build, "read_release_context", constant(context)) + monkeypatch.setattr(build, "fail_if_generated_metadata_stale", stale_check) + monkeypatch.setattr(build, "query_registry_state", fail_registry_query) + monkeypatch.setattr(build, "sync_generated_metadata", fail_metadata_sync) + monkeypatch.setattr(build, "clean_release_outputs", clean_outputs) + monkeypatch.setattr(build, "build_release_artifacts", build_artifacts) + monkeypatch.setattr(build, "write_release_manifest", write_manifest) + + build.release_artifacts(tmp_path, FakeRunner()) + + assert calls == [ + ("stale-check", True), + ("clean", None), + ("build", None), + ("manifest", None), + ("stale-check", False), + ] diff --git a/tests/unit/packaging/test_release_tool_state_checks.py b/tests/unit/packaging/test_release_tool_state_checks.py index 4fa1f8e..9500cd4 100644 --- a/tests/unit/packaging/test_release_tool_state_checks.py +++ b/tests/unit/packaging/test_release_tool_state_checks.py @@ -3,6 +3,7 @@ # ruff: noqa: E402, I001 from pathlib import Path +import subprocess import sys _LOCAL_TEST_DIR = Path(__file__).resolve().parent @@ -38,6 +39,25 @@ def test_release_state_derivation_ready_complete_partial_and_blocked( ) assert ready.status == state.ReleaseStatus.READY + unreachable_git = state.GitState( + branch="release-fix", + default_branch="master", + head_commit=git.head_commit, + head_reachable_from_origin_master=False, + upstream_ahead=1, + upstream_behind=0, + dirty=False, + tag_commit="", + remote_tag_commit="", + ) + unreachable = state.derive_release_state( + context, missing_pypi, missing_npm, formula, unreachable_git, manifest + ) + assert unreachable.status == state.ReleaseStatus.BLOCKED + assert unreachable.reasons == ( + "release commit is not reachable from origin/master", + ) + pypi = matching_pypi(context, manifest) npm = matching_npm(context, manifest, latest=context.version.npm) complete = state.derive_release_state(context, pypi, npm, formula, git, manifest) @@ -196,6 +216,7 @@ def test_release_check_allows_tag_missing_partial_without_pre_publish_smokes( branch="master", default_branch="master", head_commit=git.head_commit, + head_reachable_from_origin_master=True, upstream_ahead=0, upstream_behind=0, dirty=False, @@ -356,6 +377,7 @@ def test_publishing_git_issues_allows_local_state_only_when_requested() -> None: branch="release-fix", default_branch="master", head_commit="abc", + head_reachable_from_origin_master=True, upstream_ahead=1, upstream_behind=0, dirty=True, @@ -369,10 +391,26 @@ def test_publishing_git_issues_allows_local_state_only_when_requested() -> None: assert "current branch is not synchronized with its upstream" in strict_issues assert not state.publishing_git_issues(git, True, True) + unreachable_git = state.GitState( + branch="release-fix", + default_branch="master", + head_commit="abc", + head_reachable_from_origin_master=False, + upstream_ahead=1, + upstream_behind=0, + dirty=True, + tag_commit="", + remote_tag_commit="", + ) + assert "release commit is not reachable from origin/master" in ( + state.publishing_git_issues(unreachable_git, True, True) + ) + conflicting_tag = state.GitState( branch="release-fix", default_branch="master", head_commit="abc", + head_reachable_from_origin_master=True, upstream_ahead=1, upstream_behind=0, dirty=True, @@ -424,6 +462,7 @@ def test_finalize_allows_local_git_state_when_registries_are_present( branch="release-fix", default_branch=git.default_branch, head_commit=git.head_commit, + head_reachable_from_origin_master=True, upstream_ahead=1, upstream_behind=0, dirty=True, @@ -473,6 +512,33 @@ def capture_create_tag( assert tagged == [context.version.tag] +def test_create_and_push_tag_rejects_freshly_unreachable_head( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + context, _manifest, _formula, git = release_state_fixture(tmp_path) + unreachable_git = state.GitState( + branch=git.branch, + default_branch=git.default_branch, + head_commit=git.head_commit, + head_reachable_from_origin_master=False, + upstream_ahead=git.upstream_ahead, + upstream_behind=git.upstream_behind, + dirty=git.dirty, + tag_commit="", + remote_tag_commit="", + ) + monkeypatch.setattr(publish, "inspect_git_state", constant(unreachable_git)) + runner = FakeRunner() + + with pytest.raises( + state.ReleaseError, + match="release commit is not reachable from origin/master", + ): + publish.create_and_push_tag(context, runner) + + assert runner.commands == [] + + def test_derive_release_state_is_blocked_on_manifest_identity_mismatch( tmp_path: Path, ) -> None: @@ -516,6 +582,135 @@ def test_artifact_identity_checks_detect_matching_and_mismatching_registries( ) +def test_pypi_artifact_verification_rejects_unexpected_files( + tmp_path: Path, +) -> None: + context, manifest, _formula, _git = release_state_fixture(tmp_path) + pypi = matching_pypi(context, manifest) + unexpected_filename = ( + f"{context.package_name}-{context.version.python}-cp313-cp313-manylinux.whl" + ) + release_with_unexpected_wheel = state.PypiRelease( + exists=True, + version_key=pypi.version_key, + files={ + **pypi.files, + unexpected_filename: state.PypiFile( + unexpected_filename, + 99, + "f" * 64, + ), + }, + latest_stable=pypi.latest_stable, + ) + + assert state.verify_pypi_artifacts( + context, + release_with_unexpected_wheel, + manifest, + ) == [f"PyPI has unexpected files: {unexpected_filename}"] + + +def test_release_notes_predecessor_uses_target_history_for_delayed_release( + tmp_path: Path, +) -> None: + origin = tmp_path / "origin.git" + repository = tmp_path / "repository" + repository.mkdir() + + run_git(tmp_path, "init", "--bare", str(origin)) + run_git(repository, "init", "-b", "master") + run_git(repository, "config", "user.name", "Release Test") + run_git(repository, "config", "user.email", "release@example.com") + run_git(repository, "remote", "add", "origin", str(origin)) + + for version in ("1.0.0", "1.1.0", "1.2.0"): + (repository / "version.txt").write_text(version, encoding="utf-8") + run_git(repository, "add", "version.txt") + run_git(repository, "commit", "-m", f"Release {version}") + run_git(repository, "tag", "-a", f"v{version}", "-m", f"Release {version}") + + run_git(repository, "push", "origin", "master", "--tags") + run_git(repository, "checkout", "--detach", "v1.1.0") + + delayed_context = state.ReleaseContext( + root=repository, + package_name="crewplane", + console_script="crewplane", + version=state.ReleaseVersion.from_project("1.1.0"), + ) + assert ( + state.verified_release_notes_start_tag( + delayed_context, + state.CommandRunner(), + ) + == "v1.0.0" + ) + + run_git(repository, "checkout", "--detach", "v1.0.0") + first_context = state.ReleaseContext( + root=repository, + package_name="crewplane", + console_script="crewplane", + version=state.ReleaseVersion.from_project("1.0.0"), + ) + assert ( + state.verified_release_notes_start_tag( + first_context, + state.CommandRunner(), + ) + == "" + ) + + +def test_release_commit_ancestry_allows_a_delayed_release_checkout( + tmp_path: Path, +) -> None: + origin = tmp_path / "origin.git" + repository = tmp_path / "repository" + repository.mkdir() + + run_git(tmp_path, "init", "--bare", str(origin)) + run_git(repository, "init", "-b", "master") + run_git(repository, "config", "user.name", "Release Test") + run_git(repository, "config", "user.email", "release@example.com") + run_git(repository, "remote", "add", "origin", str(origin)) + + for version in ("1.0.0", "1.1.0"): + (repository / "version.txt").write_text(version, encoding="utf-8") + run_git(repository, "add", "version.txt") + run_git(repository, "commit", "-m", f"Release {version}") + + run_git(repository, "push", "origin", "master") + run_git(repository, "checkout", "--detach", "HEAD^") + + assert state.head_reachable_from_origin_master( + state.CommandRunner(), + repository, + ) + + run_git(repository, "checkout", "-b", "unreleased-change") + (repository / "version.txt").write_text("unreleased", encoding="utf-8") + run_git(repository, "add", "version.txt") + run_git(repository, "commit", "-m", "Unreleased change") + + assert not state.head_reachable_from_origin_master( + state.CommandRunner(), + repository, + ) + + +def run_git(root: Path, *arguments: str) -> str: + completed = subprocess.run( + ["git", *arguments], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + def test_local_artifact_identity_rejects_paths_outside_repo( tmp_path: Path, ) -> None: @@ -574,6 +769,32 @@ def fake_fetch(_url: str) -> dict[str, object]: assert npm.python_package_version == context.version.project +def test_pypi_registry_lookup_reports_highest_published_stable_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + write_minimal_repo(tmp_path, "1.2.3") + context = state.read_release_context(tmp_path) + + def fake_fetch(_url: str) -> dict[str, object]: + del _url + return { + "releases": { + "1.2.3": [], + "2.0.0-alpha.1": [{}], + "1.9.0": [{}], + "invalid": [{}], + "3.0.0": [], + } + } + + monkeypatch.setattr(state.state_types, "fetch_registry_json", fake_fetch) + + release = state.query_pypi_release(context) + + assert release.exists + assert release.latest_stable == "1.9.0" + + def test_remote_git_tag_uses_peeled_annotated_tag_commit(tmp_path: Path) -> None: class TagRunner: def run( @@ -597,6 +818,61 @@ def run( assert state.remote_git_tag_commit(TagRunner(), tmp_path, "v1.0.0") == "commit-sha" +@pytest.mark.parametrize(("merge_base_status", "expected"), ((0, True), (1, False))) +def test_release_commit_reachability_uses_fresh_origin_master( + tmp_path: Path, + merge_base_status: int, + expected: bool, +) -> None: + class AncestryRunner: + def __init__(self) -> None: + self.commands: list[tuple[str, ...]] = [] + + def run( + self, + command, + cwd: Path, + env=None, + timeout=None, + capture_output: bool = True, + check: bool = True, + ) -> state.CommandResult: + del cwd, env, timeout, capture_output, check + command_tuple = tuple(command) + self.commands.append(command_tuple) + if command_tuple[:2] == ("git", "fetch"): + return state.CommandResult(command_tuple, 0, "", "") + assert command_tuple == ( + "git", + "merge-base", + "--is-ancestor", + "HEAD", + "FETCH_HEAD", + ) + return state.CommandResult(command_tuple, merge_base_status, "", "") + + runner = AncestryRunner() + + assert state.head_reachable_from_origin_master(runner, tmp_path) is expected + assert runner.commands == [ + ( + "git", + "fetch", + "--quiet", + "--no-tags", + "origin", + "refs/heads/master", + ), + ( + "git", + "merge-base", + "--is-ancestor", + "HEAD", + "FETCH_HEAD", + ), + ] + + def test_completed_release_check_skips_pre_publish_suite( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/runtime/execution/test_provider_call_workspace.py b/tests/unit/runtime/execution/test_provider_call_workspace.py new file mode 100644 index 0000000..e943762 --- /dev/null +++ b/tests/unit/runtime/execution/test_provider_call_workspace.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from threading import Event +from unittest.mock import Mock + +import pytest + +import crewplane.runtime.execution.provider_call.workspace as workspace_module +from crewplane.runtime.execution.runtime_context import DeferredAsyncCleanupRegistry +from crewplane.runtime.workspace import PreparedWorkspace +from crewplane.runtime.workspace.setup import WorkspaceSetupCancellation +from tests.helpers.workspace_service import ( + disabled_workspace_plan, + workspace_invocation_context, + workspace_invocation_request, + workspace_output_manager, +) + + +def test_late_workspace_preparation_result_is_marked_cancelled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + asyncio.run( + _run_late_workspace_preparation_result_is_marked_cancelled( + tmp_path, + monkeypatch, + ) + ) + + +async def _run_late_workspace_preparation_result_is_marked_cancelled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + invocation_context = workspace_invocation_context() + prepared_workspace = PreparedWorkspace(repo, invocation_context) + prepare_workspace = Mock(return_value=prepared_workspace) + mark_cancelled = Mock() + cancellation_checked = Event() + allow_preparation_to_return = Event() + original_is_cancelled = WorkspaceSetupCancellation.is_cancelled + + def pause_after_cancellation_check( + setup_cancellation: WorkspaceSetupCancellation, + ) -> bool: + cancellation_requested = original_is_cancelled(setup_cancellation) + if not cancellation_requested: + cancellation_checked.set() + if not allow_preparation_to_return.wait(timeout=2): + raise TimeoutError("Test did not release workspace preparation.") + return cancellation_requested + + monkeypatch.setattr( + workspace_module, + "PREPARATION_CANCELLATION_TIMEOUT_SECONDS", + 0.05, + ) + monkeypatch.setattr( + workspace_module, + "prepare_invocation_workspace", + prepare_workspace, + ) + monkeypatch.setattr( + WorkspaceSetupCancellation, + "is_cancelled", + pause_after_cancellation_check, + ) + monkeypatch.setattr(prepared_workspace, "mark_cancelled", mark_cancelled) + request = workspace_invocation_request( + disabled_workspace_plan(repo), + workspace_output_manager(tmp_path, repo), + ) + cleanup_registry = DeferredAsyncCleanupRegistry() + preparation_task = asyncio.create_task( + workspace_module.prepare_workspace_with_cancellation( + request, + invocation_context, + cleanup_registry, + ) + ) + + try: + assert await asyncio.to_thread(cancellation_checked.wait, 2) + preparation_task.cancel() + with pytest.raises(asyncio.CancelledError): + await preparation_task + finally: + allow_preparation_to_return.set() + + cleanup_errors = await cleanup_registry.drain(2.0) + + assert cleanup_errors == () + prepare_workspace.assert_called_once() + mark_cancelled.assert_called_once_with( + workspace_module.PREPARATION_CANCELLATION_MESSAGE, + None, + ) diff --git a/tests/unit/runtime/workspace/test_branch_export_fulfillment.py b/tests/unit/runtime/workspace/test_branch_export_fulfillment.py index 8470b9c..9f6d954 100644 --- a/tests/unit/runtime/workspace/test_branch_export_fulfillment.py +++ b/tests/unit/runtime/workspace/test_branch_export_fulfillment.py @@ -149,6 +149,44 @@ def test_fulfill_branch_exports_from_history_writes_duplicate_skip_record( ) +def test_fulfill_branch_exports_ignores_inherited_git_transport_restrictions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + if shutil.which("git") is None: + pytest.skip("git is unavailable") + repo = create_git_repo(tmp_path) + plan = branch_export_plan(repo, tmp_path, branch_name="feature/restricted-env") + output = OutputManager("workspace", base_dir=tmp_path / "artifacts") + result_commit, result_tree, result_ref, bundle_path = write_result_bundle( + repo, + output.create_stage_dir("implement"), + "feature result\n", + ) + write_workspace_state( + output.stages_dir, + plan, + result_commit, + result_tree, + result_ref, + bundle_path, + ) + history = history_record_for_output(output) + monkeypatch.setenv("GIT_PROTOCOL_FROM_USER", "0") + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "https") + + fulfill_branch_exports_from_history(plan, history) + + assert ( + run_git_text( + repo, + "rev-parse", + "refs/heads/feature/restricted-env", + ) + == result_commit + ) + + def test_fulfill_branch_exports_writes_skipped_record_when_disabled( tmp_path: Path, ) -> None: @@ -287,6 +325,11 @@ def test_preview_branch_exports_verifies_chained_bundles_without_branch_ref( ) if git_commit_exists(repo, first.commit) or git_commit_exists(repo, second.commit): pytest.skip("git retained the test commits after pruning") + source_refs_before = run_git_text( + repo, + "for-each-ref", + "--format=%(refname) %(objectname)", + ) write_workspace_state( output.stages_dir, plan, @@ -313,11 +356,77 @@ def test_preview_branch_exports_verifies_chained_bundles_without_branch_ref( assert len(records) == 1 record = records[0] assert record["dry_run"] is True - assert record["status"] == "fulfilled" + assert record["status"] == "fulfilled", record assert record["operation"] == "created" assert not git_commit_exists(repo, first.commit) assert not git_commit_exists(repo, second.commit) assert not run_git_text(repo, "branch", "--list", "feature/preview") + assert ( + run_git_text(repo, "for-each-ref", "--format=%(refname) %(objectname)") + == source_refs_before + ) + + +def test_preview_and_fulfillment_reject_ambient_omitted_bundle_prerequisite( + tmp_path: Path, +) -> None: + if shutil.which("git") is None: + pytest.skip("git is unavailable") + repo = create_git_repo(tmp_path) + plan = branch_export_plan(repo, tmp_path, branch_name="feature/omitted-upstream") + output = OutputManager("workspace", base_dir=tmp_path / "artifacts") + first, second = create_prerequisite_bundle_chain( + repo, + output.stages_dir / "prepare" / "workspace-bundles" / "first.bundle", + output.create_stage_dir("implement") / "workspace-bundles" / "second.bundle", + ) + run_git_text( + repo, + "fetch", + first.path.as_posix(), + f"{first.ref}:refs/crewplane/test/ambient-first", + ) + run_git_text( + repo, + "fetch", + second.path.as_posix(), + f"{second.ref}:refs/crewplane/test/ambient-second", + ) + run_git_text(repo, "update-ref", "-d", "refs/crewplane/test/ambient-first") + run_git_text(repo, "update-ref", "-d", "refs/crewplane/test/ambient-second") + assert git_commit_exists(repo, first.commit) + assert git_commit_exists(repo, second.commit) + write_workspace_state( + output.stages_dir, + plan, + second.commit, + second.tree, + second.ref, + second.path, + ) + history = history_record_for_output(output) + + preview_records = preview_branch_exports_from_history(plan, history) + with pytest.raises(RuntimeError) as exc_info: + fulfill_branch_exports_from_history(plan, history) + + assert len(preview_records) == 1 + assert preview_records[0]["status"] == "failed_verification" + failure_message = str(preview_records[0]["failure_message"]) + assert failure_message.startswith( + "Workspace lineage source verification failed while validating recorded " + "Git artifacts:" + ) + assert str(exc_info.value) == failure_message + assert "Git did not provide diagnostic output" not in failure_message + assert "crewplane-lineage-verify-" not in failure_message + assert "Command '['git'" not in failure_message + assert not run_git_text( + repo, + "branch", + "--list", + "feature/omitted-upstream", + ) def test_preview_branch_exports_verifies_sha256_chained_bundles( @@ -364,7 +473,7 @@ def test_preview_branch_exports_verifies_sha256_chained_bundles( assert len(records) == 1 record = records[0] assert record["dry_run"] is True - assert record["status"] == "fulfilled" + assert record["status"] == "fulfilled", record assert record["operation"] == "created" assert not git_commit_exists(repo, first.commit) assert not git_commit_exists(repo, second.commit) diff --git a/tests/unit/runtime/workspace/test_service_worktree_lineage_bundles.py b/tests/unit/runtime/workspace/test_service_worktree_lineage_bundles.py index 342aef5..734767a 100644 --- a/tests/unit/runtime/workspace/test_service_worktree_lineage_bundles.py +++ b/tests/unit/runtime/workspace/test_service_worktree_lineage_bundles.py @@ -1,16 +1,21 @@ from __future__ import annotations import shutil +import subprocess from pathlib import Path import pytest +from crewplane.runtime.workspace.git import GitCommand from crewplane.runtime.workspace.worktree import ( WorktreeSourceRef, create_worktree_workspace, remove_worktree_workspace, ) -from crewplane.runtime.workspace.worktree.lineage import export_bundle +from crewplane.runtime.workspace.worktree.lineage import ( + export_bundle, + verify_source_commit_available, +) from crewplane.runtime.workspace.worktree.protected_refs import ( ProtectedRefSnapshot, ) @@ -228,6 +233,247 @@ def test_worktree_workspace_imports_depth_two_bundle_chain( remove_worktree_workspace(source, worktree.workspace_path) +def test_verify_source_commit_available_uses_bundles_for_source_verification( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + if shutil.which("git") is None: + pytest.skip("git is unavailable") + repo = create_git_repo(tmp_path) + cache_root = tmp_path / "cache" + plan = workspace_plan( + repo, + cache_root, + cleanup_on_success=True, + kind="worktree", + ) + source = plan.workspace_source + assert source is not None + first, second = create_prerequisite_bundle_chain( + repo, + tmp_path / "first.bundle", + tmp_path / "second.bundle", + ) + if git_commit_exists(repo, first.commit) or git_commit_exists(repo, second.commit): + pytest.skip("git retained the test commits after pruning") + source_refs_before = run_git_text( + repo, + "for-each-ref", + "--format=%(refname) %(objectname)", + ) + original_run = GitCommand.run + verification_fetches: list[tuple[str, ...]] = [] + + def reject_source_ref_update( + self: GitCommand, + *args: str, + ) -> subprocess.CompletedProcess[bytes]: + if self.cwd == repo and args and args[0] == "update-ref": + raise AssertionError("lineage verification mutated a source ref") + if self.cwd != repo and args and args[0] == "fetch": + verification_fetches.append(args) + return original_run(self, *args) + + monkeypatch.setattr(GitCommand, "run", reject_source_ref_update) + + verify_source_commit_available( + source, + WorktreeSourceRef( + source_kind="node", + source_node_id="second", + source_commit=second.commit, + source_tree=second.tree, + candidate_sequence=1, + bundle_path=second.path, + bundle_sha256=second.sha256, + bundle_size_bytes=second.size_bytes, + bundle_ref=second.ref, + upstream_sources=( + WorktreeSourceRef( + source_kind="node", + source_node_id="first", + source_commit=first.commit, + source_tree=first.tree, + candidate_sequence=1, + bundle_path=first.path, + bundle_sha256=first.sha256, + bundle_size_bytes=first.size_bytes, + bundle_ref=first.ref, + ), + ), + ), + ) + + assert verification_fetches + assert all("--no-auto-maintenance" in args for args in verification_fetches) + assert not git_commit_exists(repo, first.commit) + assert not git_commit_exists(repo, second.commit) + assert ( + run_git_text(repo, "for-each-ref", "--format=%(refname) %(objectname)") + == source_refs_before + ) + + +def test_verify_source_commit_available_does_not_import_base_history( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + if shutil.which("git") is None: + pytest.skip("git is unavailable") + repo = create_git_repo(tmp_path) + base_parent = run_git_text(repo, "rev-parse", "HEAD^{commit}") + (repo / "README.md").write_text("current base\n", encoding="utf-8") + run_git_text(repo, "add", "README.md") + run_git_text(repo, "commit", "-m", "current base") + plan = workspace_plan( + repo, + tmp_path / "cache", + cleanup_on_success=True, + kind="worktree", + ) + source = plan.workspace_source + assert source is not None + base_fetch_observed = False + original_run = GitCommand.run + + def assert_base_history_is_absent( + self: GitCommand, + *args: str, + ) -> subprocess.CompletedProcess[bytes]: + nonlocal base_fetch_observed + result = original_run(self, *args) + if args and args[0] == "fetch" and repo.as_posix() in args: + base_fetch_observed = True + assert not git_commit_exists(self.cwd, base_parent) + return result + + monkeypatch.setattr(GitCommand, "run", assert_base_history_is_absent) + + verify_source_commit_available( + source, + WorktreeSourceRef( + source_kind="project", + source_node_id=None, + source_commit=source.run_base_commit, + source_tree=source.source_tree, + ), + ) + + assert base_fetch_observed + + +def test_verify_source_commit_available_rejects_ambient_omitted_upstream( + tmp_path: Path, +) -> None: + if shutil.which("git") is None: + pytest.skip("git is unavailable") + repo = create_git_repo(tmp_path) + plan = workspace_plan( + repo, + tmp_path / "cache", + cleanup_on_success=True, + kind="worktree", + ) + source = plan.workspace_source + assert source is not None + first, second = create_prerequisite_bundle_chain( + repo, + tmp_path / "first.bundle", + tmp_path / "second.bundle", + ) + ambient_first_ref = "refs/crewplane/test/ambient-first" + ambient_second_ref = "refs/crewplane/test/ambient-second" + run_git_text( + repo, "fetch", first.path.as_posix(), f"{first.ref}:{ambient_first_ref}" + ) + run_git_text( + repo, + "fetch", + second.path.as_posix(), + f"{second.ref}:{ambient_second_ref}", + ) + run_git_text(repo, "update-ref", "-d", ambient_first_ref) + run_git_text(repo, "update-ref", "-d", ambient_second_ref) + assert git_commit_exists(repo, first.commit) + assert git_commit_exists(repo, second.commit) + source_refs_before = run_git_text( + repo, + "for-each-ref", + "--format=%(refname) %(objectname)", + ) + + with pytest.raises(RuntimeError) as exc_info: + verify_source_commit_available( + source, + WorktreeSourceRef( + source_kind="node", + source_node_id="second", + source_commit=second.commit, + source_tree=second.tree, + candidate_sequence=1, + bundle_path=second.path, + bundle_sha256=second.sha256, + bundle_size_bytes=second.size_bytes, + bundle_ref=second.ref, + ), + ) + + message = str(exc_info.value) + assert message.startswith( + "Workspace lineage source verification failed while validating recorded " + "Git artifacts:" + ) + assert "Git did not provide diagnostic output" not in message + assert "crewplane-lineage-verify-" not in message + assert "Command '['git'" not in message + assert ( + run_git_text(repo, "for-each-ref", "--format=%(refname) %(objectname)") + == source_refs_before + ) + + +def test_verify_source_commit_available_rejects_ambient_project_commit( + tmp_path: Path, +) -> None: + if shutil.which("git") is None: + pytest.skip("git is unavailable") + repo = create_git_repo(tmp_path) + plan = workspace_plan( + repo, + tmp_path / "cache", + cleanup_on_success=True, + kind="worktree", + ) + source = plan.workspace_source + assert source is not None + (repo / "ambient.txt").write_text("ambient\n", encoding="utf-8") + run_git_text(repo, "add", "ambient.txt") + run_git_text(repo, "commit", "-m", "ambient") + ambient_commit = run_git_text(repo, "rev-parse", "HEAD^{commit}") + ambient_tree = run_git_text(repo, "rev-parse", "HEAD^{tree}") + source_refs_before = run_git_text( + repo, + "for-each-ref", + "--format=%(refname) %(objectname)", + ) + + with pytest.raises(RuntimeError, match="requires a recorded bundle"): + verify_source_commit_available( + source, + WorktreeSourceRef( + source_kind="project", + source_node_id=None, + source_commit=ambient_commit, + source_tree=ambient_tree, + ), + ) + + assert ( + run_git_text(repo, "for-each-ref", "--format=%(refname) %(objectname)") + == source_refs_before + ) + + def test_worktree_workspace_rejects_imported_source_tree_mismatch( tmp_path: Path, ) -> None: diff --git a/tests/unit/runtime/workspace/test_service_worktree_policy.py b/tests/unit/runtime/workspace/test_service_worktree_policy.py index bd5f09d..445635e 100644 --- a/tests/unit/runtime/workspace/test_service_worktree_policy.py +++ b/tests/unit/runtime/workspace/test_service_worktree_policy.py @@ -52,6 +52,7 @@ def test_worktree_capture_rejects_common_policy_file_drift( assert source is not None (prepared.cwd / "result.txt").write_text("captured\n", encoding="utf-8") drift_path = Path(source.common_git_dir) / "info" / policy_file + drift_path.parent.mkdir(parents=True, exist_ok=True) drift_path.write_text(content, encoding="utf-8") try: diff --git a/tests/unit/runtime/workspace/test_setup.py b/tests/unit/runtime/workspace/test_setup.py index 99b1838..60cb823 100644 --- a/tests/unit/runtime/workspace/test_setup.py +++ b/tests/unit/runtime/workspace/test_setup.py @@ -122,6 +122,8 @@ def test_run_workspace_setup_uses_controlled_git_environment( monkeypatch.setenv("GIT_CONFIG_COUNT", "1") monkeypatch.setenv("GIT_CONFIG_KEY_0", "core.hooksPath") monkeypatch.setenv("GIT_CONFIG_VALUE_0", "/tmp/hooks") + monkeypatch.setenv("GIT_PROTOCOL_FROM_USER", "0") + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "https") cwd = tmp_path / "workspace" cwd.mkdir() state_path = tmp_path / "stage" / "workspace-state.json" @@ -137,7 +139,10 @@ def test_run_workspace_setup_uses_controlled_git_environment( "'GIT_WORK_TREE': os.environ.get('GIT_WORK_TREE'), " "'GIT_CONFIG_COUNT': os.environ.get('GIT_CONFIG_COUNT'), " "'GIT_CONFIG_NOSYSTEM': os.environ.get('GIT_CONFIG_NOSYSTEM'), " - "'GIT_CONFIG_GLOBAL': os.environ.get('GIT_CONFIG_GLOBAL')" + "'GIT_CONFIG_GLOBAL': os.environ.get('GIT_CONFIG_GLOBAL'), " + "'GIT_PROTOCOL_FROM_USER': " + "os.environ.get('GIT_PROTOCOL_FROM_USER'), " + "'GIT_ALLOW_PROTOCOL': os.environ.get('GIT_ALLOW_PROTOCOL')" "}, open('env.json', 'w'))" ), ] @@ -151,6 +156,8 @@ def test_run_workspace_setup_uses_controlled_git_environment( assert env_payload["GIT_CONFIG_COUNT"] != "1" assert env_payload["GIT_CONFIG_NOSYSTEM"] == "1" assert env_payload["GIT_CONFIG_GLOBAL"] == os.devnull + assert env_payload["GIT_PROTOCOL_FROM_USER"] == "0" + assert env_payload["GIT_ALLOW_PROTOCOL"] == "https" def test_run_workspace_setup_uses_process_group_capability(