From 15c4d82bcf99f539c7abf2f5aa7563451d91c020 Mon Sep 17 00:00:00 2001
From: Alex
Date: Sat, 12 Sep 2026 23:29:33 +0100
Subject: [PATCH 1/4] ci: stop attesting when the release workflow publishes to
PyPI
0.20.0 failed to upload with "Certificate's Build Config URI ... does not
match expected Trusted Publisher". Authentication was fine: trusted publishing
matches the called workflow, pypi-publish.yml, which is what PyPI is
configured with. The PEP 740 attestation the publish action attaches by
default records the workflow that STARTED the run instead, backend-release.yml,
and PyPI verifies that against the same publisher entry. The two claims can
never agree while the publish runs as a called workflow, and no publisher
configuration satisfies both.
Attest only when this file is the entry point, which covers the manual
dispatch and a release published by hand. The release chain uploads unattested
rather than failing.
The earlier rehearsal went to TestPyPI and passed, so this only appeared on the
real index.
---
.github/workflows/pypi-publish.yml | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml
index 9037f758..2ad7c716 100644
--- a/.github/workflows/pypi-publish.yml
+++ b/.github/workflows/pypi-publish.yml
@@ -6,6 +6,13 @@ name: Publish to PyPI
# 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.
+#
+# Attestations are on only when this file is the workflow that STARTED the run.
+# A PEP 740 attestation records the entry workflow in its certificate, while
+# trusted publishing matches the called workflow, so when backend-release calls
+# this one the two disagree and PyPI rejects the upload with "Certificate's
+# Build Config URI ... does not match expected Trusted Publisher". No publisher
+# configuration satisfies both, so the release chain publishes unattested.
on:
release:
@@ -116,6 +123,9 @@ jobs:
path: dist/
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
+ with:
+ # Only when this workflow is the entry point: see the note at the top.
+ attestations: ${{ contains(github.workflow_ref, '/pypi-publish.yml@') }}
publish-testpypi:
if: inputs.target == 'testpypi'
@@ -134,6 +144,8 @@ jobs:
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
+ # Only when this workflow is the entry point: see the note at the top.
+ attestations: ${{ contains(github.workflow_ref, '/pypi-publish.yml@') }}
repository-url: https://test.pypi.org/legacy/
# TestPyPI is a rehearsal target: a version that is already there
# must not fail the run.
From 55e619b81218868e258d7698c5c42dafce077aea Mon Sep 17 00:00:00 2001
From: Alex
Date: Sat, 12 Sep 2026 23:29:33 +0100
Subject: [PATCH 2/4] ci: let the image workflows be run by hand
None of the three image workflows could be started manually, so when 0.20.0
tagged before the release chain knew about the frontend and sandbox images,
there was no way to build those images for the tag short of recreating the
release. Add a workflow_dispatch trigger taking the version to build.
A manual run also gets a `move_latest` switch, so rebuilding an older tag does
not drag `latest` backwards. The rule for the moving tag now lives in the
manifest step's shell rather than in a nested expression: `latest` follows the
build unless the release is a prerelease, the run asked it not to, or it is the
rolling `develop` build.
---
.github/workflows/ci.yml | 35 +++++++++++++++++++++++-----
.github/workflows/cife.yml | 36 ++++++++++++++++++++++-------
.github/workflows/sandbox-image.yml | 36 ++++++++++++++++++++++-------
3 files changed, 85 insertions(+), 22 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b2985af5..af3d1137 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,11 +14,25 @@ on:
description: Release tag to build and push (the images are tagged with it)
type: string
required: true
+ move_latest:
+ description: Also move the `latest` tag onto this build
+ type: boolean
+ default: true
secrets:
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Release tag to build and push (the images are tagged with it)
+ type: string
+ required: true
+ move_latest:
+ description: Also move the `latest` tag onto this build
+ type: boolean
+ default: true
permissions:
contents: read
@@ -142,15 +156,24 @@ jobs:
- name: Create and push multi-arch manifests
env:
TAG: ${{ env.RELEASE_TAG }}${{ matrix.variant }}
- # A stable release moves `latest`; a prerelease published by hand
- # moves only its own tag (the release trigger fires for those too).
- LATEST: ${{ (inputs.version || !github.event.release.prerelease) && format('latest{0}', matrix.variant) || '' }}
+ VARIANT: ${{ matrix.variant }}
+ # Raw facts; the shell below decides from them what `latest` does.
+ IS_PRERELEASE: ${{ github.event.release.prerelease || false }}
+ MOVE_LATEST: ${{ inputs.move_latest }}
run: |
set -e
- # $LATEST is deliberately unquoted: it is empty for a prerelease, and
- # an empty word would create a manifest named "$repo:".
+ # `latest` follows this build, except for a release marked prerelease
+ # (the release trigger fires for those too) or a manual run that asked
+ # it not to. MOVE_LATEST is empty for a release event, where only the
+ # prerelease flag decides.
+ moving="latest${VARIANT}"
+ if [ "$IS_PRERELEASE" = true ] || [ "$MOVE_LATEST" = false ]; then
+ moving=""
+ fi
+ # $moving is deliberately unquoted: an empty word would create a
+ # manifest named "$repo:".
for repo in "$DOCKERHUB_NAMESPACE/docsgpt" "ghcr.io/${{ github.repository_owner }}/docsgpt"; do
- for name in "$TAG" $LATEST; do
+ for name in "$TAG" $moving; do
docker manifest create "$repo:$name" \
--amend "$repo:$TAG-amd64" \
--amend "$repo:$TAG-arm64"
diff --git a/.github/workflows/cife.yml b/.github/workflows/cife.yml
index 6c8c301f..11ddaa9f 100644
--- a/.github/workflows/cife.yml
+++ b/.github/workflows/cife.yml
@@ -14,11 +14,25 @@ on:
description: Release tag to build and push (the images are tagged with it)
type: string
required: true
+ move_latest:
+ description: Also move the `latest` tag onto this build
+ type: boolean
+ default: true
secrets:
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Release tag to build and push (the images are tagged with it)
+ type: string
+ required: true
+ move_latest:
+ description: Also move the `latest` tag onto this build
+ type: boolean
+ default: true
permissions:
contents: read
@@ -26,10 +40,6 @@ permissions:
env:
# The tag being published: passed in by the caller, or the release's own.
RELEASE_TAG: ${{ inputs.version || github.event.release.tag_name }}
- # A stable release also moves `latest`. A prerelease published by hand moves
- # only its own tag: the release trigger fires for those too, and they must not
- # become `latest`.
- MOVING_TAG: ${{ (inputs.version || !github.event.release.prerelease) && 'latest' || '' }}
jobs:
build:
@@ -143,13 +153,23 @@ jobs:
- name: Create and push multi-arch manifests
env:
TAG: ${{ env.RELEASE_TAG }}
- MOVING: ${{ env.MOVING_TAG }}
+ # Raw facts; the shell below decides from them what `latest` does.
+ IS_PRERELEASE: ${{ github.event.release.prerelease || false }}
+ MOVE_LATEST: ${{ inputs.move_latest }}
run: |
set -e
- # $MOVING is deliberately unquoted: it is empty for a prerelease, and
- # an empty word would create a manifest named "$repo:".
+ # `latest` follows this build, except for a release marked prerelease
+ # (the release trigger fires for those too) or a manual run that asked
+ # it not to. MOVE_LATEST is empty for a release event, where only the
+ # prerelease flag decides.
+ moving=latest
+ if [ "$IS_PRERELEASE" = true ] || [ "$MOVE_LATEST" = false ]; then
+ moving=""
+ fi
+ # $moving is deliberately unquoted: an empty word would create a
+ # manifest named "$repo:".
for repo in "$DOCKERHUB_NAMESPACE/docsgpt-fe" "ghcr.io/${{ github.repository_owner }}/docsgpt-fe"; do
- for name in "$TAG" $MOVING; do
+ for name in "$TAG" $moving; do
docker manifest create "$repo:$name" \
--amend "$repo:$TAG-amd64" \
--amend "$repo:$TAG-arm64"
diff --git a/.github/workflows/sandbox-image.yml b/.github/workflows/sandbox-image.yml
index c26e5b8d..96303100 100644
--- a/.github/workflows/sandbox-image.yml
+++ b/.github/workflows/sandbox-image.yml
@@ -21,11 +21,25 @@ on:
description: Release tag to build and push (the images are tagged with it)
type: string
required: true
+ move_latest:
+ description: Also move the `latest` tag onto this build
+ type: boolean
+ default: true
secrets:
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Release tag to build and push (the images are tagged with it)
+ type: string
+ required: true
+ move_latest:
+ description: Also move the `latest` tag onto this build
+ type: boolean
+ default: true
push:
branches: [main]
paths:
@@ -38,10 +52,6 @@ permissions:
env:
# The version being published, or `develop` for a push to main.
RELEASE_TAG: ${{ inputs.version || github.event.release.tag_name || 'develop' }}
- # A stable release also moves `latest`. A push to main moves nothing but
- # `develop`, and a prerelease published by hand moves only its own tag: the
- # release trigger fires for those too, and they must not become `latest`.
- MOVING_TAG: ${{ (inputs.version || (github.event.release.tag_name && !github.event.release.prerelease)) && 'latest' || '' }}
jobs:
build:
@@ -155,13 +165,23 @@ jobs:
- name: Create and push multi-arch manifests
env:
TAG: ${{ env.RELEASE_TAG }}
- MOVING: ${{ env.MOVING_TAG }}
+ # Raw facts; the shell below decides from them what `latest` does.
+ IS_PRERELEASE: ${{ github.event.release.prerelease || false }}
+ MOVE_LATEST: ${{ inputs.move_latest }}
run: |
set -e
- # $MOVING is deliberately unquoted: it is empty for a push to main,
- # and an empty word would create a manifest named "$repo:".
+ # `latest` follows this build, except for the rolling `develop` build,
+ # a release marked prerelease (the release trigger fires for those
+ # too), or a manual run that asked it not to. MOVE_LATEST is empty for
+ # a release event and for a push, where the other two rules decide.
+ moving=latest
+ if [ "$TAG" = develop ] || [ "$IS_PRERELEASE" = true ] || [ "$MOVE_LATEST" = false ]; then
+ moving=""
+ fi
+ # $moving is deliberately unquoted: an empty word would create a
+ # manifest named "$repo:".
for repo in "$DOCKERHUB_NAMESPACE/docsgpt-sandbox" "ghcr.io/${{ github.repository_owner }}/docsgpt-sandbox"; do
- for name in "$TAG" $MOVING; do
+ for name in "$TAG" $moving; do
docker manifest create "$repo:$name" \
--amend "$repo:$TAG-amd64" \
--amend "$repo:$TAG-arm64"
From 99b8508bdc06248e51967904934f30d47d83eb09 Mon Sep 17 00:00:00 2001
From: Alex
Date: Sat, 12 Sep 2026 23:37:14 +0100
Subject: [PATCH 3/4] ci: build the source the version names
A manual run takes the version as an input but the checkout had no ref, so it
built whatever the run was dispatched from. Dispatching off main to rebuild an
older release would have published current main under that release's tags, and
moved `latest` to it.
Check out the version instead, falling back to the run's own ref when there is
no version, which is the push that publishes `develop`. The release and call
paths already checked out the right commit; this makes them explicit and fails
loudly on a version with no tag rather than mislabelling an image. The compose
file attached to the release now comes from the tag too.
---
.github/workflows/ci.yml | 10 ++++++++++
.github/workflows/cife.yml | 5 +++++
.github/workflows/sandbox-image.yml | 5 +++++
3 files changed, 20 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index af3d1137..d115eec6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,6 +65,11 @@ jobs:
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
+ # Build the source the tag names, not whatever the run was started
+ # from: a manual run dispatched off main would otherwise publish the
+ # current branch under an older version's tags. Empty for a push,
+ # where the checked-out commit is the one being tagged `develop`.
+ ref: ${{ inputs.version || github.ref }}
persist-credentials: false
- name: Set up Docker Buildx
@@ -190,6 +195,11 @@ jobs:
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
+ # Build the source the tag names, not whatever the run was started
+ # from: a manual run dispatched off main would otherwise publish the
+ # current branch under an older version's tags. Empty for a push,
+ # where the checked-out commit is the one being tagged `develop`.
+ ref: ${{ inputs.version || github.ref }}
persist-credentials: false
- name: Attach the standalone compose file to the release
diff --git a/.github/workflows/cife.yml b/.github/workflows/cife.yml
index 11ddaa9f..cf9d1241 100644
--- a/.github/workflows/cife.yml
+++ b/.github/workflows/cife.yml
@@ -68,6 +68,11 @@ jobs:
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
+ # Build the source the tag names, not whatever the run was started
+ # from: a manual run dispatched off main would otherwise publish the
+ # current branch under an older version's tags. Empty for a push,
+ # where the checked-out commit is the one being tagged `develop`.
+ ref: ${{ inputs.version || github.ref }}
persist-credentials: false
- name: Set up Docker Buildx
diff --git a/.github/workflows/sandbox-image.yml b/.github/workflows/sandbox-image.yml
index 96303100..61e28542 100644
--- a/.github/workflows/sandbox-image.yml
+++ b/.github/workflows/sandbox-image.yml
@@ -80,6 +80,11 @@ jobs:
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
+ # Build the source the tag names, not whatever the run was started
+ # from: a manual run dispatched off main would otherwise publish the
+ # current branch under an older version's tags. Empty for a push,
+ # where the checked-out commit is the one being tagged `develop`.
+ ref: ${{ inputs.version || github.ref }}
persist-credentials: false
- name: Set up Docker Buildx
From 12016baaf2bb1c3ce0204159ce877c896d8369b7 Mon Sep 17 00:00:00 2001
From: Alex
Date: Sat, 12 Sep 2026 23:41:43 +0100
Subject: [PATCH 4/4] ci: resolve the manual version under refs/tags
The version input reached checkout as a bare ref, so a manual run given a
branch name would have built that branch and published it as an image tag,
`latest` included. It also meant a version that happens to match a branch name
would silently resolve to the branch rather than the tag.
Resolve it under refs/tags/ instead. Plain versions from backend-release keep
working, and anything that is not a tag fails at checkout.
---
.github/workflows/ci.yml | 16 ++++++++++------
.github/workflows/cife.yml | 8 +++++---
.github/workflows/sandbox-image.yml | 8 +++++---
3 files changed, 20 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d115eec6..5cd9c85e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -67,9 +67,11 @@ jobs:
with:
# Build the source the tag names, not whatever the run was started
# from: a manual run dispatched off main would otherwise publish the
- # current branch under an older version's tags. Empty for a push,
- # where the checked-out commit is the one being tagged `develop`.
- ref: ${{ inputs.version || github.ref }}
+ # current branch under an older version's tags. Resolved under
+ # refs/tags/ so a version that is also a branch name cannot win, and a
+ # version with no tag fails here instead of mislabelling an image.
+ # Falls back to the run's own ref for the push that tags `develop`.
+ ref: ${{ inputs.version && format('refs/tags/{0}', inputs.version) || github.ref }}
persist-credentials: false
- name: Set up Docker Buildx
@@ -197,9 +199,11 @@ jobs:
with:
# Build the source the tag names, not whatever the run was started
# from: a manual run dispatched off main would otherwise publish the
- # current branch under an older version's tags. Empty for a push,
- # where the checked-out commit is the one being tagged `develop`.
- ref: ${{ inputs.version || github.ref }}
+ # current branch under an older version's tags. Resolved under
+ # refs/tags/ so a version that is also a branch name cannot win, and a
+ # version with no tag fails here instead of mislabelling an image.
+ # Falls back to the run's own ref for the push that tags `develop`.
+ ref: ${{ inputs.version && format('refs/tags/{0}', inputs.version) || github.ref }}
persist-credentials: false
- name: Attach the standalone compose file to the release
diff --git a/.github/workflows/cife.yml b/.github/workflows/cife.yml
index cf9d1241..5f801b24 100644
--- a/.github/workflows/cife.yml
+++ b/.github/workflows/cife.yml
@@ -70,9 +70,11 @@ jobs:
with:
# Build the source the tag names, not whatever the run was started
# from: a manual run dispatched off main would otherwise publish the
- # current branch under an older version's tags. Empty for a push,
- # where the checked-out commit is the one being tagged `develop`.
- ref: ${{ inputs.version || github.ref }}
+ # current branch under an older version's tags. Resolved under
+ # refs/tags/ so a version that is also a branch name cannot win, and a
+ # version with no tag fails here instead of mislabelling an image.
+ # Falls back to the run's own ref for the push that tags `develop`.
+ ref: ${{ inputs.version && format('refs/tags/{0}', inputs.version) || github.ref }}
persist-credentials: false
- name: Set up Docker Buildx
diff --git a/.github/workflows/sandbox-image.yml b/.github/workflows/sandbox-image.yml
index 61e28542..c020187a 100644
--- a/.github/workflows/sandbox-image.yml
+++ b/.github/workflows/sandbox-image.yml
@@ -82,9 +82,11 @@ jobs:
with:
# Build the source the tag names, not whatever the run was started
# from: a manual run dispatched off main would otherwise publish the
- # current branch under an older version's tags. Empty for a push,
- # where the checked-out commit is the one being tagged `develop`.
- ref: ${{ inputs.version || github.ref }}
+ # current branch under an older version's tags. Resolved under
+ # refs/tags/ so a version that is also a branch name cannot win, and a
+ # version with no tag fails here instead of mislabelling an image.
+ # Falls back to the run's own ref for the push that tags `develop`.
+ ref: ${{ inputs.version && format('refs/tags/{0}', inputs.version) || github.ref }}
persist-credentials: false
- name: Set up Docker Buildx