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