diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8b6cb6ccb..296463ef2 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,6 +61,7 @@ jobs: # secrets: inherit build: # needs: ['build-lbox'] + needs: ['check-draft-release'] runs-on: ubuntu-latest outputs: hashes: ${{ steps.hash.outputs.hashes }} @@ -67,17 +90,35 @@ jobs: 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: write + 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@5a775b367a56d5bd118a224a811bba288150a563 # v2.0.0 + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 with: base64-subjects: "${{ needs.build.outputs.hashes }}" - upload-assets: true - upload-tag-name: ${{ inputs.tag }} # Tag from the initiation of the workflow + provenance-name: multiple.intoto.jsonl + upload-assets: false # Uploaded to the draft release by publish-python-package-to-release + attest_python: + needs: [build] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Needed to access the workflow's OIDC identity. + 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'] @@ -147,7 +188,13 @@ jobs: rye run pytest tests/data publish-python-package-to-release: runs-on: ubuntu-latest - needs: ['build'] + # 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: @@ -158,11 +205,18 @@ jobs: with: name: build path: ./artifact + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + 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 ${{ inputs.tag }} ./artifact/* + gh release upload "$TAG" ./artifact/* --clobber env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag }} pypi-publish: runs-on: ubuntu-latest needs: ['build', 'test-build'] @@ -184,6 +238,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', '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.attest_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'] @@ -242,11 +315,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/CONTRIBUTING.md b/CONTRIBUTING.md index 98b2474b8..31bd2a61f 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, 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 ![workflow](./docs/github-workflow.svg) diff --git a/README.md b/README.md index 597cd4aec..cbb338003 100644 --- a/README.md +++ b/README.md @@ -92,20 +92,30 @@ 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 [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. -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, for the wheel of a given release: ``` -export VERSION=3.72.2 # sdk release version -export TAG=v.3.72.2 # github tag +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 --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 +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, 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.