From 111883bfcdd95264b0e0efd41a4e09d9edddf515 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:54:40 -0400 Subject: [PATCH 1/3] Make package previews opt-in --- .github/workflows/package-check.yml | 58 +++++++++ .github/workflows/pr-preview.yml | 190 ++++++++++++++-------------- .hooks/sync_version.py | 31 +++++ 3 files changed, 187 insertions(+), 92 deletions(-) create mode 100644 .github/workflows/package-check.yml 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..304cf49 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -1,141 +1,147 @@ -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 || inputs.pr_number }} + 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. + build: 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: 10 permissions: - id-token: write contents: read - pull-requests: write + outputs: + preview_version: ${{ steps.version.outputs.preview_version }} + pr_number: ${{ steps.context.outputs.pr_number }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || format('refs/pull/{0}/head', inputs.pr_number) }} 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: Record preview context + id: context + env: + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + run: echo "pr_number=${EVENT_PR_NUMBER:-$INPUT_PR_NUMBER}" >> "$GITHUB_OUTPUT" + + - 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: 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.build.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..eb13f1e 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.isdigit(): + print("❌ `--preview-id` must contain 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 From 9886607110dda60219026e6470055b0d118b5564 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:15:42 -0400 Subject: [PATCH 2/3] Document contributing and preview workflows --- CONTRIBUTING.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bfce1ef --- /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 +ruff check . +ruff format --check . +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. Label-triggered +publication is skipped for pull requests from forks. + +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** and +enter the pull request number. Manual dispatch is useful when a label should +remain unchanged or a publication needs to be retried. From 9ea2bb32d311d295b3080602f07643431681e62a Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:02:38 -0400 Subject: [PATCH 3/3] Harden opt-in preview publishing --- .github/workflows/pr-preview.yml | 77 ++++++++++++++++++++++++++------ .hooks/sync_version.py | 4 +- CONTRIBUTING.md | 14 +++--- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 304cf49..0493175 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -11,26 +11,84 @@ on: type: string concurrency: - group: publish-pr-preview-${{ github.event.pull_request.number || inputs.pr_number }} + group: publish-pr-preview-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: false jobs: - build: + context: if: >- 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: + contents: read + 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 }} - pr_number: ${{ steps.context.outputs.pr_number }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.head.sha || format('refs/pull/{0}/head', inputs.pr_number) }} + ref: ${{ needs.context.outputs.head_sha }} fetch-depth: 0 persist-credentials: false @@ -44,13 +102,6 @@ jobs: - name: Install distribution validator run: python -m pip install "twine>=4.0.0" - - name: Record preview context - id: context - env: - EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} - INPUT_PR_NUMBER: ${{ inputs.pr_number }} - run: echo "pr_number=${EVENT_PR_NUMBER:-$INPUT_PR_NUMBER}" >> "$GITHUB_OUTPUT" - - name: Inject deterministic preview version env: PREVIEW_ID: ${{ github.run_id }} @@ -84,7 +135,7 @@ jobs: retention-days: 14 publish: - needs: build + needs: [context, build] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -108,7 +159,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PREVIEW_VERSION: ${{ needs.build.outputs.preview_version }} - PR_NUMBER: ${{ needs.build.outputs.pr_number }} + PR_NUMBER: ${{ needs.context.outputs.pr_number }} with: script: | const marker = ''; diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py index eb13f1e..32869c3 100755 --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -135,8 +135,8 @@ def read_preview_id(): print("❌ `--preview-id` requires a numeric value.") sys.exit(1) - if not preview_id.isdigit(): - print("❌ `--preview-id` must contain digits only.") + if not preview_id.isascii() or not preview_id.isdigit(): + print("❌ `--preview-id` must contain ASCII digits only.") sys.exit(1) return preview_id diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfce1ef..5775220 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,8 +16,6 @@ complete local check is: ```bash python -m pytest -ruff check . -ruff format --check . hatch build python -m twine check dist/* ``` @@ -36,14 +34,16 @@ 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. Label-triggered -publication is skipped for pull requests from forks. +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** and -enter the pull request number. Manual dispatch is useful when a label should -remain unchanged or a publication needs to be retried. +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.