diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 50267e9c3a03..f5ad531e1775 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -59,6 +59,11 @@ on: - qa-live - npm-telegram - performance + reuse_evidence: + description: Reuse the newest prior green full validation when the target differs only by release metadata such as changelog and version stamps + required: false + default: true + type: boolean live_suite_filter: description: Optional exact live/E2E suite id, or comma-separated QA live lanes such as qa-live-matrix,qa-live-telegram; blank runs all selected live suites required: false @@ -114,7 +119,7 @@ permissions: concurrency: group: full-release-validation-${{ inputs.ref }}-${{ inputs.rerun_group }} - cancel-in-progress: ${{ (inputs.ref == 'main' && inputs.rerun_group == 'all') || startsWith(inputs.ref, 'tideclaw/alpha/') }} + cancel-in-progress: ${{ (inputs.ref == 'main' && inputs.rerun_group == 'all') || startsWith(inputs.ref, 'tideclaw/alpha/') || startsWith(inputs.ref, 'release/') }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -228,10 +233,79 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" + evidence_reuse: + name: Check for reusable validation evidence + needs: [resolve_target] + if: inputs.rerun_group == 'all' && inputs.reuse_evidence + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + reuse: ${{ steps.find.outputs.reuse }} + evidence_run_id: ${{ steps.find.outputs.evidence_run_id }} + evidence_root_run_id: ${{ steps.find.outputs.evidence_root_run_id }} + evidence_run_url: ${{ steps.find.outputs.evidence_run_url }} + evidence_sha: ${{ steps.find.outputs.evidence_sha }} + evidence_manifest: ${{ steps.find.outputs.evidence_manifest }} + changed_paths: ${{ steps.find.outputs.changed_paths }} + steps: + - name: Checkout trusted workflow helper + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.ref_name }} + path: workflow + fetch-depth: 1 + persist-credentials: true + submodules: false + + - name: Checkout target SHA + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.resolve_target.outputs.sha }} + path: target + fetch-depth: 1 + persist-credentials: true + submodules: false + + - name: Find reusable validation evidence + id: find + env: + GH_TOKEN: ${{ github.token }} + TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} + RELEASE_PROFILE: ${{ inputs.release_profile }} + RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full' }} + run: | + bash workflow/scripts/github/find-reusable-release-validation.sh \ + --target-sha "$TARGET_SHA" \ + --release-profile "$RELEASE_PROFILE" \ + --run-release-soak "$RUN_RELEASE_SOAK" \ + --repo "$GITHUB_REPOSITORY" \ + --repo-dir target \ + --github-output "$GITHUB_OUTPUT" + + - name: Summarize evidence reuse + env: + REUSE: ${{ steps.find.outputs.reuse }} + REUSE_REASON: ${{ steps.find.outputs.reuse_reason }} + EVIDENCE_RUN_URL: ${{ steps.find.outputs.evidence_run_url }} + EVIDENCE_SHA: ${{ steps.find.outputs.evidence_sha }} + CHANGED_PATHS: ${{ steps.find.outputs.changed_paths }} + run: | + { + echo "## Validation evidence reuse" + echo + if [[ "$REUSE" == "true" ]]; then + echo "- Reusing evidence: ${EVIDENCE_RUN_URL}" + echo "- Evidence SHA: \`${EVIDENCE_SHA}\`" + echo "- Release-metadata-only delta: \`${CHANGED_PATHS:-none}\`" + else + echo "- No reusable evidence: ${REUSE_REASON:-unknown}" + fi + } >> "$GITHUB_STEP_SUMMARY" + docker_runtime_assets_preflight: name: Verify Docker runtime image assets - needs: [resolve_target] - if: inputs.rerun_group == 'all' + needs: [resolve_target, evidence_reuse] + if: ${{ always() && needs.resolve_target.result == 'success' && inputs.rerun_group == 'all' && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: @@ -256,8 +330,8 @@ jobs: normal_ci: name: Run normal full CI - needs: [resolve_target, docker_runtime_assets_preflight] - if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }} + needs: [resolve_target, evidence_reuse] + if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }} outputs: @@ -421,8 +495,8 @@ jobs: plugin_prerelease: name: Run plugin prerelease validation - needs: [resolve_target, docker_runtime_assets_preflight] - if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }} + 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' }} runs-on: ubuntu-24.04 timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || inputs.release_profile == 'stable' && 240 || 60 }} outputs: @@ -586,8 +660,8 @@ jobs: release_checks: name: Run release/live/Docker/QA validation - needs: [resolve_target, docker_runtime_assets_preflight] - 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) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }} + 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' }} runs-on: ubuntu-24.04 timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }} outputs: @@ -745,6 +819,13 @@ jobs: fetch_child_jobs | jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' )" + if [[ "$workflow" == "openclaw-release-checks.yml" && "$RELEASE_PROFILE" == "beta" ]]; then + # Beta treats live-provider suites as advisory; their failures must + # not fail-fast-cancel the remaining release-check matrix. + failed_jobs_json="$( + jq '[.[] | select(.name | startswith("Run repo/live E2E validation / ") | not)]' <<< "$failed_jobs_json" + )" + fi if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" @@ -1019,8 +1100,8 @@ jobs: performance: name: Run product performance evidence - needs: [resolve_target, docker_runtime_assets_preflight] - if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","performance"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }} + needs: [resolve_target, evidence_reuse] + if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","performance"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 120 }} outputs: @@ -1166,6 +1247,7 @@ jobs: needs: [ resolve_target, + evidence_reuse, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, @@ -1191,6 +1273,10 @@ jobs: NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }} PERFORMANCE_RESULT: ${{ needs.performance.result }} DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }} + 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 }} + EVIDENCE_SHA: ${{ needs.evidence_reuse.outputs.evidence_sha }} RERUN_GROUP: ${{ inputs.rerun_group }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} @@ -1450,7 +1536,23 @@ jobs: plugin_prerelease_required=0 release_checks_required=0 performance_required=0 - if [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then + 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 so stale evidence cannot pass. + evidence_state="$(gh_with_retry run view "$EVIDENCE_ROOT_RUN_ID" --json status,conclusion --jq '(.status // "") + "/" + (.conclusion // "")')" + if [[ "$evidence_state" != "completed/success" ]]; then + echo "::error::Reused evidence run ${EVIDENCE_ROOT_RUN_ID} is ${evidence_state}; evidence is no longer valid." + failed=1 + else + { + echo "### Reused validation evidence" + echo + echo "- Evidence run: ${EVIDENCE_RUN_URL}" + echo "- Evidence SHA: \`${EVIDENCE_SHA}\`" + echo "- Target SHA: \`${TARGET_SHA}\` (release-metadata-only delta)" + } >> "$GITHUB_STEP_SUMMARY" + fi + elif [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then echo "::error::Docker runtime-assets preflight ended with ${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}." failed=1 elif [[ "$RERUN_GROUP" == "all" ]]; then @@ -1534,12 +1636,19 @@ jobs: PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }} GITHUB_RUN_ID_VALUE: ${{ github.run_id }} RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }} + EVIDENCE_REUSE: ${{ needs.evidence_reuse.outputs.reuse }} + EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }} run: | set -euo pipefail - if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then + if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && "$EVIDENCE_REUSE" != "true" ]]; then echo "Release checks were skipped by rerun group; skipping automatic release evidence update." exit 0 fi + # In reuse mode the child runs live on the chain-root validation run, + # so durable evidence must reference that run id, not this wrapper. + if [[ "$EVIDENCE_REUSE" == "true" && -n "${EVIDENCE_ROOT_RUN_ID// }" ]]; then + GITHUB_RUN_ID_VALUE="$EVIDENCE_ROOT_RUN_ID" + fi if [[ -z "${RELEASES_DISPATCH_TOKEN// }" ]]; then echo "OPENCLAW_RELEASES_DISPATCH_TOKEN is not configured; skipping automatic release evidence update." exit 0 @@ -1616,10 +1725,33 @@ jobs: NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }} PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }} PERFORMANCE_CONCLUSION: ${{ needs.performance.outputs.conclusion }} + EVIDENCE_REUSE: ${{ needs.evidence_reuse.outputs.reuse }} + EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }} + EVIDENCE_MANIFEST: ${{ needs.evidence_reuse.outputs.evidence_manifest }} run: | set -euo pipefail manifest_dir="${RUNNER_TEMP}/full-release-validation" mkdir -p "$manifest_dir" + if [[ "$EVIDENCE_REUSE" == "true" ]]; then + # Inherit the evidence manifest (profile, soak, child runs) so future + # reuse lookups and evidence consumers keep resolving the chain root. + jq \ + --arg runId "$GITHUB_RUN_ID" \ + --arg runAttempt "$GITHUB_RUN_ATTEMPT" \ + --arg workflowRef "$GITHUB_REF_NAME" \ + --arg targetRef "$TARGET_REF" \ + --arg targetSha "$TARGET_SHA" \ + --arg evidenceRootRunId "$EVIDENCE_ROOT_RUN_ID" \ + '. + { + runId: $runId, + runAttempt: $runAttempt, + workflowRef: $workflowRef, + targetRef: $targetRef, + targetSha: $targetSha, + evidenceReuse: {runId: $evidenceRootRunId} + }' <<< "$EVIDENCE_MANIFEST" > "${manifest_dir}/full-release-validation-manifest.json" + exit 0 + fi jq -n \ --arg workflowName "Full Release Validation" \ --arg runId "$GITHUB_RUN_ID" \ diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index d9d3a8aafd61..b0328f3b1790 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -640,7 +640,9 @@ jobs: pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: - advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }} + # Live-provider suites depend on third-party model deployments; beta + # treats them as advisory while stable/full keep them blocking. + advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') || needs.resolve_target.outputs.release_profile == 'beta' }} ref: ${{ needs.resolve_target.outputs.revision }} include_repo_e2e: true include_release_path_suites: false diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 40982066d053..b2ae37af954a 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -342,7 +342,9 @@ gh workflow run full-release-validation.yml \ Do not use the full umbrella as the first rerun after a focused fix. If one box fails, use the failed child workflow, job, Docker lane, package profile, model provider, or QA lane for the next proof. Run the full umbrella again only when the fix changed shared release orchestration or made earlier all-box evidence stale. The umbrella's final verifier re-checks the recorded child workflow run ids, so after a child workflow is rerun successfully, rerun only the failed `Verify full validation` parent job. -For bounded recovery, pass `rerun_group` to the umbrella. `all` is the real release-candidate run, `ci` runs only the normal CI child, `plugin-prerelease` runs only the release-only plugin child, `release-checks` runs every release box, and the narrower release groups are `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, and `npm-telegram`. Focused `npm-telegram` reruns require `release_package_spec` or `npm_telegram_package_spec`; full/all runs use the canonical package Telegram E2E inside Package Acceptance. Focused cross-OS reruns can add `cross_os_suite_filter=windows/packaged-upgrade` or another OS/suite filter. QA release-check failures block normal release validation, including required OpenClaw dynamic tool drift in the standard tier. Tideclaw alpha runs may still treat non-package-safety release-check lanes as advisory. When `live_suite_filter` explicitly requests a gated QA live lane such as Discord, WhatsApp, or Slack, the matching `OPENCLAW_RELEASE_QA_*_LIVE_CI_ENABLED` repo variable must be enabled; otherwise input capture fails instead of silently skipping the lane. +Release-metadata-only commits (changelog refreshes, version stamps) do not need a new full validation: `rerun_group=all` first checks for a prior green validation whose target differs only by release metadata paths and reuses that evidence, skipping every lane. Newer umbrella runs for the same `release/*` ref and rerun group supersede in-progress ones automatically. Pass `reuse_evidence=false` to force a fresh full run. + +For bounded recovery, pass `rerun_group` to the umbrella. `all` is the real release-candidate run, `ci` runs only the normal CI child, `plugin-prerelease` runs only the release-only plugin child, `release-checks` runs every release box, and the narrower release groups are `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, and `npm-telegram`. Focused `npm-telegram` reruns require `release_package_spec` or `npm_telegram_package_spec`; full/all runs use the canonical package Telegram E2E inside Package Acceptance. Focused cross-OS reruns can add `cross_os_suite_filter=windows/packaged-upgrade` or another OS/suite filter. QA release-check failures block normal release validation, including required OpenClaw dynamic tool drift in the standard tier. Tideclaw alpha runs may still treat non-package-safety release-check lanes as advisory. With `release_profile=beta`, the `Run repo/live E2E validation` live-provider suites are advisory (warnings, not blockers); stable and full profiles keep them blocking. When `live_suite_filter` explicitly requests a gated QA live lane such as Discord, WhatsApp, or Slack, the matching `OPENCLAW_RELEASE_QA_*_LIVE_CI_ENABLED` repo variable must be enabled; otherwise input capture fails instead of silently skipping the lane. ### Vitest diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 71384bb9acde..b9ae790a0424 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -48,10 +48,19 @@ that plugin, then runs Codex CLI preflight and same-session OpenAI agent turns. ## Top-level stages -For `rerun_group=all`, a `Verify Docker runtime image assets` job gates every -other stage: it builds the `runtime-assets` Docker target with -`OPENCLAW_EXTENSIONS=diagnostics-otel,codex` before anything else dispatches. A -narrower `rerun_group` skips this preflight. +For `rerun_group=all`, a `Check for reusable validation evidence` job runs +first: it looks for the newest prior green full validation whose target differs +from the current target only by release metadata paths (changelog, version +stamps; see `RELEASE_METADATA_PATHS` in `scripts/changed-lanes.mjs`). When such +evidence exists, every lane is skipped and the umbrella verifier re-checks the +evidence run instead, so changelog-only commits do not re-drive hours of +validation. Pass `reuse_evidence=false` to force a fresh full run. + +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 +other stages and is enforced by the umbrella verifier; lanes no longer wait for +it before dispatching. A narrower `rerun_group` skips this preflight. | Stage | Details | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -64,10 +73,11 @@ narrower `rerun_group` skips this preflight. | Product performance | **Job:** `Run product performance evidence`
**Child workflow:** `OpenClaw Performance`
**Proves:** release-profile performance run (`profile=release`, `repeat=3`, `fail_on_regression=true`) against the target SHA. Required (blocking) only for `rerun_group=all` or `rerun_group=performance`; not required for narrower rerun groups.
**Rerun:** `rerun_group=performance`. | | Umbrella verifier | **Job:** `Verify full validation`
**Child workflow:** none
**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.
**Rerun:** rerun only this job after rerunning a failed child to green. | -For `ref=main` and `rerun_group=all`, a newer umbrella supersedes an older one. -When the parent is cancelled, its monitor cancels any child workflow it already -dispatched. Release branch and tag validation runs do not cancel each other by -default. +For `ref=main` with `rerun_group=all`, for `release/*` refs, and for Tideclaw +alpha refs, a newer umbrella run supersedes an older one with the same ref and +rerun group. When the parent is cancelled, its monitor cancels any child +workflow it already dispatched. Tag and pinned-SHA validation runs do not +cancel each other. ## Release checks stages @@ -188,7 +198,11 @@ QA release-check failures block normal release validation. The QA runtime tool coverage check (dynamic tool drift between `openclaw` and `codex` in the standard tier) also blocks the release-check verifier even though the underlying QA runtime parity lane is advisory. Tideclaw alpha runs may still -treat non-package-safety release-check lanes as advisory. When +treat non-package-safety release-check lanes as advisory. With +`release_profile=beta`, the `Run repo/live E2E validation` live-provider suites +are advisory: third-party model deployments change underneath a release, so +beta surfaces their failures as warnings while stable and full profiles keep +them blocking. When `live_suite_filter` explicitly requests a gated QA live lane such as Discord, WhatsApp, or Slack, the matching `OPENCLAW_RELEASE_QA_*_LIVE_CI_ENABLED` repo variable must be enabled; otherwise input capture fails instead of silently skipping the lane. diff --git a/scripts/github/find-reusable-release-validation.sh b/scripts/github/find-reusable-release-validation.sh new file mode 100755 index 000000000000..3219cd981046 --- /dev/null +++ b/scripts/github/find-reusable-release-validation.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Finds a prior green Full Release Validation run whose evidence still covers +# the target SHA: same rerun scope, equal-or-broader release profile/soak, and +# a target delta that is release-metadata-only per check-release-metadata-only. +# Always exits 0 with reuse=true/false; callers fail open to a full validation. + +REPO="${GH_REPO:-}" +WORKFLOW_FILE="full-release-validation.yml" +TARGET_SHA="" +RELEASE_PROFILE="" +RUN_RELEASE_SOAK="false" +REPO_DIR="." +MAX_CANDIDATES=12 +GITHUB_OUTPUT_FILE="${GITHUB_OUTPUT:-}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLASSIFIER="${SCRIPT_DIR}/../check-release-metadata-only.mjs" + +usage() { + cat >&2 <<'EOF' +Usage: find-reusable-release-validation.sh --target-sha --release-profile \ + [--run-release-soak ] [--repo ] [--repo-dir ] \ + [--workflow ] [--max-candidates ] [--github-output ] + +Scans recent successful Full Release Validation runs for a validation manifest +whose targetSha differs from --target-sha only by release metadata paths. +Writes reuse=true plus evidence_* outputs when found; reuse=false otherwise. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --target-sha) + TARGET_SHA="${2:-}" + shift 2 + ;; + --release-profile) + RELEASE_PROFILE="${2:-}" + shift 2 + ;; + --run-release-soak) + RUN_RELEASE_SOAK="${2:-}" + shift 2 + ;; + --repo) + REPO="${2:-}" + shift 2 + ;; + --repo-dir) + REPO_DIR="${2:-}" + shift 2 + ;; + --workflow) + WORKFLOW_FILE="${2:-}" + shift 2 + ;; + --max-candidates) + MAX_CANDIDATES="${2:-}" + shift 2 + ;; + --github-output) + GITHUB_OUTPUT_FILE="${2:-}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +write_output() { + local key="$1" + local value="$2" + if [[ -n "$GITHUB_OUTPUT_FILE" ]]; then + printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT_FILE" + else + printf '%s=%s\n' "$key" "$value" + fi +} + +no_reuse() { + echo "[evidence-reuse] no reuse: $1" >&2 + write_output reuse false + write_output reuse_reason "$1" + exit 0 +} + +profile_rank() { + case "$1" in + beta) echo 1 ;; + stable) echo 2 ;; + full) echo 3 ;; + *) echo 0 ;; + esac +} + +if [[ ! "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Expected --target-sha to be a full lowercase commit SHA; got: ${TARGET_SHA}" >&2 + exit 2 +fi +if [[ -z "$REPO" ]]; then + echo "Expected --repo or GH_REPO." >&2 + exit 2 +fi +current_rank="$(profile_rank "$RELEASE_PROFILE")" +if [[ "$current_rank" == "0" ]]; then + no_reuse "unknown release profile ${RELEASE_PROFILE}" +fi + +runs_json="" +if ! runs_json="$( + gh api -X GET "repos/${REPO}/actions/workflows/${WORKFLOW_FILE}/runs" \ + -F status=success -F event=workflow_dispatch -F per_page="$MAX_CANDIDATES" \ + --jq '[.workflow_runs[] | {id, html_url}]' +)"; then + no_reuse "could not list prior successful validation runs" +fi + +run_count="$(jq 'length' <<< "$runs_json")" +if [[ "$run_count" == "0" ]]; then + no_reuse "no prior successful validation runs" +fi + +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +for ((index = 0; index < run_count; index += 1)); do + run_id="$(jq -r ".[${index}].id" <<< "$runs_json")" + run_url="$(jq -r ".[${index}].html_url" <<< "$runs_json")" + + artifact_id="" + if ! artifact_id="$( + gh api "repos/${REPO}/actions/runs/${run_id}/artifacts?per_page=100" \ + --jq "first(.artifacts[] | select(.name == \"full-release-validation-${run_id}\" and .expired == false) | .id)" + )"; then + echo "[evidence-reuse] run ${run_id}: artifact listing failed; skipping" >&2 + continue + fi + if [[ -z "${artifact_id// }" ]]; then + echo "[evidence-reuse] run ${run_id}: no validation manifest artifact; skipping" >&2 + continue + fi + + manifest_zip="${work_dir}/manifest-${run_id}.zip" + manifest_path="${work_dir}/manifest-${run_id}.json" + if ! gh api "repos/${REPO}/actions/artifacts/${artifact_id}/zip" > "$manifest_zip"; then + echo "[evidence-reuse] run ${run_id}: manifest download failed; skipping" >&2 + continue + fi + if ! unzip -p "$manifest_zip" full-release-validation-manifest.json > "$manifest_path" 2>/dev/null; then + echo "[evidence-reuse] run ${run_id}: manifest missing from artifact; skipping" >&2 + continue + fi + + if ! jq -e ' + (.version >= 2) + and (.rerunGroup == "all") + and ((.targetSha // "") | test("^[0-9a-f]{40}$")) + ' "$manifest_path" >/dev/null 2>&1; then + echo "[evidence-reuse] run ${run_id}: manifest is not a full-scope v2 manifest; skipping" >&2 + continue + fi + + prior_profile="$(jq -r '.releaseProfile // ""' "$manifest_path")" + prior_rank="$(profile_rank "$prior_profile")" + if (( prior_rank < current_rank )); then + echo "[evidence-reuse] run ${run_id}: profile ${prior_profile} does not cover ${RELEASE_PROFILE}; skipping" >&2 + continue + fi + prior_soak="$(jq -r '.runReleaseSoak // "false"' "$manifest_path")" + if [[ "$RUN_RELEASE_SOAK" == "true" && "$prior_soak" != "true" ]]; then + echo "[evidence-reuse] run ${run_id}: no soak evidence; skipping" >&2 + continue + fi + + prior_sha="$(jq -r '.targetSha' "$manifest_path")" + # Track count/joined separately: empty-array expansion under `set -u` breaks + # on the bash 3.2 that macOS ships. + changed_paths=() + changed_path_count=0 + changed_paths_joined="" + if [[ "$prior_sha" != "$TARGET_SHA" ]]; then + compare_json="" + if ! compare_json="$( + gh api "repos/${REPO}/compare/${prior_sha}...${TARGET_SHA}" \ + --jq '{status, file_count: ((.files // []) | length), files: [(.files // [])[].filename]}' + )"; then + echo "[evidence-reuse] run ${run_id}: compare ${prior_sha}...${TARGET_SHA} failed; skipping" >&2 + continue + fi + compare_status="$(jq -r '.status' <<< "$compare_json")" + if [[ "$compare_status" != "ahead" ]]; then + echo "[evidence-reuse] run ${run_id}: target is ${compare_status} of prior evidence, not ahead; skipping" >&2 + continue + fi + file_count="$(jq -r '.file_count' <<< "$compare_json")" + # The compare API truncates at 300 files; a truncated list cannot prove a + # metadata-only delta, so fall back to full validation. + if (( file_count >= 300 )); then + echo "[evidence-reuse] run ${run_id}: delta too large to classify (${file_count} files); skipping" >&2 + continue + fi + while IFS= read -r changed_path; do + if [[ -n "$changed_path" ]]; then + changed_paths+=("$changed_path") + changed_path_count=$((changed_path_count + 1)) + changed_paths_joined="${changed_paths_joined:+${changed_paths_joined} }${changed_path}" + fi + done < <(jq -r '.files[]' <<< "$compare_json") + if (( changed_path_count == 0 )); then + echo "[evidence-reuse] run ${run_id}: delta has no file changes" >&2 + else + if ! git -C "$REPO_DIR" fetch --quiet --depth=1 origin "$prior_sha"; then + echo "[evidence-reuse] run ${run_id}: could not fetch prior SHA ${prior_sha}; skipping" >&2 + continue + fi + if ! (cd "$REPO_DIR" && node "$CLASSIFIER" --base "$prior_sha" --head "$TARGET_SHA" -- "${changed_paths[@]}"); then + echo "[evidence-reuse] run ${run_id}: delta is not release-metadata-only; skipping" >&2 + continue + fi + fi + fi + + # A reused run may itself be a reuse manifest; evidenceReuse.runId points at + # the chain root that actually executed the lanes. + evidence_root_run_id="$(jq -r '.evidenceReuse.runId // empty' "$manifest_path")" + if [[ -z "${evidence_root_run_id// }" ]]; then + evidence_root_run_id="$run_id" + fi + + echo "[evidence-reuse] reusing run ${run_id} (${run_url}) for ${TARGET_SHA}: prior sha ${prior_sha}, ${changed_path_count} metadata-only changed files" >&2 + write_output reuse true + write_output evidence_run_id "$run_id" + write_output evidence_root_run_id "$evidence_root_run_id" + write_output evidence_run_url "$run_url" + write_output evidence_sha "$prior_sha" + write_output changed_path_count "$changed_path_count" + write_output changed_paths "$changed_paths_joined" + write_output evidence_manifest "$(jq -c . "$manifest_path")" + exit 0 +done + +no_reuse "no prior validation run covers ${TARGET_SHA}" diff --git a/test/scripts/find-reusable-release-validation.test.ts b/test/scripts/find-reusable-release-validation.test.ts new file mode 100644 index 000000000000..54e49c6c872a --- /dev/null +++ b/test/scripts/find-reusable-release-validation.test.ts @@ -0,0 +1,351 @@ +// Covers the release validation evidence-reuse resolver used by +// full-release-validation.yml to skip lanes on release-metadata-only deltas. +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTempDirTracker } from "../helpers/temp-dir.js"; + +const SCRIPT_PATH = join(process.cwd(), "scripts/github/find-reusable-release-validation.sh"); +const tempDirs = createTempDirTracker(); + +afterEach(() => { + tempDirs.cleanup(); +}); + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function commitFile(repo: string, filePath: string, content: string, message: string): string { + writeFileSync(join(repo, filePath), content); + git(repo, ["add", filePath]); + git(repo, ["-c", "commit.gpgSign=false", "commit", "-qm", message]); + return git(repo, ["rev-parse", "HEAD"]); +} + +function createRepoPair() { + const origin = tempDirs.make("evidence-reuse-origin-"); + git(origin, ["init", "-q", "-b", "main"]); + git(origin, ["config", "user.email", "test-user"]); + git(origin, ["config", "user.name", "Test User"]); + // Allows depth-1 fetches of the prior evidence SHA, matching GitHub remotes. + git(origin, ["config", "uploadpack.allowReachableSHA1InWant", "true"]); + writeFileSync( + join(origin, "package.json"), + `${JSON.stringify({ name: "x", version: "2026.7.1" }, null, 2)}\n`, + ); + writeFileSync(join(origin, "CHANGELOG.md"), "# Changelog\n"); + writeFileSync(join(origin, "index.ts"), "export const value = 1;\n"); + git(origin, ["add", "-A"]); + git(origin, ["-c", "commit.gpgSign=false", "commit", "-qm", "seed"]); + const priorSha = git(origin, ["rev-parse", "HEAD"]); + return { origin, priorSha }; +} + +function cloneHead(origin: string): string { + const clone = tempDirs.make("evidence-reuse-clone-"); + execFileSync("git", ["clone", "-q", "--depth=1", origin, clone], { encoding: "utf8" }); + return clone; +} + +const FAKE_GH = `#!/usr/bin/env bash +set -euo pipefail +[[ "\${1:-}" == "api" ]] || { echo "unexpected gh command: $*" >&2; exit 1; } +shift +jq_expr="" +endpoint="" +while [[ $# -gt 0 ]]; do + case "$1" in + -X|-F) shift 2 ;; + --jq) jq_expr="$2"; shift 2 ;; + *) [[ -n "$endpoint" ]] || endpoint="$1"; shift ;; + esac +done +fixture="\${FAKE_GH_FIXTURES}/$(printf '%s' "$endpoint" | tr '/?' '__')" +if [[ "$endpoint" == */zip ]]; then + [[ -f "\${fixture}.bin" ]] || { echo "no fixture for $endpoint" >&2; exit 1; } + exec cat "\${fixture}.bin" +fi +[[ -f "\${fixture}.json" ]] || { echo "no fixture for $endpoint" >&2; exit 1; } +if [[ -n "$jq_expr" ]]; then + exec jq -r "$jq_expr" "\${fixture}.json" +fi +exec cat "\${fixture}.json" +`; + +interface FixtureOptions { + runId?: string; + manifest?: Record; + compare?: { base: string; head: string; status: string; files: string[] } | undefined; +} + +function setUpFixtures(options: FixtureOptions): { fixtures: string; binDir: string } { + const runId = options.runId ?? "111"; + const root = tempDirs.make("evidence-reuse-fixtures-"); + const fixtures = join(root, "fixtures"); + const binDir = join(root, "bin"); + mkdirSync(fixtures, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, "gh"), FAKE_GH); + chmodSync(join(binDir, "gh"), 0o755); + + writeFileSync( + join( + fixtures, + "repos_openclaw_openclaw_actions_workflows_full-release-validation.yml_runs.json", + ), + JSON.stringify({ + workflow_runs: [{ id: Number(runId), html_url: `https://example.test/runs/${runId}` }], + }), + ); + if (options.manifest) { + writeFileSync( + join(fixtures, `repos_openclaw_openclaw_actions_runs_${runId}_artifacts_per_page=100.json`), + JSON.stringify({ + artifacts: [{ id: 999, name: `full-release-validation-${runId}`, expired: false }], + }), + ); + const manifestDir = join(root, "manifest"); + mkdirSync(manifestDir, { recursive: true }); + writeFileSync( + join(manifestDir, "full-release-validation-manifest.json"), + JSON.stringify(options.manifest), + ); + execFileSync( + "zip", + [ + "-q", + "-j", + join(root, "manifest.zip"), + join(manifestDir, "full-release-validation-manifest.json"), + ], + { + encoding: "utf8", + }, + ); + execFileSync("cp", [ + join(root, "manifest.zip"), + join(fixtures, "repos_openclaw_openclaw_actions_artifacts_999_zip.bin"), + ]); + } + if (options.compare) { + writeFileSync( + join( + fixtures, + `repos_openclaw_openclaw_compare_${options.compare.base}...${options.compare.head}.json`, + ), + JSON.stringify({ + status: options.compare.status, + files: options.compare.files.map((filename) => ({ filename })), + }), + ); + } + return { fixtures, binDir }; +} + +function runResolver(args: { + repoDir: string; + targetSha: string; + releaseProfile: string; + runReleaseSoak?: string; + fixtures: string; + binDir: string; +}) { + return spawnSync( + "bash", + [ + SCRIPT_PATH, + "--target-sha", + args.targetSha, + "--release-profile", + args.releaseProfile, + "--run-release-soak", + args.runReleaseSoak ?? "false", + "--repo", + "openclaw/openclaw", + "--repo-dir", + args.repoDir, + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + PATH: `${args.binDir}:${process.env.PATH}`, + FAKE_GH_FIXTURES: args.fixtures, + GITHUB_OUTPUT: "", + }, + }, + ); +} + +function parseOutput(output: string): Record { + return Object.fromEntries( + output + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + }), + ); +} + +function manifestFor(targetSha: string, overrides: Record = {}) { + return { + version: 2, + workflowName: "Full Release Validation", + runId: "111", + rerunGroup: "all", + releaseProfile: "stable", + runReleaseSoak: "true", + targetSha, + childRuns: { normalCi: "1" }, + ...overrides, + }; +} + +describe("scripts/github/find-reusable-release-validation.sh", () => { + it("reuses evidence when the delta is release-metadata-only", () => { + const { origin, priorSha } = createRepoPair(); + const targetSha = commitFile( + origin, + "CHANGELOG.md", + "# Changelog\n\n- entry\n", + "docs(changelog): refresh", + ); + const clone = cloneHead(origin); + const { fixtures, binDir } = setUpFixtures({ + manifest: manifestFor(priorSha), + compare: { base: priorSha, head: targetSha, status: "ahead", files: ["CHANGELOG.md"] }, + }); + + const result = runResolver({ + repoDir: clone, + targetSha, + releaseProfile: "stable", + fixtures, + binDir, + }); + expect(result.status).toBe(0); + const output = parseOutput(result.stdout); + expect(output).toMatchObject({ + reuse: "true", + evidence_run_id: "111", + evidence_root_run_id: "111", + evidence_sha: priorSha, + changed_path_count: "1", + changed_paths: "CHANGELOG.md", + }); + expect(JSON.parse(output.evidence_manifest ?? "{}")).toMatchObject({ targetSha: priorSha }); + }); + + it("reuses identical targets without comparing and resolves the chain root", () => { + const { origin, priorSha } = createRepoPair(); + const clone = cloneHead(origin); + // No compare fixture: an identical target must not hit the compare API. + const { fixtures, binDir } = setUpFixtures({ + manifest: manifestFor(priorSha, { evidenceReuse: { runId: "42" } }), + }); + + const result = runResolver({ + repoDir: clone, + targetSha: priorSha, + releaseProfile: "stable", + fixtures, + binDir, + }); + expect(result.status).toBe(0); + expect(parseOutput(result.stdout)).toMatchObject({ + reuse: "true", + evidence_run_id: "111", + evidence_root_run_id: "42", + changed_path_count: "0", + }); + }); + + it("rejects deltas that touch non-metadata paths", () => { + const { origin, priorSha } = createRepoPair(); + const targetSha = commitFile( + origin, + "index.ts", + "export const value = 2;\n", + "fix: change code", + ); + const clone = cloneHead(origin); + const { fixtures, binDir } = setUpFixtures({ + manifest: manifestFor(priorSha), + compare: { base: priorSha, head: targetSha, status: "ahead", files: ["index.ts"] }, + }); + + const result = runResolver({ + repoDir: clone, + targetSha, + releaseProfile: "stable", + fixtures, + binDir, + }); + expect(result.status).toBe(0); + expect(parseOutput(result.stdout)).toMatchObject({ reuse: "false" }); + }); + + it("rejects evidence from a narrower release profile or diverged history", () => { + const { origin, priorSha } = createRepoPair(); + const targetSha = commitFile( + origin, + "CHANGELOG.md", + "# Changelog\n\n- entry\n", + "docs(changelog): refresh", + ); + const clone = cloneHead(origin); + + const beta = setUpFixtures({ manifest: manifestFor(priorSha, { releaseProfile: "beta" }) }); + const betaResult = runResolver({ + repoDir: clone, + targetSha, + releaseProfile: "stable", + fixtures: beta.fixtures, + binDir: beta.binDir, + }); + expect(betaResult.status).toBe(0); + expect(parseOutput(betaResult.stdout)).toMatchObject({ reuse: "false" }); + + const diverged = setUpFixtures({ + manifest: manifestFor(priorSha), + compare: { base: priorSha, head: targetSha, status: "diverged", files: ["CHANGELOG.md"] }, + }); + const divergedResult = runResolver({ + repoDir: clone, + targetSha, + releaseProfile: "stable", + fixtures: diverged.fixtures, + binDir: diverged.binDir, + }); + expect(divergedResult.status).toBe(0); + expect(parseOutput(divergedResult.stdout)).toMatchObject({ reuse: "false" }); + }); + + it("reports no reuse when no prior runs or manifests exist", () => { + const { origin, priorSha } = createRepoPair(); + const clone = cloneHead(origin); + const { fixtures, binDir } = setUpFixtures({}); + + const result = runResolver({ + repoDir: clone, + targetSha: priorSha, + releaseProfile: "beta", + fixtures, + binDir, + }); + expect(result.status).toBe(0); + const output = parseOutput(result.stdout); + expect(output.reuse).toBe("false"); + expect(output.reuse_reason).toContain("no prior validation run covers"); + }); +});