mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-10-31 10:11:49 +08:00
ci(release): verify releases before publishing artifacts
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
*.sh text eol=lf
|
||||
Makefile text eol=lf
|
||||
.github/workflows/*.yml text eol=lf
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ name: E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
branches: [ develop, main ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [ develop, main ]
|
||||
pull_request:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user