From c1f944a96034f6f7af8a284d363cd7f08aa34070 Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Wed, 27 May 2026 19:50:00 -0400 Subject: [PATCH 1/6] ci: split build from publish, gate OIDC with environment Refactors ci.yml so the credential-bearing job runs only an upload command against a pre-built, checksum-verified artifact. No dependency installer or build toolchain executes in the same job as the OIDC token. Topology: compile -> test -> tag -> build-artifact -> publish - build-artifact (new, no secrets): poetry build, sha256sum the resulting wheel + sdist, upload dist/ + SHA256SUMS via actions/upload-artifact. - publish (rewritten): no checkout, no setup-python, no poetry. Downloads the artifact, verifies SHA256SUMS, then runs pypa/gh-action-pypi-publish (an upload-only action). - Workflow-root permissions: {} with per-job grants; publish gets id-token: write + contents: read and nothing else. - environment: pypi-production on publish, so the PyPI Trusted Publisher binding and the environment's deployment rules can pin OIDC minting to refs/heads/main. - All new uses: pinned to commit SHA with a trailing version comment. Follow-ups outside this PR (registry/GitHub side): - Create the pypi-production environment in GitHub repo settings with a deployment-branch rule restricting it to refs/heads/main. - Update the PyPI Trusted Publisher binding to require environment name pypi-production. - Confirm branch protection on main so the ref restriction is meaningful. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 75 +++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7527ac9..a49e5fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,24 @@ on: push: workflow_dispatch: +permissions: {} + +# Cancel superseded runs on PR branches; never cancel main, since a +# mid-flight publish would orphan the tag from the PyPI upload and the +# next run would skip publish because the tag already exists. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + PYTHON_VERSION: "3.10" + POETRY_VERSION: "1.5.1" + jobs: compile: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout repo @@ -15,11 +30,11 @@ jobs: - name: Set up python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: - python-version: "3.10" + python-version: ${{ env.PYTHON_VERSION }} - name: Bootstrap poetry run: | - curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + curl -sSL https://install.python-poetry.org | python - -y --version "${{ env.POETRY_VERSION }}" - name: Install dependencies run: poetry install @@ -30,6 +45,9 @@ jobs: test: needs: [ compile ] runs-on: ubuntu-latest + permissions: + contents: read + steps: - name: Checkout repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -37,11 +55,11 @@ jobs: - name: Set up python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: - python-version: "3.10" + python-version: ${{ env.PYTHON_VERSION }} - name: Bootstrap poetry run: | - curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + curl -sSL https://install.python-poetry.org | python - -y --version "${{ env.POETRY_VERSION }}" - name: Install dependencies run: poetry install @@ -94,12 +112,11 @@ jobs: --title "${{ steps.check.outputs.version }}" \ --generate-notes - publish: - needs: [ compile, test, tag ] + build-artifact: + needs: [ tag ] if: needs.tag.outputs.should_publish == 'true' runs-on: ubuntu-latest permissions: - id-token: write contents: read steps: @@ -109,14 +126,54 @@ jobs: - name: Set up python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: - python-version: "3.10" + python-version: ${{ env.PYTHON_VERSION }} - name: Bootstrap poetry run: | - curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + curl -sSL https://install.python-poetry.org | python - -y --version "${{ env.POETRY_VERSION }}" - name: Build distribution run: poetry build + # Hand-off integrity: the publish job re-verifies these against the + # downloaded artifact before uploading to PyPI, so a future + # artifact-store compromise can't silently swap the bundle. + - name: Compute checksums + working-directory: dist + run: sha256sum -- *.whl *.tar.gz > ../SHA256SUMS + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-dist + path: | + dist/ + SHA256SUMS + if-no-files-found: error + retention-days: 1 + + publish: + needs: [ tag, build-artifact ] + if: needs.tag.outputs.should_publish == 'true' + runs-on: ubuntu-latest + # Environment-scoped OIDC: the environment's deployment branch rule + # restricts this job to refs/heads/main, and the PyPI Trusted Publisher + # binding requires this environment name — so a publish from any other + # ref or workflow is rejected end-to-end. + environment: pypi-production + permissions: + id-token: write + contents: read + + steps: + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-dist + + - name: Verify checksums + working-directory: dist + run: sha256sum -c ../SHA256SUMS + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 From 8ddc55f08824954b5227195258c48c32adb3c008 Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Tue, 2 Jun 2026 18:52:57 -0400 Subject: [PATCH 2/6] ci: carry dist checksums via job output, not inside the artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review: bundling SHA256SUMS inside the python-dist artifact gave no integrity protection against the artifact-store compromise the comment claimed to guard against — an attacker who can tamper with the bundle rewrites the checksums to match. Move the checksums to the build-artifact job's output (sha256sums), which lives in the workflow run's output metadata — a separate subsystem from the artifact blob store. publish reads them via needs.build-artifact.outputs and verifies the downloaded wheel/sdist against them, so a swapped bundle is caught even if its store-side recorded digest were rewritten too. This layers on top of download-artifact v8's built-in verification, which already checks the downloaded bundle against GitHub's recorded digest (digest-mismatch: error by default). - build-artifact: SHA256SUMS no longer uploaded; emitted as a multi-line job output. Upload is now path: dist/ only. - publish: download into dist/ (the artifact root changed now that SHA256SUMS is gone), verify against the job output via env. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a49e5fc..2421673 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + outputs: + sha256sums: ${{ steps.checksums.outputs.sha256sums }} steps: - name: Checkout repo @@ -135,20 +137,27 @@ jobs: - name: Build distribution run: poetry build - # Hand-off integrity: the publish job re-verifies these against the - # downloaded artifact before uploading to PyPI, so a future - # artifact-store compromise can't silently swap the bundle. + # Hand-off integrity, out-of-band: the checksums travel to publish + # through the job-output channel, which is a separate subsystem + # from the artifact blob store. Bundling them inside the artifact + # (as a SHA256SUMS file) would let an attacker who tampers with the + # bundle rewrite the checksums to match — defeating the check. The + # job output can't be rewritten by an artifact-store compromise. - name: Compute checksums + id: checksums working-directory: dist - run: sha256sum -- *.whl *.tar.gz > ../SHA256SUMS + run: | + { + echo 'sha256sums<> "$GITHUB_OUTPUT" - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-dist - path: | - dist/ - SHA256SUMS + path: dist/ if-no-files-found: error retention-days: 1 @@ -166,14 +175,20 @@ jobs: contents: read steps: + # download-artifact verifies the downloaded bundle against GitHub's + # recorded digest by default (digest-mismatch: error); the step + # below adds the out-of-band check against the build job's output. - name: Download artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-dist + path: dist - name: Verify checksums working-directory: dist - run: sha256sum -c ../SHA256SUMS + env: + SHA256SUMS: ${{ needs.build-artifact.outputs.sha256sums }} + run: echo "$SHA256SUMS" | sha256sum -c - - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 From b3aab6542329857005aa4e83325b9836167bf40a Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Tue, 2 Jun 2026 18:55:47 -0400 Subject: [PATCH 3/6] ci: drop bespoke dist checksum, rely on download-artifact verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guarding against a GitHub-side artifact-store compromise is overkill and out of step with the sibling SDK workflows. download-artifact@v8 already verifies the downloaded bundle against GitHub's recorded digest by default (digest-mismatch: error), which covers the realistic threat (transit corruption / tampering). The core win of this PR — the credential-bearing publish job runs no build toolchain — is unchanged. Removes the Compute checksums / Verify checksums steps and the sha256sums job output added earlier. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2421673..079ea01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,8 +118,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - outputs: - sha256sums: ${{ steps.checksums.outputs.sha256sums }} steps: - name: Checkout repo @@ -137,22 +135,6 @@ jobs: - name: Build distribution run: poetry build - # Hand-off integrity, out-of-band: the checksums travel to publish - # through the job-output channel, which is a separate subsystem - # from the artifact blob store. Bundling them inside the artifact - # (as a SHA256SUMS file) would let an attacker who tampers with the - # bundle rewrite the checksums to match — defeating the check. The - # job output can't be rewritten by an artifact-store compromise. - - name: Compute checksums - id: checksums - working-directory: dist - run: | - { - echo 'sha256sums<> "$GITHUB_OUTPUT" - - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -176,19 +158,12 @@ jobs: steps: # download-artifact verifies the downloaded bundle against GitHub's - # recorded digest by default (digest-mismatch: error); the step - # below adds the out-of-band check against the build job's output. + # recorded digest by default (digest-mismatch: error). - name: Download artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-dist path: dist - - name: Verify checksums - working-directory: dist - env: - SHA256SUMS: ${{ needs.build-artifact.outputs.sha256sums }} - run: echo "$SHA256SUMS" | sha256sum -c - - - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 From 3d93f08fe35ae636fb7aca65450092b4ffa93d2b Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Mon, 15 Jun 2026 15:21:33 -0400 Subject: [PATCH 4/6] [skip ci] test skipping CI impact on Bugbot From 7069bfe445189543697c802c83e08b24e29d8b9f Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Thu, 25 Jun 2026 09:50:56 -0400 Subject: [PATCH 5/6] ci: bump verify-openapi-spec action to 1.0.3 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 079ea01..0d32f28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: # version tagged but unpublished, and subsequent runs would skip # publish because the tag already exists. - name: Verify bundled OpenAPI spec - uses: PhenoML/sdk-shared-actions/verify-openapi-spec@1.0.2 + uses: PhenoML/sdk-shared-actions/verify-openapi-spec@1.0.3 with: spec-path: src/phenoml/openapi/openapi.json From 5a2edb39bcb48d4ebadb88627bd24f6cc84f74e9 Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Thu, 25 Jun 2026 09:51:52 -0400 Subject: [PATCH 6/6] ci: bump sync-fern-artifacts reusable workflow to 1.0.3 Keeps the sdk-shared-actions references in lockstep with the verify-openapi-spec bump in ci.yml. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/sync-fern-artifacts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-fern-artifacts.yml b/.github/workflows/sync-fern-artifacts.yml index 301b0a1..0a4a39b 100644 --- a/.github/workflows/sync-fern-artifacts.yml +++ b/.github/workflows/sync-fern-artifacts.yml @@ -20,6 +20,6 @@ jobs: sync: permissions: contents: write - uses: PhenoML/sdk-shared-actions/.github/workflows/sync-fern-artifacts.yml@1.0.2 + uses: PhenoML/sdk-shared-actions/.github/workflows/sync-fern-artifacts.yml@1.0.3 with: spec-path: src/phenoml/openapi/openapi.json