From 18e9f89be87dec1198e1ab5580a1e4c5e71de8f1 Mon Sep 17 00:00:00 2001 From: Aaron Bacchi Date: Tue, 3 Nov 2026 08:40:40 -0500 Subject: [PATCH 1/4] [DEVOPS-12319] Publish SDK releases as draft, upload assets, then publish last Immutable releases reject asset uploads once a release is published. The publish workflow now requires the release to be a draft, attaches the wheel and sdist while it is a draft, and publishes the release after PyPI publish succeeds. Replace the SLSA generic generator with actions/attest-build-provenance. The SLSA upload has failed on every release since v7.6.0, so v7.7.0 to v7.17.0 shipped without it. Update the README provenance section and add maintainer release steps to CONTRIBUTING. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 70 ++++++++++++++++++++++++++++------- CONTRIBUTING.md | 8 ++++ README.md | 20 +++++----- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8b6cb6ccb..4d07e3a69 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,28 @@ permissions: id-token: write jobs: + # Immutable releases reject asset uploads once a release is published, so the + # release must be a draft while this workflow attaches assets. It is published + # by the publish-release job at the end. + check-draft-release: + runs-on: ubuntu-latest + permissions: + contents: write # needed to read draft releases + steps: + - name: Check that the release is a draft + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + run: | + if ! is_draft=$(gh release view "$TAG" --json isDraft --jq .isDraft); then + echo "::error::No release found for $TAG. Create it as a draft (do not publish it), then rerun." + exit 1 + fi + if [ "$is_draft" != "true" ]; then + echo "::error::Release $TAG is already published. Assets can only be added to a draft release." + exit 1 + fi # build-lbox: # permissions: # actions: read @@ -39,9 +61,8 @@ jobs: # secrets: inherit build: # needs: ['build-lbox'] + needs: ['check-draft-release'] runs-on: ubuntu-latest - outputs: - hashes: ${{ steps.hash.outputs.hashes }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -59,25 +80,26 @@ jobs: run: | rye sync rye build - - name: "Generate hashes" - id: hash - run: | - cd dist && echo "hashes=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: build path: ./dist provenance_python: needs: [build] + runs-on: ubuntu-latest permissions: - actions: read - contents: write + contents: read id-token: write # Needed to access the workflow's OIDC identity. - uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@5a775b367a56d5bd118a224a811bba288150a563 # v2.0.0 - with: - base64-subjects: "${{ needs.build.outputs.hashes }}" - upload-assets: true - upload-tag-name: ${{ inputs.tag }} # Tag from the initiation of the workflow + attestations: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: build + path: ./dist + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: dist/* test-build: if: ${{ !inputs.skip-tests }} needs: ['build'] @@ -160,9 +182,10 @@ jobs: path: ./artifact - name: Upload dist to release run: | - gh release upload ${{ inputs.tag }} ./artifact/* + gh release upload "$TAG" ./artifact/* env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag }} pypi-publish: runs-on: ubuntu-latest needs: ['build', 'test-build'] @@ -184,6 +207,25 @@ jobs: uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 with: packages-dir: artifact/ + publish-release: + runs-on: ubuntu-latest + needs: ['publish-python-package-to-release', 'provenance_python', 'pypi-publish'] + # always() is needed because pypi-publish can follow a skipped test-build. + if: | + always() && + needs.publish-python-package-to-release.result == 'success' && + needs.provenance_python.result == 'success' && + needs.pypi-publish.result == 'success' + permissions: + contents: write + steps: + - name: Publish release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + run: | + gh release edit "$TAG" --draft=false --verify-tag container-publish: runs-on: ubuntu-latest needs: ['build', 'test-build'] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98b2474b8..a379d1214 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,14 @@ As a note, the repository is organized as a monorepo, with each module residing Your contributions will be released as soon as it is approved and merged into the `develop` branch. Please consult the [Labelbox](https://docs.labelbox.com/docs/contacting-customer-support) team for if you need a more specific timeframe of when your contributions may be released. +For maintainers cutting a release: + +1. Push the release tag (for example `v7.18.0`). +2. Create a GitHub release for that tag with the release notes and save it as a **draft**. Do not publish it. +3. Run the Github Publish Workflow with that tag. It uploads the wheel and sdist to the draft release, attests them, publishes to PyPI, and then publishes the release. + +The release must stay a draft until the workflow publishes it: releases are immutable once published, so no assets can be added afterwards. If the workflow fails before the last step, fix the problem and rerun it; the release stays a draft. + ## Github Workflows ![workflow](./docs/github-workflow.svg) diff --git a/README.md b/README.md index 597cd4aec..75e0cd7c7 100644 --- a/README.md +++ b/README.md @@ -92,20 +92,20 @@ c.InteractiveShellApp.exec_lines = [ ## Provenance -To enhance the software supply chain security of Labelbox's users, as of v.3.72.2, every Labelbox SDK release contains a [SLSA Level 3 Provenance](https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/generic/README.md) document. -This document provides detailed information about the build process, including the repository and branch from which the package was generated. +To enhance the software supply chain security of Labelbox's users, every Labelbox SDK release is built by the [publish workflow](.github/workflows/publish.yml) and comes with signed build provenance: -By using the [SLSA framework's official verifier](https://github.com/slsa-framework/slsa-verifier), you can verify the provenance document to ensure that the package is from a trusted source. Verifying the provenance helps confirm that the package has not been tampered with and was built in a secure environment. +* A [GitHub artifact attestation](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations) for the wheel and sdist, which records the repository, workflow and commit that built them. +* A [PyPI attestation](https://docs.pypi.org/attestations/) for each file published to PyPI. -Example of usage for the v.3.72.2 release wheel: +Verifying the provenance confirms that the package was built from this repository by our release workflow and has not been tampered with. For example, with the [GitHub CLI](https://cli.github.com/): ``` -export VERSION=3.72.2 # sdk release version -export TAG=v.3.72.2 # github tag +export VERSION= # sdk release version pip download --no-deps labelbox==${VERSION} -curl --location -O \ - https://github.com/Labelbox/labelbox-python/releases/download/${TAG}/multiple.intoto.jsonl - -slsa-verifier verify-artifact --source-branch develop --builder-id 'https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.0.0' --source-uri "git+https://github.com/Labelbox/labelbox-python" --provenance-path multiple.intoto.jsonl ./labelbox-${VERSION}-py3-none-any.whl +gh attestation verify ./labelbox-${VERSION}-py3-none-any.whl \ + --repo Labelbox/labelbox-python \ + --signer-workflow Labelbox/labelbox-python/.github/workflows/publish.yml ``` + +GitHub attestations start with the first release after v7.17.0. Releases v7.7.0 through v7.17.0 have only the PyPI attestation. Releases v.3.72.2 through v7.6.0 instead include a [SLSA Level 3 Provenance](https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/generic/README.md) document (`multiple.intoto.jsonl`) that can be checked with [slsa-verifier](https://github.com/slsa-framework/slsa-verifier). From c552b987f2671a72afeea671936fd0f0e03428df Mon Sep 17 00:00:00 2001 From: Aaron Bacchi Date: Tue, 3 Nov 2026 08:46:46 -0500 Subject: [PATCH 2/4] [DEVOPS-12319] Keep SLSA provenance alongside GitHub attestations Restore the SLSA generic provenance (multiple.intoto.jsonl) and attach it to the draft release with the wheel and sdist. The generator broke when it was pinned to a SHA in #2051: it must be referenced by tag. Reference v2.1.0 by tag and turn off its own release upload, which cannot target draft releases. Also reference the container SLSA generator by tag, and let it run when production tests are skipped. Restore the slsa-verifier example in the README. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 32 ++++++++++++++++++++++++++++---- README.md | 14 ++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4d07e3a69..2d9014b8e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -63,6 +63,8 @@ jobs: # needs: ['build-lbox'] needs: ['check-draft-release'] runs-on: ubuntu-latest + outputs: + hashes: ${{ steps.hash.outputs.hashes }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -80,11 +82,27 @@ jobs: run: | rye sync rye build + - name: "Generate hashes" + id: hash + run: | + cd dist && echo "hashes=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: build path: ./dist + # The SLSA generators must be referenced by tag, not SHA: they use the tag to + # fetch and verify their builder, and slsa-verifier checks it. provenance_python: + needs: [build] + permissions: + actions: read + contents: read + id-token: write # Needed to access the workflow's OIDC identity. + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 + with: + base64-subjects: "${{ needs.build.outputs.hashes }}" + upload-assets: false # Uploaded to the draft release by publish-python-package-to-release + attest_python: needs: [build] runs-on: ubuntu-latest permissions: @@ -169,7 +187,7 @@ jobs: rye run pytest tests/data publish-python-package-to-release: runs-on: ubuntu-latest - needs: ['build'] + needs: ['build', 'provenance_python'] permissions: contents: write steps: @@ -180,6 +198,10 @@ jobs: with: name: build path: ./artifact + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.provenance_python.outputs.provenance-name }} + path: ./artifact - name: Upload dist to release run: | gh release upload "$TAG" ./artifact/* @@ -209,12 +231,12 @@ jobs: packages-dir: artifact/ publish-release: runs-on: ubuntu-latest - needs: ['publish-python-package-to-release', 'provenance_python', 'pypi-publish'] + needs: ['publish-python-package-to-release', 'attest_python', 'pypi-publish'] # always() is needed because pypi-publish can follow a skipped test-build. if: | always() && needs.publish-python-package-to-release.result == 'success' && - needs.provenance_python.result == 'success' && + needs.attest_python.result == 'success' && needs.pypi-publish.result == 'success' permissions: contents: write @@ -284,11 +306,13 @@ jobs: provenance_container: needs: [container-publish] + # always() is needed because container-publish can follow a skipped test-build. + if: always() && needs.container-publish.result == 'success' permissions: actions: read # for detecting the Github Actions environment. id-token: write # for creating OIDC tokens for signing. packages: write # for uploading attestations. - uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@5a775b367a56d5bd118a224a811bba288150a563 # v2.0.0 + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0 with: image: ${{ needs. container-publish.outputs.image }} digest: ${{ needs. container-publish.outputs.digest }} diff --git a/README.md b/README.md index 75e0cd7c7..cbb338003 100644 --- a/README.md +++ b/README.md @@ -94,18 +94,28 @@ c.InteractiveShellApp.exec_lines = [ To enhance the software supply chain security of Labelbox's users, every Labelbox SDK release is built by the [publish workflow](.github/workflows/publish.yml) and comes with signed build provenance: +* A [SLSA Level 3 Provenance](https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/generic/README.md) document (`multiple.intoto.jsonl`) attached to the GitHub release. * A [GitHub artifact attestation](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations) for the wheel and sdist, which records the repository, workflow and commit that built them. * A [PyPI attestation](https://docs.pypi.org/attestations/) for each file published to PyPI. -Verifying the provenance confirms that the package was built from this repository by our release workflow and has not been tampered with. For example, with the [GitHub CLI](https://cli.github.com/): +Verifying the provenance confirms that the package was built from this repository by our release workflow and has not been tampered with. For example, for the wheel of a given release: ``` export VERSION= # sdk release version +export TAG=v${VERSION} # github tag pip download --no-deps labelbox==${VERSION} +# GitHub attestation, with the GitHub CLI gh attestation verify ./labelbox-${VERSION}-py3-none-any.whl \ --repo Labelbox/labelbox-python \ --signer-workflow Labelbox/labelbox-python/.github/workflows/publish.yml + +# SLSA provenance, with slsa-verifier +curl --location -O \ + https://github.com/Labelbox/labelbox-python/releases/download/${TAG}/multiple.intoto.jsonl +slsa-verifier verify-artifact --source-branch develop \ + --source-uri "git+https://github.com/Labelbox/labelbox-python" \ + --provenance-path multiple.intoto.jsonl ./labelbox-${VERSION}-py3-none-any.whl ``` -GitHub attestations start with the first release after v7.17.0. Releases v7.7.0 through v7.17.0 have only the PyPI attestation. Releases v.3.72.2 through v7.6.0 instead include a [SLSA Level 3 Provenance](https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/generic/README.md) document (`multiple.intoto.jsonl`) that can be checked with [slsa-verifier](https://github.com/slsa-framework/slsa-verifier). +GitHub attestations start with the first release after v7.17.0. Releases v7.7.0 through v7.17.0 have only the PyPI attestation, because the SLSA provenance upload was broken for them. Releases v.3.72.2 through v7.6.0 have the SLSA provenance and verify the same way. From c699ea46718d4d8ce46b568859a08c8472545cc9 Mon Sep 17 00:00:00 2001 From: Aaron Bacchi Date: Tue, 3 Nov 2026 08:53:28 -0500 Subject: [PATCH 3/4] [DEVOPS-12319] Grant contents: write to the SLSA generator job The generic generator declares contents: write for its upload job, so the caller must grant it even with upload-assets: false (slsa-framework/slsa-github-generator#2044). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2d9014b8e..91f352d23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -96,7 +96,7 @@ jobs: needs: [build] permissions: actions: read - contents: read + contents: write # Required even with upload-assets: false, see slsa-framework/slsa-github-generator#2044 id-token: write # Needed to access the workflow's OIDC identity. uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 with: From c2d8c8f0f5d9e38e30b1dbe489759866bbdeeb88 Mon Sep 17 00:00:00 2001 From: Aaron Bacchi Date: Tue, 3 Nov 2026 09:06:57 -0500 Subject: [PATCH 4/4] [DEVOPS-12319] Upload release assets after tests pass and allow retries Address Cursor Bugbot review on #2098: - Upload to the draft release only after test-build passes or is skipped, so a failed test run (as on the first v7.17.0 run) leaves the draft empty. - Use --clobber so Re-run failed jobs can retry a partial upload. The release is still a draft at this point. - Set the SLSA provenance name explicitly and download it by that name rather than relying on the generator output. - Document retrying with Re-run failed jobs in CONTRIBUTING. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 15 ++++++++++++--- CONTRIBUTING.md | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 91f352d23..296463ef2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -101,6 +101,7 @@ jobs: uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 with: base64-subjects: "${{ needs.build.outputs.hashes }}" + provenance-name: multiple.intoto.jsonl upload-assets: false # Uploaded to the draft release by publish-python-package-to-release attest_python: needs: [build] @@ -187,7 +188,13 @@ jobs: rye run pytest tests/data publish-python-package-to-release: runs-on: ubuntu-latest - needs: ['build', 'provenance_python'] + # Upload only after tests pass, so a failed test run leaves the draft empty. + needs: ['build', 'provenance_python', 'test-build'] + if: | + always() && + needs.build.result == 'success' && + needs.provenance_python.result == 'success' && + (needs.test-build.result == 'success' || needs.test-build.result == 'skipped') permissions: contents: write steps: @@ -200,11 +207,13 @@ jobs: path: ./artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: ${{ needs.provenance_python.outputs.provenance-name }} + name: multiple.intoto.jsonl path: ./artifact + # --clobber lets "Re-run failed jobs" retry a partial upload. The release + # is still a draft here (check-draft-release), so nothing public changes. - name: Upload dist to release run: | - gh release upload "$TAG" ./artifact/* + gh release upload "$TAG" ./artifact/* --clobber env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ inputs.tag }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a379d1214..31bd2a61f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ For maintainers cutting a release: 2. Create a GitHub release for that tag with the release notes and save it as a **draft**. Do not publish it. 3. Run the Github Publish Workflow with that tag. It uploads the wheel and sdist to the draft release, attests them, publishes to PyPI, and then publishes the release. -The release must stay a draft until the workflow publishes it: releases are immutable once published, so no assets can be added afterwards. If the workflow fails before the last step, fix the problem and rerun it; the release stays a draft. +The release must stay a draft until the workflow publishes it: releases are immutable once published, so no assets can be added afterwards. If the workflow fails, the release stays a draft. Fix the problem, then use **Re-run failed jobs** on the same run. Do not start a new run once the PyPI publish has succeeded: PyPI rejects a version that already exists, so the new run cannot publish the release. If that happens, publish the draft release by hand. ## Github Workflows