Skip to content

ci(release): split build/test from publish via artifacts#21

Open
isaacbmiller wants to merge 1 commit intoisaac/harden-release-3from
isaac/harden-release-4
Open

ci(release): split build/test from publish via artifacts#21
isaacbmiller wants to merge 1 commit intoisaac/harden-release-3from
isaac/harden-release-4

Conversation

@isaacbmiller
Copy link
Copy Markdown

Stacked on #20.

Refactors the release workflow so jobs holding id-token: write have only two steps: download artifacts, invoke pypa/gh-action-pypi-publish (Critical #1).

New job graph

extract-tag -> build -> test-wheel -> publish-testpypi -> publish-pypi -> sync-version-to-main
Job Permissions Purpose
build none sed patching + build + twine check + upload 3 artifact sets
test-wheel none download dspy wheel, run subset pytest
publish-testpypi id-token only, env: testpypi 2 steps: download + publish
publish-pypi id-token only, env: pypi 4 steps: download dspy + publish, download dspy-ai + publish
sync-version-to-main contents:write only unchanged from #20

Rationale

Today build-and-publish-test-pypi and build-and-publish-pypi each 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: write is pypa/gh-action-pypi-publish itself, operating on artifacts produced in a separate, unprivileged job.

Notes

  • Uses testpypi as the environment name for publish-testpypi. The testpypi GitHub environment is already provisioned with a Trusted Publisher on test.pypi.org.
  • Uses pypi for publish-pypi (required-reviewer gate preserved).
  • Test pytest coverage identical: tests/metadata/test_metadata.py tests/predict on the real wheel.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 22, 2026

Greptile Summary

This PR refactors the release workflow to implement PyPI's recommended security practice: jobs holding id-token: write now contain only artifact-download and pypa/gh-action-pypi-publish steps, with all sed patching, building, and testing moved into unprivileged jobs. The new job graph is extract-tag → build → test-wheel → publish-testpypi → publish-pypi → sync-version-to-main, and the old bug of the testpypi job using the pypi environment name is also corrected to testpypi.

  • publish-testpypi has no if: github.repository_owner == 'stanfordnlp' guard — on forks that push tags, the job will enter the testpypi GitHub environment, which may not be provisioned, causing the run to hang or fail.
  • The sed patterns for the testpypi pyproject.toml steps omit spaces around = (e.g. version="...") while all other sed steps use the defensive version *= *"..." form; a mismatch would silently produce an artifact with the placeholder version/name.

Confidence Score: 4/5

Safe 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 if guard on publish-testpypi could cause fork runs to hang, and the narrower sed patterns on the testpypi steps could silently produce a wrong artifact. Both are straightforward one-line fixes.

.github/workflows/build_and_release.yml — lines 44–47 (sed patterns) and line 130 (missing if guard)

Important Files Changed

Filename Overview
.github/workflows/build_and_release.yml Refactors release pipeline to isolate OIDC-minting steps: adds unprivileged build and test-wheel jobs, splits publish into publish-testpypi (env: testpypi) and publish-pypi (env: pypi); publish-testpypi lacks the if: stanfordnlp guard present on the other publish jobs, and the testpypi artifact itself is never tested before publishing.

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
Loading

Reviews (1): Last reviewed commit: "ci(release): split build/test from publi..." | Re-trigger Greptile

Comment on lines +130 to +146
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/
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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

Comment on lines +44 to 47
- 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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:

Suggested change
- 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

@isaacbmiller isaacbmiller force-pushed the isaac/harden-release-4 branch from 87454a9 to 4e67890 Compare April 22, 2026 18:12
@isaacbmiller isaacbmiller force-pushed the isaac/harden-release-3 branch from 14c719f to de6933a Compare April 22, 2026 18:19
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>
@isaacbmiller isaacbmiller force-pushed the isaac/harden-release-4 branch from 4e67890 to da54ac4 Compare April 22, 2026 18:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant