From dfd4cb3efcc70bf8fa2f6ecaa8b78410d818df1e Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 21 Jul 2026 16:12:45 +0800 Subject: [PATCH 1/9] feat(workflows): add graph publish precheck - run low-memory HStore compose before image publication - import the bundled example graph and verify Gremlin CRUD - enforce runtime resource limits and strict cleanup checks - document CI scope and the future 3x3 topology TODO --- .../_publish_pd_store_server_reusable.yml | 271 +++++++++++++++++- README.md | 25 +- 2 files changed, 286 insertions(+), 10 deletions(-) diff --git a/.github/workflows/_publish_pd_store_server_reusable.yml b/.github/workflows/_publish_pd_store_server_reusable.yml index dbceb26..d236dc9 100644 --- a/.github/workflows/_publish_pd_store_server_reusable.yml +++ b/.github/workflows/_publish_pd_store_server_reusable.yml @@ -270,32 +270,184 @@ jobs: - name: Start compose stack with local images run: | - if [ ! -f "docker/docker-compose.yml" ]; then - echo "ERROR: docker/docker-compose.yml not found in $REPOSITORY_URL@$SOURCE_SHA" - echo "Please update the compose file path in this workflow." + if [ -f "docker/docker-compose.dev.yml" ]; then + compose_file="docker/docker-compose.dev.yml" + elif [ -f "docker/docker-compose.yml" ]; then + compose_file="docker/docker-compose.yml" + echo "WARN: docker/docker-compose.dev.yml is unavailable; using legacy docker/docker-compose.yml" + else + echo "ERROR: no supported compose file found in $REPOSITORY_URL@$SOURCE_SHA" + echo "Expected docker/docker-compose.dev.yml or docker/docker-compose.yml." exit 1 fi + echo "COMPOSE_FILE=$compose_file" >> "$GITHUB_ENV" + + cat > /tmp/hg-ci-patch-server-config.sh <<'PATCH_SERVER' + #!/usr/bin/env bash + set -euo pipefail + + set_property() { + local file="$1" key="$2" value="$3" escaped + escaped=${key//./\.} + if grep -qE "^[[:space:]]*${escaped}[[:space:]]*=" "$file"; then + sed -E -i "s|^[[:space:]]*${escaped}[[:space:]]*=.*|${key}=${value}|" "$file" + else + printf '%s=%s\n' "$key" "$value" >> "$file" + fi + } + + set_yaml_scalar() { + local file="$1" key="$2" value="$3" + if grep -qE "^[[:space:]]*${key}:" "$file"; then + sed -E -i "s|^([[:space:]]*)${key}:.*|\1${key}: ${value}|" "$file" + else + printf '%s: %s\n' "$key" "$value" >> "$file" + fi + } + + set_property ./conf/rest-server.properties batch.max_write_threads 4 + set_property ./conf/rest-server.properties restserver.min_free_memory 32 + set_yaml_scalar ./conf/gremlin-server.yaml threadPoolWorker 4 + set_yaml_scalar ./conf/gremlin-server.yaml gremlinPool 4 + + echo "Applied CI Server resource configuration" + exec ./docker-entrypoint.sh + PATCH_SERVER + chmod 755 /tmp/hg-ci-patch-server-config.sh cat > /tmp/docker-compose.ci.override.yml <- + -Xms128m -Xmx128m + -XX:ActiveProcessorCount=2 + -XX:MaxMetaspaceSize=192m + -XX:MaxDirectMemorySize=128m + -XX:+UseContainerSupport + -Dlog4j2.asyncLoggerConfigRingBufferSize=8192 + -DAsyncLoggerConfig.RingBufferSize=8192 + -Dthread.pool.grpc.core=8 + -Dthread.pool.grpc.max=32 + -Dthread.pool.grpc.queue=512 + -Djob.interruptableThreadPool.core=1 + -Djob.interruptableThreadPool.max=8 + -Djob.interruptableThreadPool.queue=256 + -Djob.uninterruptibleThreadPool.core=0 + -Djob.uninterruptibleThreadPool.max=4 + -Djob.uninterruptibleThreadPool.queue=128 + -Dpartition.default-shard-count=1 + -Dpartition.store-max-shard-count=12 store: image: hg-ci/store:precheck + container_name: hg-store pull_policy: never + mem_limit: 1024m + cpus: 2.0 + environment: + JAVA_OPTS: >- + -Xms128m -Xmx128m + -XX:ActiveProcessorCount=2 + -XX:MaxMetaspaceSize=192m + -XX:MaxDirectMemorySize=128m + -XX:+UseContainerSupport + -Dlog4j2.asyncLoggerConfigRingBufferSize=8192 + -DAsyncLoggerConfig.RingBufferSize=8192 + -Drocksdb.total_memory_size=134217728 + -Drocksdb.write_buffer_size=2097152 + -Drocksdb.min_write_buffer_number_to_merge=2 + -Dthread.pool.grpc.core=8 + -Dthread.pool.grpc.max=32 + -Dthread.pool.grpc.queue=512 + -Dthread.pool.scan.core=4 + -Dthread.pool.scan.max=16 + -Dthread.pool.scan.queue=256 + -Djob.interruptableThreadPool.core=2 + -Djob.interruptableThreadPool.max=8 + -Djob.interruptableThreadPool.queue=256 + -Djob.uninterruptibleThreadPool.core=0 + -Djob.uninterruptibleThreadPool.max=4 + -Djob.uninterruptibleThreadPool.queue=128 + -Dquery.push-down.threads=8 + -Dquery.push-down.fetch_batch=1000 + -Dquery.push-down.fetch_timeout=30000 + -Dquery.push-down.memory_limit_count=10000 + -Dquery.push-down.index_size_limit_count=10000 + -Draft.disruptorBufferSize=512 + -Draft.metrics=false + -Draft.maxReplicatorInflightMsgs=32 + -Draft.maxEntriesSize=64 + -Draft.maxBodySize=262144 server: image: hg-ci/server:precheck + container_name: hg-server pull_policy: never + mem_limit: 1536m + cpus: 4.0 + entrypoint: + - /usr/bin/dumb-init + - "--" + - /opt/hg-ci/patch-server-config.sh + environment: + JAVA_OPTS: >- + -Xms256m -Xmx384m + -XX:ActiveProcessorCount=4 + -XX:MaxMetaspaceSize=256m + -XX:MaxDirectMemorySize=128m + -XX:+UseContainerSupport + -Dlog4j2.asyncLoggerConfigRingBufferSize=8192 + -DAsyncLoggerConfig.RingBufferSize=8192 + volumes: + - type: bind + source: /tmp/hg-ci-patch-server-config.sh + target: /opt/hg-ci/patch-server-config.sh + read_only: true COMPOSE_OVERRIDE docker compose \ -p hg-ci-precheck \ - -f docker/docker-compose.yml \ + -f "$compose_file" \ -f /tmp/docker-compose.ci.override.yml \ up -d --wait --wait-timeout "$WAIT_TIMEOUT_SEC" - docker compose -p hg-ci-precheck -f docker/docker-compose.yml -f /tmp/docker-compose.ci.override.yml ps + docker compose -p hg-ci-precheck -f "$compose_file" -f /tmp/docker-compose.ci.override.yml ps + + - name: Verify CI resource limits + run: | + set -euo pipefail + + assert_memory_limit() { + local container="$1" expected_bytes="$2" actual_bytes + actual_bytes="$(docker inspect --format '{{.HostConfig.Memory}}' "$container")" + if [ "$actual_bytes" != "$expected_bytes" ]; then + echo "Unexpected memory limit for ${container}: expected ${expected_bytes}, got ${actual_bytes}" + exit 1 + fi + echo "${container} memory limit: ${actual_bytes} bytes" + } + + assert_memory_limit hg-pd 1073741824 + assert_memory_limit hg-store 1073741824 + assert_memory_limit hg-server 1610612736 + + for container in hg-pd hg-store hg-server; do + docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container" \ + | grep '^JAVA_OPTS=' + docker exec "$container" bash -lc "ps -eo args | grep '[j]ava'" + done + docker exec hg-server grep -E \ + '^(batch.max_write_threads|restserver.min_free_memory)=' \ + /hugegraph-server/conf/rest-server.properties + docker exec hg-server grep -E \ + '^[[:space:]]*(threadPoolWorker|gremlinPool):' \ + /hugegraph-server/conf/gremlin-server.yaml + + docker stats --no-stream hg-pd hg-store hg-server - name: Verify integration endpoints run: | @@ -303,19 +455,122 @@ jobs: curl -fsS --connect-timeout 3 --max-time 8 "http://127.0.0.1:8520/v1/health" >/dev/null curl -fsS --connect-timeout 3 --max-time 8 "http://127.0.0.1:8080/versions" >/dev/null + - name: Import bundled example graph + run: | + set -euo pipefail + + docker exec hg-server test -r /hugegraph-server/scripts/example.groovy + docker exec hg-server bash -lc \ + 'cd /hugegraph-server && bin/gremlin-console.sh -- -e scripts/example.groovy' \ + | tee /tmp/hg-ci-example-import.log + + grep -Fq '>>>> query all vertices: size=6' /tmp/hg-ci-example-import.log + grep -Fq '>>>> query all edges: size=6' /tmp/hg-ci-example-import.log + + - name: Verify bundled graph with Gremlin CRUD + env: + CI_VERTEX_NAME: hg-ci-${{ github.run_id }}-${{ github.run_attempt }} + run: | + set -euo pipefail + + gremlin_request() { + local label="$1" script="$2" payload response_file + response_file="/tmp/hg-ci-gremlin-${label}.json" + payload="$( + jq -nc --arg gremlin "$script" \ + '{gremlin:$gremlin,bindings:{},language:"gremlin-groovy",aliases:{graph:"DEFAULT-hugegraph",g:"__g_DEFAULT-hugegraph"}}' + )" + curl --fail-with-body -sS \ + --compressed \ + --connect-timeout 3 \ + --max-time 30 \ + -H 'Content-Type: application/json' \ + --data-binary "$payload" \ + http://127.0.0.1:8080/gremlin \ + | tee "$response_file" + jq -e '.status.code == 200' "$response_file" >/dev/null + } + + baseline_script="[[vertices:g.V().count().next(),edges:g.E().count().next(),names:g.V().values('name').order().toList(),markoCreated:g.V().has('person','name','marko').out('created').values('name').order().toList()]]" + gremlin_request baseline "$baseline_script" + jq -e ' + .result.data[0].vertices == 6 and + .result.data[0].edges == 6 and + .result.data[0].names == ["josh","lop","marko","peter","ripple","vadas"] and + .result.data[0].markoCreated == ["lop"] + ' /tmp/hg-ci-gremlin-baseline.json >/dev/null + + create_script="ci=graph.addVertex(T.label,'person','name','${CI_VERTEX_NAME}','age',40,'city','CI');marko=g.V().has('person','name','marko').next();marko.addEdge('knows',ci,'date','ci','weight',0.1d);graph.tx().commit();'${CI_VERTEX_NAME}'" + gremlin_request create "$create_script" + jq -e --arg name "$CI_VERTEX_NAME" '.result.data == [$name]' \ + /tmp/hg-ci-gremlin-create.json >/dev/null + + read_created_script="[[vertices:g.V().count().next(),edges:g.E().count().next(),matches:g.V().has('person','name','marko').out('knows').has('name','${CI_VERTEX_NAME}').count().next()]]" + gremlin_request read-created "$read_created_script" + jq -e ' + .result.data[0].vertices == 7 and + .result.data[0].edges == 7 and + .result.data[0].matches == 1 + ' /tmp/hg-ci-gremlin-read-created.json >/dev/null + + update_script="g.V().has('person','name','${CI_VERTEX_NAME}').property('age',41).iterate();graph.tx().commit();g.V().has('person','name','${CI_VERTEX_NAME}').values('age').next()" + gremlin_request update "$update_script" + jq -e '.result.data == [41]' /tmp/hg-ci-gremlin-update.json >/dev/null + + delete_script="ci=g.V().has('person','name','${CI_VERTEX_NAME}').next();g.V(ci).bothE().drop().iterate();g.V(ci).drop().iterate();graph.tx().commit();[[vertices:g.V().count().next(),edges:g.E().count().next()]]" + gremlin_request delete "$delete_script" + jq -e ' + .result.data[0].vertices == 6 and + .result.data[0].edges == 6 + ' /tmp/hg-ci-gremlin-delete.json >/dev/null + + gremlin_request final "$baseline_script" + jq -e ' + .result.data[0].vertices == 6 and + .result.data[0].edges == 6 and + .result.data[0].names == ["josh","lop","marko","peter","ripple","vadas"] + ' /tmp/hg-ci-gremlin-final.json >/dev/null + + docker stats --no-stream hg-pd hg-store hg-server + - name: Dump compose logs on failure if: ${{ failure() }} run: | - docker compose -p hg-ci-precheck -f docker/docker-compose.yml -f /tmp/docker-compose.ci.override.yml logs --no-color --tail=200 || true + docker compose -p hg-ci-precheck -f "$COMPOSE_FILE" -f /tmp/docker-compose.ci.override.yml ps || true + docker stats --no-stream hg-pd hg-store hg-server || true + for container in hg-pd hg-store hg-server; do + docker inspect --format '{{json .State.Health}}' "$container" || true + done + for artifact in /tmp/hg-ci-example-import.log /tmp/hg-ci-gremlin-*.json; do + if [ -f "$artifact" ]; then + echo "===== ${artifact} =====" + cat "$artifact" + fi + done + docker compose -p hg-ci-precheck -f "$COMPOSE_FILE" -f /tmp/docker-compose.ci.override.yml logs --no-color --tail=200 || true - name: Stop compose stack if: ${{ always() }} run: | + set -euo pipefail + docker compose \ -p hg-ci-precheck \ - -f docker/docker-compose.yml \ + -f "$COMPOSE_FILE" \ -f /tmp/docker-compose.ci.override.yml \ - down -v --remove-orphans || true + down -v --remove-orphans + + leftover_containers="$(docker ps -aq --filter label=com.docker.compose.project=hg-ci-precheck)" + leftover_networks="$(docker network ls -q --filter label=com.docker.compose.project=hg-ci-precheck)" + leftover_volumes="$(docker volume ls -q --filter label=com.docker.compose.project=hg-ci-precheck)" + if [ -n "$leftover_containers$leftover_networks$leftover_volumes" ]; then + echo "Compose cleanup left resources behind" + docker ps -a --filter label=com.docker.compose.project=hg-ci-precheck + docker network ls --filter label=com.docker.compose.project=hg-ci-precheck + docker volume ls --filter label=com.docker.compose.project=hg-ci-precheck + exit 1 + fi + echo "Compose cleanup removed all project containers, networks, and volumes" - name: Post-check cleanup if: ${{ always() }} diff --git a/README.md b/README.md index 648de85..f73f670 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,22 @@ changing public images or production cache state. `pd/store/server` is the most important publishing flow in this repository and uses a dedicated reusable workflow: [`.github/workflows/_publish_pd_store_server_reusable.yml`](./.github/workflows/_publish_pd_store_server_reusable.yml). +The strict integration precheck builds local amd64 PD, Store, and HStore Server +images, starts the upstream `docker/docker-compose.dev.yml` topology with +`pull_policy: never`, and runs a functional graph check before any image is +published. Compatible source revisions that have the same service contract but +only contain `docker/docker-compose.yml` use that legacy file as a fallback. The +check executes the Server image's bundled +`/hugegraph-server/scripts/example.groovy` file, verifies the six-vertex, +six-edge sample graph, then performs separate Gremlin read, create, update, and +delete requests. The final query must return to the original 6V/6E baseline. + +The precheck override constrains the three JVMs and Store buffers for a small +CI workload. PD and Store are limited to 1 GiB each, Server to 1.5 GiB, while +heap, direct memory, RocksDB, Raft, and worker queues use a low-memory test +profile. These settings are functional-test limits, not production guidance or +a performance baseline. + ```text source branch (master / release-x.y.z) | @@ -110,7 +126,7 @@ changing public images or production cache state. | v integration_precheck (optional) - (compose health check for pd/store/server-hstore) + (low-memory compose + bundled example graph + Gremlin CRUD) | v publish_amd64 (matrix x4 modules) @@ -178,12 +194,17 @@ Reusable workflows are the real implementation layer. `_publish_pd_store_server_reusable.yml` handles the pd/store/server flow: - shared source SHA resolution and latest hash gate -- strict integration precheck for pd/store/server (hstore backend, `hugegraph/server`) +- strict low-memory integration precheck for pd/store/server (hstore backend, `hugegraph/server`) +- import of the Server image's bundled `example.groovy` graph and Gremlin CRUD validation - staged image publication with `*-amd64` then `*-arm64` - manifest merge to final tag (`latest` or release version) - remove temporary `*-amd64` and `*-arm64` tags after successful manifest publish - standalone server smoke test for `hugegraph/hugegraph` +The current precheck intentionally uses a 1 PD + 1 Store + 1 Server topology so +it fits standard GitHub-hosted runners. A full 3 PD + 3 Store + 3 Server compose +gate remains a TODO for a larger runner or a reliable lower-resource simulation. + Wrapper workflows provide the source repository, branch, and mode-specific inputs. Standard wrappers may also pass `build_matrix_json`, while the pd/store/server matrix is defined inside `_publish_pd_store_server_reusable.yml`. From 8ca488367fb8383958f7f40cfc0a7b41c9930fcb Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 21 Jul 2026 16:37:08 +0800 Subject: [PATCH 2/9] fix(workflows): guard compose cleanup - avoid unbound COMPOSE_FILE in failure paths - skip compose commands when setup never completed - preserve strict leftover resource checks --- .../_publish_pd_store_server_reusable.yml | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/_publish_pd_store_server_reusable.yml b/.github/workflows/_publish_pd_store_server_reusable.yml index d236dc9..8e2bb93 100644 --- a/.github/workflows/_publish_pd_store_server_reusable.yml +++ b/.github/workflows/_publish_pd_store_server_reusable.yml @@ -536,7 +536,12 @@ jobs: - name: Dump compose logs on failure if: ${{ failure() }} run: | - docker compose -p hg-ci-precheck -f "$COMPOSE_FILE" -f /tmp/docker-compose.ci.override.yml ps || true + if [ -n "${COMPOSE_FILE:-}" ] && [ -f /tmp/docker-compose.ci.override.yml ]; then + docker compose -p hg-ci-precheck -f "$COMPOSE_FILE" -f /tmp/docker-compose.ci.override.yml ps || true + docker compose -p hg-ci-precheck -f "$COMPOSE_FILE" -f /tmp/docker-compose.ci.override.yml logs --no-color --tail=200 || true + else + echo "Compose stack was not started; COMPOSE_FILE or CI override is unavailable" + fi docker stats --no-stream hg-pd hg-store hg-server || true for container in hg-pd hg-store hg-server; do docker inspect --format '{{json .State.Health}}' "$container" || true @@ -547,18 +552,21 @@ jobs: cat "$artifact" fi done - docker compose -p hg-ci-precheck -f "$COMPOSE_FILE" -f /tmp/docker-compose.ci.override.yml logs --no-color --tail=200 || true - name: Stop compose stack if: ${{ always() }} run: | set -euo pipefail - docker compose \ - -p hg-ci-precheck \ - -f "$COMPOSE_FILE" \ - -f /tmp/docker-compose.ci.override.yml \ - down -v --remove-orphans + if [ -n "${COMPOSE_FILE:-}" ] && [ -f /tmp/docker-compose.ci.override.yml ]; then + docker compose \ + -p hg-ci-precheck \ + -f "$COMPOSE_FILE" \ + -f /tmp/docker-compose.ci.override.yml \ + down -v --remove-orphans + else + echo "Compose stack was not started; skipping compose down" + fi leftover_containers="$(docker ps -aq --filter label=com.docker.compose.project=hg-ci-precheck)" leftover_networks="$(docker network ls -q --filter label=com.docker.compose.project=hg-ci-precheck)" From 09c0e580b0a5ff1373a1be2826997545cc50578c Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 22 Jul 2026 12:46:43 +0800 Subject: [PATCH 3/9] refactor(workflows): promote tested images - build and validate amd64 candidates once before pushing - move arm64 builds to native GitHub runners without QEMU - add a non-publishing exact-master dry-run path - document cache isolation and Dockerfile follow-up --- .../_publish_pd_store_server_reusable.yml | 214 ++++++++---------- .../publish_latest_pd_store_server_image.yml | 8 +- README.md | 35 ++- 3 files changed, 123 insertions(+), 134 deletions(-) diff --git a/.github/workflows/_publish_pd_store_server_reusable.yml b/.github/workflows/_publish_pd_store_server_reusable.yml index 8e2bb93..5541c20 100644 --- a/.github/workflows/_publish_pd_store_server_reusable.yml +++ b/.github/workflows/_publish_pd_store_server_reusable.yml @@ -26,6 +26,11 @@ on: required: false default: true type: boolean + dry_run: + description: "build and validate every architecture without publishing" + required: false + default: false + type: boolean wait_timeout_sec: description: "docker compose wait timeout in seconds" required: false @@ -83,6 +88,7 @@ jobs: BRANCH: ${{ inputs.branch }} ENABLE_HASH_GATE: ${{ inputs.enable_hash_gate }} LAST_HASH_VALUE: ${{ inputs.last_hash_value }} + DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail @@ -90,6 +96,10 @@ jobs: echo "Invalid mode: $MODE. Expected latest or release." exit 1 fi + if [ "$MODE" = "release" ] && [ "$DRY_RUN" = "true" ]; then + echo "dry_run is only supported by latest mode" + exit 1 + fi source_sha="$(git ls-remote "https://github.com/${REPOSITORY_URL}.git" "refs/heads/${BRANCH}" | awk '{print $1}')" if [ -z "$source_sha" ]; then @@ -100,7 +110,11 @@ jobs: if [ "$MODE" = "latest" ]; then if [ "$BRANCH" = "master" ]; then version_tag="latest" - publish_images="true" + if [ "$DRY_RUN" = "true" ]; then + publish_images="false" + else + publish_images="true" + fi else safe_branch="$( printf '%s' "$BRANCH" \ @@ -117,7 +131,7 @@ jobs: fi cache_channel="latest" need_update="true" - if [ "$ENABLE_HASH_GATE" = "true" ] && [ "$source_sha" = "$LAST_HASH_VALUE" ]; then + if [ "$DRY_RUN" != "true" ] && [ "$ENABLE_HASH_GATE" = "true" ] && [ "$source_sha" = "$LAST_HASH_VALUE" ]; then need_update="false" fi else @@ -173,6 +187,7 @@ jobs: env: CACHE_CHANNEL: ${{ steps.prepare.outputs.cache_channel }} PUBLISH_IMAGES: ${{ steps.prepare.outputs.publish_images }} + DRY_RUN: ${{ inputs.dry_run }} run: | { echo "## PD / Store / Server image build" @@ -180,27 +195,31 @@ jobs: echo "- Cache backend: Docker Hub registry (BuildKit, mode=max)" echo "- Cache channel: \`$CACHE_CHANNEL\`" echo "- Publish images: \`$PUBLISH_IMAGES\`" + echo "- Dry run: \`$DRY_RUN\`" if [ "$PUBLISH_IMAGES" = "true" ]; then - echo "- Cache writers: integration precheck and amd64 publish jobs" + echo "- Cache writers: amd64 candidate and native arm64 jobs" else echo "- Cache policy: read-only validation (no registry exports)" fi - echo "- Cache reader: arm64 publish jobs" - echo "- PD: \`hugegraph/pd:buildcache-$CACHE_CHANNEL\`" - echo "- Store: \`hugegraph/store:buildcache-$CACHE_CHANNEL\`" - echo "- Server (HStore): \`hugegraph/server:buildcache-$CACHE_CHANNEL\`" - echo "- Server (standalone): \`hugegraph/hugegraph:buildcache-$CACHE_CHANNEL\`" + echo "- ARM builder: native \`ubuntu-24.04-arm\` (no QEMU)" + echo "- amd64 caches: \`buildcache-$CACHE_CHANNEL\`" + echo "- arm64 caches: \`buildcache-arm64-$CACHE_CHANNEL\`" } >> "$GITHUB_STEP_SUMMARY" - integration_precheck: + build_test_publish_amd64: needs: prepare - if: ${{ needs.prepare.outputs.need_update == 'true' && inputs.strict_mode }} + if: ${{ needs.prepare.outputs.need_update == 'true' }} runs-on: ubuntu-latest env: REPOSITORY_URL: ${{ inputs.repository_url }} SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} MVN_ARGS: ${{ inputs.mvn_args }} WAIT_TIMEOUT_SEC: ${{ inputs.wait_timeout_sec }} + VERSION_TAG: ${{ needs.prepare.outputs.version_tag }} + PD_IMAGE: hugegraph/pd:${{ needs.prepare.outputs.version_tag }}-amd64 + STORE_IMAGE: hugegraph/store:${{ needs.prepare.outputs.version_tag }}-amd64 + HSTORE_IMAGE: hugegraph/server:${{ needs.prepare.outputs.version_tag }}-amd64 + STANDALONE_IMAGE: hugegraph/hugegraph:${{ needs.prepare.outputs.version_tag }}-amd64 steps: - name: Validate wait timeout run: | @@ -239,7 +258,7 @@ jobs: file: ./hugegraph-pd/Dockerfile platforms: linux/amd64 load: true - tags: hg-ci/pd:precheck + tags: ${{ env.PD_IMAGE }} cache-from: type=registry,ref=hugegraph/pd:buildcache-${{ needs.prepare.outputs.cache_channel }} cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/pd:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} build-args: ${{ env.MVN_ARGS }} @@ -251,7 +270,7 @@ jobs: file: ./hugegraph-store/Dockerfile platforms: linux/amd64 load: true - tags: hg-ci/store:precheck + tags: ${{ env.STORE_IMAGE }} cache-from: type=registry,ref=hugegraph/store:buildcache-${{ needs.prepare.outputs.cache_channel }} cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/store:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} build-args: ${{ env.MVN_ARGS }} @@ -263,12 +282,25 @@ jobs: file: ./hugegraph-server/Dockerfile-hstore platforms: linux/amd64 load: true - tags: hg-ci/server:precheck + tags: ${{ env.HSTORE_IMAGE }} cache-from: type=registry,ref=hugegraph/server:buildcache-${{ needs.prepare.outputs.cache_channel }} cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/server:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} build-args: ${{ env.MVN_ARGS }} + - name: Build x86 standalone Server candidate + uses: docker/build-push-action@v7 + with: + context: . + file: ./hugegraph-server/Dockerfile + platforms: linux/amd64 + load: true + tags: ${{ env.STANDALONE_IMAGE }} + cache-from: type=registry,ref=hugegraph/hugegraph:buildcache-${{ needs.prepare.outputs.cache_channel }} + cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/hugegraph:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} + build-args: ${{ env.MVN_ARGS }} + - name: Start compose stack with local images + if: ${{ inputs.strict_mode }} run: | if [ -f "docker/docker-compose.dev.yml" ]; then compose_file="docker/docker-compose.dev.yml" @@ -318,7 +350,7 @@ jobs: cat > /tmp/docker-compose.ci.override.yml </dev/null curl -fsS --connect-timeout 3 --max-time 8 "http://127.0.0.1:8520/v1/health" >/dev/null curl -fsS --connect-timeout 3 --max-time 8 "http://127.0.0.1:8080/versions" >/dev/null - name: Import bundled example graph + if: ${{ inputs.strict_mode }} run: | set -euo pipefail @@ -468,6 +503,7 @@ jobs: grep -Fq '>>>> query all edges: size=6' /tmp/hg-ci-example-import.log - name: Verify bundled graph with Gremlin CRUD + if: ${{ inputs.strict_mode }} env: CI_VERTEX_NAME: hg-ci-${{ github.run_id }}-${{ github.run_attempt }} run: | @@ -534,7 +570,7 @@ jobs: docker stats --no-stream hg-pd hg-store hg-server - name: Dump compose logs on failure - if: ${{ failure() }} + if: ${{ failure() && inputs.strict_mode }} run: | if [ -n "${COMPOSE_FILE:-}" ] && [ -f /tmp/docker-compose.ci.override.yml ]; then docker compose -p hg-ci-precheck -f "$COMPOSE_FILE" -f /tmp/docker-compose.ci.override.yml ps || true @@ -580,113 +616,49 @@ jobs: fi echo "Compose cleanup removed all project containers, networks, and volumes" - - name: Post-check cleanup - if: ${{ always() }} + - name: Smoke test standalone Server candidate + if: ${{ success() }} run: | - docker system prune -af || true - docker builder prune -af || true + set -euo pipefail + docker run -d --name=hg-ci-standalone -p 18080:8080 "$STANDALONE_IMAGE" + for attempt in $(seq 1 30); do + if curl -fsS --connect-timeout 3 --max-time 8 http://127.0.0.1:18080/versions >/dev/null; then + echo "Standalone candidate is ready after attempt ${attempt}" + exit 0 + fi + if ! docker inspect --format '{{.State.Running}}' hg-ci-standalone | grep -qx true; then + docker logs hg-ci-standalone + exit 1 + fi + sleep 2 + done + docker logs hg-ci-standalone + exit 1 - publish_amd64: - needs: [prepare, integration_precheck] - if: ${{ needs.prepare.outputs.need_update == 'true' && (needs.integration_precheck.result == 'success' || needs.integration_precheck.result == 'skipped') }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.prepare.outputs.build_matrix_json) }} - env: - REPOSITORY_URL: ${{ inputs.repository_url }} - SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} - VERSION_TAG: ${{ needs.prepare.outputs.version_tag }} - MVN_ARGS: ${{ inputs.mvn_args }} - CACHE_CHANNEL: ${{ needs.prepare.outputs.cache_channel }} - steps: - - name: Resolve tags (${{ matrix.module }}) - id: tags - env: - IMAGE_REPO: ${{ matrix.image_repo }} + - name: Push tested amd64 candidates + if: ${{ success() && needs.prepare.outputs.publish_images == 'true' }} run: | - image_amd64="${IMAGE_REPO}:${VERSION_TAG}-amd64" - module_cache_ref="${IMAGE_REPO}:buildcache-${CACHE_CHANNEL}" - - { - echo "image_amd64=$image_amd64" - echo "module_cache_ref=$module_cache_ref" - } >> "$GITHUB_OUTPUT" - - - name: Checkout source (${{ matrix.module }}) - uses: actions/checkout@v7 - with: - repository: ${{ env.REPOSITORY_URL }} - ref: ${{ env.SOURCE_SHA }} - fetch-depth: 2 - - - name: Set up Docker Buildx (${{ matrix.module }}) - uses: docker/setup-buildx-action@v4 - with: - version: latest - - - name: Login to Docker Hub (${{ matrix.module }}) - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - - - name: Build and load amd64 image for smoke test (${{ matrix.module }}) - if: ${{ matrix.smoke_test }} - uses: docker/build-push-action@v7 - with: - context: . - file: ${{ matrix.dockerfile }} - platforms: linux/amd64 - load: true - tags: ${{ steps.tags.outputs.image_amd64 }} - cache-from: type=registry,ref=${{ steps.tags.outputs.module_cache_ref }} - cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref={0},mode=max', steps.tags.outputs.module_cache_ref) || '' }} - build-args: ${{ env.MVN_ARGS }} - - - name: Build and push amd64 image (${{ matrix.module }}) - if: ${{ !matrix.smoke_test }} - uses: docker/build-push-action@v7 - with: - context: . - file: ${{ matrix.dockerfile }} - platforms: linux/amd64 - push: ${{ needs.prepare.outputs.publish_images == 'true' }} - tags: ${{ steps.tags.outputs.image_amd64 }} - cache-from: type=registry,ref=${{ steps.tags.outputs.module_cache_ref }} - cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref={0},mode=max', steps.tags.outputs.module_cache_ref) || '' }} - build-args: ${{ env.MVN_ARGS }} + set -euo pipefail + for image in "$PD_IMAGE" "$STORE_IMAGE" "$HSTORE_IMAGE" "$STANDALONE_IMAGE"; do + image_id="$(docker image inspect --format '{{.Id}}' "$image")" + echo "Pushing tested candidate ${image} (${image_id})" + docker push "$image" + done - - name: Smoke test standalone server amd64 - if: ${{ matrix.smoke_test }} - env: - IMAGE_URL: ${{ steps.tags.outputs.image_amd64 }} - run: | - docker rm -f graph >/dev/null 2>&1 || true - docker run -d --name=graph -p 18080:8080 "$IMAGE_URL" - sleep 20 - curl -fsS http://127.0.0.1:18080 >/dev/null || exit 1 - docker ps -a - sleep 20 - curl -fsS http://127.0.0.1:18080 >/dev/null || exit 1 - - - name: Push tested amd64 image (${{ matrix.module }}) - if: ${{ matrix.smoke_test && needs.prepare.outputs.publish_images == 'true' }} - env: - IMAGE_URL: ${{ steps.tags.outputs.image_amd64 }} - run: | - docker push "$IMAGE_URL" + - name: Cleanup standalone container + if: ${{ always() }} + run: docker rm -f hg-ci-standalone >/dev/null 2>&1 || true - - name: Cleanup smoke test container - if: ${{ always() && matrix.smoke_test }} + - name: Post-check cleanup + if: ${{ always() }} run: | - docker rm -f graph >/dev/null 2>&1 || true + docker system prune -af || true + docker builder prune -af || true publish_arm64: - needs: [prepare, integration_precheck, publish_amd64] - if: ${{ needs.prepare.outputs.need_update == 'true' && needs.publish_amd64.result == 'success' && (needs.integration_precheck.result == 'success' || needs.integration_precheck.result == 'skipped') }} - runs-on: ubuntu-latest + needs: [prepare, build_test_publish_amd64] + if: ${{ needs.prepare.outputs.need_update == 'true' && needs.build_test_publish_amd64.result == 'success' }} + runs-on: ubuntu-24.04-arm strategy: fail-fast: false matrix: @@ -704,7 +676,7 @@ jobs: IMAGE_REPO: ${{ matrix.image_repo }} run: | image_arm64="${IMAGE_REPO}:${VERSION_TAG}-arm64" - module_cache_ref="${IMAGE_REPO}:buildcache-${CACHE_CHANNEL}" + module_cache_ref="${IMAGE_REPO}:buildcache-arm64-${CACHE_CHANNEL}" { echo "image_arm64=$image_arm64" @@ -718,9 +690,6 @@ jobs: ref: ${{ env.SOURCE_SHA }} fetch-depth: 2 - - name: Set up QEMU (${{ matrix.module }}) - uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx (${{ matrix.module }}) uses: docker/setup-buildx-action@v4 with: @@ -741,11 +710,12 @@ jobs: push: ${{ needs.prepare.outputs.publish_images == 'true' }} tags: ${{ steps.tags.outputs.image_arm64 }} cache-from: type=registry,ref=${{ steps.tags.outputs.module_cache_ref }} + cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref={0},mode=max', steps.tags.outputs.module_cache_ref) || '' }} build-args: ${{ env.MVN_ARGS }} publish_manifest: - needs: [prepare, publish_amd64, publish_arm64] - if: ${{ needs.prepare.outputs.need_update == 'true' && needs.prepare.outputs.publish_images == 'true' && needs.publish_amd64.result == 'success' && needs.publish_arm64.result == 'success' }} + needs: [prepare, build_test_publish_amd64, publish_arm64] + if: ${{ needs.prepare.outputs.need_update == 'true' && needs.prepare.outputs.publish_images == 'true' && needs.build_test_publish_amd64.result == 'success' && needs.publish_arm64.result == 'success' }} runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/.github/workflows/publish_latest_pd_store_server_image.yml b/.github/workflows/publish_latest_pd_store_server_image.yml index 314347a..2a88c3a 100644 --- a/.github/workflows/publish_latest_pd_store_server_image.yml +++ b/.github/workflows/publish_latest_pd_store_server_image.yml @@ -18,6 +18,11 @@ on: required: false default: true description: 'whether integration precheck is mandatory before publish' + dry_run: + type: boolean + required: false + default: false + description: 'build and validate master without publishing images or updating the hash' wait_timeout_sec: required: false default: '300' @@ -36,8 +41,9 @@ jobs: branch: ${{ github.event.inputs.latest_source_branch || 'master' }} mvn_args: ${{ github.event.inputs.mvn_args || '' }} strict_mode: ${{ github.event_name != 'workflow_dispatch' || (github.event.inputs.latest_source_branch || 'master') != 'master' || github.event.inputs.strict_mode == true || github.event.inputs.strict_mode == 'true' }} + dry_run: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.dry_run == true || github.event.inputs.dry_run == 'true') }} wait_timeout_sec: ${{ github.event.inputs.wait_timeout_sec || '300' }} - enable_hash_gate: ${{ github.event_name != 'workflow_dispatch' || (github.event.inputs.latest_source_branch || 'master') == 'master' }} + enable_hash_gate: ${{ (github.event_name != 'workflow_dispatch' || (github.event.inputs.latest_source_branch || 'master') == 'master') && !(github.event.inputs.dry_run == true || github.event.inputs.dry_run == 'true') }} last_hash_value: ${{ vars.LAST_SERVER_HASH }} last_hash_name: LAST_SERVER_HASH hash_repo_owner: hugegraph diff --git a/README.md b/README.md index f73f670..a1314f6 100644 --- a/README.md +++ b/README.md @@ -96,13 +96,18 @@ Latest wrappers that expose `latest_source_branch` use two execution policies: This allows an upstream Dockerfile branch to be benchmarked before merge without changing public images or production cache state. +For pd/store/server, a manual `master` run can also set `dry_run=true`. This +forces a fresh exact-master amd64 integration check and native-arm64 build even +when the source hash is unchanged, while disabling image pushes, cache exports, +manifest creation, and hash updates. + ## Critical Path: PD/Store/Server `pd/store/server` is the most important publishing flow in this repository and uses a dedicated reusable workflow: [`.github/workflows/_publish_pd_store_server_reusable.yml`](./.github/workflows/_publish_pd_store_server_reusable.yml). -The strict integration precheck builds local amd64 PD, Store, and HStore Server -images, starts the upstream `docker/docker-compose.dev.yml` topology with +The amd64 candidate job builds PD, Store, HStore Server, and standalone Server +exactly once. It starts the upstream `docker/docker-compose.dev.yml` topology with `pull_policy: never`, and runs a functional graph check before any image is published. Compatible source revisions that have the same service contract but only contain `docker/docker-compose.yml` use that legacy file as a fallback. The @@ -110,6 +115,9 @@ check executes the Server image's bundled `/hugegraph-server/scripts/example.groovy` file, verifies the six-vertex, six-edge sample graph, then performs separate Gremlin read, create, update, and delete requests. The final query must return to the original 6V/6E baseline. +The same loaded standalone candidate then passes its smoke test. Only after all +enabled checks succeed are those exact local image IDs pushed as temporary +`*-amd64` tags; the publishing stage does not rebuild them. The precheck override constrains the three JVMs and Store buffers for a small CI workload. PD and Store are limited to 1 GiB each, Server to 1.5 GiB, while @@ -125,18 +133,17 @@ a performance baseline. (resolve source SHA, version tag, hash gate) | v - integration_precheck (optional) - (low-memory compose + bundled example graph + Gremlin CRUD) - | - v - publish_amd64 (matrix x4 modules) + build_test_publish_amd64 (x4 candidates) +-------------------------------------------------+ | pd | store | server-hstore | server-standalone | +-------------------------------------------------+ + low-memory compose + bundled graph + Gremlin CRUD + standalone smoke test + push the exact tested amd64 image IDs push x.y.z-amd64 (or latest-amd64) | v - publish_arm64 (matrix x4 modules) + publish_arm64 on native ARM (matrix x4 modules) push x.y.z-arm64 (or latest-arm64) | v @@ -156,7 +163,13 @@ Tag behavior: Execution note: -- `publish_arm64` runs after `publish_amd64` by design, so x86 users can get a usable image earlier and arm64 compute is not spent when amd64 fails. +- `publish_arm64` runs after the amd64 candidate gate, so ARM compute is not + spent when the functional checks fail. +- ARM builds use GitHub's native `ubuntu-24.04-arm` runner and do not install + QEMU. The upstream Dockerfiles still run Maven separately on that runner. + Reusing one cross-architecture Java distribution is a future Dockerfile + optimization and requires auditing JNI, native libraries, and downloaded + platform-specific artifacts first. ## Why The Wrappers Stay Split @@ -194,9 +207,9 @@ Reusable workflows are the real implementation layer. `_publish_pd_store_server_reusable.yml` handles the pd/store/server flow: - shared source SHA resolution and latest hash gate -- strict low-memory integration precheck for pd/store/server (hstore backend, `hugegraph/server`) +- build-once amd64 candidates followed by strict low-memory integration precheck for pd/store/server (hstore backend, `hugegraph/server`) - import of the Server image's bundled `example.groovy` graph and Gremlin CRUD validation -- staged image publication with `*-amd64` then `*-arm64` +- exact-tested amd64 publication followed by native-runner `*-arm64` builds - manifest merge to final tag (`latest` or release version) - remove temporary `*-amd64` and `*-arm64` tags after successful manifest publish - standalone server smoke test for `hugegraph/hugegraph` From 2b29e81ba433411701796720cbbc797d92d905c8 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 22 Jul 2026 13:08:08 +0800 Subject: [PATCH 4/9] perf(workflows): select arm build strategy - use cached x86 build stages when Dockerfiles pin BUILDPLATFORM - fall back to native ARM for legacy release Dockerfiles - isolate native ARM caches and keep dry-runs read-only - document measured QEMU and native runner tradeoffs --- .../_publish_pd_store_server_reusable.yml | 67 +++++++++++++++++-- README.md | 18 ++--- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/.github/workflows/_publish_pd_store_server_reusable.yml b/.github/workflows/_publish_pd_store_server_reusable.yml index 5541c20..a9b451f 100644 --- a/.github/workflows/_publish_pd_store_server_reusable.yml +++ b/.github/workflows/_publish_pd_store_server_reusable.yml @@ -78,6 +78,8 @@ jobs: version_tag: ${{ steps.prepare.outputs.version_tag }} cache_channel: ${{ steps.prepare.outputs.cache_channel }} publish_images: ${{ steps.prepare.outputs.publish_images }} + arm_native: ${{ steps.prepare.outputs.arm_native }} + arm_runner: ${{ steps.prepare.outputs.arm_runner }} build_matrix_json: ${{ steps.prepare.outputs.build_matrix_json }} steps: - name: Resolve mode and source ref @@ -145,11 +147,43 @@ jobs: publish_images="true" fi + arm_native="false" + for dockerfile in \ + hugegraph-pd/Dockerfile \ + hugegraph-store/Dockerfile \ + hugegraph-server/Dockerfile-hstore \ + hugegraph-server/Dockerfile; do + raw_url="https://raw.githubusercontent.com/${REPOSITORY_URL}/${source_sha}/${dockerfile}" + if ! dockerfile_content="$( + curl --fail --silent --show-error --location --retry 3 "$raw_url" + )"; then + echo "Failed to inspect ${dockerfile}; selecting native ARM" + arm_native="true" + break + fi + first_from="$( + awk 'toupper($1) == "FROM" { print; exit }' <<< "$dockerfile_content" + )" + if ! grep -iE "^FROM[[:space:]]+--platform=\\\$BUILDPLATFORM[[:space:]]+" \ + <<< "$first_from" >/dev/null; then + echo "${dockerfile} does not pin its build stage to BUILDPLATFORM; selecting native ARM" + arm_native="true" + break + fi + done + if [ "$arm_native" = "true" ]; then + arm_runner="ubuntu-24.04-arm" + else + arm_runner="ubuntu-latest" + fi + { echo "source_sha=$source_sha" echo "version_tag=$version_tag" echo "cache_channel=$cache_channel" echo "publish_images=$publish_images" + echo "arm_native=$arm_native" + echo "arm_runner=$arm_runner" echo "need_update=$need_update" echo "build_matrix_json<> "$GITHUB_STEP_SUMMARY" build_test_publish_amd64: @@ -658,7 +702,7 @@ jobs: publish_arm64: needs: [prepare, build_test_publish_amd64] if: ${{ needs.prepare.outputs.need_update == 'true' && needs.build_test_publish_amd64.result == 'success' }} - runs-on: ubuntu-24.04-arm + runs-on: ${{ needs.prepare.outputs.arm_runner }} strategy: fail-fast: false matrix: @@ -669,6 +713,7 @@ jobs: VERSION_TAG: ${{ needs.prepare.outputs.version_tag }} MVN_ARGS: ${{ inputs.mvn_args }} CACHE_CHANNEL: ${{ needs.prepare.outputs.cache_channel }} + ARM_NATIVE: ${{ needs.prepare.outputs.arm_native }} steps: - name: Resolve tags (${{ matrix.module }}) id: tags @@ -676,7 +721,11 @@ jobs: IMAGE_REPO: ${{ matrix.image_repo }} run: | image_arm64="${IMAGE_REPO}:${VERSION_TAG}-arm64" - module_cache_ref="${IMAGE_REPO}:buildcache-arm64-${CACHE_CHANNEL}" + if [ "$ARM_NATIVE" = "true" ]; then + module_cache_ref="${IMAGE_REPO}:buildcache-arm64-${CACHE_CHANNEL}" + else + module_cache_ref="${IMAGE_REPO}:buildcache-${CACHE_CHANNEL}" + fi { echo "image_arm64=$image_arm64" @@ -690,6 +739,10 @@ jobs: ref: ${{ env.SOURCE_SHA }} fetch-depth: 2 + - name: Set up QEMU (${{ matrix.module }}) + if: ${{ needs.prepare.outputs.arm_native != 'true' }} + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx (${{ matrix.module }}) uses: docker/setup-buildx-action@v4 with: @@ -710,7 +763,7 @@ jobs: push: ${{ needs.prepare.outputs.publish_images == 'true' }} tags: ${{ steps.tags.outputs.image_arm64 }} cache-from: type=registry,ref=${{ steps.tags.outputs.module_cache_ref }} - cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref={0},mode=max', steps.tags.outputs.module_cache_ref) || '' }} + cache-to: ${{ needs.prepare.outputs.arm_native == 'true' && needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref={0},mode=max', steps.tags.outputs.module_cache_ref) || '' }} build-args: ${{ env.MVN_ARGS }} publish_manifest: diff --git a/README.md b/README.md index a1314f6..1f47368 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ This allows an upstream Dockerfile branch to be benchmarked before merge without changing public images or production cache state. For pd/store/server, a manual `master` run can also set `dry_run=true`. This -forces a fresh exact-master amd64 integration check and native-arm64 build even +forces a fresh exact-master amd64 integration check and arm64 build even when the source hash is unchanged, while disabling image pushes, cache exports, manifest creation, and hash updates. @@ -143,7 +143,7 @@ a performance baseline. push x.y.z-amd64 (or latest-amd64) | v - publish_arm64 on native ARM (matrix x4 modules) + publish_arm64 (matrix x4 modules) push x.y.z-arm64 (or latest-arm64) | v @@ -165,11 +165,13 @@ Execution note: - `publish_arm64` runs after the amd64 candidate gate, so ARM compute is not spent when the functional checks fail. -- ARM builds use GitHub's native `ubuntu-24.04-arm` runner and do not install - QEMU. The upstream Dockerfiles still run Maven separately on that runner. - Reusing one cross-architecture Java distribution is a future Dockerfile - optimization and requires auditing JNI, native libraries, and downloaded - platform-specific artifacts first. +- ARM runner selection follows the exact source SHA. When all four Dockerfiles + use `FROM --platform=$BUILDPLATFORM`, an x86 runner reuses the amd64 candidate + cache and limits QEMU to target-image runtime steps. A measured native-ARM + trial on current master made each module slower because it rebuilt Maven on + ARM. Older release Dockerfiles without that build-stage pin instead use a + native ARM runner and isolated ARM cache, avoiding a full Maven build under + emulation. Dry-runs read existing caches but never export new ones. ## Why The Wrappers Stay Split @@ -209,7 +211,7 @@ Reusable workflows are the real implementation layer. - shared source SHA resolution and latest hash gate - build-once amd64 candidates followed by strict low-memory integration precheck for pd/store/server (hstore backend, `hugegraph/server`) - import of the Server image's bundled `example.groovy` graph and Gremlin CRUD validation -- exact-tested amd64 publication followed by native-runner `*-arm64` builds +- exact-tested amd64 publication followed by cached `*-arm64` builds - manifest merge to final tag (`latest` or release version) - remove temporary `*-amd64` and `*-arm64` tags after successful manifest publish - standalone server smoke test for `hugegraph/hugegraph` From ab66b1063b81cfc6d2138d980d941d827d68a0b3 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 22 Jul 2026 14:43:17 +0800 Subject: [PATCH 5/9] refactor(workflows): consolidate staged builds - decouple release source branch from image version tag - combine arm64 builds into one reusable runner job - combine manifest creation and temporary tag cleanup - keep amd64 integration validation ahead of publishing --- .../_publish_pd_store_server_reusable.yml | 235 +++++++----------- .../publish_release_pd_store_server_image.yml | 7 +- 2 files changed, 96 insertions(+), 146 deletions(-) diff --git a/.github/workflows/_publish_pd_store_server_reusable.yml b/.github/workflows/_publish_pd_store_server_reusable.yml index a9b451f..7e0dd71 100644 --- a/.github/workflows/_publish_pd_store_server_reusable.yml +++ b/.github/workflows/_publish_pd_store_server_reusable.yml @@ -16,6 +16,11 @@ on: description: "source branch name" required: true type: string + version_tag: + description: "release image tag, independent from the source branch" + required: false + default: '' + type: string mvn_args: description: "mvn build args" required: false @@ -78,8 +83,6 @@ jobs: version_tag: ${{ steps.prepare.outputs.version_tag }} cache_channel: ${{ steps.prepare.outputs.cache_channel }} publish_images: ${{ steps.prepare.outputs.publish_images }} - arm_native: ${{ steps.prepare.outputs.arm_native }} - arm_runner: ${{ steps.prepare.outputs.arm_runner }} build_matrix_json: ${{ steps.prepare.outputs.build_matrix_json }} steps: - name: Resolve mode and source ref @@ -88,6 +91,7 @@ jobs: MODE: ${{ inputs.mode }} REPOSITORY_URL: ${{ inputs.repository_url }} BRANCH: ${{ inputs.branch }} + REQUESTED_VERSION_TAG: ${{ inputs.version_tag }} ENABLE_HASH_GATE: ${{ inputs.enable_hash_gate }} LAST_HASH_VALUE: ${{ inputs.last_hash_value }} DRY_RUN: ${{ inputs.dry_run }} @@ -137,9 +141,9 @@ jobs: need_update="false" fi else - version_tag="$(echo "$BRANCH" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1)" - if [ -z "$version_tag" ]; then - echo "Branch name does not contain a valid version number (x.x.x): $BRANCH" + version_tag="$REQUESTED_VERSION_TAG" + if ! [[ "$version_tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid release version_tag: $version_tag. Expected x.y.z." exit 1 fi need_update="true" @@ -147,43 +151,11 @@ jobs: publish_images="true" fi - arm_native="false" - for dockerfile in \ - hugegraph-pd/Dockerfile \ - hugegraph-store/Dockerfile \ - hugegraph-server/Dockerfile-hstore \ - hugegraph-server/Dockerfile; do - raw_url="https://raw.githubusercontent.com/${REPOSITORY_URL}/${source_sha}/${dockerfile}" - if ! dockerfile_content="$( - curl --fail --silent --show-error --location --retry 3 "$raw_url" - )"; then - echo "Failed to inspect ${dockerfile}; selecting native ARM" - arm_native="true" - break - fi - first_from="$( - awk 'toupper($1) == "FROM" { print; exit }' <<< "$dockerfile_content" - )" - if ! grep -iE "^FROM[[:space:]]+--platform=\\\$BUILDPLATFORM[[:space:]]+" \ - <<< "$first_from" >/dev/null; then - echo "${dockerfile} does not pin its build stage to BUILDPLATFORM; selecting native ARM" - arm_native="true" - break - fi - done - if [ "$arm_native" = "true" ]; then - arm_runner="ubuntu-24.04-arm" - else - arm_runner="ubuntu-latest" - fi - { echo "source_sha=$source_sha" echo "version_tag=$version_tag" echo "cache_channel=$cache_channel" echo "publish_images=$publish_images" - echo "arm_native=$arm_native" - echo "arm_runner=$arm_runner" echo "need_update=$need_update" echo "build_matrix_json<> "$GITHUB_STEP_SUMMARY" build_test_publish_amd64: @@ -702,135 +663,112 @@ jobs: publish_arm64: needs: [prepare, build_test_publish_amd64] if: ${{ needs.prepare.outputs.need_update == 'true' && needs.build_test_publish_amd64.result == 'success' }} - runs-on: ${{ needs.prepare.outputs.arm_runner }} - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.prepare.outputs.build_matrix_json) }} + runs-on: ubuntu-latest env: REPOSITORY_URL: ${{ inputs.repository_url }} SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} VERSION_TAG: ${{ needs.prepare.outputs.version_tag }} MVN_ARGS: ${{ inputs.mvn_args }} CACHE_CHANNEL: ${{ needs.prepare.outputs.cache_channel }} - ARM_NATIVE: ${{ needs.prepare.outputs.arm_native }} + BUILD_MATRIX_JSON: ${{ needs.prepare.outputs.build_matrix_json }} steps: - - name: Resolve tags (${{ matrix.module }}) - id: tags - env: - IMAGE_REPO: ${{ matrix.image_repo }} - run: | - image_arm64="${IMAGE_REPO}:${VERSION_TAG}-arm64" - if [ "$ARM_NATIVE" = "true" ]; then - module_cache_ref="${IMAGE_REPO}:buildcache-arm64-${CACHE_CHANNEL}" - else - module_cache_ref="${IMAGE_REPO}:buildcache-${CACHE_CHANNEL}" - fi - - { - echo "image_arm64=$image_arm64" - echo "module_cache_ref=$module_cache_ref" - } >> "$GITHUB_OUTPUT" - - - name: Checkout source (${{ matrix.module }}) + - name: Checkout source uses: actions/checkout@v7 with: repository: ${{ env.REPOSITORY_URL }} ref: ${{ env.SOURCE_SHA }} fetch-depth: 2 - - name: Set up QEMU (${{ matrix.module }}) - if: ${{ needs.prepare.outputs.arm_native != 'true' }} + - name: Set up QEMU uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx (${{ matrix.module }}) + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 with: version: latest - - name: Login to Docker Hub (${{ matrix.module }}) + - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - - name: Build and push arm64 image (${{ matrix.module }}) - uses: docker/build-push-action@v7 - with: - context: . - file: ${{ matrix.dockerfile }} - platforms: linux/arm64 - push: ${{ needs.prepare.outputs.publish_images == 'true' }} - tags: ${{ steps.tags.outputs.image_arm64 }} - cache-from: type=registry,ref=${{ steps.tags.outputs.module_cache_ref }} - cache-to: ${{ needs.prepare.outputs.arm_native == 'true' && needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref={0},mode=max', steps.tags.outputs.module_cache_ref) || '' }} - build-args: ${{ env.MVN_ARGS }} + - name: Build arm64 images sequentially + env: + PUBLISH_IMAGES: ${{ needs.prepare.outputs.publish_images }} + run: | + set -euo pipefail + + output_flags=(--output type=cacheonly) + if [ "$PUBLISH_IMAGES" = "true" ]; then + output_flags=(--push) + fi + + while IFS= read -r item; do + module="$(jq -r '.module' <<< "$item")" + image_repo="$(jq -r '.image_repo' <<< "$item")" + dockerfile="$(jq -r '.dockerfile' <<< "$item")" + image_arm64="${image_repo}:${VERSION_TAG}-arm64" + cache_ref="${image_repo}:buildcache-${CACHE_CHANNEL}" + + echo "Building ${module}: ${image_arm64}" + build_command=( + docker buildx build + --file "$dockerfile" + --platform linux/arm64 + --tag "$image_arm64" + --cache-from "type=registry,ref=${cache_ref}" + ) + while IFS= read -r build_arg; do + if [ -n "$build_arg" ]; then + build_command+=(--build-arg "$build_arg") + fi + done <<< "$MVN_ARGS" + build_command+=("${output_flags[@]}" .) + "${build_command[@]}" + done < <(jq -c '.[]' <<< "$BUILD_MATRIX_JSON") publish_manifest: needs: [prepare, build_test_publish_amd64, publish_arm64] if: ${{ needs.prepare.outputs.need_update == 'true' && needs.prepare.outputs.publish_images == 'true' && needs.build_test_publish_amd64.result == 'success' && needs.publish_arm64.result == 'success' }} runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.prepare.outputs.build_matrix_json) }} env: VERSION_TAG: ${{ needs.prepare.outputs.version_tag }} + BUILD_MATRIX_JSON: ${{ needs.prepare.outputs.build_matrix_json }} steps: - - name: Resolve tags (${{ matrix.module }}) - id: tags - env: - IMAGE_REPO: ${{ matrix.image_repo }} - run: | - image_final="${IMAGE_REPO}:${VERSION_TAG}" - image_amd64="${IMAGE_REPO}:${VERSION_TAG}-amd64" - image_arm64="${IMAGE_REPO}:${VERSION_TAG}-arm64" - - { - echo "image_final=$image_final" - echo "image_amd64=$image_amd64" - echo "image_arm64=$image_arm64" - } >> "$GITHUB_OUTPUT" - - - name: Set up Docker Buildx (${{ matrix.module }}) + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 with: version: latest - - name: Login to Docker Hub (${{ matrix.module }}) + - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - - name: Create multi-arch manifest (${{ matrix.module }}) - run: | - docker buildx imagetools create \ - --tag "${{ steps.tags.outputs.image_final }}" \ - "${{ steps.tags.outputs.image_amd64 }}" \ - "${{ steps.tags.outputs.image_arm64 }}" - - - name: Inspect multi-arch manifest (${{ matrix.module }}) + - name: Create and inspect multi-arch manifests run: | - docker buildx imagetools inspect "${{ steps.tags.outputs.image_final }}" - - - name: Delete temporary arch tags after manifest publish (${{ matrix.module }}) + set -euo pipefail + while IFS= read -r image_repo; do + image_final="${image_repo}:${VERSION_TAG}" + image_amd64="${image_repo}:${VERSION_TAG}-amd64" + image_arm64="${image_repo}:${VERSION_TAG}-arm64" + docker buildx imagetools create \ + --tag "$image_final" \ + "$image_amd64" \ + "$image_arm64" + docker buildx imagetools inspect "$image_final" + done < <(jq -r '.[].image_repo' <<< "$BUILD_MATRIX_JSON") + + - name: Delete temporary arch tags after manifest publish continue-on-error: true env: - IMAGE_REPO: ${{ matrix.image_repo }} - VERSION_TAG: ${{ env.VERSION_TAG }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} run: | set -euo pipefail - namespace="${IMAGE_REPO%%/*}" - repository="${IMAGE_REPO#*/}" - if [ "$namespace" = "$repository" ]; then - echo "Invalid image repo format: $IMAGE_REPO" - exit 1 - fi - login_payload="$(jq -nc --arg u "$DOCKERHUB_USERNAME" --arg p "$DOCKERHUB_PASSWORD" '{username:$u,password:$p}')" auth_token="$( printf '%s' "$login_payload" \ @@ -845,9 +783,15 @@ jobs: fi delete_tag_with_retry() { - local tag="$1" + local image_repo="$1" tag="$2" namespace repository local attempt=1 local max_attempts=5 + namespace="${image_repo%%/*}" + repository="${image_repo#*/}" + if [ "$namespace" = "$repository" ]; then + echo "Invalid image repo format: $image_repo" + return 1 + fi while [ "$attempt" -le "$max_attempts" ]; do if ! status_code="$( curl -sS -o /tmp/dockerhub-delete-response.txt -w "%{http_code}" -X DELETE \ @@ -855,37 +799,38 @@ jobs: "https://hub.docker.com/v2/repositories/${namespace}/${repository}/tags/${tag}/" )"; then status_code="000" - echo "Delete ${IMAGE_REPO}:${tag} failed to reach Docker Hub (curl error)" + echo "Delete ${image_repo}:${tag} failed to reach Docker Hub (curl error)" fi if [ "$status_code" = "204" ] || [ "$status_code" = "404" ]; then - echo "Tag ${IMAGE_REPO}:${tag} delete status: $status_code" + echo "Tag ${image_repo}:${tag} delete status: $status_code" return 0 fi if [ "$attempt" -lt "$max_attempts" ]; then - echo "Delete ${IMAGE_REPO}:${tag} failed with HTTP ${status_code}, retrying (${attempt}/${max_attempts})" + echo "Delete ${image_repo}:${tag} failed with HTTP ${status_code}, retrying (${attempt}/${max_attempts})" sleep $((attempt * 5)) fi attempt=$((attempt + 1)) done - echo "Delete ${IMAGE_REPO}:${tag} failed after ${max_attempts} attempts" + echo "Delete ${image_repo}:${tag} failed after ${max_attempts} attempts" cat /tmp/dockerhub-delete-response.txt || true return 1 } cleanup_failures=0 - if ! delete_tag_with_retry "${VERSION_TAG}-amd64"; then - echo "Warning: failed to delete ${IMAGE_REPO}:${VERSION_TAG}-amd64" - cleanup_failures=1 - fi - - if ! delete_tag_with_retry "${VERSION_TAG}-arm64"; then - echo "Warning: failed to delete ${IMAGE_REPO}:${VERSION_TAG}-arm64" - cleanup_failures=1 - fi + while IFS= read -r image_repo; do + if ! delete_tag_with_retry "$image_repo" "${VERSION_TAG}-amd64"; then + echo "Warning: failed to delete ${image_repo}:${VERSION_TAG}-amd64" + cleanup_failures=1 + fi + if ! delete_tag_with_retry "$image_repo" "${VERSION_TAG}-arm64"; then + echo "Warning: failed to delete ${image_repo}:${VERSION_TAG}-arm64" + cleanup_failures=1 + fi + done < <(jq -r '.[].image_repo' <<< "$BUILD_MATRIX_JSON") if [ "$cleanup_failures" -ne 0 ]; then echo "Temporary arch-tag cleanup completed with warnings" diff --git a/.github/workflows/publish_release_pd_store_server_image.yml b/.github/workflows/publish_release_pd_store_server_image.yml index 93c5bf0..78b42d3 100644 --- a/.github/workflows/publish_release_pd_store_server_image.yml +++ b/.github/workflows/publish_release_pd_store_server_image.yml @@ -4,9 +4,13 @@ on: workflow_dispatch: inputs: branch: + required: true + default: 'master' + description: 'source branch used to build the release images' + version_tag: required: true default: '' - description: 'The branch name should be like *-x.x.x, for example release-1.0.0' + description: 'image version tag, for example 1.7.0' mvn_args: required: false default: '' @@ -32,6 +36,7 @@ jobs: mode: release repository_url: apache/hugegraph branch: ${{ inputs.branch }} + version_tag: ${{ inputs.version_tag }} mvn_args: ${{ inputs.mvn_args }} strict_mode: ${{ inputs.strict_mode }} wait_timeout_sec: ${{ inputs.wait_timeout_sec || '300' }} From 3c5e436461f9cbd74dae9e6d0b541531b8317707 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 22 Jul 2026 14:52:24 +0800 Subject: [PATCH 6/9] refactor(workflows): publish multiarch once - load amd64 and arm64 candidates into the containerd image store - run integration checks against final local image tags - push validated multi-platform indexes without temporary tags - document the simplified publication and release inputs --- .../_publish_pd_store_server_reusable.yml | 239 +++--------------- README.md | 77 +++--- 2 files changed, 76 insertions(+), 240 deletions(-) diff --git a/.github/workflows/_publish_pd_store_server_reusable.yml b/.github/workflows/_publish_pd_store_server_reusable.yml index 7e0dd71..faa8944 100644 --- a/.github/workflows/_publish_pd_store_server_reusable.yml +++ b/.github/workflows/_publish_pd_store_server_reusable.yml @@ -203,15 +203,16 @@ jobs: echo "- Publish images: \`$PUBLISH_IMAGES\`" echo "- Dry run: \`$DRY_RUN\`" if [ "$PUBLISH_IMAGES" = "true" ]; then - echo "- Cache writer: amd64 candidate job" + echo "- Cache writer: tested multi-platform candidate job" else echo "- Cache policy: read-only validation (no registry exports)" fi - echo "- ARM strategy: one x86 job, shared build stage, QEMU target-image stages" + echo "- Build strategy: one x86 job loads amd64/arm64 images into the containerd image store" + echo "- Test platform: locally loaded amd64 variants from the final multi-platform tags" echo "- Shared per-module caches: \`buildcache-$CACHE_CHANNEL\`" } >> "$GITHUB_STEP_SUMMARY" - build_test_publish_amd64: + build_test_publish_multiarch: needs: prepare if: ${{ needs.prepare.outputs.need_update == 'true' }} runs-on: ubuntu-latest @@ -221,10 +222,10 @@ jobs: MVN_ARGS: ${{ inputs.mvn_args }} WAIT_TIMEOUT_SEC: ${{ inputs.wait_timeout_sec }} VERSION_TAG: ${{ needs.prepare.outputs.version_tag }} - PD_IMAGE: hugegraph/pd:${{ needs.prepare.outputs.version_tag }}-amd64 - STORE_IMAGE: hugegraph/store:${{ needs.prepare.outputs.version_tag }}-amd64 - HSTORE_IMAGE: hugegraph/server:${{ needs.prepare.outputs.version_tag }}-amd64 - STANDALONE_IMAGE: hugegraph/hugegraph:${{ needs.prepare.outputs.version_tag }}-amd64 + PD_IMAGE: hugegraph/pd:${{ needs.prepare.outputs.version_tag }} + STORE_IMAGE: hugegraph/store:${{ needs.prepare.outputs.version_tag }} + HSTORE_IMAGE: hugegraph/server:${{ needs.prepare.outputs.version_tag }} + STANDALONE_IMAGE: hugegraph/hugegraph:${{ needs.prepare.outputs.version_tag }} steps: - name: Validate wait timeout run: | @@ -240,6 +241,19 @@ jobs: ref: ${{ env.SOURCE_SHA }} fetch-depth: 2 + - name: Set up Docker with containerd image store + uses: docker/setup-docker-action@v5 + with: + daemon-config: | + { + "features": { + "containerd-snapshotter": true + } + } + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 with: @@ -256,54 +270,63 @@ jobs: docker system prune -af || true docker builder prune -af || true - - name: Build x86 PD image for integration check + - name: Build multi-platform PD candidate uses: docker/build-push-action@v7 with: context: . file: ./hugegraph-pd/Dockerfile - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 load: true tags: ${{ env.PD_IMAGE }} cache-from: type=registry,ref=hugegraph/pd:buildcache-${{ needs.prepare.outputs.cache_channel }} cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/pd:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} build-args: ${{ env.MVN_ARGS }} - - name: Build x86 Store image for integration check + - name: Build multi-platform Store candidate uses: docker/build-push-action@v7 with: context: . file: ./hugegraph-store/Dockerfile - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 load: true tags: ${{ env.STORE_IMAGE }} cache-from: type=registry,ref=hugegraph/store:buildcache-${{ needs.prepare.outputs.cache_channel }} cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/store:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} build-args: ${{ env.MVN_ARGS }} - - name: Build x86 Server(hstore) image for integration check + - name: Build multi-platform Server(hstore) candidate uses: docker/build-push-action@v7 with: context: . file: ./hugegraph-server/Dockerfile-hstore - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 load: true tags: ${{ env.HSTORE_IMAGE }} cache-from: type=registry,ref=hugegraph/server:buildcache-${{ needs.prepare.outputs.cache_channel }} cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/server:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} build-args: ${{ env.MVN_ARGS }} - - name: Build x86 standalone Server candidate + - name: Build multi-platform standalone Server candidate uses: docker/build-push-action@v7 with: context: . file: ./hugegraph-server/Dockerfile - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 load: true tags: ${{ env.STANDALONE_IMAGE }} cache-from: type=registry,ref=hugegraph/hugegraph:buildcache-${{ needs.prepare.outputs.cache_channel }} cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/hugegraph:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} build-args: ${{ env.MVN_ARGS }} + - name: Verify locally loaded platform variants + run: | + set -euo pipefail + for image in "$PD_IMAGE" "$STORE_IMAGE" "$HSTORE_IMAGE" "$STANDALONE_IMAGE"; do + docker image inspect --platform linux/amd64 "$image" >/dev/null + docker image inspect --platform linux/arm64 "$image" >/dev/null + echo "Loaded amd64 and arm64 variants: ${image}" + done + - name: Start compose stack with local images if: ${{ inputs.strict_mode }} run: | @@ -640,13 +663,12 @@ jobs: docker logs hg-ci-standalone exit 1 - - name: Push tested amd64 candidates + - name: Push tested multi-platform candidates if: ${{ success() && needs.prepare.outputs.publish_images == 'true' }} run: | set -euo pipefail for image in "$PD_IMAGE" "$STORE_IMAGE" "$HSTORE_IMAGE" "$STANDALONE_IMAGE"; do - image_id="$(docker image inspect --format '{{.Id}}' "$image")" - echo "Pushing tested candidate ${image} (${image_id})" + echo "Pushing tested multi-platform candidate ${image}" docker push "$image" done @@ -660,186 +682,9 @@ jobs: docker system prune -af || true docker builder prune -af || true - publish_arm64: - needs: [prepare, build_test_publish_amd64] - if: ${{ needs.prepare.outputs.need_update == 'true' && needs.build_test_publish_amd64.result == 'success' }} - runs-on: ubuntu-latest - env: - REPOSITORY_URL: ${{ inputs.repository_url }} - SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} - VERSION_TAG: ${{ needs.prepare.outputs.version_tag }} - MVN_ARGS: ${{ inputs.mvn_args }} - CACHE_CHANNEL: ${{ needs.prepare.outputs.cache_channel }} - BUILD_MATRIX_JSON: ${{ needs.prepare.outputs.build_matrix_json }} - steps: - - name: Checkout source - uses: actions/checkout@v7 - with: - repository: ${{ env.REPOSITORY_URL }} - ref: ${{ env.SOURCE_SHA }} - fetch-depth: 2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - with: - version: latest - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - - - name: Build arm64 images sequentially - env: - PUBLISH_IMAGES: ${{ needs.prepare.outputs.publish_images }} - run: | - set -euo pipefail - - output_flags=(--output type=cacheonly) - if [ "$PUBLISH_IMAGES" = "true" ]; then - output_flags=(--push) - fi - - while IFS= read -r item; do - module="$(jq -r '.module' <<< "$item")" - image_repo="$(jq -r '.image_repo' <<< "$item")" - dockerfile="$(jq -r '.dockerfile' <<< "$item")" - image_arm64="${image_repo}:${VERSION_TAG}-arm64" - cache_ref="${image_repo}:buildcache-${CACHE_CHANNEL}" - - echo "Building ${module}: ${image_arm64}" - build_command=( - docker buildx build - --file "$dockerfile" - --platform linux/arm64 - --tag "$image_arm64" - --cache-from "type=registry,ref=${cache_ref}" - ) - while IFS= read -r build_arg; do - if [ -n "$build_arg" ]; then - build_command+=(--build-arg "$build_arg") - fi - done <<< "$MVN_ARGS" - build_command+=("${output_flags[@]}" .) - "${build_command[@]}" - done < <(jq -c '.[]' <<< "$BUILD_MATRIX_JSON") - - publish_manifest: - needs: [prepare, build_test_publish_amd64, publish_arm64] - if: ${{ needs.prepare.outputs.need_update == 'true' && needs.prepare.outputs.publish_images == 'true' && needs.build_test_publish_amd64.result == 'success' && needs.publish_arm64.result == 'success' }} - runs-on: ubuntu-latest - env: - VERSION_TAG: ${{ needs.prepare.outputs.version_tag }} - BUILD_MATRIX_JSON: ${{ needs.prepare.outputs.build_matrix_json }} - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - with: - version: latest - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - - - name: Create and inspect multi-arch manifests - run: | - set -euo pipefail - while IFS= read -r image_repo; do - image_final="${image_repo}:${VERSION_TAG}" - image_amd64="${image_repo}:${VERSION_TAG}-amd64" - image_arm64="${image_repo}:${VERSION_TAG}-arm64" - docker buildx imagetools create \ - --tag "$image_final" \ - "$image_amd64" \ - "$image_arm64" - docker buildx imagetools inspect "$image_final" - done < <(jq -r '.[].image_repo' <<< "$BUILD_MATRIX_JSON") - - - name: Delete temporary arch tags after manifest publish - continue-on-error: true - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} - run: | - set -euo pipefail - - login_payload="$(jq -nc --arg u "$DOCKERHUB_USERNAME" --arg p "$DOCKERHUB_PASSWORD" '{username:$u,password:$p}')" - auth_token="$( - printf '%s' "$login_payload" \ - | curl --fail-with-body -sS -X POST "https://hub.docker.com/v2/users/login/" \ - -H "Content-Type: application/json" \ - --data-binary @- \ - | jq -r '.token' - )" - if [ -z "$auth_token" ] || [ "$auth_token" = "null" ]; then - echo "Failed to get Docker Hub auth token" - exit 1 - fi - - delete_tag_with_retry() { - local image_repo="$1" tag="$2" namespace repository - local attempt=1 - local max_attempts=5 - namespace="${image_repo%%/*}" - repository="${image_repo#*/}" - if [ "$namespace" = "$repository" ]; then - echo "Invalid image repo format: $image_repo" - return 1 - fi - while [ "$attempt" -le "$max_attempts" ]; do - if ! status_code="$( - curl -sS -o /tmp/dockerhub-delete-response.txt -w "%{http_code}" -X DELETE \ - -H "Authorization: JWT $auth_token" \ - "https://hub.docker.com/v2/repositories/${namespace}/${repository}/tags/${tag}/" - )"; then - status_code="000" - echo "Delete ${image_repo}:${tag} failed to reach Docker Hub (curl error)" - fi - - if [ "$status_code" = "204" ] || [ "$status_code" = "404" ]; then - echo "Tag ${image_repo}:${tag} delete status: $status_code" - return 0 - fi - - if [ "$attempt" -lt "$max_attempts" ]; then - echo "Delete ${image_repo}:${tag} failed with HTTP ${status_code}, retrying (${attempt}/${max_attempts})" - sleep $((attempt * 5)) - fi - attempt=$((attempt + 1)) - done - - echo "Delete ${image_repo}:${tag} failed after ${max_attempts} attempts" - cat /tmp/dockerhub-delete-response.txt || true - return 1 - } - - cleanup_failures=0 - - while IFS= read -r image_repo; do - if ! delete_tag_with_retry "$image_repo" "${VERSION_TAG}-amd64"; then - echo "Warning: failed to delete ${image_repo}:${VERSION_TAG}-amd64" - cleanup_failures=1 - fi - if ! delete_tag_with_retry "$image_repo" "${VERSION_TAG}-arm64"; then - echo "Warning: failed to delete ${image_repo}:${VERSION_TAG}-arm64" - cleanup_failures=1 - fi - done < <(jq -r '.[].image_repo' <<< "$BUILD_MATRIX_JSON") - - if [ "$cleanup_failures" -ne 0 ]; then - echo "Temporary arch-tag cleanup completed with warnings" - exit 1 - fi - update_latest_hash: - needs: [prepare, publish_manifest] - if: ${{ inputs.mode == 'latest' && inputs.branch == 'master' && inputs.enable_hash_gate && needs.prepare.outputs.need_update == 'true' && needs.publish_manifest.result == 'success' }} + needs: [prepare, build_test_publish_multiarch] + if: ${{ inputs.mode == 'latest' && inputs.branch == 'master' && inputs.enable_hash_gate && needs.prepare.outputs.need_update == 'true' && needs.build_test_publish_multiarch.result == 'success' }} runs-on: ubuntu-latest steps: - name: Validate hash update inputs diff --git a/README.md b/README.md index 1f47368..d897d42 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,10 @@ The two publishing modes behave differently: - updates the stored `LAST_*_HASH` variable after a successful publish - `release` mode - - manual publish from a versioned branch such as `release-1.7.0` + - manual publish from an explicitly selected source branch or commit - always publishes when invoked - - derives the image tag from the release branch version + - uses an explicit `version_tag`, independent from the source branch (for + example, source `master` can publish tag `1.7.0`) ## Multi-Platform Build Performance @@ -97,17 +98,18 @@ This allows an upstream Dockerfile branch to be benchmarked before merge without changing public images or production cache state. For pd/store/server, a manual `master` run can also set `dry_run=true`. This -forces a fresh exact-master amd64 integration check and arm64 build even -when the source hash is unchanged, while disabling image pushes, cache exports, -manifest creation, and hash updates. +forces a fresh exact-master multi-platform build and integration check even when +the source hash is unchanged, while disabling image pushes, cache exports, and +hash updates. ## Critical Path: PD/Store/Server `pd/store/server` is the most important publishing flow in this repository and uses a dedicated reusable workflow: [`.github/workflows/_publish_pd_store_server_reusable.yml`](./.github/workflows/_publish_pd_store_server_reusable.yml). -The amd64 candidate job builds PD, Store, HStore Server, and standalone Server -exactly once. It starts the upstream `docker/docker-compose.dev.yml` topology with +One candidate job builds PD, Store, HStore Server, and standalone Server as +amd64/arm64 images and loads both variants into Docker's containerd image store. +It starts the upstream `docker/docker-compose.dev.yml` topology with `pull_policy: never`, and runs a functional graph check before any image is published. Compatible source revisions that have the same service contract but only contain `docker/docker-compose.yml` use that legacy file as a fallback. The @@ -115,9 +117,10 @@ check executes the Server image's bundled `/hugegraph-server/scripts/example.groovy` file, verifies the six-vertex, six-edge sample graph, then performs separate Gremlin read, create, update, and delete requests. The final query must return to the original 6V/6E baseline. -The same loaded standalone candidate then passes its smoke test. Only after all -enabled checks succeed are those exact local image IDs pushed as temporary -`*-amd64` tags; the publishing stage does not rebuild them. +The same loaded standalone candidate then passes its smoke test. Docker selects +the local amd64 variants for these checks. Only after all enabled checks succeed +are the already loaded multi-platform final tags pushed; the publishing stage +does not rebuild images and does not create temporary architecture tags. The precheck override constrains the three JVMs and Store buffers for a small CI workload. PD and Store are limited to 1 GiB each, Server to 1.5 GiB, while @@ -126,30 +129,21 @@ profile. These settings are functional-test limits, not production guidance or a performance baseline. ```text - source branch (master / release-x.y.z) + source branch or commit | v prepare job - (resolve source SHA, version tag, hash gate) + (resolve source SHA, explicit version tag, hash gate) | v - build_test_publish_amd64 (x4 candidates) + build_test_publish_multiarch (one job) +-------------------------------------------------+ | pd | store | server-hstore | server-standalone | +-------------------------------------------------+ + build and load linux/amd64 + linux/arm64 variants low-memory compose + bundled graph + Gremlin CRUD standalone smoke test - push the exact tested amd64 image IDs - push x.y.z-amd64 (or latest-amd64) - | - v - publish_arm64 (matrix x4 modules) - push x.y.z-arm64 (or latest-arm64) - | - v - publish_manifest (matrix x4 modules) - merge amd64+arm64 => x.y.z (or latest) manifest - then delete temporary -amd64 / -arm64 tags + push loaded x.y.z (or latest) indexes | v update_latest_hash (latest mode only, optional) @@ -157,21 +151,17 @@ a performance baseline. Tag behavior: -- If the `amd64` publish succeeds but the `arm64` publish fails, manifest is not created and the `*-amd64` tag remains available. -- If both amd64 and arm64 succeed, manifest publish runs and then removes temporary `*-amd64` and `*-arm64` tags. -- End users should primarily use `latest` or release version tags (`x.y.z`). +- Final tags contain both `linux/amd64` and `linux/arm64` variants. +- No temporary `*-amd64` or `*-arm64` tags are created. +- Failed builds or functional checks stop the job before any candidate is pushed. Execution note: -- `publish_arm64` runs after the amd64 candidate gate, so ARM compute is not - spent when the functional checks fail. -- ARM runner selection follows the exact source SHA. When all four Dockerfiles - use `FROM --platform=$BUILDPLATFORM`, an x86 runner reuses the amd64 candidate - cache and limits QEMU to target-image runtime steps. A measured native-ARM - trial on current master made each module slower because it rebuilt Maven on - ARM. Older release Dockerfiles without that build-stage pin instead use a - native ARM runner and isolated ARM cache, avoiding a full Maven build under - emulation. Dry-runs read existing caches but never export new ones. +- All four current Dockerfiles use `FROM --platform=$BUILDPLATFORM` for their + portable build stages. The x86 runner therefore performs Maven work natively + and limits QEMU to ARM target-image runtime steps. +- The single job shares checkout, Docker, QEMU, Buildx, and login setup. Dry-runs + read existing caches but never export new ones. ## Why The Wrappers Stay Split @@ -184,12 +174,12 @@ Although the `latest` and `release` wrappers look similar, they encode different - `release` is the intentional publication path. - It is triggered manually. - - It expects a release branch and publishes that branch as a versioned image. + - Its source `branch` and destination `version_tag` are independent. - It should run even if the source is unchanged, because the operator is explicitly asking for a release publication. Most wrappers use [`.github/workflows/_publish_image_reusable.yml`](./.github/workflows/_publish_image_reusable.yml). -The pd/store/server wrappers use [`.github/workflows/_publish_pd_store_server_reusable.yml`](./.github/workflows/_publish_pd_store_server_reusable.yml), which adds integration precheck plus staged amd64/arm64 publish and manifest merge. +The pd/store/server wrappers use [`.github/workflows/_publish_pd_store_server_reusable.yml`](./.github/workflows/_publish_pd_store_server_reusable.yml), which adds an integration precheck and single-job multi-platform publication. ## Reusable Workflow Responsibilities @@ -209,11 +199,10 @@ Reusable workflows are the real implementation layer. `_publish_pd_store_server_reusable.yml` handles the pd/store/server flow: - shared source SHA resolution and latest hash gate -- build-once amd64 candidates followed by strict low-memory integration precheck for pd/store/server (hstore backend, `hugegraph/server`) +- build and locally load multi-platform candidates followed by strict low-memory integration precheck for pd/store/server (hstore backend, `hugegraph/server`) - import of the Server image's bundled `example.groovy` graph and Gremlin CRUD validation -- exact-tested amd64 publication followed by cached `*-arm64` builds -- manifest merge to final tag (`latest` or release version) -- remove temporary `*-amd64` and `*-arm64` tags after successful manifest publish +- publication of the loaded amd64/arm64 index directly to the final tag +- independent release source and destination version inputs - standalone server smoke test for `hugegraph/hugegraph` The current precheck intentionally uses a 1 PD + 1 Store + 1 Server topology so @@ -274,7 +263,9 @@ For example, [`.github/workflows/publish_latest_pd_store_server_image.yml`](./.g ## Practical Notes - `latest` workflows typically run on a schedule and accept manual dispatch. -- `release` workflows typically accept only manual dispatch with a branch input. +- `release` workflows typically accept only manual dispatch. The specialized + pd/store/server release wrapper accepts both a source `branch` and an + independent `version_tag`. - Most image workflows inherit credentials and settings through a reusable workflow. - If you change shared standard behavior, update `_publish_image_reusable.yml` first. - If you change pd/store/server behavior, update `_publish_pd_store_server_reusable.yml` first. From 85338fb09f7b9b3985aebc2be8282f7f3f42d617 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 22 Jul 2026 15:08:37 +0800 Subject: [PATCH 7/9] fix(workflows): drop checkout credentials - disable persisted credentials for external source checkout - keep source builds read-only after checkout - address the remaining applicable PR security comment --- .github/workflows/_publish_pd_store_server_reusable.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/_publish_pd_store_server_reusable.yml b/.github/workflows/_publish_pd_store_server_reusable.yml index faa8944..1676e72 100644 --- a/.github/workflows/_publish_pd_store_server_reusable.yml +++ b/.github/workflows/_publish_pd_store_server_reusable.yml @@ -240,6 +240,7 @@ jobs: repository: ${{ env.REPOSITORY_URL }} ref: ${{ env.SOURCE_SHA }} fetch-depth: 2 + persist-credentials: false - name: Set up Docker with containerd image store uses: docker/setup-docker-action@v5 From 267dfdbd68e40925476158949e82eb33dc41fd31 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 22 Jul 2026 15:14:14 +0800 Subject: [PATCH 8/9] fix(workflows): harden publish gates - prevent dry runs from updating the latest source hash - retry final multi-platform image pushes on transient failures - require local standalone images and remove dead matrix output - align release documentation with branch-only source resolution --- .../_publish_pd_store_server_reusable.yml | 50 ++++++------------- README.md | 7 +-- 2 files changed, 19 insertions(+), 38 deletions(-) diff --git a/.github/workflows/_publish_pd_store_server_reusable.yml b/.github/workflows/_publish_pd_store_server_reusable.yml index 1676e72..8830115 100644 --- a/.github/workflows/_publish_pd_store_server_reusable.yml +++ b/.github/workflows/_publish_pd_store_server_reusable.yml @@ -83,7 +83,6 @@ jobs: version_tag: ${{ steps.prepare.outputs.version_tag }} cache_channel: ${{ steps.prepare.outputs.cache_channel }} publish_images: ${{ steps.prepare.outputs.publish_images }} - build_matrix_json: ${{ steps.prepare.outputs.build_matrix_json }} steps: - name: Resolve mode and source ref id: prepare @@ -157,36 +156,6 @@ jobs: echo "cache_channel=$cache_channel" echo "publish_images=$publish_images" echo "need_update=$need_update" - echo "build_matrix_json<> "$GITHUB_OUTPUT" - name: Summarize build cache strategy @@ -649,7 +618,7 @@ jobs: if: ${{ success() }} run: | set -euo pipefail - docker run -d --name=hg-ci-standalone -p 18080:8080 "$STANDALONE_IMAGE" + docker run --pull=never -d --name=hg-ci-standalone -p 18080:8080 "$STANDALONE_IMAGE" for attempt in $(seq 1 30); do if curl -fsS --connect-timeout 3 --max-time 8 http://127.0.0.1:18080/versions >/dev/null; then echo "Standalone candidate is ready after attempt ${attempt}" @@ -669,8 +638,19 @@ jobs: run: | set -euo pipefail for image in "$PD_IMAGE" "$STORE_IMAGE" "$HSTORE_IMAGE" "$STANDALONE_IMAGE"; do - echo "Pushing tested multi-platform candidate ${image}" - docker push "$image" + pushed="false" + for attempt in 1 2 3; do + echo "Pushing tested multi-platform candidate ${image} (attempt ${attempt}/3)" + if docker push "$image"; then + pushed="true" + break + fi + sleep $((attempt * 5)) + done + if [ "$pushed" != "true" ]; then + echo "Failed to push ${image} after 3 attempts" + exit 1 + fi done - name: Cleanup standalone container @@ -685,7 +665,7 @@ jobs: update_latest_hash: needs: [prepare, build_test_publish_multiarch] - if: ${{ inputs.mode == 'latest' && inputs.branch == 'master' && inputs.enable_hash_gate && needs.prepare.outputs.need_update == 'true' && needs.build_test_publish_multiarch.result == 'success' }} + if: ${{ inputs.mode == 'latest' && inputs.branch == 'master' && inputs.enable_hash_gate && needs.prepare.outputs.need_update == 'true' && needs.prepare.outputs.publish_images == 'true' && needs.build_test_publish_multiarch.result == 'success' }} runs-on: ubuntu-latest steps: - name: Validate hash update inputs diff --git a/README.md b/README.md index d897d42..b8b3b91 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The two publishing modes behave differently: - updates the stored `LAST_*_HASH` variable after a successful publish - `release` mode - - manual publish from an explicitly selected source branch or commit + - manual publish from an explicitly selected source branch - always publishes when invoked - uses an explicit `version_tag`, independent from the source branch (for example, source `master` can publish tag `1.7.0`) @@ -129,7 +129,7 @@ profile. These settings are functional-test limits, not production guidance or a performance baseline. ```text - source branch or commit + source branch | v prepare job @@ -210,7 +210,8 @@ it fits standard GitHub-hosted runners. A full 3 PD + 3 Store + 3 Server compose gate remains a TODO for a larger runner or a reliable lower-resource simulation. Wrapper workflows provide the source repository, branch, and mode-specific inputs. -Standard wrappers may also pass `build_matrix_json`, while the pd/store/server matrix is defined inside `_publish_pd_store_server_reusable.yml`. +Standard wrappers may also pass `build_matrix_json`; the specialized +pd/store/server workflow defines its four image builds directly. ## How To Extend From 80b99228892735443661b12740f09b258e5850ce Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 22 Jul 2026 15:27:08 +0800 Subject: [PATCH 9/9] fix(workflows): retry emulated builds - consolidate multi-platform builds behind one helper - retry transient QEMU runtime-stage failures per module - reuse completed BuildKit layers between attempts - preserve local multi-platform loading and cache policy --- .../_publish_pd_store_server_reusable.yml | 102 ++++++++++-------- 1 file changed, 55 insertions(+), 47 deletions(-) diff --git a/.github/workflows/_publish_pd_store_server_reusable.yml b/.github/workflows/_publish_pd_store_server_reusable.yml index 8830115..4620113 100644 --- a/.github/workflows/_publish_pd_store_server_reusable.yml +++ b/.github/workflows/_publish_pd_store_server_reusable.yml @@ -191,6 +191,8 @@ jobs: MVN_ARGS: ${{ inputs.mvn_args }} WAIT_TIMEOUT_SEC: ${{ inputs.wait_timeout_sec }} VERSION_TAG: ${{ needs.prepare.outputs.version_tag }} + CACHE_CHANNEL: ${{ needs.prepare.outputs.cache_channel }} + PUBLISH_IMAGES: ${{ needs.prepare.outputs.publish_images }} PD_IMAGE: hugegraph/pd:${{ needs.prepare.outputs.version_tag }} STORE_IMAGE: hugegraph/store:${{ needs.prepare.outputs.version_tag }} HSTORE_IMAGE: hugegraph/server:${{ needs.prepare.outputs.version_tag }} @@ -240,53 +242,59 @@ jobs: docker system prune -af || true docker builder prune -af || true - - name: Build multi-platform PD candidate - uses: docker/build-push-action@v7 - with: - context: . - file: ./hugegraph-pd/Dockerfile - platforms: linux/amd64,linux/arm64 - load: true - tags: ${{ env.PD_IMAGE }} - cache-from: type=registry,ref=hugegraph/pd:buildcache-${{ needs.prepare.outputs.cache_channel }} - cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/pd:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} - build-args: ${{ env.MVN_ARGS }} - - - name: Build multi-platform Store candidate - uses: docker/build-push-action@v7 - with: - context: . - file: ./hugegraph-store/Dockerfile - platforms: linux/amd64,linux/arm64 - load: true - tags: ${{ env.STORE_IMAGE }} - cache-from: type=registry,ref=hugegraph/store:buildcache-${{ needs.prepare.outputs.cache_channel }} - cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/store:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} - build-args: ${{ env.MVN_ARGS }} - - - name: Build multi-platform Server(hstore) candidate - uses: docker/build-push-action@v7 - with: - context: . - file: ./hugegraph-server/Dockerfile-hstore - platforms: linux/amd64,linux/arm64 - load: true - tags: ${{ env.HSTORE_IMAGE }} - cache-from: type=registry,ref=hugegraph/server:buildcache-${{ needs.prepare.outputs.cache_channel }} - cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/server:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} - build-args: ${{ env.MVN_ARGS }} - - - name: Build multi-platform standalone Server candidate - uses: docker/build-push-action@v7 - with: - context: . - file: ./hugegraph-server/Dockerfile - platforms: linux/amd64,linux/arm64 - load: true - tags: ${{ env.STANDALONE_IMAGE }} - cache-from: type=registry,ref=hugegraph/hugegraph:buildcache-${{ needs.prepare.outputs.cache_channel }} - cache-to: ${{ needs.prepare.outputs.publish_images == 'true' && format('type=registry,ref=hugegraph/hugegraph:buildcache-{0},mode=max', needs.prepare.outputs.cache_channel) || '' }} - build-args: ${{ env.MVN_ARGS }} + - name: Build multi-platform candidates with retries + run: | + set -euo pipefail + + build_candidate() { + local module="$1" image="$2" dockerfile="$3" cache_repo="$4" + local attempt succeeded + local -a build_command + succeeded="false" + + for attempt in 1 2 3; do + echo "Building ${module} (attempt ${attempt}/3) at $(date -u +%FT%TZ)" + build_command=( + docker buildx build + --file "$dockerfile" + --platform "linux/amd64,linux/arm64" + --tag "$image" + --cache-from "type=registry,ref=${cache_repo}:buildcache-${CACHE_CHANNEL}" + --load + ) + if [ "$PUBLISH_IMAGES" = "true" ]; then + build_command+=( + --cache-to "type=registry,ref=${cache_repo}:buildcache-${CACHE_CHANNEL},mode=max" + ) + fi + while IFS= read -r build_arg; do + if [ -n "$build_arg" ]; then + build_command+=(--build-arg "$build_arg") + fi + done <<< "$MVN_ARGS" + build_command+=(.) + + if "${build_command[@]}"; then + succeeded="true" + echo "Built ${module} at $(date -u +%FT%TZ)" + break + fi + if [ "$attempt" -lt 3 ]; then + echo "Build failed for ${module}; retrying with cached layers" + sleep $((attempt * 5)) + fi + done + + if [ "$succeeded" != "true" ]; then + echo "Failed to build ${module} after 3 attempts" + return 1 + fi + } + + build_candidate pd "$PD_IMAGE" ./hugegraph-pd/Dockerfile hugegraph/pd + build_candidate store "$STORE_IMAGE" ./hugegraph-store/Dockerfile hugegraph/store + build_candidate server-hstore "$HSTORE_IMAGE" ./hugegraph-server/Dockerfile-hstore hugegraph/server + build_candidate server-standalone "$STANDALONE_IMAGE" ./hugegraph-server/Dockerfile hugegraph/hugegraph - name: Verify locally loaded platform variants run: |