diff --git a/.github/workflows/package-check.yml b/.github/workflows/package-check.yml new file mode 100644 index 0000000..b5336d2 --- /dev/null +++ b/.github/workflows/package-check.yml @@ -0,0 +1,58 @@ +name: Package Check + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: package-check-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + package-check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install build tooling + uses: ./.github/actions/setup-hatch + + - name: Install test and distribution tooling + run: python -m pip install ".[test]" "twine>=4.0.0" + + - name: Run unit tests + run: python -m pytest -q tests/unit + + - name: Build distributions + run: hatch build + + - name: Validate distributions + run: python -m twine check dist/* + + - name: Install and smoke-test wheel + run: | + python -m venv "$RUNNER_TEMP/package-check" + "$RUNNER_TEMP/package-check/bin/pip" install --upgrade pip + "$RUNNER_TEMP/package-check/bin/pip" install dist/*.whl + "$RUNNER_TEMP/package-check/bin/python" -c "import socketdev; from socketdev.version import __version__; print('wheel smoke OK', __version__)" + + - name: Upload distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: socketdev-${{ github.sha }} + path: dist/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 27f7711..0493175 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -1,141 +1,198 @@ -name: PR Preview +name: Publish PR Preview + on: pull_request: - types: [opened, synchronize, ready_for_review] + types: [labeled] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to publish + required: true + type: string -# Cancel an in-flight preview when the PR is pushed again -- previews publish -# to Test PyPI, so superseded runs shouldn't keep churning. concurrency: - group: pr-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: publish-pr-preview-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: false jobs: - preview: - # Skip on: - # - PRs from forks (no access to publish secrets / OIDC) - # - Dependabot PRs: preview-publishing a dependency bump to Test PyPI is - # pointless (no package version bump) and would fail the version check. + context: if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login != 'dependabot[bot]' + github.event_name == 'workflow_dispatch' || + (github.event.label.name == 'publish-preview' && + github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest + timeout-minutes: 5 permissions: - id-token: write contents: read - pull-requests: write + pull-requests: read + outputs: + pr_number: ${{ steps.context.outputs.pr_number }} + head_sha: ${{ steps.context.outputs.head_sha }} + steps: + - name: Validate pull request context + id: context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + WORKFLOW_REF: ${{ github.ref }} + with: + script: | + const rawPrNumber = context.eventName === 'workflow_dispatch' + ? process.env.INPUT_PR_NUMBER + : process.env.EVENT_PR_NUMBER; + if (!/^[1-9][0-9]*$/.test(rawPrNumber || '')) { + core.setFailed('Pull request number must contain ASCII digits only.'); + return; + } + + if (context.eventName === 'workflow_dispatch') { + const defaultRef = `refs/heads/${process.env.DEFAULT_BRANCH}`; + if (process.env.WORKFLOW_REF !== defaultRef) { + core.setFailed(`Run manual previews from ${defaultRef}.`); + return; + } + } + + const prNumber = Number(rawPrNumber); + if (!Number.isSafeInteger(prNumber)) { + core.setFailed('Pull request number is outside the supported range.'); + return; + } + const {data: pullRequest} = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pullRequest.state !== 'open') { + core.setFailed(`Pull request #${prNumber} is not open.`); + return; + } + if (pullRequest.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.setFailed('Preview publication is limited to branches in this repository.'); + return; + } + + core.setOutput('pr_number', String(prNumber)); + core.setOutput('head_sha', pullRequest.head.sha); + + build: + needs: context + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + preview_version: ${{ steps.version.outputs.preview_version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.context.outputs.head_sha }} fetch-depth: 0 persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.13' + python-version: "3.12" - name: Install build tooling uses: ./.github/actions/setup-hatch - - name: Inject full dynamic version - run: python .hooks/sync_version.py --dev + - name: Install distribution validator + run: python -m pip install "twine>=4.0.0" - - name: Check if version exists on Test PyPI - id: version_check + - name: Inject deterministic preview version + env: + PREVIEW_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} run: | - VERSION=$(hatch version | cut -d+ -f1) - echo "VERSION=$VERSION" >> $GITHUB_ENV - if curl -s -f https://test.pypi.org/pypi/socketdev/$VERSION/json > /dev/null; then - echo "Version ${VERSION} already exists on Test PyPI" - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "Version ${VERSION} not found on Test PyPI - proceeding with test deployment" - echo "exists=false" >> $GITHUB_OUTPUT - fi - - - name: Clean previous builds - run: rm -rf dist/ build/ *.egg-info - - - name: Get Hatch version + PREVIEW_ID=$((PREVIEW_ID * 100 + RUN_ATTEMPT)) + python .hooks/sync_version.py --dev --preview-id "$PREVIEW_ID" --skip-lock + + - name: Read preview version id: version - run: | - VERSION=$(hatch version | cut -d+ -f1) - echo "VERSION=$VERSION" >> $GITHUB_ENV + run: echo "preview_version=$(hatch version)" >> "$GITHUB_OUTPUT" - - name: Build package - if: steps.version_check.outputs.exists != 'true' + - name: Build and validate distributions run: | hatch build + python -m twine check dist/* + + - name: Install and smoke-test wheel locally + run: | + python -m venv "$RUNNER_TEMP/preview-check" + "$RUNNER_TEMP/preview-check/bin/pip" install --upgrade pip + "$RUNNER_TEMP/preview-check/bin/pip" install dist/*.whl + "$RUNNER_TEMP/preview-check/bin/python" -c "import socketdev; from socketdev.version import __version__; print('preview wheel smoke OK', __version__)" + + - name: Upload preview distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: socketdev-preview-${{ github.run_id }}-${{ github.run_attempt }} + path: dist/* + if-no-files-found: error + retention-days: 14 + + publish: + needs: [context, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + pull-requests: write + steps: + - name: Download preview distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: socketdev-preview-${{ github.run_id }}-${{ github.run_attempt }} + path: dist - - name: Publish to Test PyPI - if: steps.version_check.outputs.exists != 'true' + - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ verbose: true - - name: Comment on PR - if: steps.version_check.outputs.exists != 'true' + - name: Comment on pull request uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - VERSION: ${{ env.VERSION }} + PREVIEW_VERSION: ${{ needs.build.outputs.preview_version }} + PR_NUMBER: ${{ needs.context.outputs.pr_number }} with: script: | - const version = process.env.VERSION; - const prNumber = context.payload.pull_request.number; - const owner = context.repo.owner; - const repo = context.repo.repo; - // Find existing bot comments - const comments = await github.rest.issues.listComments({ + const marker = ''; + const prNumber = Number(process.env.PR_NUMBER); + const version = process.env.PREVIEW_VERSION; + const body = `${marker} + 🚀 SDK preview published: \`socketdev==${version}\` + + \`\`\`bash + pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple socketdev==${version} + \`\`\` + + TestPyPI's package index can take several minutes to expose a newly uploaded version.`; + const {data: comments} = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, }); - - const botComment = comments.data.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('🚀 Preview package published!') + const existing = comments.find(comment => + comment.user.type === 'Bot' && comment.body.includes(marker) ); - - const comment = ` - 🚀 Preview package published! - - Install with: - \`\`\`bash - pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple socketdev==${version} - \`\`\``; - - if (botComment) { - // Update existing comment + if (existing) { await github.rest.issues.updateComment({ - owner: owner, - repo: repo, - comment_id: botComment.id, - body: comment + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, }); } else { - // Create new comment await github.rest.issues.createComment({ - owner: owner, - repo: repo, + owner: context.repo.owner, + repo: context.repo.repo, issue_number: prNumber, - body: comment + body, }); } - - - name: Verify package is available - if: steps.version_check.outputs.exists != 'true' - id: verify_package - env: - VERSION: ${{ env.VERSION }} - run: | - for i in {1..30}; do - if pip install --index-url 'https://test.pypi.org/simple/' --extra-index-url 'https://pypi.org/simple' socketdev==${VERSION}; then - echo "Package ${VERSION} is now available and installable on Test PyPI" - pip uninstall -y socketdev - echo "success=true" >> $GITHUB_OUTPUT - exit 0 - fi - echo "Attempt $i: Package not yet installable, waiting 20s... (${i}/30)" - sleep 20 - done - echo "success=false" >> $GITHUB_OUTPUT - exit 1 diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py index 7a8ab24..32869c3 100755 --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -124,13 +124,44 @@ def run_uv_lock() -> bool: return before != after +def read_preview_id(): + if "--preview-id" not in sys.argv: + return None + + option_index = sys.argv.index("--preview-id") + try: + preview_id = sys.argv[option_index + 1] + except IndexError: + print("❌ `--preview-id` requires a numeric value.") + sys.exit(1) + + if not preview_id.isascii() or not preview_id.isdigit(): + print("❌ `--preview-id` must contain ASCII digits only.") + sys.exit(1) + return preview_id + + def main(): dev_mode = "--dev" in sys.argv + skip_lock = "--skip-lock" in sys.argv + preview_id = read_preview_id() current_version = read_version_from_version_file(VERSION_FILE) previous_version = read_version_from_git("socketdev/version.py") print(f"Current: {current_version}, Previous: {previous_version}") + if preview_id is not None: + if not dev_mode: + print("❌ `--preview-id` can only be used with `--dev`.") + sys.exit(1) + base_version = current_version.split(".dev")[0] + new_version = f"{base_version}.dev{preview_id}" + inject_version(new_version) + if not skip_lock: + run_uv_lock() + print(f"✅ Prepared deterministic preview version {new_version}.") + sys.exit(0) + if current_version == previous_version: if dev_mode: base_version = current_version.split(".dev")[0] if ".dev" in current_version else current_version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5775220 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +## Development setup + +Use Python 3.9 or newer. Create and activate a virtual environment, then +install the package with its development and test dependencies: + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install -e ".[dev,test]" +``` + +Before opening a pull request, run the focused checks for your change. The +complete local check is: + +```bash +python -m pytest +hatch build +python -m twine check dist/* +``` + +## Pull request validation + +The `Package Check` workflow runs automatically for pull requests. It runs the +unit tests, builds and validates the distributions, smoke-tests the wheel, and +uploads the distributions as workflow artifacts. It does not publish a package. + +## Publishing a pull request preview + +Preview publication is intentionally opt-in. Only request a preview for code +that is trusted to run with the repository's publishing permissions. + +For a pull request from this repository, apply the `publish-preview` label. The +`Publish PR Preview` workflow will build and validate a uniquely versioned +`socketdev` prerelease, publish it to TestPyPI, and add or update a pull request +comment with the exact version and installation command. Both label-triggered +and manually dispatched previews are limited to open pull requests whose +branches belong to this repository. + +The workflow reacts when the label is added; pushing another commit while the +label remains on the pull request does not publish a new preview. To publish the +new pull request head or retry a failed publication, remove `publish-preview` +and apply it again. + +Maintainers can also open **Actions > Publish PR Preview > Run workflow**, run +it from the repository's default branch, and enter the pull request number. +Manual dispatch is useful when a label should remain unchanged or a +publication needs to be retried.