diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index e970e60c05a3..9570c9775cbf 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -382,6 +382,27 @@ jobs: --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \ . + prepare_release_candidate: + name: Prepare shared release candidate + needs: [resolve_target, evidence_reuse] + if: ${{ always() && needs.resolve_target.result == 'success' && needs.evidence_reuse.outputs.reuse != 'true' && inputs.release_package_spec == '' && inputs.package_acceptance_package_spec == '' && contains(fromJSON('["all","plugin-prerelease","release-checks","cross-os","live-e2e","package"]'), inputs.rerun_group) }} + permissions: + actions: read + contents: read + packages: write + uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml + with: + ref: ${{ needs.resolve_target.outputs.sha }} + prepare_only: true + include_repo_e2e: false + include_release_path_suites: false + include_openwebui: false + include_live_suites: false + allow_unreleased_changelog: ${{ inputs.allow_unreleased_changelog || (inputs.target_context_ref == '' && (inputs.ref == 'main' || inputs.ref == 'refs/heads/main')) }} + release_test_profile: ${{ inputs.release_profile }} + shared_image_artifact_namespace: full-release + shared_image_policy: no-push-artifact + normal_ci: name: Run normal full CI needs: [resolve_target, evidence_reuse] @@ -578,8 +599,8 @@ jobs: plugin_prerelease: name: Run plugin prerelease validation - needs: [resolve_target, evidence_reuse] - if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} + needs: [resolve_target, evidence_reuse, prepare_release_candidate] + if: ${{ always() && needs.resolve_target.result == 'success' && (needs.prepare_release_candidate.result == 'success' || needs.prepare_release_candidate.result == 'skipped') && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || inputs.release_profile == 'stable' && 240 || 60 }} outputs: @@ -595,6 +616,7 @@ jobs: TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} + CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} run: | set -euo pipefail @@ -759,12 +781,16 @@ jobs: dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-plugin-prerelease" dispatch_run_name="Plugin Prerelease ${dispatch_id}" - dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" -f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id" + args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id") + if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then + args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") + fi + dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" "${args[@]}" release_checks: name: Run release/live/Docker/QA validation - needs: [resolve_target, evidence_reuse] - if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} + needs: [resolve_target, evidence_reuse, prepare_release_candidate] + if: ${{ always() && needs.resolve_target.result == 'success' && (needs.prepare_release_candidate.result == 'success' || needs.prepare_release_candidate.result == 'skipped') && contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: ${{ inputs.release_profile != 'beta' && 240 || 60 }} outputs: @@ -792,6 +818,7 @@ jobs: RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }} PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }} + CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} run: | set -euo pipefail @@ -1099,6 +1126,9 @@ jobs: if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC") fi + if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then + args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") + fi dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks" dispatch_run_name="OpenClaw Release Checks ${dispatch_id}" @@ -1463,6 +1493,7 @@ jobs: resolve_target, evidence_reuse, docker_runtime_assets_preflight, + prepare_release_candidate, normal_ci, plugin_prerelease, release_checks, @@ -1488,6 +1519,9 @@ jobs: PERFORMANCE_RESULT: ${{ needs.performance.result }} RELEASE_PROFILE: ${{ inputs.release_profile }} DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }} + PREPARE_RELEASE_CANDIDATE_RESULT: ${{ needs.prepare_release_candidate.result }} + RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }} + PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} EVIDENCE_REUSE: ${{ needs.evidence_reuse.outputs.reuse }} EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }} EVIDENCE_RUN_URL: ${{ needs.evidence_reuse.outputs.evidence_run_url }} @@ -1682,26 +1716,26 @@ jobs: | .[]) ' || echo "_Unable to summarize jobs for run ${run_id}._" echo - echo "### Longest queues: ${label}" + echo "### Longest start delays: ${label}" echo gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq ".jobs[] | @json" | jq -sr ' def ts: fromdateiso8601; - "| Job | Result | Queue minutes | Run minutes |", + "| Job | Result | Start delay minutes | Run minutes |", "| --- | --- | ---: | ---: |", ([.[] | select(.created_at != null and .started_at != null) | . + { - queueMin: ((((.started_at | ts) - (.created_at | ts)) / 60) * 10 | round / 10), + startDelayMin: ((((.started_at | ts) - (.created_at | ts)) / 60) * 10 | round / 10), durationMin: (if .completed_at == null then null else ((((.completed_at | ts) - (.started_at | ts)) / 60) * 10 | round / 10) end) } - | select(.queueMin > 0) - | {name, conclusion, queueMin, durationMin}] - | sort_by(.queueMin) + | select(.startDelayMin > 0) + | {name, conclusion, startDelayMin, durationMin}] + | sort_by(.startDelayMin) | reverse | .[0:10] - | map("| `" + (.name | gsub("\\|"; "\\|")) + "` | `" + ((.conclusion // "") | tostring) + "` | " + (.queueMin | tostring) + " | " + ((.durationMin // "") | tostring) + " |") + | map("| `" + (.name | gsub("\\|"; "\\|")) + "` | `" + ((.conclusion // "") | tostring) + "` | " + (.startDelayMin | tostring) + " | " + ((.durationMin // "") | tostring) + " |") | .[]) - ' || echo "_Unable to summarize queue times for run ${run_id}._" + ' || echo "_Unable to summarize start delays for run ${run_id}._" } >> "$GITHUB_STEP_SUMMARY" } @@ -1758,6 +1792,18 @@ jobs: plugin_prerelease_required=0 release_checks_required=0 performance_required=0 + candidate_required=0 + if [[ -z "${RELEASE_PACKAGE_SPEC// }" && -z "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then + case "$RERUN_GROUP" in + all|plugin-prerelease|release-checks|cross-os|live-e2e|package) + candidate_required=1 + ;; + esac + fi + if [[ "$candidate_required" == "1" && "$EVIDENCE_REUSE" != "true" && "$PREPARE_RELEASE_CANDIDATE_RESULT" != "success" ]]; then + echo "::error::Shared release candidate preparation ended with ${PREPARE_RELEASE_CANDIDATE_RESULT}." + failed=1 + fi if [[ "$RERUN_GROUP" == "all" && "$EVIDENCE_REUSE" == "true" ]]; then # Lanes were skipped because a prior green validation covers this # target; re-verify the chain-root run and its recorded child runs diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index a9d26161a1c0..8748568843f3 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -130,6 +130,55 @@ on: default: false type: boolean workflow_call: + outputs: + candidate_artifact_json: + description: Immutable package and Docker image artifact tuple + value: ${{ jobs.prepare_docker_e2e_image.outputs.candidate_artifact_json }} + package_artifact_name: + description: Immutable package artifact name + value: ${{ jobs.prepare_docker_e2e_image.outputs.package_artifact_name }} + package_artifact_id: + description: Immutable package artifact id + value: ${{ jobs.prepare_docker_e2e_image.outputs.package_artifact_id }} + package_artifact_digest: + description: Package artifact service digest + value: ${{ jobs.prepare_docker_e2e_image.outputs.package_artifact_digest }} + package_artifact_run_id: + description: Package artifact producer run id + value: ${{ jobs.prepare_docker_e2e_image.outputs.package_artifact_run_id }} + package_artifact_run_attempt: + description: Package artifact producer run attempt + value: ${{ jobs.prepare_docker_e2e_image.outputs.package_artifact_run_attempt }} + package_file_name: + description: Package tarball filename + value: ${{ jobs.prepare_docker_e2e_image.outputs.package_file_name }} + package_source_sha: + description: Package source commit + value: ${{ jobs.prepare_docker_e2e_image.outputs.package_source_sha }} + package_sha256: + description: Package tarball SHA-256 + value: ${{ jobs.prepare_docker_e2e_image.outputs.package_sha256 }} + package_version: + description: Package version + value: ${{ jobs.prepare_docker_e2e_image.outputs.package_version }} + shared_image_artifact_name: + description: Immutable Docker image artifact name + value: ${{ jobs.prepare_docker_e2e_image.outputs.image_artifact_name }} + shared_image_artifact_id: + description: Immutable Docker image artifact id + value: ${{ jobs.prepare_docker_e2e_image.outputs.image_artifact_id }} + shared_image_artifact_digest: + description: Docker image artifact service digest + value: ${{ jobs.prepare_docker_e2e_image.outputs.image_artifact_digest }} + shared_image_artifact_run_id: + description: Docker image artifact producer run id + value: ${{ jobs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + shared_image_artifact_run_attempt: + description: Docker image artifact producer run attempt + value: ${{ jobs.prepare_docker_e2e_image.outputs.image_artifact_run_attempt }} + shared_image_archive_sha256: + description: Docker image archive SHA-256 + value: ${{ jobs.prepare_docker_e2e_image.outputs.image_archive_sha256 }} inputs: advisory: description: Treat failures as advisory for the caller @@ -146,6 +195,11 @@ on: required: false default: false type: boolean + prepare_only: + description: Prepare immutable package and Docker image artifacts without running validation lanes + required: false + default: false + type: boolean ref: description: Ref, tag, or SHA to validate required: true @@ -250,6 +304,36 @@ on: required: false default: direct type: string + shared_image_artifact_name: + description: Existing immutable Docker image artifact name + required: false + default: "" + type: string + shared_image_artifact_id: + description: Existing immutable Docker image artifact id + required: false + default: "" + type: string + shared_image_artifact_digest: + description: Existing Docker image artifact service SHA-256 digest + required: false + default: "" + type: string + shared_image_artifact_run_id: + description: Producer run id for the Docker image artifact + required: false + default: "" + type: string + shared_image_artifact_run_attempt: + description: Producer run attempt for the Docker image artifact + required: false + default: "" + type: string + shared_image_archive_sha256: + description: SHA-256 of the Docker image archive inside the artifact + required: false + default: "" + type: string docker_e2e_bare_image: description: Existing bare Docker E2E image to reuse; blank derives from package SHA/ref required: false @@ -464,6 +548,12 @@ jobs: PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }} PROVIDED_FUNCTIONAL_IMAGE: ${{ inputs.docker_e2e_functional_image }} SHARED_IMAGE_ARTIFACT_NAMESPACE: ${{ inputs.shared_image_artifact_namespace }} + SHARED_IMAGE_ARTIFACT_DIGEST: ${{ inputs.shared_image_artifact_digest }} + SHARED_IMAGE_ARTIFACT_ID: ${{ inputs.shared_image_artifact_id }} + SHARED_IMAGE_ARTIFACT_NAME: ${{ inputs.shared_image_artifact_name }} + SHARED_IMAGE_ARTIFACT_RUN_ATTEMPT: ${{ inputs.shared_image_artifact_run_attempt }} + SHARED_IMAGE_ARTIFACT_RUN_ID: ${{ inputs.shared_image_artifact_run_id }} + SHARED_IMAGE_ARCHIVE_SHA256: ${{ inputs.shared_image_archive_sha256 }} SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} shell: bash run: | @@ -526,6 +616,34 @@ jobs: } fi + image_tuple_present=0 + for value in \ + "$SHARED_IMAGE_ARTIFACT_DIGEST" \ + "$SHARED_IMAGE_ARTIFACT_ID" \ + "$SHARED_IMAGE_ARTIFACT_NAME" \ + "$SHARED_IMAGE_ARTIFACT_RUN_ATTEMPT" \ + "$SHARED_IMAGE_ARTIFACT_RUN_ID" \ + "$SHARED_IMAGE_ARCHIVE_SHA256"; do + if [[ -n "${value// }" ]]; then + image_tuple_present=1 + fi + done + if [[ "$image_tuple_present" == "1" ]]; then + [[ "$SHARED_IMAGE_ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ && + "$SHARED_IMAGE_ARTIFACT_ID" =~ ^[1-9][0-9]*$ && + -n "${SHARED_IMAGE_ARTIFACT_NAME// }" && + "$SHARED_IMAGE_ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ && + "$SHARED_IMAGE_ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ && + "$SHARED_IMAGE_ARCHIVE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { + echo "Docker image artifact selection requires the complete immutable artifact tuple." >&2 + exit 1 + } + [[ "$SHARED_IMAGE_ARTIFACT_NAME" == *"-${SHARED_IMAGE_ARTIFACT_RUN_ID}-${SHARED_IMAGE_ARTIFACT_RUN_ATTEMPT}" ]] || { + echo "Docker image artifact name does not bind the declared producer run attempt." >&2 + exit 1 + } + fi + case "$SHARED_IMAGE_POLICY" in existing-only) if [[ -z "${PROVIDED_BARE_IMAGE// }" && -z "${PROVIDED_FUNCTIONAL_IMAGE// }" ]]; then @@ -546,6 +664,10 @@ jobs: echo "shared_image_policy=no-push-artifact builds local image artifacts and rejects provided images." >&2 exit 1 } + if [[ "$image_tuple_present" == "1" && "$package_tuple_present" != "1" ]]; then + echo "Reusing a Docker image artifact requires its immutable package identity tuple." >&2 + exit 1 + fi ;; *) echo "shared_image_policy must be existing-only or no-push-artifact." >&2 @@ -1773,7 +1895,7 @@ jobs: prepare_docker_e2e_image: needs: validate_selected_ref - if: inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != '' + if: inputs.prepare_only || inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != '' continue-on-error: ${{ inputs.advisory }} runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }} @@ -1782,12 +1904,13 @@ jobs: contents: read packages: read outputs: + candidate_artifact_json: ${{ steps.candidate_manifest.outputs.json }} image: ${{ steps.image.outputs.image }} bare_image: ${{ steps.image.outputs.bare_image }} functional_image: ${{ steps.image.outputs.functional_image }} - needs_bare_image: ${{ steps.plan.outputs.needs_bare_image }} + needs_bare_image: ${{ inputs.shared_image_artifact_id != '' && '1' || steps.plan.outputs.needs_bare_image }} needs_e2e_image: ${{ steps.plan.outputs.needs_e2e_image }} - needs_functional_image: ${{ steps.plan.outputs.needs_functional_image }} + needs_functional_image: ${{ inputs.shared_image_artifact_id != '' && '1' || steps.plan.outputs.needs_functional_image }} needs_live_image: ${{ steps.plan.outputs.needs_live_image }} needs_package: ${{ steps.plan.outputs.needs_package }} bare_exists: ${{ steps.image_exists.outputs.bare_exists }} @@ -1802,12 +1925,12 @@ jobs: package_artifact_run_attempt: ${{ steps.upload_package.outputs.artifact-id && github.run_attempt || steps.input_package_artifact.outputs.run_attempt }} package_file_name: ${{ steps.package.outputs.file_name }} package_source_sha: ${{ steps.package.outputs.source_sha }} - image_artifact_name: ${{ steps.image_artifact.outputs.artifact_name }} - image_archive_sha256: ${{ steps.image_artifact.outputs.archive_sha256 }} - image_artifact_id: ${{ steps.upload_image_artifact.outputs.artifact-id }} - image_artifact_digest: ${{ steps.upload_image_artifact.outputs.artifact-digest }} - image_artifact_run_id: ${{ github.run_id }} - image_artifact_run_attempt: ${{ github.run_attempt }} + image_artifact_name: ${{ steps.image_artifact.outputs.artifact_name || inputs.shared_image_artifact_name }} + image_archive_sha256: ${{ steps.image_artifact.outputs.archive_sha256 || inputs.shared_image_archive_sha256 }} + image_artifact_id: ${{ steps.upload_image_artifact.outputs.artifact-id || inputs.shared_image_artifact_id }} + image_artifact_digest: ${{ steps.upload_image_artifact.outputs.artifact-digest || inputs.shared_image_artifact_digest }} + image_artifact_run_id: ${{ steps.upload_image_artifact.outputs.artifact-id && github.run_id || inputs.shared_image_artifact_run_id }} + image_artifact_run_attempt: ${{ steps.upload_image_artifact.outputs.artifact-id && github.run_attempt || inputs.shared_image_artifact_run_attempt }} env: DOCKER_BUILD_SUMMARY: "false" DOCKER_BUILD_RECORD_UPLOAD: "false" @@ -1834,6 +1957,7 @@ jobs: shell: bash env: LANES: ${{ inputs.docker_lanes }} + PREPARE_ONLY: ${{ inputs.prepare_only }} INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include_release_path_suites }} INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }} RELEASE_TEST_PROFILE: ${{ inputs.release_test_profile }} @@ -1844,7 +1968,7 @@ jobs: set -euo pipefail mkdir -p .artifacts/docker-tests - if [[ "$INCLUDE_RELEASE_PATH_SUITES" == "true" ]]; then + if [[ "$PREPARE_ONLY" == "true" || "$INCLUDE_RELEASE_PATH_SUITES" == "true" ]]; then export OPENCLAW_DOCKER_ALL_PROFILE=release-path export OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL=1 elif [[ -n "$LANES" ]]; then @@ -1858,6 +1982,13 @@ jobs: plan_path=".artifacts/docker-tests/plan.json" node .release-harness/scripts/test-docker-all.mjs --plan-json > "$plan_path" node .release-harness/scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT" + if [[ "$PREPARE_ONLY" == "true" ]]; then + grep -Fxq "needs_bare_image=1" "$GITHUB_OUTPUT" && + grep -Fxq "needs_functional_image=1" "$GITHUB_OUTPUT" || { + echo "Shared release candidate preparation requires both Docker image variants." >&2 + exit 1 + } + fi echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT" - name: Setup Node environment @@ -2066,7 +2197,7 @@ jobs: - name: Upload OpenClaw Docker E2E package id: upload_package - if: steps.plan.outputs.needs_package == '1' && (inputs.package_artifact_id == '' || inputs.package_artifact_run_id != github.run_id) + if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_id == '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: docker-e2e-package-${{ github.run_id }}-${{ github.run_attempt }} @@ -2086,17 +2217,24 @@ jobs: set -euo pipefail repository="${GITHUB_REPOSITORY,,}" image_tag="${PACKAGE_TAG:-$SELECTED_SHA}" + bare_context_sha="$( + sed '/^FROM bare AS build$/,$d' scripts/e2e/Dockerfile | sha256sum | awk '{print $1}' + )" if [[ "$SHARED_IMAGE_POLICY" == "no-push-artifact" ]]; then - bare_image="openclaw-docker-e2e-bare:${image_tag}" + bare_image="openclaw-docker-e2e-bare:base-${bare_context_sha:0:32}" + bare_cache_image="ghcr.io/${repository}-docker-e2e-bare:base-${bare_context_sha:0:32}" functional_image="openclaw-docker-e2e-functional:${image_tag}" else bare_image="${PROVIDED_BARE_IMAGE:-ghcr.io/${repository}-docker-e2e-bare:${image_tag}}" + bare_cache_image="" functional_image="${PROVIDED_FUNCTIONAL_IMAGE:-ghcr.io/${repository}-docker-e2e-functional:${image_tag}}" fi image="$functional_image" echo "image=$image" >> "$GITHUB_OUTPUT" echo "bare_image=$bare_image" >> "$GITHUB_OUTPUT" + echo "bare_cache_image=$bare_cache_image" >> "$GITHUB_OUTPUT" echo "functional_image=$functional_image" >> "$GITHUB_OUTPUT" + echo "bare_context_sha=$bare_context_sha" >> "$GITHUB_OUTPUT" echo "Shared Docker E2E bare image: \`$bare_image\`" >> "$GITHUB_STEP_SUMMARY" echo "Shared Docker E2E functional image: \`$functional_image\`" >> "$GITHUB_STEP_SUMMARY" @@ -2154,52 +2292,56 @@ jobs: echo "functional_exists=$functional_exists" >> "$GITHUB_OUTPUT" echo "needs_build=$needs_build" >> "$GITHUB_OUTPUT" - - name: Setup Docker builder - if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1' - uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 - with: - max-cache-size-mb: 800000 - - name: Build bare Docker E2E image artifact - if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_bare_image == '1' + if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_bare_image == '1' && inputs.shared_image_artifact_id == '' shell: bash env: + CACHE_IMAGE_REF: ${{ steps.image.outputs.bare_cache_image }} IMAGE_REF: ${{ steps.image.outputs.bare_image }} run: | set -euo pipefail - # Docker's local image exporter cannot load attestation manifest lists. - timeout --kill-after=30s 45m docker buildx build \ - --load \ + cache=() + if docker manifest inspect "$CACHE_IMAGE_REF" >/dev/null 2>&1; then + if bash .release-harness/scripts/ci-docker-pull-retry.sh "$CACHE_IMAGE_REF"; then + cache=(--cache-from "$CACHE_IMAGE_REF") + echo "Using package-independent bare image cache: $CACHE_IMAGE_REF" + else + echo "::warning::Bare image cache pull failed; continuing with a cold build." + fi + fi + timeout --kill-after=30s 45m env DOCKER_BUILDKIT=1 docker build \ --file ./scripts/e2e/Dockerfile \ --target bare \ --platform linux/amd64 \ --tag "$IMAGE_REF" \ - --sbom=false \ - --provenance=false \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + "${cache[@]}" \ . - name: Build functional Docker E2E image artifact - if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_functional_image == '1' + if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_functional_image == '1' && inputs.shared_image_artifact_id == '' shell: bash env: IMAGE_REF: ${{ steps.image.outputs.functional_image }} + BARE_IMAGE_REF: ${{ steps.image.outputs.bare_image }} run: | set -euo pipefail - # Docker's local image exporter cannot load attestation manifest lists. - timeout --kill-after=30s 45m docker buildx build \ - --load \ + cache=() + if docker image inspect "$BARE_IMAGE_REF" >/dev/null 2>&1; then + cache=(--cache-from "$BARE_IMAGE_REF") + fi + timeout --kill-after=30s 45m env DOCKER_BUILDKIT=1 docker build \ --file ./scripts/e2e/Dockerfile \ --target functional \ --build-context openclaw_package=.artifacts/docker-e2e-package \ --platform linux/amd64 \ --tag "$IMAGE_REF" \ - --sbom=false \ - --provenance=false \ + "${cache[@]}" \ . - name: Pack Docker E2E image artifact id: image_artifact - if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1' + if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1' && inputs.shared_image_artifact_id == '' shell: bash env: BARE_IMAGE: ${{ steps.image.outputs.bare_image }} @@ -2234,7 +2376,7 @@ jobs: - name: Upload Docker E2E image artifact id: upload_image_artifact - if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1' + if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1' && inputs.shared_image_artifact_id == '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.image_artifact.outputs.artifact_name }} @@ -2243,6 +2385,48 @@ jobs: compression-level: 0 retention-days: 7 + - name: Emit immutable release candidate tuple + id: candidate_manifest + if: steps.plan.outputs.needs_e2e_image == '1' + env: + IMAGE_ARCHIVE_SHA256: ${{ steps.image_artifact.outputs.archive_sha256 || inputs.shared_image_archive_sha256 }} + IMAGE_ARTIFACT_DIGEST: ${{ steps.upload_image_artifact.outputs.artifact-digest || inputs.shared_image_artifact_digest }} + IMAGE_ARTIFACT_ID: ${{ steps.upload_image_artifact.outputs.artifact-id || inputs.shared_image_artifact_id }} + IMAGE_ARTIFACT_NAME: ${{ steps.image_artifact.outputs.artifact_name || inputs.shared_image_artifact_name }} + IMAGE_ARTIFACT_RUN_ATTEMPT: ${{ steps.upload_image_artifact.outputs.artifact-id && github.run_attempt || inputs.shared_image_artifact_run_attempt }} + IMAGE_ARTIFACT_RUN_ID: ${{ steps.upload_image_artifact.outputs.artifact-id && github.run_id || inputs.shared_image_artifact_run_id }} + PACKAGE_ARTIFACT_DIGEST: ${{ steps.upload_package.outputs.artifact-digest || inputs.package_artifact_digest }} + PACKAGE_ARTIFACT_ID: ${{ steps.upload_package.outputs.artifact-id || inputs.package_artifact_id }} + PACKAGE_ARTIFACT_NAME: ${{ steps.upload_package.outputs.artifact-id && format('docker-e2e-package-{0}-{1}', github.run_id, github.run_attempt) || inputs.package_artifact_name }} + PACKAGE_ARTIFACT_RUN_ATTEMPT: ${{ steps.upload_package.outputs.artifact-id && github.run_attempt || inputs.package_artifact_run_attempt }} + PACKAGE_ARTIFACT_RUN_ID: ${{ steps.upload_package.outputs.artifact-id && github.run_id || inputs.package_artifact_run_id }} + PACKAGE_FILE_NAME: ${{ steps.package.outputs.file_name }} + PACKAGE_SHA256: ${{ steps.package.outputs.sha256 }} + PACKAGE_SOURCE_SHA: ${{ steps.package.outputs.source_sha }} + PACKAGE_VERSION: ${{ steps.package.outputs.version }} + shell: bash + run: | + set -euo pipefail + json="$(jq -cn \ + --arg packageArtifactName "$PACKAGE_ARTIFACT_NAME" \ + --arg packageArtifactId "$PACKAGE_ARTIFACT_ID" \ + --arg packageArtifactDigest "$PACKAGE_ARTIFACT_DIGEST" \ + --arg packageArtifactRunId "$PACKAGE_ARTIFACT_RUN_ID" \ + --arg packageArtifactRunAttempt "$PACKAGE_ARTIFACT_RUN_ATTEMPT" \ + --arg packageFileName "$PACKAGE_FILE_NAME" \ + --arg packageSourceSha "$PACKAGE_SOURCE_SHA" \ + --arg packageSha256 "$PACKAGE_SHA256" \ + --arg packageVersion "$PACKAGE_VERSION" \ + --arg imageArtifactName "$IMAGE_ARTIFACT_NAME" \ + --arg imageArtifactId "$IMAGE_ARTIFACT_ID" \ + --arg imageArtifactDigest "$IMAGE_ARTIFACT_DIGEST" \ + --arg imageArtifactRunId "$IMAGE_ARTIFACT_RUN_ID" \ + --arg imageArtifactRunAttempt "$IMAGE_ARTIFACT_RUN_ATTEMPT" \ + --arg imageArchiveSha256 "$IMAGE_ARCHIVE_SHA256" \ + '$ARGS.named')" + jq -e 'all(.[]; type == "string" and length > 0)' <<< "$json" >/dev/null + echo "json=$json" >> "$GITHUB_OUTPUT" + docker_e2e_image_ready: needs: prepare_docker_e2e_image if: always() && needs.prepare_docker_e2e_image.result != 'skipped' diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 290d4ec560b5..1df3f576a828 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -105,6 +105,11 @@ on: required: false default: "" type: string + candidate_artifact_json: + description: Immutable package and Docker image artifact tuple from Full Release Validation + required: false + default: "" + type: string concurrency: group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }} @@ -552,15 +557,15 @@ jobs: contents: read packages: read outputs: - artifact_digest: ${{ steps.release_package_upload.outputs.artifact-digest }} - artifact_id: ${{ steps.release_package_upload.outputs.artifact-id }} - artifact_name: ${{ steps.artifact.outputs.name }} - artifact_run_attempt: ${{ steps.artifact.outputs.run_attempt }} - artifact_run_id: ${{ steps.artifact.outputs.run_id }} - package_file_name: ${{ steps.artifact.outputs.file_name }} - package_sha256: ${{ steps.package.outputs.sha256 }} - package_version: ${{ steps.package.outputs.package_version }} - source_sha: ${{ steps.package.outputs.source_sha }} + artifact_digest: ${{ steps.release_package_upload.outputs.artifact-digest || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactDigest }} + artifact_id: ${{ steps.release_package_upload.outputs.artifact-id || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactId }} + artifact_name: ${{ steps.artifact.outputs.name || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactName }} + artifact_run_attempt: ${{ steps.artifact.outputs.run_attempt || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactRunAttempt }} + artifact_run_id: ${{ steps.artifact.outputs.run_id || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactRunId }} + package_file_name: ${{ steps.artifact.outputs.file_name || fromJSON(inputs.candidate_artifact_json || '{}').packageFileName }} + package_sha256: ${{ steps.package.outputs.sha256 || fromJSON(inputs.candidate_artifact_json || '{}').packageSha256 }} + package_version: ${{ steps.package.outputs.package_version || fromJSON(inputs.candidate_artifact_json || '{}').packageVersion }} + source_sha: ${{ steps.package.outputs.source_sha || fromJSON(inputs.candidate_artifact_json || '{}').packageSourceSha }} steps: - name: Checkout trusted workflow ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -572,6 +577,7 @@ jobs: - name: Set artifact metadata id: artifact + if: inputs.candidate_artifact_json == '' run: | { echo "file_name=openclaw-current.tgz" @@ -581,6 +587,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Setup Node environment + if: inputs.candidate_artifact_json == '' uses: ./.github/actions/setup-node-env with: node-version: ${{ env.NODE_VERSION }} @@ -589,6 +596,7 @@ jobs: - name: Resolve release package artifact id: package + if: inputs.candidate_artifact_json == '' shell: bash env: PACKAGE_REF: ${{ needs.resolve_target.outputs.revision }} @@ -627,6 +635,7 @@ jobs: - name: Upload release package artifact id: release_package_upload + if: inputs.candidate_artifact_json == '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.artifact.outputs.name }} @@ -637,6 +646,7 @@ jobs: if-no-files-found: error - name: Validate release package artifact binding + if: inputs.candidate_artifact_json == '' env: ARTIFACT_DIGEST: ${{ steps.release_package_upload.outputs.artifact-digest }} ARTIFACT_ID: ${{ steps.release_package_upload.outputs.artifact-id }} @@ -674,6 +684,36 @@ jobs: exit 1 } + - name: Validate shared release candidate identity + if: inputs.candidate_artifact_json != '' + env: + CANDIDATE_ARTIFACT_JSON: ${{ inputs.candidate_artifact_json }} + SELECTED_SHA: ${{ needs.resolve_target.outputs.revision }} + run: | + set -euo pipefail + jq -e \ + --arg sha "$SELECTED_SHA" \ + 'def digits: test("^[1-9][0-9]*$"); + def hex40: test("^[a-f0-9]{40}$"); + def hex64: test("^[a-f0-9]{64}$"); + (.packageArtifactName | type == "string" and length > 0) and + (.packageArtifactId | tostring | digits) and + (.packageArtifactDigest | hex64) and + (.packageArtifactRunId | tostring | digits) and + (.packageArtifactRunAttempt | tostring | digits) and + (.packageFileName | test("^[A-Za-z0-9][A-Za-z0-9._-]*\\.tgz$")) and + (.packageSourceSha | hex40) and + .packageSourceSha == $sha and + (.packageSha256 | hex64) and + (.packageVersion | type == "string" and length > 0) and + (.imageArtifactName | type == "string" and length > 0) and + (.imageArtifactId | tostring | digits) and + (.imageArtifactDigest | hex64) and + (.imageArtifactRunId | tostring | digits) and + (.imageArtifactRunAttempt | tostring | digits) and + (.imageArchiveSha256 | hex64)' + <<< "$CANDIDATE_ARTIFACT_JSON" >/dev/null + install_smoke_release_checks: needs: [resolve_target] if: contains(fromJSON('["all","install-smoke"]'), needs.resolve_target.outputs.rerun_group) @@ -825,6 +865,12 @@ jobs: package_version: ${{ needs.prepare_release_package.outputs.package_version }} codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }} shared_image_artifact_namespace: release-docker + shared_image_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactName || '' }} + shared_image_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactId || '' }} + shared_image_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactDigest || '' }} + shared_image_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactRunId || '' }} + shared_image_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactRunAttempt || '' }} + shared_image_archive_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArchiveSha256 || '' }} shared_image_policy: no-push-artifact secrets: *live_e2e_release_secrets @@ -860,6 +906,7 @@ jobs: telegram_mode: mock-openai telegram_advisory: ${{ needs.resolve_target.outputs.release_profile == 'beta' }} shared_image_artifact_namespace: release-package + candidate_artifact_json: ${{ inputs.candidate_artifact_json }} shared_image_policy: no-push-artifact secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index 57b0d159787a..e2ba8e362cf1 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -243,6 +243,11 @@ on: required: false default: package-acceptance type: string + candidate_artifact_json: + description: Immutable Docker image artifact tuple from Full Release Validation + required: false + default: "" + type: string suite_profile: description: "Acceptance profile: smoke, package, product, full, or custom" required: false @@ -825,6 +830,12 @@ jobs: include_live_suites: ${{ needs.resolve_package.outputs.include_live_suites == 'true' }} live_models_only: false shared_image_artifact_namespace: ${{ inputs.shared_image_artifact_namespace }} + shared_image_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactName || '' }} + shared_image_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactId || '' }} + shared_image_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactDigest || '' }} + shared_image_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactRunId || '' }} + shared_image_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactRunAttempt || '' }} + shared_image_archive_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArchiveSha256 || '' }} shared_image_policy: ${{ inputs.shared_image_policy }} secrets: &docker_acceptance_secrets OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index 7c8a47a2511d..70a32c94a22f 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -25,6 +25,11 @@ on: required: false default: "" type: string + candidate_artifact_json: + description: Immutable package and Docker image artifact tuple from Full Release Validation + required: false + default: "" + type: string permissions: contents: read @@ -121,9 +126,16 @@ jobs: }).map((shard) => ({ check_name: shard.checkName, extensions_csv: shard.extensionIds.join(","), - runner: [0, 1, 2, 3].includes(shard.index) - ? "blacksmith-8vcpu-ubuntu-2404" - : "blacksmith-4vcpu-ubuntu-2404", + vitest_max_workers: shard.extensionIds.some((extensionId) => + extensionId.startsWith("memory-"), + ) + ? 4 + : 1, + runner: shard.extensionIds.some((extensionId) => extensionId.startsWith("memory-")) + ? "blacksmith-16vcpu-ubuntu-2404" + : [0, 1, 2, 3].includes(shard.index) + ? "blacksmith-8vcpu-ubuntu-2404" + : "blacksmith-4vcpu-ubuntu-2404", shard_index: shard.index + 1, task: "extensions-batch", })); @@ -349,7 +361,7 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=8192 OPENCLAW_EXTENSION_BATCH_PARALLEL: 2 - OPENCLAW_VITEST_MAX_WORKERS: 1 + OPENCLAW_VITEST_MAX_WORKERS: ${{ matrix.vitest_max_workers }} OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }} run: pnpm test:extensions:batch "$OPENCLAW_EXTENSION_BATCH" -- --retry=1 --exclude extensions/codex/src/app-server/run-attempt.test.ts @@ -553,6 +565,21 @@ jobs: include_live_suites: false live_models_only: false shared_image_artifact_namespace: plugin-prerelease + package_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactName || '' }} + package_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactId || '' }} + package_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactDigest || '' }} + package_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactRunId || '' }} + package_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactRunAttempt || '' }} + package_file_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageFileName || '' }} + package_source_sha: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageSourceSha || '' }} + package_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageSha256 || '' }} + package_version: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageVersion || '' }} + shared_image_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactName || '' }} + shared_image_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactId || '' }} + shared_image_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactDigest || '' }} + shared_image_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactRunId || '' }} + shared_image_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactRunAttempt || '' }} + shared_image_archive_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArchiveSha256 || '' }} shared_image_policy: no-push-artifact plugin-prerelease-suite: diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 31232369080e..7760dcea4b0d 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -77,6 +77,14 @@ run. Evidence reuse runs only from `main` or a canonical SHA-pinned `release-ci/*` ref whose workflow commit remains on trusted `main` lineage; other workflow refs run the selected lanes fresh. +Fresh package-facing validation prepares one immutable tarball plus one Docker +image artifact before dispatching Plugin Prerelease and OpenClaw Release Checks. +Both children verify the same package SHA, artifact IDs, service digests, +producer run attempt, and Docker archive digest before use. The package-independent +bare Docker layer uses a content-addressed GHCR cache; candidate-specific images +remain immutable GitHub artifacts. Focused runs with an explicit published +package spec keep the existing package path instead. + Also for `rerun_group=all`, a `Verify Docker runtime image assets` job builds the `runtime-assets` Docker target with `OPENCLAW_EXTENSIONS=diagnostics-otel,codex`. It runs in parallel with the @@ -86,6 +94,7 @@ it before dispatching. A narrower `rerun_group` skips this preflight. | Stage | Details | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Target resolution | **Job:** `Resolve target ref`
**Child workflow:** none
**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.
**Rerun:** rerun the umbrella if this fails. | +| Shared candidate | **Job:** `Prepare shared release candidate`
**Child workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Proves:** packs and validates one exact-SHA package, builds one functional Docker image, and records immutable package and image artifact tuples for both package-facing child workflows.
**Rerun:** rerun the affected package, plugin-prerelease, cross-OS, or live/E2E group. | | Docker assets preflight | **Job:** `Verify Docker runtime image assets`
**Child workflow:** none
**Proves:** the `runtime-assets` Docker build target still succeeds before any other stage dispatches. Runs only for `rerun_group=all`.
**Rerun:** rerun the umbrella with `rerun_group=all`. | | Vitest and normal CI | **Job:** `Run normal full CI`
**Child workflow:** `CI`
**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.
**Rerun:** `rerun_group=ci`. | | Plugin prerelease | **Job:** `Run plugin prerelease validation`
**Child workflow:** `Plugin Prerelease`
**Proves:** release-only plugin static checks, agentic plugin coverage, full plugin batch shards, plugin prerelease Docker lanes, and a non-blocking `plugin-inspector-advisory` artifact for compatibility triage.
**Rerun:** `rerun_group=plugin-prerelease`. | @@ -122,13 +131,14 @@ cancel each other. ## Release checks stages `OpenClaw Release Checks` is the largest child workflow. It resolves the target -once and prepares a shared `release-package-under-test` artifact when package -or Docker-facing stages need it. +once and validates the umbrella's shared package artifact when available. A +direct or focused dispatch prepares its own `release-package-under-test` +artifact when package or Docker-facing stages need it. | Stage | Details | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Release target | **Job:** `Resolve target ref`
**Backing workflow:** none
**Tests:** selected ref, optional expected SHA, profile, rerun group, and focused live suite filter.
**Rerun:** `rerun_group=release-checks`. | -| Package artifact | **Job:** `Prepare release package artifact`
**Backing workflow:** none
**Tests:** packs or resolves one candidate tarball and uploads `release-package-under-test` for downstream package-facing checks.
**Rerun:** the affected package, cross-OS, or live/E2E group. | +| Package artifact | **Job:** `Prepare release package artifact`
**Backing workflow:** none
**Tests:** validates the umbrella's immutable package tuple, or packs one candidate tarball for a direct/focused Release Checks dispatch, then exposes it to downstream package-facing checks.
**Rerun:** the affected package, cross-OS, or live/E2E group. | | Install smoke | **Job:** `Run install smoke`
**Backing workflow:** `Install Smoke`
**Tests:** full install path with root Dockerfile smoke image reuse, QR package install, root and gateway Docker smokes, installer Docker tests, and Bun global install image-provider smoke.
**Rerun:** `rerun_group=install-smoke`. | | Cross-OS | **Job:** `cross_os_release_checks`
**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`
**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.
**Rerun:** `rerun_group=cross-os`. | | Repo and live E2E | **Job:** `Run repo/live E2E validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** repository E2E, live cache, OpenAI websocket streaming, native live provider and plugin shards, and Docker-backed live model/backend/gateway harnesses selected by `release_profile`.
**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.
**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. | diff --git a/scripts/ci-run-timings.d.mts b/scripts/ci-run-timings.d.mts index 4057fcda16a4..7d1db0271fa4 100644 --- a/scripts/ci-run-timings.d.mts +++ b/scripts/ci-run-timings.d.mts @@ -14,7 +14,7 @@ export function summarizeRunTimings( limit?: number, ): { byDuration: Array<{ name: string; durationSeconds: number }>; - byQueue: Array<{ name: string; queueSeconds: number }>; + byStartDelay: Array<{ name: string; startDelaySeconds: number }>; conclusion: unknown; status: unknown; wallSeconds: number | null; diff --git a/scripts/ci-run-timings.mjs b/scripts/ci-run-timings.mjs index cb756ae215a6..0cefe786f769 100644 --- a/scripts/ci-run-timings.mjs +++ b/scripts/ci-run-timings.mjs @@ -124,7 +124,9 @@ function collectRunTimingContext(run) { conclusion: job.conclusion ?? "", durationSeconds: secondsBetween(started, completed), name: job.name, - queueSeconds: secondsBetween(created, started), + // Actions exposes job start time, but not the split between `needs` + // dependency wait and runner queue. Keep the combined delay honest. + startDelaySeconds: secondsBetween(created, started), started, completed, status: job.status, @@ -146,9 +148,9 @@ export function summarizeRunTimings(run, limit = 15) { .filter((job) => job.durationSeconds !== null) .toSorted((left, right) => right.durationSeconds - left.durationSeconds) .slice(0, limit); - const byQueue = [...jobs] - .filter((job) => job.queueSeconds !== null && (job.durationSeconds ?? 0) > 5) - .toSorted((left, right) => right.queueSeconds - left.queueSeconds) + const byStartDelay = [...jobs] + .filter((job) => job.startDelaySeconds !== null && (job.durationSeconds ?? 0) > 5) + .toSorted((left, right) => right.startDelaySeconds - left.startDelaySeconds) .slice(0, limit); const badJobs = jobs.filter( (job) => job.conclusion && !["success", "skipped", "cancelled"].includes(job.conclusion), @@ -156,7 +158,7 @@ export function summarizeRunTimings(run, limit = 15) { return { byDuration, - byQueue, + byStartDelay, conclusion: run.conclusion ?? "", status: run.status ?? "", wallSeconds: secondsBetween(created, updated), @@ -347,7 +349,9 @@ function summarizeJobs(run) { Number.isFinite(firstStart) && Number.isFinite(lastComplete) ? secondsBetween(firstStart, lastComplete) : null, - firstQueueSeconds: Number.isFinite(firstStart) ? secondsBetween(created, firstStart) : null, + firstStartDelaySeconds: Number.isFinite(firstStart) + ? secondsBetween(created, firstStart) + : null, jobCount: successfulDurations.length, maxDurationSeconds: successfulDurations.length === 0 ? null : Math.max(...successfulDurations), p90DurationSeconds: percentile(successfulDurations, 0.9), @@ -360,7 +364,7 @@ function printSection(title, jobs, metric) { console.log(title); for (const job of jobs) { console.log( - `${String(job.name).padEnd(48)} ${formatSeconds(job[metric]).padStart(6)} queue=${formatSeconds(job.queueSeconds).padStart(6)} ${job.status}/${job.conclusion}`, + `${String(job.name).padEnd(48)} ${formatSeconds(job[metric]).padStart(6)} start-delay=${formatSeconds(job.startDelaySeconds).padStart(6)} ${job.status}/${job.conclusion}`, ); } } @@ -447,7 +451,7 @@ async function main() { run.headSha.slice(0, 10), `wall=${formatSeconds(summary.wallSeconds)}`, `exec=${formatSeconds(summary.executionWindowSeconds)}`, - `firstQueue=${formatSeconds(summary.firstQueueSeconds)}`, + `firstStartDelay=${formatSeconds(summary.firstStartDelaySeconds)}`, `jobs=${summary.jobCount}`, `avg=${formatSeconds(summary.avgDurationSeconds)}`, `p90=${formatSeconds(summary.p90DurationSeconds)}`, @@ -486,7 +490,11 @@ async function main() { ); } printSection("\nSlowest jobs", summary.byDuration, "durationSeconds"); - printSection("\nLongest queues", summary.byQueue, "queueSeconds"); + printSection( + "\nLongest start delays (dependencies + runner queue)", + summary.byStartDelay, + "startDelaySeconds", + ); if (summary.badJobs.length > 0) { console.log("\nFailed jobs"); for (const job of summary.badJobs) { diff --git a/scripts/lib/extension-test-plan.mjs b/scripts/lib/extension-test-plan.mjs index e0bf910a9d52..8a07f16a5feb 100644 --- a/scripts/lib/extension-test-plan.mjs +++ b/scripts/lib/extension-test-plan.mjs @@ -53,7 +53,7 @@ const EXTENSION_TEST_COST_MULTIPLIERS = { "test/vitest/vitest.extension-matrix.config.ts": 0.28, "test/vitest/vitest.extension-mattermost.config.ts": 0.75, "test/vitest/vitest.extension-media.config.ts": 0.7, - "test/vitest/vitest.extension-memory.config.ts": 0.25, + "test/vitest/vitest.extension-memory.config.ts": 1, "test/vitest/vitest.extension-messaging.config.ts": 0.4, "test/vitest/vitest.extension-misc.config.ts": 0.7, "test/vitest/vitest.extension-msteams.config.ts": 0.5, diff --git a/test/scripts/ci-run-timings.test.ts b/test/scripts/ci-run-timings.test.ts index 2fca75d2e747..890965140522 100644 --- a/test/scripts/ci-run-timings.test.ts +++ b/test/scripts/ci-run-timings.test.ts @@ -10,7 +10,7 @@ import { } from "../../scripts/ci-run-timings.mjs"; describe("scripts/ci-run-timings.mjs", () => { - it("separates queue time from job duration", () => { + it("separates start delay from job duration without mislabeling dependency wait", () => { const summary = summarizeRunTimings( { conclusion: "success", @@ -49,7 +49,7 @@ describe("scripts/ci-run-timings.mjs", () => { ["slow", 60], ["queued", 10], ]); - expect(summary.byQueue.map((job) => [job.name, job.queueSeconds])).toEqual([ + expect(summary.byStartDelay.map((job) => [job.name, job.startDelaySeconds])).toEqual([ ["queued", 50], ["slow", 20], ]); diff --git a/test/scripts/openclaw-cross-os-release-workflow.test.ts b/test/scripts/openclaw-cross-os-release-workflow.test.ts index b0e1fd78fac3..d8645872c374 100644 --- a/test/scripts/openclaw-cross-os-release-workflow.test.ts +++ b/test/scripts/openclaw-cross-os-release-workflow.test.ts @@ -83,15 +83,24 @@ describe("cross-OS release checks workflow", () => { const release = readWorkflow(RELEASE_CHECKS_PATH); const producer = job(release, "prepare_release_package"); expect(producer.outputs).toMatchObject({ - artifact_digest: "${{ steps.release_package_upload.outputs.artifact-digest }}", - artifact_id: "${{ steps.release_package_upload.outputs.artifact-id }}", - artifact_name: "${{ steps.artifact.outputs.name }}", - artifact_run_attempt: "${{ steps.artifact.outputs.run_attempt }}", - artifact_run_id: "${{ steps.artifact.outputs.run_id }}", - package_file_name: "${{ steps.artifact.outputs.file_name }}", - package_sha256: "${{ steps.package.outputs.sha256 }}", - package_version: "${{ steps.package.outputs.package_version }}", - source_sha: "${{ steps.package.outputs.source_sha }}", + artifact_digest: + "${{ steps.release_package_upload.outputs.artifact-digest || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactDigest }}", + artifact_id: + "${{ steps.release_package_upload.outputs.artifact-id || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactId }}", + artifact_name: + "${{ steps.artifact.outputs.name || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactName }}", + artifact_run_attempt: + "${{ steps.artifact.outputs.run_attempt || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactRunAttempt }}", + artifact_run_id: + "${{ steps.artifact.outputs.run_id || fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactRunId }}", + package_file_name: + "${{ steps.artifact.outputs.file_name || fromJSON(inputs.candidate_artifact_json || '{}').packageFileName }}", + package_sha256: + "${{ steps.package.outputs.sha256 || fromJSON(inputs.candidate_artifact_json || '{}').packageSha256 }}", + package_version: + "${{ steps.package.outputs.package_version || fromJSON(inputs.candidate_artifact_json || '{}').packageVersion }}", + source_sha: + "${{ steps.package.outputs.source_sha || fromJSON(inputs.candidate_artifact_json || '{}').packageSourceSha }}", }); expect(step(producer, "Checkout trusted workflow ref").with).toMatchObject({ ref: "${{ github.sha }}", diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 7d807f8c5d25..3921077911f8 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -35,6 +35,7 @@ const DOCKER_E2E_PLAN_ACTION = ".github/actions/docker-e2e-plan/action.yml"; const RELEASE_CHECKS_WORKFLOW = ".github/workflows/openclaw-release-checks.yml"; const RELEASE_TELEGRAM_QA_WORKFLOW = ".github/workflows/openclaw-release-telegram-qa.yml"; const RELEASE_PUBLISH_WORKFLOW = ".github/workflows/openclaw-release-publish.yml"; +const PLUGIN_PRERELEASE_WORKFLOW = ".github/workflows/plugin-prerelease.yml"; const OPENCLAW_NPM_RELEASE_WORKFLOW = ".github/workflows/openclaw-npm-release.yml"; const PLUGIN_CLAWHUB_RELEASE_WORKFLOW = ".github/workflows/plugin-clawhub-release.yml"; const PLUGIN_NPM_RELEASE_WORKFLOW = ".github/workflows/plugin-npm-release.yml"; @@ -1561,6 +1562,7 @@ describe("package artifact reuse", () => { OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: "${{ inputs.published_upgrade_survivor_baselines }}", OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: "${{ inputs.published_upgrade_survivor_scenarios }}", + PREPARE_ONLY: "${{ inputs.prepare_only }}", RELEASE_TEST_PROFILE: "${{ inputs.release_test_profile }}", }); expect(workflow).toContain("plan_docker_lane_groups:"); @@ -1645,15 +1647,72 @@ describe("package artifact reuse", () => { expect(dockerE2ePlanAction).not.toContain('docker pull "${OPENCLAW_DOCKER_E2E_'); }); - it("uses Blacksmith Docker build caching for prepared E2E images", () => { + it("reuses a content-addressed bare image for prepared E2E images", () => { const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8"); + expect(workflow).toContain("bare_context_sha="); + expect(workflow).toContain("-docker-e2e-bare:base-${bare_context_sha:0:32}"); + expect(workflow).toContain('docker manifest inspect "$CACHE_IMAGE_REF"'); + expect(workflow).toContain('cache=(--cache-from "$CACHE_IMAGE_REF")'); + expect(workflow).not.toContain('docker tag "$CACHE_IMAGE_REF" "$IMAGE_REF"'); + expect(workflow).toContain( + "Shared release candidate preparation requires both Docker image variants.", + ); + expect(workflow).toContain( + "inputs.shared_image_artifact_id != '' && '1' || steps.plan.outputs.needs_bare_image", + ); + expect(workflow).toContain("env DOCKER_BUILDKIT=1 docker build"); + expect(workflow).toContain( + 'if bash .release-harness/scripts/ci-docker-pull-retry.sh "$CACHE_IMAGE_REF"; then', + ); + expect(workflow).toContain("Bare image cache pull failed; continuing with a cold build."); + expect(workflow).toContain("--build-context openclaw_package=.artifacts/docker-e2e-package"); + expect(workflow).toContain('cache=(--cache-from "$BARE_IMAGE_REF")'); + expect(workflow).not.toContain('docker push "$CACHE_IMAGE_REF"'); expect(workflow).toContain("uses: useblacksmith/setup-docker-builder@"); expect(workflow).toContain("uses: useblacksmith/build-push-action@"); expect(workflow).not.toContain("cache-from: type=gha,scope=docker-e2e"); expect(workflow).not.toContain("cache-to: type=gha,mode=max,scope=docker-e2e"); }); + it("prepares one immutable candidate for release validation children", () => { + const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); + const prepare = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "prepare_release_candidate"); + const pluginDispatch = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "plugin_prerelease"), + "Dispatch and monitor plugin prerelease", + ); + const releaseDispatch = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "release_checks"), + "Dispatch and monitor release checks", + ); + + expect(prepare.uses).toBe("./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml"); + expect(prepare.with).toMatchObject({ + prepare_only: true, + shared_image_policy: "no-push-artifact", + }); + expect(pluginDispatch.run).toContain( + 'args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON")', + ); + expect(releaseDispatch.run).toContain( + 'args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON")', + ); + expect(workflow).toContain("Shared release candidate preparation ended with"); + }); + + it("gives memory extension shards enough CPU without lowering their planner cost", () => { + const workflow = readFileSync(PLUGIN_PRERELEASE_WORKFLOW, "utf8"); + + expect(workflow).toContain('extensionId.startsWith("memory-")'); + expect(workflow).toContain('"blacksmith-16vcpu-ubuntu-2404"'); + expect(workflow).toContain("vitest_max_workers:"); + expect(workflow).toContain("OPENCLAW_VITEST_MAX_WORKERS: ${{ matrix.vitest_max_workers }}"); + expect(readFileSync("scripts/lib/extension-test-plan.mjs", "utf8")).toContain( + '"test/vitest/vitest.extension-memory.config.ts": 1', + ); + }); + it("shards broad native live tests instead of one serial live-all job", () => { const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8"); const retryHelper = readFileSync("scripts/ci-live-command-retry.sh", "utf8"); @@ -3462,21 +3521,21 @@ describe("package artifact reuse", () => { expect(output).toContain("Tideclaw alpha"); }); - it("summarizes queue time separately from execution time in full validation", () => { + it("summarizes start delay separately from execution time in full validation", () => { const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); const parsedWorkflow = readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW); const summaryJob = parsedWorkflow.jobs?.summary; const manifestStep = workflowStep(summaryJob ?? {}, "Write release validation manifest"); expect(workflow).toContain("### Slowest jobs: ${label}"); - expect(workflow).toContain("### Longest queues: ${label}"); + expect(workflow).toContain("### Longest start delays: ${label}"); expect(workflow).toContain("Write release validation manifest"); expect(workflow).toContain("PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }}"); expect(workflow).toContain("Upload release validation manifest"); expect(workflow).toContain("Failed child detail: ${label}"); expect(workflow).toContain("actions/runs/${run_id}/artifacts?per_page=100"); expect(workflow).toContain("full-release-validation-${{ github.run_id }}"); - expect(workflow).toContain("| Job | Result | Queue minutes | Run minutes |"); + expect(workflow).toContain("| Job | Result | Start delay minutes | Run minutes |"); expect(workflow).toContain( 'gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100"', ); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 6ff22e3f42a8..f41e4bcffa51 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -435,7 +435,13 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { expect(normalCiScript).toContain('dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}"'); expect(normalCiScript).not.toContain("full_release_validation=true"); expect(pluginPrereleaseScript).toContain( - 'dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" -f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id"', + 'args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id")', + ); + expect(pluginPrereleaseScript).toContain( + 'args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON")', + ); + expect(pluginPrereleaseScript).toContain( + 'dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" "${args[@]}"', ); expect(pluginManifestScript).toContain("await import("); expect(pluginManifestScript).toContain('"./scripts/lib/plugin-prerelease-test-plan.mjs"'); @@ -546,7 +552,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { (step: WorkflowStep) => step.name === "Run plugin prerelease static shard", ).run, ).toContain('bash -c "$PLUGIN_PRERELEASE_COMMAND"'); - expect(dockerSuite).toEqual({ + expect(dockerSuite).toMatchObject({ if: "${{ inputs.full_release_validation && needs.preflight.outputs.run_plugin_prerelease_docker == 'true' }}", name: "plugin-prerelease-docker-suite", needs: ["preflight"], diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index 87ef4bd06a15..42f3d9201c90 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -583,9 +583,17 @@ describe("release validation no-push transport", () => { expect(producer.outputs?.image_archive_sha256).toContain("archive_sha256"); expect(producer.outputs?.image_artifact_id).toContain("artifact-id"); expect(producer.outputs?.image_artifact_digest).toContain("artifact-digest"); - expect(producer.outputs?.image_artifact_run_id).toBe("${{ github.run_id }}"); - expect(producer.outputs?.image_artifact_run_attempt).toBe("${{ github.run_attempt }}"); } + expect(dockerProducer.outputs?.image_artifact_run_id).toContain("github.run_id"); + expect(dockerProducer.outputs?.image_artifact_run_id).toContain( + "inputs.shared_image_artifact_run_id", + ); + expect(dockerProducer.outputs?.image_artifact_run_attempt).toContain("github.run_attempt"); + expect(dockerProducer.outputs?.image_artifact_run_attempt).toContain( + "inputs.shared_image_artifact_run_attempt", + ); + expect(liveProducer.outputs?.image_artifact_run_id).toBe("${{ github.run_id }}"); + expect(liveProducer.outputs?.image_artifact_run_attempt).toBe("${{ github.run_attempt }}"); expect(dockerProducer.outputs?.package_artifact_id).toContain("artifact-id"); expect(dockerProducer.outputs?.package_artifact_digest).toContain("artifact-digest"); expect(dockerProducer.outputs?.package_artifact_run_attempt).toContain("run_attempt"); @@ -631,13 +639,21 @@ describe("release validation no-push transport", () => { ]) { const build = step(dockerProducer, name); expect(build.if).toContain("shared_image_policy == 'no-push-artifact'"); - expect(build.run).toContain("--load"); - expect(build.run).toContain("--sbom=false"); - expect(build.run).toContain("--provenance=false"); expect(build.run).not.toContain("--push"); expect(build.run).not.toContain("--sbom=true"); expect(build.run).not.toContain("--provenance=mode=max"); } + const bareBuild = step(dockerProducer, "Build bare Docker E2E image artifact"); + expect(bareBuild.run).toContain("docker build"); + expect(bareBuild.run).toContain("--target bare"); + expect(bareBuild.run).toContain('--tag "$IMAGE_REF"'); + const functionalBuild = step(dockerProducer, "Build functional Docker E2E image artifact"); + expect(functionalBuild.run).toContain("docker build"); + expect(functionalBuild.run).toContain("--target functional"); + expect(functionalBuild.run).toContain( + "--build-context openclaw_package=.artifacts/docker-e2e-package", + ); + expect(functionalBuild.run).toContain('--tag "$IMAGE_REF"'); const packDockerArtifact = step(dockerProducer, "Pack Docker E2E image artifact"); expect(packDockerArtifact.env?.PACKAGE_SHA256).toBe("${{ steps.package.outputs.sha256 }}"); expect(packDockerArtifact.run).toContain("shared-image-artifact.sh"); @@ -693,7 +709,7 @@ describe("release validation no-push transport", () => { } expect(step(dockerProducer, "Upload Docker E2E image artifact")).toMatchObject({ id: "upload_image_artifact", - if: "inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1'", + if: "inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1' && inputs.shared_image_artifact_id == ''", with: { "if-no-files-found": "error" }, }); expect(step(liveProducer, "Pack live-test image artifact").run).toContain( diff --git a/test/scripts/release-workflow-matrix-plan.test.ts b/test/scripts/release-workflow-matrix-plan.test.ts index 31586d7f4231..5db2449ab07c 100644 --- a/test/scripts/release-workflow-matrix-plan.test.ts +++ b/test/scripts/release-workflow-matrix-plan.test.ts @@ -58,6 +58,7 @@ function requiredJob(definition: WorkflowDocument, name: string): WorkflowJob { // Direct dispatches build from the selected ref. Only trusted workflow callers // may provide the complete immutable package artifact tuple. const WORKFLOW_CALL_ONLY_INPUTS = new Set([ + "prepare_only", "package_artifact_name", "package_artifact_id", "package_artifact_digest", @@ -67,6 +68,12 @@ const WORKFLOW_CALL_ONLY_INPUTS = new Set([ "package_source_sha", "package_sha256", "package_version", + "shared_image_artifact_name", + "shared_image_artifact_id", + "shared_image_artifact_digest", + "shared_image_artifact_run_id", + "shared_image_artifact_run_attempt", + "shared_image_archive_sha256", ]); const PROFILE_EXPECTATIONS = [ diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index c417a14e6bf2..7fa3972a37d7 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -866,6 +866,11 @@ describe("scoped vitest configs", () => { it("normalizes memory extension include patterns relative to the scoped dir", () => { const testConfig = requireTestConfig(defaultExtensionMemoryConfig); expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual([ + "test/setup.ts", + "test/setup.extensions.ts", + "test/setup-openclaw-runtime.ts", + ]); expect(testConfig.include).toEqual([ "memory-core/**/*.test.ts", "memory-lancedb/**/*.test.ts",