From fb0d0dc707a1aa10d0b58ddf3fb00248e4da040b Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Thu, 5 Nov 2026 10:30:22 +0000
Subject: [PATCH 1/4] fix(ci): align dev checks and Docker release versions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Handle branch-created push payloads without fetching deleted integration refs.
Use dev for integration gates and keep ordinary PR/push path filtering.
Bind Docker tags, labels and binary versions to the resolved source commit.
Publish exact versions before serialized, current-release-only alias promotion;
keep manual and prerelease builds from promoting stable aliases. Validate the
contracts with offline fixture tests and preserve legacy publishing safeguards.
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Codex
---
.agents/skills/tenderdash-release/SKILL.md | 8 +-
.github/workflows/build.yml | 11 +-
.github/workflows/check-generated.yml | 9 +-
.github/workflows/docker.yml | 121 +++++++++----
.github/workflows/docs-deployment.yml | 6 +-
.github/workflows/e2e.yml | 8 +-
.github/workflows/govulncheck.yml | 3 -
.github/workflows/lint.yml | 15 +-
.github/workflows/markdown-links.yml | 8 +-
.github/workflows/markdown-linter.yml | 5 +-
.github/workflows/proto-lint.yml | 3 -
.github/workflows/release.yml | 13 +-
.github/workflows/tests.yml | 9 +-
.github/workflows/vet.yml | 3 -
DOCKER/Dockerfile | 3 +-
Makefile | 5 +-
RELEASES.md | 17 +-
scripts/docker-metadata.py | 118 ++++++++++++
scripts/test-local-gates.sh | 10 +-
scripts/tests/test_docker_metadata.py | 201 +++++++++++++++++++++
20 files changed, 485 insertions(+), 91 deletions(-)
create mode 100644 scripts/docker-metadata.py
create mode 100644 scripts/tests/test_docker_metadata.py
diff --git a/.agents/skills/tenderdash-release/SKILL.md b/.agents/skills/tenderdash-release/SKILL.md
index 767958ff8..faadfd06c 100644
--- a/.agents/skills/tenderdash-release/SKILL.md
+++ b/.agents/skills/tenderdash-release/SKILL.md
@@ -151,9 +151,11 @@ For unsigned/dev releases, state explicitly which binary artifacts are omitted;
Docker publication remains separate.
Publishing the draft triggers `.github/workflows/docker.yml`. Check that the
-Platform rollout and release request authorize this publication. Stable releases
-currently promote Docker `latest` and the major alias: an older-line security
-release must not run that promotion without a separately approved safe path.
+Platform rollout and release request authorize this publication. Verify exact source
+SHA, binary version and image labels. Stable alias promotion is serialized and
+requires the current latest stable release with no higher stable tag. Prereleases,
+manual runs and older-line releases must not replace stable aliases. Historical
+source without explicit Docker VERSION support needs a separately reviewed path.
After publication, verify the remote tag SHA, release assets, Docker image tags
and digests. If asynchronous publishing fails, report the partial state and resume
only the missing step; do not move tags or repeat a completed release blindly.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 91b273a7a..ece6951e8 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -12,9 +12,11 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
+
+# Branch creation/rename events can refer to the deleted old branch.
+env:
+ GIT_DIFF: ${{ github.event_name == 'push' && github.event.created && 'all' || '' }}
+
jobs:
build:
name: Build
@@ -30,6 +32,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: technote-space/get-diff-action@v6
+ if: github.event_name != 'push' || !github.event.created
with:
PATTERNS: |
.github/workflows/build.yml
@@ -68,6 +71,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: technote-space/get-diff-action@v6
+ if: github.event_name != 'push' || !github.event.created
with:
PATTERNS: |
.github/workflows/build.yml
@@ -100,6 +104,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: technote-space/get-diff-action@v6
+ if: github.event_name != 'push' || !github.event.created
with:
PATTERNS: |
.github/workflows/build.yml
diff --git a/.github/workflows/check-generated.yml b/.github/workflows/check-generated.yml
index 4cc91762a..d06e2c3a8 100644
--- a/.github/workflows/check-generated.yml
+++ b/.github/workflows/check-generated.yml
@@ -14,13 +14,14 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
permissions:
contents: read
+# Branch creation/rename events can refer to the deleted old branch.
+env:
+ GIT_DIFF: ${{ github.event_name == 'push' && github.event.created && 'all' || '' }}
+
jobs:
check-mocks:
runs-on: ubuntu-26.04
@@ -28,6 +29,7 @@ jobs:
- uses: actions/checkout@v7
- uses: technote-space/get-diff-action@v6
+ if: github.event_name != 'push' || !github.event.created
with:
# FILES matches bare file names only, so the workflow path goes in
# PATTERNS (minimatch globs) to re-run the check when it changes.
@@ -70,6 +72,7 @@ jobs:
- uses: actions/checkout@v7
- uses: technote-space/get-diff-action@v6
+ if: github.event_name != 'push' || !github.event.created
with:
PATTERNS: |
**/*.proto
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 1a1c97ad9..dcfb04000 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -7,7 +7,7 @@ on:
inputs:
tag:
type: string
- description: "Docker tag"
+ description: "Existing release tag to build (empty uses the selected branch/tag)"
required: false
platforms:
@@ -29,10 +29,30 @@ permissions:
jobs:
build:
runs-on: ubuntu-26.04
+ outputs:
+ source_sha: ${{ steps.source.outputs.source_sha }}
+ digest: ${{ steps.docker_build.outputs.digest }}
env:
PLATFORMS: ${{ github.event_name == 'release' && 'linux/amd64,linux/arm64' || github.event.inputs.platforms }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - name: Validate Docker publication contract
+ run: python3 -B -m unittest discover -s scripts/tests -p test_docker_metadata.py
+
+ - name: Resolve exact build source and version
+ id: source
+ env:
+ LATEST_RELEASE_TAG: pending
+ run: python3 scripts/docker-metadata.py
+
+ - name: Checkout resolved source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ steps.source.outputs.source_sha }}
+ fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
@@ -49,45 +69,16 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Set suffix to Docker tags
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- id: suffix
- if: ${{ github.event.release != null }}
- with:
- result-encoding: string
- script: "return (context.payload.release.tag_name.includes('-dev') ? '-dev' : '');"
-
- - name: Determine TENDERMINT_BUILD_OPTIONS
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- id: TENDERMINT_BUILD_OPTIONS
- env:
- REF_TYPE: ${{ github.ref_type }}
- REF_NAME: ${{ github.ref_name }}
- with:
- result-encoding: string
- script: |
- if (process.env.REF_TYPE === 'tag' && !process.env.REF_NAME.includes('-dev')) {
- return 'tenderdash,stable'
- }
- return 'tenderdash,dev'
-
- name: Set Docker tags and labels
id: docker_meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: dashpay/tenderdash
- tags: |
- type=semver,pattern={{version}},value=${{ github.event.inputs.tag }}
- type=raw,priority=650,value=${{ github.event.inputs.tag }},enable=${{ github.event.inputs.tag != '' }}
- type=ref,event=branch
- type=ref,event=pr
- type=match,pattern=v(\d+),group=1
- type=match,pattern=v(\d+.\d+),group=1
- type=match,pattern=v(\d+.\d+.\d+),group=1
- type=match,pattern=v(.*),group=1,suffix=,enable=${{ contains(github.event.release.tag_name, '-dev') }}
- flavor: |
- latest=${{ github.event.release != null && !contains(github.event.release.tag_name, '-dev') }}
- suffix=${{ steps.suffix.outputs.result }}
+ tags: ${{ steps.source.outputs.tags }}
+ labels: |
+ org.opencontainers.image.version=${{ steps.source.outputs.version }}
+ org.opencontainers.image.revision=${{ steps.source.outputs.source_sha }}
+ flavor: latest=false
# We build some dependencies and libraries separately. Layers are cached
# on Docker Hub, as Github caches are not available for other branches.
@@ -105,7 +96,8 @@ jobs:
cache-to: |
type=registry,ref=dashpay/tenderdash:buildcache-deps,mode=max
build-args: |
- TENDERMINT_BUILD_OPTIONS="${{ steps.TENDERMINT_BUILD_OPTIONS.outputs.result }}"
+ TENDERMINT_BUILD_OPTIONS=${{ steps.source.outputs.build_options }}
+ VERSION=${{ steps.source.outputs.version }}
- name: Publish to Docker Hub
id: docker_build
@@ -122,6 +114,61 @@ jobs:
type=registry,ref=dashpay/tenderdash:buildcache-deps
cache-to: type=gha,mode=max
build-args: |
- TENDERMINT_BUILD_OPTIONS="${{ steps.TENDERMINT_BUILD_OPTIONS.outputs.result }}"
+ TENDERMINT_BUILD_OPTIONS=${{ steps.source.outputs.build_options }}
+ VERSION=${{ steps.source.outputs.version }}
- name: Show Docker image digest
run: echo ${{ steps.docker_build.outputs.digest }}
+
+ # Serialize alias checks/writes without replacing pending release promotions.
+ promote-aliases:
+ if: github.event_name == 'release'
+ needs: build
+ runs-on: ubuntu-26.04
+ concurrency:
+ group: docker-stable-aliases
+ cancel-in-progress: false
+ queue: max
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.build.outputs.source_sha }}
+ fetch-depth: 0
+ - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
+ - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Read current latest release after building
+ if: github.event_name == 'release'
+ id: latest
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ with:
+ result-encoding: string
+ script: |
+ try {
+ const {data} = await github.rest.repos.getLatestRelease(context.repo);
+ return data.tag_name;
+ } catch (error) {
+ if (error.status === 404) return '';
+ throw error;
+ }
+
+ - name: Resolve stable aliases
+ if: github.event_name == 'release'
+ id: aliases
+ env:
+ LATEST_RELEASE_TAG: ${{ steps.latest.outputs.result }}
+ run: python3 scripts/docker-metadata.py
+
+ - name: Promote current stable aliases
+ if: github.event_name == 'release' && steps.aliases.outputs.aliases != ''
+ env:
+ ALIASES: ${{ steps.aliases.outputs.aliases }}
+ SOURCE_IMAGE: dashpay/tenderdash@${{ needs.build.outputs.digest }}
+ run: |
+ set --
+ while IFS= read -r alias; do
+ set -- "$@" --tag "dashpay/tenderdash:$alias"
+ done <<< "$ALIASES"
+ docker buildx imagetools create "$@" "$SOURCE_IMAGE"
diff --git a/.github/workflows/docs-deployment.yml b/.github/workflows/docs-deployment.yml
index cfe435168..710b23077 100644
--- a/.github/workflows/docs-deployment.yml
+++ b/.github/workflows/docs-deployment.yml
@@ -36,7 +36,7 @@ concurrency:
jobs:
build:
- if: github.event_name != 'push' || github.ref_name == github.event.repository.default_branch
+ if: github.event_name != 'push' || github.ref == 'refs/heads/dev'
name: Build mdBook and RPC documentation
runs-on: ubuntu-26.04
steps:
@@ -62,14 +62,14 @@ jobs:
retention-days: 14
if-no-files-found: error
- name: Upload Pages artifact
- if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == github.event.repository.default_branch)
+ if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/dev')
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: book/book
deploy:
name: Deploy to GitHub Pages
- if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == github.event.repository.default_branch)
+ if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/dev')
needs: build
runs-on: ubuntu-slim
timeout-minutes: 15
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 78df98cd2..7773bf48b 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -9,9 +9,10 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
+
+# Branch creation/rename events can refer to the deleted old branch.
+env:
+ GIT_DIFF: ${{ github.event_name == 'push' && github.event.created && 'all' || '' }}
jobs:
e2e-test:
@@ -33,6 +34,7 @@ jobs:
with:
submodules: true
- uses: technote-space/get-diff-action@v6
+ if: github.event_name != 'push' || !github.event.created
with:
PATTERNS: |
.github/workflows/e2e.yml
diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml
index 5cbacfa5b..6e2ee560c 100644
--- a/.github/workflows/govulncheck.yml
+++ b/.github/workflows/govulncheck.yml
@@ -8,9 +8,6 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
permissions:
contents: read
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 0c22691ab..ec5f69551 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -13,9 +13,10 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
+
+# Branch creation/rename events can refer to the deleted old branch.
+env:
+ GIT_DIFF: ${{ github.event_name == 'push' && github.event.created && 'all' || '' }}
jobs:
golangci:
@@ -36,6 +37,7 @@ jobs:
fetch-depth: 0
- uses: technote-space/get-diff-action@v6
+ if: github.event_name != 'push' || !github.event.created
with:
PATTERNS: |
.github/workflows/lint.yml
@@ -75,9 +77,10 @@ jobs:
# which the full-depth checkout above guarantees is present.
args: >-
--timeout 10m
- ${{ github.event_name == 'pull_request' && format('--new-from-rev=origin/{0}', github.base_ref) || '' }}
+ ${{ github.event_name == 'pull_request' && format('--new-from-rev=origin/{0}', github.base_ref) || (github.event.created && '--new-from-rev=HEAD') || '' }}
github-token: ${{ secrets.github_token }}
# The action refuses --new-* args while only-new-issues is set, so the
# two are mutually exclusive: pull requests filter via --new-from-rev
- # above, and pushes keep the action's own filtering.
- only-new-issues: ${{ github.event_name != 'pull_request' }}
+ # above. A new branch has no before commit, so its baseline is HEAD;
+ # ordinary pushes keep the action's own filtering.
+ only-new-issues: ${{ github.event_name != 'pull_request' && !github.event.created }}
diff --git a/.github/workflows/markdown-links.yml b/.github/workflows/markdown-links.yml
index 09b3f1304..6dead8629 100644
--- a/.github/workflows/markdown-links.yml
+++ b/.github/workflows/markdown-links.yml
@@ -10,9 +10,10 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
+
+# Branch creation/rename events can refer to the deleted old branch.
+env:
+ GIT_DIFF: ${{ github.event_name == 'push' && github.event.created && 'all' || '' }}
jobs:
markdown-link-check:
@@ -20,6 +21,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: technote-space/get-diff-action@v6
+ if: github.event_name != 'push' || !github.event.created
with:
PATTERNS: |
**/**.md
diff --git a/.github/workflows/markdown-linter.yml b/.github/workflows/markdown-linter.yml
index a6d5e687a..e7d6c9fbc 100644
--- a/.github/workflows/markdown-linter.yml
+++ b/.github/workflows/markdown-linter.yml
@@ -9,9 +9,6 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
permissions:
contents: read
@@ -35,7 +32,7 @@ jobs:
uses: super-linter/super-linter@2da136927bd4a73596db63044b504547c62cb854 # v9.0.0
env:
VALIDATE_ALL_CODEBASE: true
- DEFAULT_BRANCH: ${{ github.base_ref || github.event.repository.default_branch }}
+ DEFAULT_BRANCH: ${{ github.base_ref || github.ref_name }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VALIDATE_MD: true
VALIDATE_OPENAPI: true
diff --git a/.github/workflows/proto-lint.yml b/.github/workflows/proto-lint.yml
index d8453beed..b63ebae07 100644
--- a/.github/workflows/proto-lint.yml
+++ b/.github/workflows/proto-lint.yml
@@ -11,9 +11,6 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
jobs:
lint:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 158ac5c7a..93e94a6cb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,6 +5,10 @@ on:
paths:
- '.agents/skills/tenderdash-release/**'
- 'scripts/release/**'
+ - 'scripts/docker-metadata.py'
+ - 'scripts/tests/test_docker_metadata.py'
+ - '.github/workflows/docker.yml'
+ - 'DOCKER/Dockerfile'
- 'scripts/test-local-gates.sh'
- 'Makefile'
- '.github/workflows/release.yml'
@@ -12,12 +16,13 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
paths:
- '.agents/skills/tenderdash-release/**'
- 'scripts/release/**'
+ - 'scripts/docker-metadata.py'
+ - 'scripts/tests/test_docker_metadata.py'
+ - '.github/workflows/docker.yml'
+ - 'DOCKER/Dockerfile'
- 'scripts/test-local-gates.sh'
- 'Makefile'
- '.github/workflows/release.yml'
@@ -32,6 +37,8 @@ jobs:
- uses: actions/checkout@v7
- name: Check local release planning and artifact invariants
run: python3 -B -m unittest discover -s scripts/release -p test_release.py
+ - name: Check Docker publication contract
+ run: python3 -B -m unittest discover -s scripts/tests -p test_docker_metadata.py
- name: Check lint base selection
run: sh scripts/test-local-gates.sh
- name: Check shell syntax
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index ca619331d..d00739dcb 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -6,9 +6,11 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
+
+# Branch creation/rename events can refer to the deleted old branch.
+env:
+ GIT_DIFF: ${{ github.event_name == 'push' && github.event.created && 'all' || '' }}
+
jobs:
tests:
runs-on: ubuntu-26.04
@@ -20,6 +22,7 @@ jobs:
- uses: actions/checkout@v7
- uses: technote-space/get-diff-action@v6
+ if: github.event_name != 'push' || !github.event.created
with:
PATTERNS: |
.github/workflows/tests.yml
diff --git a/.github/workflows/vet.yml b/.github/workflows/vet.yml
index b9f0bc94e..c33f26583 100644
--- a/.github/workflows/vet.yml
+++ b/.github/workflows/vet.yml
@@ -15,9 +15,6 @@ on:
branches:
- master
- dev
- # Keep both names until the integration-branch migration is complete.
- - v1.8-dev
- - v1.9-dev
jobs:
vet:
diff --git a/DOCKER/Dockerfile b/DOCKER/Dockerfile
index 25368cdb7..13ee1c9fc 100644
--- a/DOCKER/Dockerfile
+++ b/DOCKER/Dockerfile
@@ -46,9 +46,10 @@ COPY . .
ARG TENDERMINT_BUILD_OPTIONS=tenderdash
ARG GOOS=linux
+ARG VERSION
RUN rm -r /src/tenderdash/third_party && ln -s /src/bls/third_party /src/tenderdash/third_party && \
- make build-binary && \
+ if [ -n "$VERSION" ]; then make build-binary VERSION="$VERSION"; else make build-binary; fi && \
make build_abcidump && \
go clean && go clean -cache -fuzzcache -testcache -modcache
diff --git a/Makefile b/Makefile
index c60d8e139..7201bf372 100644
--- a/Makefile
+++ b/Makefile
@@ -279,9 +279,8 @@ format:
GOLANGCI_LINT_VERSION ?= v2.15.0
GOLANGCI_LINT := $(GO) run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
-# Prefer the integration branch. The explicit fallback supports the planned rename;
-# remove it after v1.8-dev becomes dev. Override for stable/stacked PR targets.
-LINT_BASE ?= $(shell git rev-parse --verify --quiet refs/remotes/origin/dev >/dev/null && echo origin/dev || (git rev-parse --verify --quiet refs/remotes/origin/v1.8-dev >/dev/null && echo origin/v1.8-dev))
+# Override for stable/stacked PR targets.
+LINT_BASE ?= $(shell git rev-parse --verify --quiet refs/remotes/origin/dev >/dev/null && echo origin/dev)
# What CI enforces: findings on lines this branch changed. The repository carries
# several hundred pre-existing findings, so an unfiltered run can never exit 0 --
diff --git a/RELEASES.md b/RELEASES.md
index f60c07cad..6d6112a38 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -62,10 +62,19 @@ and arm64 binaries with detached GPG signatures, tarballs, `SHA256SUMS`, and
plus the local database license, provenance and NOTICE when present in that
source revision. It does not upload, overwrite assets, or select a release.
-When preparing an older deployment-line fix, check Docker alias behavior before
-publishing: the current workflow promotes every non-dev release to `latest`
-and the major alias. An older-line release needs a separate approved publishing
-path that preserves newer aliases. Do not publish and repair the aliases later.
+Docker publication binds the release tag to its exact source SHA and passes the
+version explicitly to the build. Prereleases publish their complete version only.
+A separate serialized promotion step updates `latest`, major and minor aliases
+only for the current GitHub latest stable release, and never below a higher stable
+version already tagged. API failures stop promotion. An older-line release keeps
+its exact version image without replacing those aliases.
+
+Manual Docker runs do not promote stable aliases. Enter an existing release tag
+to build that exact source, or leave it empty to build the selected branch/tag.
+Branch builds carry an `unreleased--` binary version and a SHA image
+tag; integration also publishes the `dev` image tag. The workflow does not publish
+images automatically on dev pushes. Tagged source predating explicit Docker
+`VERSION` support requires a separately reviewed legacy publishing path.
Local checks for this workflow:
diff --git a/scripts/docker-metadata.py b/scripts/docker-metadata.py
new file mode 100644
index 000000000..b0c4a39ad
--- /dev/null
+++ b/scripts/docker-metadata.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""Bind Docker publication metadata to a validated source commit."""
+
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+
+
+RELEASE_TAG = re.compile(
+ r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
+)
+
+
+def release_version(tag):
+ match = RELEASE_TAG.fullmatch(tag)
+ if not match or len(tag) > 128:
+ raise ValueError("expected an existing vX.Y.Z[-prerelease] release tag")
+ suffix = match[4]
+ if suffix and any(
+ part.isdigit() and len(part) > 1 and part[0] == "0"
+ for part in suffix.split(".")
+ ):
+ raise ValueError("numeric prerelease identifiers cannot have leading zeroes")
+ return tuple(int(match[i]) for i in (1, 2, 3)), suffix
+
+
+def plan(event_name, event, ref, latest_release, repo=Path(".")):
+ def git(*args):
+ return subprocess.check_output(
+ ["git", "-C", str(repo), *args], text=True
+ ).strip()
+
+ if event_name not in ("release", "workflow_dispatch"):
+ raise ValueError("Docker publication requires a release or manual event")
+ release = event.get("release", {})
+ tag = (
+ release.get("tag_name", "")
+ if event_name == "release"
+ else event.get("inputs", {}).get("tag", "")
+ )
+ if not tag and ref.startswith("refs/tags/"):
+ tag = ref.removeprefix("refs/tags/")
+ head = git("rev-parse", "HEAD")
+ if tag:
+ numbers, suffix = release_version(tag)
+ source = git("rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}")
+ if event_name == "release" and source != head:
+ raise ValueError("release checkout does not match its tag")
+ version = tag[1:]
+ tags = [version]
+ prerelease = bool(suffix) or release.get("prerelease", False)
+ stable_versions = []
+ for candidate in git("tag", "--list", "v*").splitlines():
+ try:
+ candidate_version, candidate_suffix = release_version(candidate)
+ except ValueError:
+ continue
+ if not candidate_suffix:
+ stable_versions.append(candidate_version)
+ # The API must identify this release as latest; an older line must also
+ # never overwrite aliases for a higher version already tagged locally.
+ if (
+ event_name == "release"
+ and not prerelease
+ and latest_release in ("", tag)
+ and numbers >= max(stable_versions, default=numbers)
+ ):
+ tags += [f"{numbers[0]}.{numbers[1]}", str(numbers[0]), "latest"]
+ options = "tenderdash,dev" if prerelease else "tenderdash,stable"
+ else:
+ if not ref.startswith("refs/heads/"):
+ raise ValueError("manual build requires a branch or release tag")
+ branch = ref.removeprefix("refs/heads/")
+ safe_branch = re.sub(r"[^a-zA-Z0-9_.-]", "-", branch)
+ source = head
+ version = f"unreleased-{safe_branch}-{source}"
+ branch_tag = "dev" if branch == "dev" else f"branch-{safe_branch.lower()}"[:128]
+ tags = [branch_tag, f"sha-{source}"]
+ options = "tenderdash,dev"
+ dockerfile = git("show", f"{source}:DOCKER/Dockerfile")
+ if not re.search(r"^ARG VERSION(?:=.*)?$", dockerfile, re.MULTILINE):
+ raise ValueError(
+ "source lacks explicit Docker VERSION support; use a reviewed legacy publishing path"
+ )
+ return {
+ "source_sha": source,
+ "version": version,
+ "tags": tags,
+ "build_options": options,
+ }
+
+
+def main():
+ event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
+ result = plan(
+ os.environ["GITHUB_EVENT_NAME"],
+ event,
+ os.environ["GITHUB_REF"],
+ os.environ["LATEST_RELEASE_TAG"],
+ )
+ with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
+ for key in ("source_sha", "version", "build_options"):
+ output.write(f"{key}={result[key]}\n")
+ output.write("tags< output.log 2>&1
grep -Fx 'vet ./...' "$GATE_LOG"
grep -E 'golangci-lint@[^ ]+ run --timeout 10m$' "$GATE_LOG"
+for version in 2.0.0 2.0.0-rc.1 unreleased-dev-0123456789abcdef; do
+ : > "$GATE_LOG"
+ make --no-print-directory build-binary VERSION="$version" > output.log 2>&1
+ grep -F -- "-X github.com/dashpay/tenderdash/version.TMCoreSemVer=$version" "$GATE_LOG"
+done
echo 'Local quality gate tests passed'
diff --git a/scripts/tests/test_docker_metadata.py b/scripts/tests/test_docker_metadata.py
new file mode 100644
index 000000000..3c89ec070
--- /dev/null
+++ b/scripts/tests/test_docker_metadata.py
@@ -0,0 +1,201 @@
+"""Offline source, version and alias contracts for Docker publication."""
+
+import importlib.util
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+from unittest import mock
+
+SCRIPT = Path(__file__).resolve().parents[1] / "docker-metadata.py"
+
+
+class DockerMetadataTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ spec = importlib.util.spec_from_file_location("docker_metadata", SCRIPT)
+ cls.module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(cls.module)
+
+ def setUp(self):
+ self.scratch = tempfile.TemporaryDirectory()
+ self.addCleanup(self.scratch.cleanup)
+ self.repo = Path(self.scratch.name)
+ self.git("init", "-q", "-b", "dev")
+ (self.repo / "DOCKER").mkdir()
+ (self.repo / "DOCKER/Dockerfile").write_text(
+ 'ARG VERSION\nRUN make build-binary VERSION="$VERSION"\n'
+ )
+ self.git("add", "DOCKER/Dockerfile")
+ self.git(
+ "-c",
+ "user.name=Test",
+ "-c",
+ "user.email=test@example.invalid",
+ "commit",
+ "-qm",
+ "source",
+ )
+ self.sha = self.git("rev-parse", "HEAD")
+
+ def git(self, *args):
+ return subprocess.check_output(
+ ["git", "-C", str(self.repo), *args], text=True
+ ).strip()
+
+ def plan(
+ self,
+ name="release",
+ tag="v2.0.0",
+ prerelease=False,
+ latest="v2.0.0",
+ ref="refs/heads/dev",
+ ):
+ event = (
+ {"release": {"tag_name": tag, "prerelease": prerelease}}
+ if name == "release"
+ else {"inputs": {"tag": tag}}
+ )
+ return self.module.plan(name, event, ref, latest, self.repo)
+
+ def test_stable_release_binds_source_version_and_aliases(self):
+ self.git("tag", "v2.0.0")
+ result = self.plan()
+ self.assertEqual(result["source_sha"], self.sha)
+ self.assertEqual(result["version"], "2.0.0")
+ self.assertEqual(result["tags"], ["2.0.0", "2.0", "2", "latest"])
+
+ def test_any_prerelease_suffix_cannot_promote_stable_aliases(self):
+ for tag in ["v2.0.0-dev.1", "v2.0.0-rc.1", "v2.0.0-beta"]:
+ with self.subTest(tag=tag):
+ self.git("tag", tag)
+ result = self.plan(tag=tag, latest=tag)
+ self.assertEqual(result["tags"], [tag[1:]])
+ self.assertEqual(result["build_options"], "tenderdash,dev")
+
+ def test_prerelease_flag_prevents_stable_aliases_for_plain_tag(self):
+ self.git("tag", "v2.0.0")
+ self.assertEqual(self.plan(prerelease=True)["tags"], ["2.0.0"])
+
+ def test_older_line_cannot_overwrite_newer_aliases_even_if_marked_latest(self):
+ self.git("tag", "v1.8.3")
+ self.git("tag", "v2.0.0")
+ self.assertEqual(self.plan(tag="v1.8.3", latest="v1.8.3")["tags"], ["1.8.3"])
+
+ def test_nonlatest_release_and_manual_release_never_promote_aliases(self):
+ self.git("tag", "v2.0.0")
+ self.assertEqual(self.plan(latest="v1.8.2")["tags"], ["2.0.0"])
+ self.assertEqual(self.plan(name="workflow_dispatch")["tags"], ["2.0.0"])
+
+ def test_first_stable_release_can_promote_aliases(self):
+ self.git("tag", "v2.0.0")
+ self.assertIn("latest", self.plan(latest="")["tags"])
+
+ def test_manual_tag_resolves_tag_not_selected_workflow_branch(self):
+ self.git("tag", "v2.0.0")
+ self.git(
+ "-c",
+ "user.name=Test",
+ "-c",
+ "user.email=test@example.invalid",
+ "commit",
+ "-qm",
+ "later",
+ "--allow-empty",
+ )
+ self.assertEqual(self.plan(name="workflow_dispatch")["source_sha"], self.sha)
+ with self.assertRaises(ValueError):
+ self.plan()
+
+ def test_manual_branch_is_unreleased_and_has_sha_tag(self):
+ result = self.plan(name="workflow_dispatch", tag="", ref="refs/heads/dev")
+ self.assertEqual(result["source_sha"], self.sha)
+ self.assertEqual(result["version"], f"unreleased-dev-{self.sha}")
+ self.assertEqual(result["tags"], ["dev", f"sha-{self.sha}"])
+
+ def test_manual_tag_selected_in_ui_uses_release_version_without_aliases(self):
+ self.git("tag", "v2.0.0")
+ result = self.plan(name="workflow_dispatch", tag="", ref="refs/tags/v2.0.0")
+ self.assertEqual(result["version"], "2.0.0")
+ self.assertEqual(result["tags"], ["2.0.0"])
+
+ def test_invalid_missing_tag_and_unexpected_event_fail_closed(self):
+ for tag in [
+ "latest",
+ "v2.0",
+ "v02.0.0",
+ "v2.0.0\nlatest",
+ "v2.0.0-01",
+ "v2.0.0+build.1",
+ ]:
+ with (
+ self.subTest(tag=tag),
+ self.assertRaises((ValueError, subprocess.CalledProcessError)),
+ ):
+ self.plan(name="workflow_dispatch", tag=tag)
+ with self.assertRaises(ValueError):
+ self.plan(name="push")
+
+ def test_tagged_source_without_version_override_fails_closed(self):
+ (self.repo / "DOCKER/Dockerfile").write_text("RUN make build-binary\n")
+ self.git("add", "DOCKER/Dockerfile")
+ self.git(
+ "-c",
+ "user.name=Test",
+ "-c",
+ "user.email=test@example.invalid",
+ "commit",
+ "-qm",
+ "legacy",
+ )
+ self.git("tag", "v2.0.0")
+ with self.assertRaises(ValueError):
+ self.plan()
+
+ def test_manual_branch_without_version_override_fails_closed(self):
+ (self.repo / "DOCKER/Dockerfile").write_text("RUN make build-binary\n")
+ self.git("add", "DOCKER/Dockerfile")
+ self.git(
+ "-c",
+ "user.name=Test",
+ "-c",
+ "user.email=test@example.invalid",
+ "commit",
+ "-qm",
+ "legacy",
+ )
+ with self.assertRaises(ValueError):
+ self.plan(name="workflow_dispatch", tag="")
+
+ def test_cli_emits_bound_version_tags_and_aliases(self):
+ self.git("tag", "v2.0.0")
+ event_path = self.repo / "event.json"
+ event_path.write_text('{"release":{"tag_name":"v2.0.0","prerelease":false}}')
+ output = self.repo / "output"
+ with mock.patch.dict(
+ os.environ,
+ {
+ "GITHUB_EVENT_NAME": "release",
+ "GITHUB_REF": "refs/tags/v2.0.0",
+ "GITHUB_EVENT_PATH": str(event_path),
+ "GITHUB_OUTPUT": str(output),
+ "LATEST_RELEASE_TAG": "v2.0.0",
+ },
+ ):
+ subprocess.run(["python3", "-B", str(SCRIPT)], cwd=self.repo, check=True)
+ content = output.read_text()
+ self.assertIn(f"source_sha={self.sha}\nversion=2.0.0\n", content)
+ self.assertIn("type=raw,value=2.0.0\n", content)
+ self.assertIn(
+ "aliases<
Date: Thu, 5 Nov 2026 10:51:45 +0000
Subject: [PATCH 2/4] fix(ci): declare stale maintenance permissions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Keep stale cache and issue/PR maintenance working with a read-only
repository token default, without granting repository contents write.
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Codex GPT-6
---
.github/workflows/stale.yml | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 863f7cb2c..b53a262ee 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -3,6 +3,11 @@ on:
schedule:
- cron: "0 0 * * *"
+permissions:
+ actions: write
+ issues: write
+ pull-requests: write
+
jobs:
stale:
runs-on: ubuntu-slim
From ae95c0017f33a99e3c35d7056df1722e9231a826 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Fri, 6 Nov 2026 12:51:21 +0000
Subject: [PATCH 3/4] fix(ci): promote stable aliases only on an affirmative
latest-release match
An empty latest-release lookup (404) no longer authorizes alias promotion,
so a release withdrawn or turned into a prerelease while its image builds
cannot overwrite latest, major and minor aliases.
Co-Authored-By: Claude Opus 5.5
---
scripts/docker-metadata.py | 7 ++++---
scripts/tests/test_docker_metadata.py | 6 +++++-
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/scripts/docker-metadata.py b/scripts/docker-metadata.py
index b0c4a39ad..2fc1f9906 100644
--- a/scripts/docker-metadata.py
+++ b/scripts/docker-metadata.py
@@ -59,12 +59,13 @@ def git(*args):
continue
if not candidate_suffix:
stable_versions.append(candidate_version)
- # The API must identify this release as latest; an older line must also
- # never overwrite aliases for a higher version already tagged locally.
+ # The API must affirmatively identify this release as latest (an empty
+ # result fails closed); an older line must also never overwrite aliases
+ # for a higher version already tagged locally.
if (
event_name == "release"
and not prerelease
- and latest_release in ("", tag)
+ and latest_release == tag
and numbers >= max(stable_versions, default=numbers)
):
tags += [f"{numbers[0]}.{numbers[1]}", str(numbers[0]), "latest"]
diff --git a/scripts/tests/test_docker_metadata.py b/scripts/tests/test_docker_metadata.py
index 3c89ec070..5cc084462 100644
--- a/scripts/tests/test_docker_metadata.py
+++ b/scripts/tests/test_docker_metadata.py
@@ -90,7 +90,11 @@ def test_nonlatest_release_and_manual_release_never_promote_aliases(self):
def test_first_stable_release_can_promote_aliases(self):
self.git("tag", "v2.0.0")
- self.assertIn("latest", self.plan(latest="")["tags"])
+ self.assertIn("latest", self.plan(latest="v2.0.0")["tags"])
+
+ def test_missing_latest_release_never_promotes_aliases(self):
+ self.git("tag", "v2.0.0")
+ self.assertEqual(self.plan(latest="")["tags"], ["2.0.0"])
def test_manual_tag_resolves_tag_not_selected_workflow_branch(self):
self.git("tag", "v2.0.0")
From f361cfcdc029d37def3c3c25537ad0c1491c0684 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Fri, 6 Nov 2026 12:52:45 +0000
Subject: [PATCH 4/4] test(ci): pin that the build job's pending latest never
promotes aliases
Co-Authored-By: Claude Opus 5.5
---
scripts/tests/test_docker_metadata.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/scripts/tests/test_docker_metadata.py b/scripts/tests/test_docker_metadata.py
index 5cc084462..c7082b09a 100644
--- a/scripts/tests/test_docker_metadata.py
+++ b/scripts/tests/test_docker_metadata.py
@@ -96,6 +96,12 @@ def test_missing_latest_release_never_promotes_aliases(self):
self.git("tag", "v2.0.0")
self.assertEqual(self.plan(latest="")["tags"], ["2.0.0"])
+ def test_build_job_pending_latest_never_promotes_aliases(self):
+ # docker.yml's build job passes LATEST_RELEASE_TAG=pending; only the
+ # promote-aliases job may resolve aliases.
+ self.git("tag", "v2.0.0")
+ self.assertEqual(self.plan(latest="pending")["tags"], ["2.0.0"])
+
def test_manual_tag_resolves_tag_not_selected_workflow_branch(self):
self.git("tag", "v2.0.0")
self.git(