Run this after pushing a vX.Y.Z tag to main. It exists because the
v2.0.1 release produced a green workflow run that built nothing: the three
build jobs were guarded on github.event.base_ref, which is empty for a tag
pushed to a branch-protected main, so every job reported skipped and the run
was still green. Nothing in our process caught it.
The rule this checklist enforces: a green run is not proof. A digest in the registry is proof.
Set the version once, and define the digest helper every step below uses:
export VER=v2.1.0 # the tag you just pushed
# Resolve a tag's manifest digest. Returns non-zero and prints nothing when the
# tag does not exist — do NOT pipe inspect straight into sha256sum: on a failed
# lookup it hashes empty input and returns
# sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,
# a real-looking digest. Steps 2-4 would then report a missing image as present,
# and two missing tags would compare equal and pass.
dg() {
local raw
raw=$(docker buildx imagetools inspect --raw "$1" 2>/dev/null) || return 1
[ -n "$raw" ] || return 1
printf '%s' "$raw" | sha256sum | awk '{print "sha256:"$1}'
}Check the helper itself before trusting it — this must print MISSING:
dg ghcr.io/linagora/openrag:v0.0.0-does-not-exist || echo MISSINGWhy
--raw | sha256sumand not--format '{{.Manifest.Digest}}': buildx v0.30.1 silently ignores that--formattemplate and prints its default human output instead. Comparing those strings makes every image look like it drifted. A manifest digest is the sha256 of the raw manifest bytes, so this form is both correct and self-verifying. Validated againstv2.0.1, where it reproduces the published digest exactly.
| Image | ghcr.io | Docker Hub |
|---|---|---|
| API | ghcr.io/linagora/openrag |
linagoraai/openrag |
| Ray | ghcr.io/linagora/openrag-ray |
— (ghcr only, by design) |
| Admin UI | ghcr.io/linagora/openrag-admin-ui |
linagoraai/openrag-admin-ui |
Each gets two tags: $VER and latest.
A skipped job is the exact v2.0.1 failure mode, and gh run list shows such a
run as success. Assert on per-job conclusions, never on the run's.
gh run list --workflow build.yml --limit 5 \
--json databaseId,headBranch,event,status,conclusion,createdAt \
--jq '.[] | "\(.databaseId) \(.event) \(.headBranch) \(.status)/\(.conclusion) \(.createdAt)"'Take the run id for the tag push, then:
RUN_ID=<id-from-above>
gh run view "$RUN_ID" --json jobs \
--jq '.jobs[] | "\(.conclusion)\t\(.name)"'PASS requires all four jobs success:
verify-tag, build-and-push-image, build-and-push-image-ray,
build-and-push-image-admin-ui.
FAIL on any skipped — that is the v2.0.1 bug recurring. A hard gate:
bad=$(gh run view "$RUN_ID" --json jobs \
--jq '[.jobs[] | select(.conclusion != "success")] | length') || exit 1
if [ "$bad" -ne 0 ]; then
echo "FAIL: $bad job(s) did not conclude success — do not continue" >&2
exit 1
fi
echo "OK: every job concluded success"Written as a gate, not a print: a command that only reports the count still exits 0 when the count is non-zero, so a release could continue straight past a skipped build job — the very thing this step exists to stop.
If verify-tag failed loudly, the tag is not an ancestor of origin/main —
fix the tag placement, do not rerun.
imagetools inspect reads the registry directly (anonymous, no pull, no
docker login). If the tag was never pushed, this errors — which is the point.
for img in ghcr.io/linagora/openrag ghcr.io/linagora/openrag-ray \
ghcr.io/linagora/openrag-admin-ui \
linagoraai/openrag linagoraai/openrag-admin-ui; do
d=$(dg "$img:$VER"); printf '%-42s %s\n' "$img:$VER" "${d:-MISSING}"
donePASS: five sha256:… digests, zero MISSING.
latest is published unconditionally by build.yml, so a mismatch here means
latest is stale and every docker pull without a tag gets the wrong build.
for img in ghcr.io/linagora/openrag ghcr.io/linagora/openrag-ray \
ghcr.io/linagora/openrag-admin-ui \
linagoraai/openrag linagoraai/openrag-admin-ui; do
v=$(dg "$img:$VER"); l=$(dg "$img:latest")
if [ -n "$v" ] && [ "$v" = "$l" ]; then echo "OK $img"
else echo "DRIFT $img"; echo " $VER = ${v:-none}"; echo " latest= ${l:-none}"; fi
donePASS: all OK.
Both registries are pushed from one docker/build-push-action step, so the
digests must be identical. A difference means one push failed and was
back-filled from a different build.
for pair in "ghcr.io/linagora/openrag linagoraai/openrag" \
"ghcr.io/linagora/openrag-admin-ui linagoraai/openrag-admin-ui"; do
set -- $pair; a=$(dg "$1:$VER"); b=$(dg "$2:$VER")
# The -n guards matter: without them two MISSING tags are both empty, compare
# equal, and print OK.
if [ -n "$a" ] && [ -n "$b" ] && [ "$a" = "$b" ]; then
echo "OK $1 == $2"
else
echo "MISMATCH $1=${a:-MISSING} $2=${b:-MISSING}"
fi
donePASS: both OK.
Steps 2–4 read metadata. This proves the bytes are actually fetchable.
RepoDigests entries are repo@sha256:…, while dg returns a bare
sha256:… — strip the repository prefix before comparing, or the two can never
match literally.
docker pull "linagoraai/openrag:$VER"
pulled=$(docker image inspect "linagoraai/openrag:$VER" \
--format '{{index .RepoDigests 0}}' | cut -d@ -f2)
registry=$(dg "linagoraai/openrag:$VER") || { echo "FAIL: tag not in registry" >&2; exit 1; }
[ "$pulled" = "$registry" ] \
&& echo "OK: pulled digest matches the registry ($pulled)" \
|| { echo "FAIL: pulled=$pulled registry=$registry" >&2; exit 1; }PASS: OK.
The strongest check, and the one that catches a build from the wrong commit:
the version baked into the image must equal the tag. app.version comes from
importlib.metadata, i.e. from pyproject.toml at build time.
docker run --rm --entrypoint grep "linagoraai/openrag:$VER" -m1 '^version' /app/pyproject.toml
# expect: version = "2.1.0" (tag minus the leading v)And on a running stack (the endpoint is unauthenticated):
curl -fsS http://<host>:8080/version
# {"version":"2.1.0"}PASS: both report the release version. A mismatch means the tag sat on a commit that predates the version bump — the images are mislabelled and must be rebuilt from a corrected tag.
git fetch origin main --tags
git tag --contains "$VER" >/dev/null 2>&1
git merge-base --is-ancestor "$VER" origin/main && echo "OK: $VER is on main" || echo "FAIL: not on main"
git log -1 --format='%H %s' "$VER"PASS: OK, and the commit is the release-branch merge commit.
The chart and compose pins are part of the release surface; shipping them pointing at the previous version is a silent regression for anyone deploying from the tag.
Compare against $VER exactly. A filter that merely matches something
version-shaped is satisfied by a stale pin left at the previous release — which
is the regression this step is meant to catch.
fail=0
# appVersion must be $VER without its leading v
want_app=${VER#v}
got_app=$(git show "$VER:infra/charts/openrag-stack/Chart.yaml" \
| awk -F'"' '/^appVersion:/{print $2}')
[ "$got_app" = "$want_app" ] \
&& echo "OK appVersion=$got_app" \
|| { echo "FAIL appVersion=$got_app want=$want_app"; fail=1; }
# Each OpenRag image in the chart, checked by repository. Do NOT just count
# version-shaped tags: values.yaml also pins third-party images (vllm, milvus,
# infinity) whose versions have nothing to do with this release.
# An empty result means the values layout changed and this check no longer finds
# the pin — that is a FAIL, not a pass.
for repo in 'linagora/openrag-ray' 'linagoraai/openrag-admin-ui' 'linagoraai/openrag'; do
got=$(git show "$VER:infra/charts/openrag-stack/values.yaml" \
| grep -A4 "repository: \"$repo\"$" \
| awk -F'"' '/^[[:space:]]*tag:/{print $2; exit}')
[ "$got" = "$VER" ] \
&& echo "OK $repo -> $got" \
|| { echo "FAIL $repo -> ${got:-NOT FOUND} (want $VER)"; fail=1; }
done
# compose pins (2 expected: openrag, openrag-admin-ui)
cpins=$(git show "$VER:infra/compose/docker-compose.yaml" \
| grep -cE "image: linagoraai/openrag(-admin-ui)?:$VER$")
[ "$cpins" -eq 2 ] \
&& echo "OK 2 compose pins at $VER" \
|| { echo "FAIL $cpins compose pins at $VER (expected 2)"; fail=1; }
[ "$fail" -eq 0 ] && echo "step 8 PASS" || { echo "step 8 FAIL" >&2; exit 1; }Chart version is bumped independently of appVersion (it tracks chart
changes, not the app release), so check it by eye against the previous release
rather than against $VER:
git show "$VER:infra/charts/openrag-stack/Chart.yaml" | grep -E '^version:'PASS: step 8 PASS, and chart version moved.
Reconstructed from the run log and the tag, 2026-07-30. Three build.yml runs
fired for the same tag name:
| run | time (UTC) | commit | result |
|---|---|---|---|
| 30034799802 | 18:41 | c08c5e9f (release/2.0.1 → main merge) |
3 jobs skipped, run green |
| 30035356446 | 18:49 | c08c5e9f — tag re-pushed unchanged |
3 jobs skipped again |
| 30039985149 | 19:55 | 6a18a534 (CI hotfix #764 merge) |
verify-tag + 3 builds success |
The shipped v2.0.1 tag therefore sits on the CI-hotfix merge commit, not
on the release-branch merge. The tagged tree still carries the version bump
(c08c5e9f is its ancestor), so the images are correct — confirmed above.
Rules this produces:
- A skipped job is a failure. The first two runs reported
successat the run level. Only per-job conclusions revealed the truth. That is step 1. - Never re-push a tag to "retry". Run 2 proves it is deterministic: the workflow that executes is the one at the tagged commit, so re-pushing the same tag re-runs the same broken file. Move the tag to a fixed commit, or fix nothing and diagnose.
- Verify images before announcing.
build.ymldoes not create GitHub Releases. For v2.0.1 the Release was published at 20:01, six minutes after the images finally landed at 19:55. Keep that order: tag → images verified → Release notes. - Dry-run the verification itself against the previous release. Doing that
for v2.0.1 is what exposed the broken
--formatflag above. A checklist that silently reports nonsense is worse than none.
main's current build.yml is not the file that successfully built
v2.0.1. PR #767 hardened it afterwards (exact-tag regex, tag name/SHA passed as
env instead of ${{ }} interpolation, persist-credentials: false). The next
GA tag is the first time that hardened guard ever runs.
Pre-flighted locally on 2026-07-30:
- Regex
^v[0-9]+\.[0-9]+\.[0-9]+$—v2.1.0accepted;v2.1,2.1.0,v1.2.3-rc1,v1.0-hardeningrejected loudly;v2.1.0-rc.Nfiltered out by the job-levelifand left tobuild_rc.yml. Correct on all six. persist-credentials: false+git fetch --no-tags origin main— verified an anonymous fetch of this repo succeeds, so the guard can still reachmain. This holds only while the repo is public; if it is ever made private, that fetch breaks and every GA build blocks.
The residual risk is acceptable because the hardened guard's failure mode is
exit 1 — loud and blocking — not v2.0.1's silent skip. But treat step 1 as
mandatory, not a formality.
Record the outcome on the GitHub Release or the milestone. If any step fails, the release is not done — publishing images is the deliverable, and the tag alone delivers nothing.