ci(release): verify releases before publishing artifacts

This commit is contained in:
pnoker
2026-08-12 01:04:49 +08:00
parent 6c9149f9b2
commit a58064cc5c
10 changed files with 162 additions and 84 deletions
+3
View File
@@ -0,0 +1,3 @@
*.sh text eol=lf
Makefile text eol=lf
.github/workflows/*.yml text eol=lf
+9
View File
@@ -1,6 +1,13 @@
name: Web CI
on:
workflow_call:
inputs:
e2e_base_url:
description: 'Optional running web environment URL for Playwright e2e'
required: false
type: string
default: ''
pull_request:
paths:
- dc3-web/**
@@ -27,6 +34,7 @@ concurrency:
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
working-directory: dc3-web
@@ -67,6 +75,7 @@ jobs:
e2e:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.e2e_base_url != '' }}
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
working-directory: dc3-web
+9 -11
View File
@@ -15,11 +15,6 @@ on:
- .github/workflows/deploy-ghpages.yml
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: deploy-ghpages
cancel-in-progress: true
@@ -27,6 +22,9 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
defaults:
run:
working-directory: dc3-web
@@ -52,25 +50,25 @@ jobs:
- name: Build (mock mode)
run: pnpm build:mock
- name: Configure Pages
id: pages
uses: actions/configure-pages@v5
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: dc3-web/dist
deploy:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
with:
# Tolerate transient Pages-backend queue stalls (the deployment
# occasionally lands in deployment_queued and needs extra polls).
+90 -37
View File
@@ -4,6 +4,8 @@ on:
push:
tags:
- v*
# Manual runs validate the complete build without publishing mutable tags.
# To republish a release, run the workflow against its existing v* tag.
workflow_dispatch:
permissions:
@@ -19,9 +21,57 @@ env:
DOCKER_REGISTRY_ALIYUN: registry.cn-beijing.aliyuncs.com/dc3
jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
publish: ${{ steps.variables.outputs.publish }}
service_version: ${{ steps.variables.outputs.service_version }}
service_series: ${{ steps.variables.outputs.service_series }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- name: Validate release identity
id: variables
run: |
set -euo pipefail
VERSION=$(awk -F'[<>]' '/<parent>/,/<\/parent>/{if($2=="version") print $3}' pom.xml | head -1)
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid project version in pom.xml: $VERSION" >&2
exit 1
fi
SERIES="${VERSION%.*}"
PUBLISH=false
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
EXPECTED_TAG="v$VERSION"
if [[ "$GITHUB_REF_NAME" != "$EXPECTED_TAG" ]]; then
echo "Release tag '$GITHUB_REF_NAME' does not match pom.xml version '$EXPECTED_TAG'." >&2
exit 1
fi
PUBLISH=true
fi
echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT"
echo "service_version=$VERSION" >> "$GITHUB_OUTPUT"
echo "service_series=$SERIES" >> "$GITHUB_OUTPUT"
verify-backend:
uses: ./.github/workflows/test.yml
verify-web:
uses: ./.github/workflows/ci-web.yml
build-backend:
needs:
- validate
- verify-backend
- verify-web
runs-on: ubuntu-latest
timeout-minutes: 180
environment: release
permissions:
contents: read
actions: write # for GitHub Actions cache (--cache-to type=gha)
@@ -29,21 +79,6 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- name: Extract variables
id: variables
run: |
set -euo pipefail
# Read project.version from pom.xml without invoking Maven (the
# whole build runs inside Docker now; the runner does not need
# a JDK at all). iot-dc3's <version> is inherited from <parent>.
VERSION=$(awk -F'[<>]' '/<parent>/,/<\/parent>/{if($2=="version") print $3}' pom.xml | head -1)
SERIES="$VERSION"
if [[ "$VERSION" == *.*.* ]]; then
SERIES="${VERSION%.*}"
fi
echo "service_version=$VERSION" >> "$GITHUB_OUTPUT"
echo "service_series=$SERIES" >> "$GITHUB_OUTPUT"
- name: Set QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8
@@ -51,22 +86,25 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c
- name: Login Docker Default Registry
if: needs.validate.outputs.publish == 'true'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login Docker Aliyun Registry
if: needs.validate.outputs.publish == 'true'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
with:
registry: registry.cn-beijing.aliyuncs.com
username: ${{ vars.ALIYUN_DOCKERHUB_USERNAME }}
password: ${{ secrets.ALIYUN_DOCKERHUB_TOKEN }}
- name: Build and push all images
- name: Build backend images
env:
SERVICE_VERSION: ${{ steps.variables.outputs.service_version }}
SERVICE_SERIES: ${{ steps.variables.outputs.service_series }}
PUBLISH: ${{ needs.validate.outputs.publish }}
SERVICE_VERSION: ${{ needs.validate.outputs.service_version }}
SERVICE_SERIES: ${{ needs.validate.outputs.service_series }}
run: |
set -euo pipefail
@@ -108,7 +146,21 @@ jobs:
)
for name in "${services[@]}"; do
echo "Building and pushing: $name"
output_args=(--output=type=cacheonly)
tag_args=()
if [[ "$PUBLISH" == "true" ]]; then
output_args=(--push)
tag_args=(
--tag "$DOCKER_REGISTRY_DEFAULT/$name:latest"
--tag "$DOCKER_REGISTRY_DEFAULT/$name:$SERVICE_SERIES"
--tag "$DOCKER_REGISTRY_DEFAULT/$name:$SERVICE_VERSION"
--tag "$DOCKER_REGISTRY_ALIYUN/$name:latest"
--tag "$DOCKER_REGISTRY_ALIYUN/$name:$SERVICE_SERIES"
--tag "$DOCKER_REGISTRY_ALIYUN/$name:$SERVICE_VERSION"
)
fi
echo "Building $name (publish=$PUBLISH)"
# Single root-level Dockerfile defines a target stage per service.
# The shared `builder` stage (mvn package) runs ONCE for the
# whole reactor; BuildKit reuses it across every target image.
@@ -121,18 +173,22 @@ jobs:
--build-arg PROFILE="$PROFILE" \
--cache-from type=gha,scope=dc3-builder \
--cache-to type=gha,scope=dc3-builder,mode=max \
--push \
--tag "$DOCKER_REGISTRY_DEFAULT/$name:latest" \
--tag "$DOCKER_REGISTRY_DEFAULT/$name:$SERVICE_SERIES" \
--tag "$DOCKER_REGISTRY_DEFAULT/$name:$SERVICE_VERSION" \
--tag "$DOCKER_REGISTRY_ALIYUN/$name:latest" \
--tag "$DOCKER_REGISTRY_ALIYUN/$name:$SERVICE_SERIES" \
--tag "$DOCKER_REGISTRY_ALIYUN/$name:$SERVICE_VERSION" \
"${output_args[@]}" \
"${tag_args[@]}" \
.
done
build-web:
needs:
- validate
- verify-backend
- verify-web
runs-on: ubuntu-latest
timeout-minutes: 60
environment: release
permissions:
contents: read
actions: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -144,12 +200,14 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c
- name: Login DockerHub
if: needs.validate.outputs.publish == 'true'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login Aliyun Registry
if: needs.validate.outputs.publish == 'true'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
with:
registry: registry.cn-beijing.aliyuncs.com
@@ -167,12 +225,12 @@ jobs:
type=match,pattern=v(.*),group=1
type=raw,value=latest
- name: Build and Push
- name: Build web image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: .
file: dc3-web/Dockerfile
push: true
push: ${{ needs.validate.outputs.publish == 'true' }}
platforms: linux/arm64,linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -184,8 +242,10 @@ jobs:
needs:
- build-backend
- build-web
- validate
if: startsWith(github.ref, 'refs/tags/v')
timeout-minutes: 20
environment: release
permissions:
contents: write
steps:
@@ -194,17 +254,10 @@ jobs:
with:
fetch-depth: 0
- name: Extract variables
id: variables
run: |
set -euo pipefail
VERSION=$(awk -F'[<>]' '/<parent>/,/<\/parent>/{if($2=="version") print $3}' pom.xml | head -1)
echo "service_version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Generate release notes
id: release_notes
env:
SERVICE_VERSION: ${{ steps.variables.outputs.service_version }}
SERVICE_VERSION: ${{ needs.validate.outputs.service_version }}
SERVICE_WORKSPACE: ${{ github.workspace }}
run: |
set -euo pipefail
@@ -223,7 +276,7 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228
with:
name: ${{ steps.variables.outputs.service_version }}
name: ${{ needs.validate.outputs.service_version }}
body: ${{ steps.release_notes.outputs.release_notes }}
tag_name: ${{ github.ref_name }}
draft: false
+1 -1
View File
@@ -2,7 +2,7 @@ name: E2E
on:
pull_request:
branches: [ develop ]
branches: [ develop, main ]
workflow_dispatch:
permissions:
+1
View File
@@ -1,6 +1,7 @@
name: Test
on:
workflow_call:
push:
branches: [ develop, main ]
pull_request:
+5
View File
@@ -346,6 +346,11 @@ docs(release): update generated changelog
Commit that file separately from behaviour, configuration, or tooling changes.
The root `pom.xml` version is the release identity. After committing the version and generated changelog on `main`, run
`make tag`; the script creates only the matching annotated `v<project.version>` tag. It must not calculate a new version
or create a GitHub Release directly. The `Docker Images` workflow owns release verification, image publishing, and
GitHub Release creation. Keep its `release` environment protected with required reviewers and tag restrictions.
## Commit rules
AI coding agents must not commit without explicit confirmation for that specific commit.
+8 -5
View File
@@ -100,15 +100,18 @@ By default this reads the current version from `pom.xml`, compares `HEAD` with t
make changelog FROM=v2025.9.3 TO=HEAD VERSION=2026.5.22
```
To cut a release, switch to `main` and create the next semver tag (this also opens a GitHub Release):
The root `pom.xml` version is the release identity. Update and commit that version and the generated changelog first,
then switch to a clean, up-to-date `main` and create the matching tag:
```bash
make tag # patch: v2025.9.3 -> v2025.9.4
make tag minor # minor: v2025.9.4 -> v2025.10.0
make tag major # major: v2025.10.0 -> v2026.0.0
make tag # pom.xml 2026.5.22 -> tag v2026.5.22
```
`bash dc3/bin/tag.sh --dry-run` previews the next tag without pushing. Tagging only runs on `main`.
`bash dc3/bin/tag.sh --dry-run` previews the exact tag without pushing. The script refuses dirty, non-`main`,
out-of-date, duplicate, or malformed-version releases. It only pushes the annotated tag; the `Docker Images` workflow
then reruns backend and web verification, checks that the tag matches `pom.xml`, publishes images, and creates the
GitHub Release. Configure the `release` environment with required reviewers, protected `v*` tags, and the registry
credentials. A manual workflow run performs a non-publishing build unless it targets an existing matching `v*` tag.
Generated changelog-only release commits are skipped by default so rerunning the command after committing
`CHANGE.md` remains stable. Set `INCLUDE_CHANGELOG_COMMITS=true` only when those commits should appear in release notes.
+3 -4
View File
@@ -252,12 +252,11 @@ deploy: package
&& $(MVN_SUB) clean deploy -P deploy
tag:
@dc3/bin/tag.sh $(filter-out $@,$(MAKECMDGOALS))
@dc3/bin/tag.sh
# Catch-all for tag's extra words (e.g. `make tag minor` passes "minor" as a goal).
# Reject genuine typos like `make pakcage` instead of silently succeeding.
# Reject unknown targets instead of silently succeeding.
%:
@$(if $(MAKECMDGOALS),$(if $(filter tag,$(MAKECMDGOALS)),,echo "Unknown target '$@'. Run 'make help' for available targets." && exit 1))
@echo "Unknown target '$@'. Run 'make help' for available targets." && exit 1
changelog:
@FROM="$(FROM)" TO="$(TO)" VERSION="$(VERSION)" CHANGE_FILE="$(CHANGE_FILE)" dc3/bin/changelog.py
+33 -26
View File
@@ -17,20 +17,20 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# Create a semver release tag on main: vYYYY.M.P
# Usage: make tag [patch|minor|major] (or: dc3/bin/tag.sh <bump> [--dry-run])
# Create the release tag that exactly matches the root pom.xml version.
# Usage: make tag (or: dc3/bin/tag.sh [--dry-run])
set -euo pipefail
bump=""
dryrun=0
for arg in "$@"; do
case "$arg" in
patch|minor|major) bump="$arg" ;;
--dry-run) dryrun=1 ;;
*) echo "unknown argument: $arg (expected: patch|minor|major|--dry-run)" >&2; exit 1 ;;
*) echo "unknown argument: $arg (expected: --dry-run)" >&2; exit 1 ;;
esac
done
[ -n "$bump" ] || bump="patch"
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" != "main" ]; then
@@ -38,32 +38,39 @@ if [ "$branch" != "main" ]; then
exit 1
fi
git pull --tags --quiet
last=$(git tag -l "v*" --sort=-v:refname | head -1)
if [ -z "$last" ]; then
echo "No 'v*' tag found. Create an initial baseline tag first, e.g.:" >&2
echo " git tag v2025.9.3 && git push origin v2025.9.3" >&2
if [ -n "$(git status --porcelain)" ]; then
echo "Release tagging requires a clean working tree." >&2
exit 1
fi
re='^v([0-9]+)\.([0-9]+)\.([0-9]+)$'
if ! [[ $last =~ $re ]]; then
echo "Unparseable last tag: $last (expected vYYYY.M.P)" >&2
git fetch origin main --tags --quiet
local_head=$(git rev-parse HEAD)
remote_head=$(git rev-parse origin/main)
if [ "$local_head" != "$remote_head" ]; then
echo "Local main must exactly match origin/main before tagging." >&2
exit 1
fi
major=${BASH_REMATCH[1]}; minor=${BASH_REMATCH[2]}; patch=${BASH_REMATCH[3]}
case "$bump" in
patch) patch=$((patch + 1)) ;;
minor) minor=$((minor + 1)); patch=0 ;;
major) major=$((major + 1)); minor=0; patch=0 ;;
esac
newtag="v${major}.${minor}.${patch}"
echo "last: $last -> new: $newtag"
version=$(awk -F'[<>]' '/<parent>/,/<\/parent>/{if($2=="version"){print $3; exit}}' pom.xml)
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid project version in pom.xml: '$version' (expected YYYY.M.P)." >&2
exit 1
fi
newtag="v${version}"
if git rev-parse -q --verify "refs/tags/$newtag" >/dev/null; then
echo "Release tag already exists locally: $newtag" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/$newtag" >/dev/null 2>&1; then
echo "Release tag already exists on origin: $newtag" >&2
exit 1
fi
echo "release tag: $newtag (commit $local_head)"
[ "$dryrun" = "1" ] && { echo "(dry-run, not tagging)"; exit 0; }
git tag "$newtag"
git tag -a "$newtag" -m "Release $newtag"
git push origin "$newtag"
gh release create "$newtag" --generate-notes --title "$newtag"
echo "Tagged and released $newtag"
echo "Pushed $newtag. The Docker Images workflow will verify, publish, and create the GitHub Release."