From d983febe43b8fa27b60a08033e53ebd2d721da04 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 8 Sep 2026 18:46:20 +0100 Subject: [PATCH 1/4] ci: publish Docker images and the PyPI package from the release workflow backend-release creates the GitHub release with GITHUB_TOKEN, and GitHub never starts workflows from events that token produces, so the `release: published` triggers on the Docker and PyPI publish workflows only fired for releases made by hand. 0.18.0 got no Docker images for that reason, and 0.19.0 reached PyPI by a manual run. backend-release now calls both publish workflows after creating the release, passing the version it tagged. Both workflows gain a `workflow_call` trigger with a `version` input and read the tag from it or from the release event, so a release created by hand still publishes through the release trigger. The Docker Hub credentials are passed by name; the PyPI job authenticates through trusted publishing, which matches the called workflow's filename and environment, so the publisher configuration is unchanged. Permissions in backend-release move from the workflow to the jobs: the release job writes contents; the Docker call writes contents (the compose file attached to the release) and packages; the PyPI call gets an OIDC token. --- .github/workflows/backend-release.yml | 38 +++++++++++++++++++++++++-- .github/workflows/ci.yml | 33 +++++++++++++++++++---- .github/workflows/pypi-publish.yml | 28 ++++++++++++++------ 3 files changed, 84 insertions(+), 15 deletions(-) diff --git a/.github/workflows/backend-release.yml b/.github/workflows/backend-release.yml index 0c9e3f64..3d2576a8 100644 --- a/.github/workflows/backend-release.yml +++ b/.github/workflows/backend-release.yml @@ -1,5 +1,12 @@ name: Backend release +# A version bump on main tags the commit, creates the GitHub release, then +# publishes the Docker images and the PyPI package by calling those workflows. +# The release is created with GITHUB_TOKEN, and GitHub never starts workflows +# from events that token produces, so the `release: published` triggers on the +# publish workflows would not fire (0.18.0 got no images that way). Releases +# created by hand still publish through those triggers. + on: push: branches: [main] @@ -7,8 +14,7 @@ on: - 'docsgpt/version.py' workflow_dispatch: -permissions: - contents: write +permissions: {} concurrency: group: backend-release @@ -18,6 +24,11 @@ jobs: release: if: github.repository == 'arc53/DocsGPT' runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version: ${{ steps.ver.outputs.version }} + created: ${{ steps.check.outputs.exists == 'false' }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -71,3 +82,26 @@ jobs: gh release create "$VERSION" \ --title "v$VERSION" \ --generate-notes + + docker: + needs: release + if: needs.release.outputs.created == 'true' + uses: $/.github/workflows/ci.yml + with: + version: ${{ needs.release.outputs.version }} + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + permissions: + contents: write # release-assets attaches the compose file to the release + packages: write + + pypi: + needs: release + if: needs.release.outputs.created == 'true' + uses: $/.github/workflows/pypi-publish.yml + with: + version: ${{ needs.release.outputs.version }} + permissions: + contents: read + id-token: write diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 187ac4df..dc780607 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,31 @@ name: Build and push DocsGPT Docker image +# Runs for a release created by hand (the release event), or called by the +# backend-release workflow with the version it just tagged: GitHub never starts +# workflows from events GITHUB_TOKEN produces, so a bot-created release does not +# fire the release trigger on its own. + on: release: types: [published] + workflow_call: + inputs: + version: + description: Release tag to build and push (the images are tagged with it) + type: string + required: true + secrets: + DOCKER_USERNAME: + required: true + DOCKER_PASSWORD: + required: true + +permissions: + contents: read + +env: + # The tag being published: passed in by the caller, or the release's own. + RELEASE_TAG: ${{ inputs.version || github.event.release.tag_name }} jobs: build: @@ -58,7 +81,7 @@ jobs: ghcr.io/${{ github.repository_owner }}/docsgpt labels: | org.opencontainers.image.title=DocsGPT${{ matrix.variant }} - org.opencontainers.image.version=${{ github.event.release.tag_name }} + org.opencontainers.image.version=${{ env.RELEASE_TAG }} - name: Build and push platform-specific images uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 @@ -71,8 +94,8 @@ jobs: EXTRAS=${{ matrix.variant == '-docling' && 'docling' || '' }} INSTALL_TESSERACT=${{ matrix.variant == '-docling' && 'true' || 'false' }} tags: | - ${{ env.DOCKERHUB_NAMESPACE }}/docsgpt:${{ github.event.release.tag_name }}${{ matrix.variant }}-${{ matrix.platform == 'linux/arm64' && 'arm64' || 'amd64' }} - ghcr.io/${{ github.repository_owner }}/docsgpt:${{ github.event.release.tag_name }}${{ matrix.variant }}-${{ matrix.platform == 'linux/arm64' && 'arm64' || 'amd64' }} + ${{ env.DOCKERHUB_NAMESPACE }}/docsgpt:${{ env.RELEASE_TAG }}${{ matrix.variant }}-${{ matrix.platform == 'linux/arm64' && 'arm64' || 'amd64' }} + ghcr.io/${{ github.repository_owner }}/docsgpt:${{ env.RELEASE_TAG }}${{ matrix.variant }}-${{ matrix.platform == 'linux/arm64' && 'arm64' || 'amd64' }} labels: ${{ steps.meta.outputs.labels }} provenance: false sbom: false @@ -118,7 +141,7 @@ jobs: - name: Create and push multi-arch manifests env: - TAG: ${{ github.event.release.tag_name }}${{ matrix.variant }} + TAG: ${{ env.RELEASE_TAG }}${{ matrix.variant }} LATEST: latest${{ matrix.variant }} run: | set -e @@ -145,6 +168,6 @@ jobs: - name: Attach the standalone compose file to the release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ github.event.release.tag_name }} + TAG: ${{ env.RELEASE_TAG }} run: | gh release upload "$TAG" deployment/docker-compose-standalone.yaml --clobber diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 8e977c56..c5c559cc 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -1,13 +1,21 @@ name: Publish to PyPI # Trusted publishing: PyPI trusts this workflow file in arc53/DocsGPT running -# in the `pypi` environment, so no API token is stored. A published release -# (which the backend-release workflow creates when docsgpt/version.py changes -# on main) publishes to PyPI; a manual run publishes to TestPyPI by default. +# in the `pypi` environment, so no API token is stored. The backend-release +# workflow calls this one after it tags a version bump on main (a release it +# creates with GITHUB_TOKEN never fires the release trigger); a release created +# by hand publishes through that trigger; a manual run publishes to TestPyPI +# by default. on: release: types: [published] + workflow_call: + inputs: + version: + description: Version the archives must carry (the release tag) + type: string + required: true workflow_dispatch: inputs: target: @@ -19,6 +27,11 @@ on: permissions: contents: read +env: + # The version being released: passed in by the caller, or the release's tag; + # empty for a manual run, which publishes whatever main builds. + RELEASE_VERSION: ${{ inputs.version || github.event.release.tag_name }} + jobs: build: if: github.repository == 'arc53/DocsGPT' @@ -37,11 +50,9 @@ jobs: run: uv build - name: The archives carry the released version - if: github.event_name == 'release' - env: - TAG: ${{ github.event.release.tag_name }} + if: env.RELEASE_VERSION != '' run: | - ls "dist/docsgpt-${TAG}.tar.gz" "dist/docsgpt-${TAG}-py3-none-any.whl" + ls "dist/docsgpt-${RELEASE_VERSION}.tar.gz" "dist/docsgpt-${RELEASE_VERSION}-py3-none-any.whl" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -50,7 +61,8 @@ jobs: if-no-files-found: error publish-pypi: - if: github.event_name == 'release' || inputs.target == 'pypi' + # A release event, a call from backend-release, or a manual run aimed at PyPI. + if: github.event_name == 'release' || inputs.version != '' || inputs.target == 'pypi' needs: build runs-on: ubuntu-latest environment: From d47a86e3c7a079a11488bbf808e93d3d0524f623 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 8 Sep 2026 18:57:47 +0100 Subject: [PATCH 2/4] ci: gate publishing on the release, rehearse the call path, and lint with a current zizmor - backend-release checks the tag and the release separately and publishes only when it created the release in this run. A run that pushed the tag and then failed to create the release used to skip both on re-run. - pypi-publish accepts a `target` from callers (default pypi) so the reusable-workflow path can be rehearsed against TestPyPI; the TestPyPI job skips files that already exist there. - A temporary workflow, to be removed before merge, calls pypi-publish the way backend-release does, aimed at TestPyPI, on this pull request: PyPI verifies the job_workflow_ref claim, which names the called file, so the run proves the trusted-publishing path through workflow_call. - zizmor-action v0.6.3: the pinned v0.5.2 bundles zizmor 1.23, which cannot parse the `uses: $/...` self-repository syntax and aborted the audit. --- .github/workflows/backend-release.yml | 27 ++++++++++++++----- .github/workflows/pypi-publish.yml | 17 ++++++++---- .../workflows/tmp-release-chain-rehearsal.yml | 22 +++++++++++++++ .github/workflows/zizmor.yml | 2 +- 4 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/tmp-release-chain-rehearsal.yml diff --git a/.github/workflows/backend-release.yml b/.github/workflows/backend-release.yml index 3d2576a8..4efe4665 100644 --- a/.github/workflows/backend-release.yml +++ b/.github/workflows/backend-release.yml @@ -28,7 +28,10 @@ jobs: contents: write outputs: version: ${{ steps.ver.outputs.version }} - created: ${{ steps.check.outputs.exists == 'false' }} + # The publish jobs run only for a release this run created. A release + # that already existed was either made by hand (its release event + # published it) or made by an earlier run: re-run that run's failed jobs. + created: ${{ steps.check.outputs.release == 'missing' }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -45,20 +48,30 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Resolved version: $VERSION" - - name: Check if tag already exists + - name: Check what already exists id: check env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.ver.outputs.version }} run: | + # The tag and the release are checked separately: a run that pushed + # the tag and then failed to create the release must still create + # it (and publish) when re-run. if git ls-remote --tags --exit-code origin "refs/tags/$VERSION" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - echo "Tag $VERSION already exists on origin — skipping." + echo "tag=exists" >> "$GITHUB_OUTPUT" + echo "Tag $VERSION already exists on origin." else - echo "exists=false" >> "$GITHUB_OUTPUT" + echo "tag=missing" >> "$GITHUB_OUTPUT" + fi + if gh release view "$VERSION" >/dev/null 2>&1; then + echo "release=exists" >> "$GITHUB_OUTPUT" + echo "Release $VERSION already exists — nothing to publish." + else + echo "release=missing" >> "$GITHUB_OUTPUT" fi - name: Create and push tag - if: steps.check.outputs.exists == 'false' + if: steps.check.outputs.tag == 'missing' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.ver.outputs.version }} @@ -74,7 +87,7 @@ jobs: "$VERSION" - name: Create GitHub release - if: steps.check.outputs.exists == 'false' + if: steps.check.outputs.release == 'missing' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.ver.outputs.version }} diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index c5c559cc..6f480f8a 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -13,9 +13,13 @@ on: workflow_call: inputs: version: - description: Version the archives must carry (the release tag) + description: Version the archives must carry (empty skips the check) type: string - required: true + default: '' + target: + description: Package index to publish to (pypi or testpypi) + type: string + default: pypi workflow_dispatch: inputs: target: @@ -61,8 +65,8 @@ jobs: if-no-files-found: error publish-pypi: - # A release event, a call from backend-release, or a manual run aimed at PyPI. - if: github.event_name == 'release' || inputs.version != '' || inputs.target == 'pypi' + # A release event, or a call or manual run aimed at PyPI (the call default). + if: github.event_name == 'release' || inputs.target == 'pypi' needs: build runs-on: ubuntu-latest environment: @@ -79,7 +83,7 @@ jobs: - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 publish-testpypi: - if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' + if: inputs.target == 'testpypi' needs: build runs-on: ubuntu-latest environment: @@ -96,3 +100,6 @@ jobs: - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ + # TestPyPI is a rehearsal target: a version that is already there + # must not fail the run. + skip-existing: true diff --git a/.github/workflows/tmp-release-chain-rehearsal.yml b/.github/workflows/tmp-release-chain-rehearsal.yml new file mode 100644 index 00000000..e453df04 --- /dev/null +++ b/.github/workflows/tmp-release-chain-rehearsal.yml @@ -0,0 +1,22 @@ +name: TEMPORARY release-chain rehearsal (remove before merge) + +# Exercises the exact mechanism backend-release uses to publish: a same-repo +# reusable-workflow call of pypi-publish.yml with trusted publishing, aimed at +# TestPyPI. PyPI verifies the job_workflow_ref claim against the registered +# filename, which for a reusable workflow is the called file, so this run +# either proves the call path or shows the publisher error before 0.20.0. + +on: + pull_request: + branches: [main] + +permissions: {} + +jobs: + pypi-testpypi: + uses: $/.github/workflows/pypi-publish.yml + with: + target: testpypi + permissions: + contents: read + id-token: write diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index a7ff1b31..ca565e08 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -22,4 +22,4 @@ jobs: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 From fa34e0dc010771828728b4f8a8ce6413a17deb72 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 8 Sep 2026 18:59:39 +0100 Subject: [PATCH 3/4] ci: remove the release-chain rehearsal workflow Run 34260211236 on the pull request called pypi-publish.yml through workflow_call aimed at TestPyPI: the trusted-publishing exchange succeeded, attestations were generated, and both archives were uploaded (already present, so skipped). Same-repository reusable workflows pass PyPI's job_workflow_ref check, which names the called file. --- .../workflows/tmp-release-chain-rehearsal.yml | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/tmp-release-chain-rehearsal.yml diff --git a/.github/workflows/tmp-release-chain-rehearsal.yml b/.github/workflows/tmp-release-chain-rehearsal.yml deleted file mode 100644 index e453df04..00000000 --- a/.github/workflows/tmp-release-chain-rehearsal.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: TEMPORARY release-chain rehearsal (remove before merge) - -# Exercises the exact mechanism backend-release uses to publish: a same-repo -# reusable-workflow call of pypi-publish.yml with trusted publishing, aimed at -# TestPyPI. PyPI verifies the job_workflow_ref claim against the registered -# filename, which for a reusable workflow is the called file, so this run -# either proves the call path or shows the publisher error before 0.20.0. - -on: - pull_request: - branches: [main] - -permissions: {} - -jobs: - pypi-testpypi: - uses: $/.github/workflows/pypi-publish.yml - with: - target: testpypi - permissions: - contents: read - id-token: write From 9e1cead170f3d44656da79808c514078b632978c Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 8 Sep 2026 19:12:28 +0100 Subject: [PATCH 4/4] ci: fail the PyPI publish workflow on an unknown target A caller passing a target other than pypi or testpypi would skip both publish jobs and upload nothing; the build job now fails with the value instead. Empty (the release event) stays valid. --- .github/workflows/pypi-publish.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 6f480f8a..12adcf9b 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -50,6 +50,15 @@ jobs: # A publishing workflow must not restore a cache another job could have poisoned. enable-cache: false + - name: The publish target is one this workflow knows + # A caller's typo would otherwise skip both publish jobs and upload nothing. + if: inputs.target != '' && inputs.target != 'pypi' && inputs.target != 'testpypi' + env: + TARGET: ${{ inputs.target }} + run: | + echo "::error::unknown target '$TARGET': expected pypi or testpypi" + exit 1 + - name: Build the sdist and wheel run: uv build