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(