name: Publish PR Preview on: pull_request: types: [labeled] workflow_dispatch: inputs: pr_number: description: Pull request number to publish required: true type: string publish_test_pypi: description: Publish the Python package to TestPyPI required: true default: true type: boolean publish_docker: description: Publish socketdev/cli:pr- to Docker Hub required: true default: false type: boolean sdk_preview_version: description: Optional exact TestPyPI socketdev prerelease for the Docker image required: false type: string concurrency: group: publish-pr-preview-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: false jobs: context: if: >- github.event_name == 'workflow_dispatch' || (github.event.label.name == 'publish-preview' || github.event.label.name == 'publish-docker-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 }} 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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install build tooling uses: ./.github/actions/setup-hatch - name: Install distribution validator run: python -m pip install "twine>=4.0.0" - name: Inject deterministic preview version env: PREVIEW_ID: ${{ github.run_id }} RUN_ATTEMPT: ${{ github.run_attempt }} run: | 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: echo "preview_version=$(hatch version)" >> "$GITHUB_OUTPUT" - name: Build and validate distributions run: | hatch build python -m twine check dist/* - name: Install and inspect wheel locally run: | python -m venv "$RUNNER_TEMP/preview-check" "$RUNNER_TEMP/preview-check/bin/pip" install --no-deps dist/*.whl "$RUNNER_TEMP/preview-check/bin/python" - <<'PY' import compileall import importlib.metadata import pathlib import sysconfig distribution = importlib.metadata.distribution("socketsecurity") entry_points = {entry_point.name for entry_point in distribution.entry_points} assert "socketcli" in entry_points package = pathlib.Path(sysconfig.get_paths()["purelib"]) / "socketsecurity" assert compileall.compile_dir(package, quiet=1) print("preview wheel smoke OK", distribution.version) PY - name: Upload preview distributions uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: socketsecurity-preview-${{ github.run_id }}-${{ github.run_attempt }} path: dist/* if-no-files-found: error retention-days: 14 publish-package: needs: [context, build] if: >- github.event.label.name == 'publish-preview' || (github.event_name == 'workflow_dispatch' && inputs.publish_test_pypi) 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: socketsecurity-preview-${{ github.run_id }}-${{ github.run_attempt }} path: dist - 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 pull request uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PREVIEW_VERSION: ${{ needs.build.outputs.preview_version }} PR_NUMBER: ${{ needs.context.outputs.pr_number }} with: script: | const marker = ''; const prNumber = Number(process.env.PR_NUMBER); const version = process.env.PREVIEW_VERSION; const body = `${marker} 🚀 CLI preview published: \`socketsecurity==${version}\` \`\`\`bash pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple socketsecurity==${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 existing = comments.find(comment => comment.user.type === 'Bot' && comment.body.includes(marker) ); if (existing) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body, }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body, }); } publish-docker: needs: [context, build] if: >- github.event.label.name == 'publish-docker-preview' || (github.event_name == 'workflow_dispatch' && inputs.publish_docker) runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read pull-requests: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Keep the Dockerfile and credential-handling action on trusted code. # The pull request enters this job only through the built wheel. ref: ${{ github.event.repository.default_branch }} fetch-depth: 1 persist-credentials: false - name: Download preview distributions uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: socketsecurity-preview-${{ github.run_id }}-${{ github.run_attempt }} path: dist - name: Set up Docker publishing uses: ./.github/actions/setup-docker with: # QEMU is required to build the arm64 layer on an amd64 runner. enable-qemu: "true" dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker preview uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: # Build from the checked-out workspace, not the default Git context. The # wheel is only present here as a downloaded artifact, and this also keeps # Dockerfile.preview on the default-branch checkout rather than the PR ref. context: . file: Dockerfile.preview push: true pull: true # Match the arch matrix of the release and stable images so a preview is a # drop-in replacement for socketdev/cli:latest on arm64 runners too. The # arm64 layer builds under emulation, so expect roughly double the runtime. platforms: linux/amd64,linux/arm64 tags: socketdev/cli:pr-${{ needs.context.outputs.pr_number }} build-args: | SDK_PREVIEW_VERSION=${{ inputs.sdk_preview_version }} - name: Comment on pull request uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PR_NUMBER: ${{ needs.context.outputs.pr_number }} with: script: | const marker = ''; const prNumber = Number(process.env.PR_NUMBER); const body = `${marker} 🐳 Docker preview published: \`socketdev/cli:pr-${prNumber}\` This mutable tag is only created when a Docker preview is explicitly requested.`; const {data: comments} = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, }); const existing = comments.find(comment => comment.user.type === 'Bot' && comment.body.includes(marker) ); if (existing) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body, }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body, }); }