diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..df340cd18 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +Makefile text eol=lf +.github/workflows/*.yml text eol=lf diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml index 0ad1be59a..2c750d865 100644 --- a/.github/workflows/ci-web.yml +++ b/.github/workflows/ci-web.yml @@ -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 diff --git a/.github/workflows/deploy-ghpages.yml b/.github/workflows/deploy-ghpages.yml index f5e951a47..73d19a258 100644 --- a/.github/workflows/deploy-ghpages.yml +++ b/.github/workflows/deploy-ghpages.yml @@ -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). diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 81ced1763..c6a356f3f 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -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>/{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 is inherited from . - VERSION=$(awk -F'[<>]' '//,/<\/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>/{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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 50ea2c8ab..5b0fb1b4f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -2,7 +2,7 @@ name: E2E on: pull_request: - branches: [ develop ] + branches: [ develop, main ] workflow_dispatch: permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92fc2bf27..c6f144372 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,7 @@ name: Test on: + workflow_call: push: branches: [ develop, main ] pull_request: diff --git a/AGENTS.md b/AGENTS.md index 475f1cfc8..6c9da9576 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe8ff8038..82c8f1cc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/Makefile b/Makefile index 934696e3e..5b9cc8674 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/dc3/bin/tag.sh b/dc3/bin/tag.sh index 35a5762a5..b13805772 100755 --- a/dc3/bin/tag.sh +++ b/dc3/bin/tag.sh @@ -17,20 +17,20 @@ # along with this program. If not, see . # -# Create a semver release tag on main: vYYYY.M.P -# Usage: make tag [patch|minor|major] (or: dc3/bin/tag.sh [--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>/{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."