ci(release): split build/test from publish via artifacts#21
ci(release): split build/test from publish via artifacts#21isaacbmiller wants to merge 1 commit intoisaac/harden-release-3from
Conversation
Greptile SummaryThis PR refactors the release workflow to implement PyPI's recommended security practice: jobs holding
Confidence Score: 4/5Safe to merge with minor corrections — the core security hardening is correctly implemented, but two low-risk issues should be addressed before the next tag push. No P0/P1 bugs. Two P2 findings: the missing .github/workflows/build_and_release.yml — lines 44–47 (sed patterns) and line 130 (missing if guard) Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[extract-tag] --> B[build\nno permissions\nbuilds 3 artifacts]
B --> C[test-wheel\nno permissions\ninstalls dspy wheel + runs pytest]
C --> D[publish-testpypi\nid-token write only\nenv: testpypi]
D --> E[publish-pypi\nid-token write only\nenv: pypi\nstanfordnlp repos only]
A --> F[sync-version-to-main\ncontents write only\nstanfordnlp repos only]
E --> F
Reviews (1): Last reviewed commit: "ci(release): split build/test from publi..." | Re-trigger Greptile |
| publish-testpypi: | ||
| needs: [build, test-wheel] | ||
| runs-on: ubuntu-latest | ||
| environment: | ||
| name: testpypi | ||
| permissions: | ||
| id-token: write # IMPORTANT: mandatory for trusted publishing | ||
| steps: | ||
| - name: Download testpypi artifacts | ||
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | ||
| with: | ||
| name: dist-testpypi | ||
| path: dist/ | ||
| - name: Publish distribution 📦 to test-PyPI | ||
| uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 | ||
| with: | ||
| repository-url: https://test.pypi.org/legacy/ |
There was a problem hiding this comment.
publish-testpypi missing repository-owner guard
Unlike publish-pypi and sync-version-to-main, this job has no if: github.repository_owner == 'stanfordnlp' condition. On any fork that pushes a tag, the runner will enter the testpypi GitHub environment. If that environment doesn't exist on the fork, the job either hangs awaiting manual approval or errors out, causing the overall workflow run to fail. Adding the same guard used on the other publish jobs would prevent this.
| publish-testpypi: | |
| needs: [build, test-wheel] | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: testpypi | |
| permissions: | |
| id-token: write # IMPORTANT: mandatory for trusted publishing | |
| steps: | |
| - name: Download testpypi artifacts | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | |
| with: | |
| name: dist-testpypi | |
| path: dist/ | |
| - name: Publish distribution 📦 to test-PyPI | |
| uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 | |
| with: | |
| repository-url: https://test.pypi.org/legacy/ | |
| publish-testpypi: | |
| needs: [build, test-wheel] | |
| if: github.repository_owner == 'stanfordnlp' | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: testpypi | |
| permissions: | |
| id-token: write # IMPORTANT: mandatory for trusted publishing |
| - name: Update version in pyproject.toml (testpypi) | ||
| run: sed -i '/#replace_package_version_marker/{n;s/version="[^"]*"/version="${{ steps.check_version.outputs.version }}"/;}' pyproject.toml | ||
| - name: Update package name in pyproject.toml | ||
| - name: Update package name in pyproject.toml (testpypi) | ||
| run: sed -i '/#replace_package_name_marker/{n;s/name="[^"]*"/name="dspy-ai-test-isaac"/;}' pyproject.toml |
There was a problem hiding this comment.
Inconsistent
sed pattern for testpypi vs. dspy builds
The testpypi sed commands use version="..." / name="..." (no spaces around =), while the dspy and dspy-ai steps use the more permissive version *= *"..." / name *= *"...". If pyproject.toml is formatted with spaces around = (e.g. version = "0.0.0"), the testpypi substitutions will silently no-op, building and uploading an artifact with the original placeholder version/name. The dspy sed pattern on line 66 shows the correct, defensive form:
| - name: Update version in pyproject.toml (testpypi) | |
| run: sed -i '/#replace_package_version_marker/{n;s/version="[^"]*"/version="${{ steps.check_version.outputs.version }}"/;}' pyproject.toml | |
| - name: Update package name in pyproject.toml | |
| - name: Update package name in pyproject.toml (testpypi) | |
| run: sed -i '/#replace_package_name_marker/{n;s/name="[^"]*"/name="dspy-ai-test-isaac"/;}' pyproject.toml | |
| - name: Update version in pyproject.toml (testpypi) | |
| run: sed -i '/#replace_package_version_marker/{n;s/version *= *"[^"]*"/version="${{ steps.check_version.outputs.version }}"/;}' pyproject.toml | |
| - name: Update package name in pyproject.toml (testpypi) | |
| run: sed -i '/#replace_package_name_marker/{n;s/name *= *"[^"]*"/name="dspy-ai-test-isaac"/;}' pyproject.toml |
87454a9 to
4e67890
Compare
14c719f to
de6933a
Compare
Refactors the release workflow so the jobs that hold id-token: write for PyPI Trusted Publishing have only two steps: download the pre-built distribution artifacts and invoke pypa/gh-action-pypi-publish. This matches PyPA and PyPI security-model guidance to minimize the privileged blast radius. Job graph: extract-tag -> build -> test-wheel -> publish-testpypi -> publish-pypi -> sync-version-to-main - build (no id-token, no contents:write): performs the sed-based version/name patching, builds and validates three dist sets (dspy-ai-test-isaac for TestPyPI; dspy and dspy-ai for PyPI), and uploads each as a named artifact. - test-wheel (no privileged permissions): downloads the real dspy wheel and runs the existing pytest subset against it. - publish-testpypi (id-token: write only, environment testpypi): 2 steps - download + upload to TestPyPI. - publish-pypi (id-token: write only, environment pypi): 4 steps - download dspy artifacts + publish, download dspy-ai artifacts + publish. - sync-version-to-main (contents: write only): unchanged from PR #3. Refs PyPI security-model guidance: https://docs.pypi.org/trusted-publishers/security-model/ ('Limit the scope of your publishing job'). Refs PyPA publishing guide: https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
4e67890 to
da54ac4
Compare
Stacked on #20.
Refactors the release workflow so jobs holding
id-token: writehave only two steps: download artifacts, invokepypa/gh-action-pypi-publish(Critical #1).New job graph
buildtest-wheelpublish-testpypipublish-pypisync-version-to-mainRationale
Today
build-and-publish-test-pypiandbuild-and-publish-pypieach run sed, build, pytest, publish all in the same OIDC-minting job. PyPI's security-model doc explicitly calls this an anti-pattern: any poisoned step can exfiltrate the short-lived OIDC token and upload arbitrary wheels under the project's trusted-publisher identity.After this PR, the only code that runs with
id-token: writeispypa/gh-action-pypi-publishitself, operating on artifacts produced in a separate, unprivileged job.Notes
testpypias the environment name forpublish-testpypi. ThetestpypiGitHub environment is already provisioned with a Trusted Publisher on test.pypi.org.pypiforpublish-pypi(required-reviewer gate preserved).tests/metadata/test_metadata.py tests/predicton the real wheel.