diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index da95626e6db..9ca95272de9 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -45,6 +45,7 @@ jobs: candidate_count: ${{ steps.plan.outputs.candidate_count }} skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }} matrix: ${{ steps.plan.outputs.matrix }} + plan_json: ${{ steps.plan.outputs.plan_json }} steps: - name: Checkout uses: actions/checkout@v6 @@ -148,12 +149,14 @@ jobs: has_candidates="true" fi matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)" + plan_json="$(jq -c . .local/plugin-clawhub-release-plan.json)" { echo "candidate_count=${candidate_count}" echo "skipped_published_count=${skipped_published_count}" echo "has_candidates=${has_candidates}" echo "matrix=${matrix_json}" + echo "plan_json=${plan_json}" } >> "$GITHUB_OUTPUT" echo "Plugin release candidates:" @@ -216,21 +219,26 @@ jobs: with: persist-credentials: false repository: ${{ env.CLAWHUB_REPOSITORY }} - ref: main + ref: ${{ env.CLAWHUB_REF }} path: clawhub-source - fetch-depth: 0 + fetch-depth: 1 - - name: Checkout pinned ClawHub CLI revision - working-directory: clawhub-source - env: - CLAWHUB_REF: ${{ env.CLAWHUB_REF }} - run: git checkout --detach "${CLAWHUB_REF}" + - name: Cache ClawHub CLI Bun artifacts + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: clawhub-cli-bun-${{ runner.os }}-${{ env.CLAWHUB_REF }}-${{ hashFiles('clawhub-source/bun.lock', 'clawhub-source/bun.lockb') }} + restore-keys: | + clawhub-cli-bun-${{ runner.os }}-${{ env.CLAWHUB_REF }}- - name: Install ClawHub CLI dependencies + id: clawhub_install + continue-on-error: true working-directory: clawhub-source - run: bun install --frozen-lockfile + run: bash "$GITHUB_WORKSPACE/scripts/install-clawhub-cli-deps.sh" - name: Bootstrap ClawHub CLI + if: steps.clawhub_install.outcome == 'success' run: | cat > "$RUNNER_TEMP/clawhub" <<'EOF' #!/usr/bin/env bash @@ -241,9 +249,15 @@ jobs: echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - name: Verify package-local runtime build + id: runtime_build + if: steps.clawhub_install.outcome == 'success' + continue-on-error: true run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" - name: Preview publish command + id: preview_publish + if: steps.clawhub_install.outcome == 'success' && steps.runtime_build.outcome == 'success' + continue-on-error: true env: CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} SOURCE_REPO: ${{ github.repository }} @@ -253,9 +267,129 @@ jobs: PACKAGE_DIR: ${{ matrix.plugin.packageDir }} run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}" - publish_plugins_clawhub: + - name: Write preview result + if: always() + env: + PLUGIN_JSON: ${{ toJson(matrix.plugin) }} + INSTALL_OUTCOME: ${{ steps.clawhub_install.outcome }} + RUNTIME_BUILD_OUTCOME: ${{ steps.runtime_build.outcome }} + PREVIEW_OUTCOME: ${{ steps.preview_publish.outcome }} + run: | + set -euo pipefail + mkdir -p .local/clawhub-preview-results + node --input-type=module <<'EOF' + import { writeFileSync } from "node:fs"; + const plugin = JSON.parse(process.env.PLUGIN_JSON ?? "{}"); + const outcomes = { + install: process.env.INSTALL_OUTCOME || "skipped", + runtimeBuild: process.env.RUNTIME_BUILD_OUTCOME || "skipped", + preview: process.env.PREVIEW_OUTCOME || "skipped", + }; + const failed = Object.entries(outcomes).filter(([, outcome]) => outcome !== "success"); + const result = { + status: failed.length === 0 ? "success" : "failure", + failedSteps: failed.map(([step, outcome]) => ({ step, outcome })), + plugin, + }; + const id = String(plugin.extensionId ?? plugin.packageName ?? "plugin").replace(/[^A-Za-z0-9_.-]+/g, "-"); + writeFileSync(`.local/clawhub-preview-results/${id}.json`, `${JSON.stringify(result, null, 2)}\n`); + EOF + + - name: Upload preview result + if: always() + uses: actions/upload-artifact@v7 + with: + name: plugin-clawhub-preview-${{ strategy.job-index }} + path: .local/clawhub-preview-results/*.json + if-no-files-found: error + + - name: Fail failed preview cell + if: always() && (steps.clawhub_install.outcome != 'success' || steps.runtime_build.outcome != 'success' || steps.preview_publish.outcome != 'success') + run: exit 1 + + collect_preview_results: needs: [preview_plugins_clawhub, preview_plugin_pack] - if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' + if: always() && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + passed_count: ${{ steps.collect.outputs.passed_count }} + failed_count: ${{ steps.collect.outputs.failed_count }} + passed_matrix: ${{ steps.collect.outputs.passed_matrix }} + steps: + - name: Download preview results + id: download + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: plugin-clawhub-preview-* + path: .local/clawhub-preview-results + merge-multiple: true + + - name: Collect preview results + id: collect + env: + ORIGINAL_MATRIX: ${{ needs.preview_plugins_clawhub.outputs.matrix }} + run: | + set -euo pipefail + node --input-type=module <<'EOF' > .local/clawhub-preview-summary.json + import { readdirSync, readFileSync } from "node:fs"; + import { join } from "node:path"; + + const original = JSON.parse(process.env.ORIGINAL_MATRIX || "[]"); + const resultDir = ".local/clawhub-preview-results"; + const results = []; + try { + for (const file of readdirSync(resultDir)) { + if (file.endsWith(".json")) { + results.push(JSON.parse(readFileSync(join(resultDir, file), "utf8"))); + } + } + } catch { + // Missing artifacts are accounted for below. + } + + const keyFor = (plugin) => `${plugin.packageName ?? ""}@${plugin.version ?? ""}`; + const resultByKey = new Map(results.map((result) => [keyFor(result.plugin ?? {}), result])); + const passed = []; + const failed = []; + for (const plugin of original) { + const result = resultByKey.get(keyFor(plugin)); + if (result?.status === "success") { + passed.push(plugin); + } else { + failed.push({ + plugin, + failedSteps: result?.failedSteps ?? [{ step: "preview-result", outcome: "missing" }], + }); + } + } + console.log(JSON.stringify({ passed, failed }, null, 2)); + EOF + + passed_matrix="$(jq -c '.passed' .local/clawhub-preview-summary.json)" + passed_count="$(jq -r '.passed | length' .local/clawhub-preview-summary.json)" + failed_count="$(jq -r '.failed | length' .local/clawhub-preview-summary.json)" + { + echo "passed_count=${passed_count}" + echo "failed_count=${failed_count}" + echo "passed_matrix=${passed_matrix}" + } >> "$GITHUB_OUTPUT" + { + echo "### ClawHub preview results" + echo + echo "- Passed: \`${passed_count}\`" + echo "- Failed: \`${failed_count}\`" + if [[ "${failed_count}" != "0" ]]; then + echo + jq -r '.failed[] | "- \(.plugin.packageName)@\(.plugin.version): \(.failedSteps | map("\(.step)=\(.outcome)") | join(", "))"' .local/clawhub-preview-summary.json + fi + } >> "$GITHUB_STEP_SUMMARY" + + publish_plugins_clawhub: + needs: [preview_plugins_clawhub, collect_preview_results] + if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.collect_preview_results.outputs.passed_count != '0' runs-on: ubuntu-latest environment: clawhub-plugin-release permissions: @@ -265,7 +399,7 @@ jobs: fail-fast: false max-parallel: 12 matrix: - plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} + plugin: ${{ fromJson(needs.collect_preview_results.outputs.passed_matrix) }} steps: - name: Checkout uses: actions/checkout@v6 @@ -297,19 +431,21 @@ jobs: with: persist-credentials: false repository: ${{ env.CLAWHUB_REPOSITORY }} - ref: main + ref: ${{ env.CLAWHUB_REF }} path: clawhub-source - fetch-depth: 0 + fetch-depth: 1 - - name: Checkout pinned ClawHub CLI revision - working-directory: clawhub-source - env: - CLAWHUB_REF: ${{ env.CLAWHUB_REF }} - run: git checkout --detach "${CLAWHUB_REF}" + - name: Cache ClawHub CLI Bun artifacts + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: clawhub-cli-bun-${{ runner.os }}-${{ env.CLAWHUB_REF }}-${{ hashFiles('clawhub-source/bun.lock', 'clawhub-source/bun.lockb') }} + restore-keys: | + clawhub-cli-bun-${{ runner.os }}-${{ env.CLAWHUB_REF }}- - name: Install ClawHub CLI dependencies working-directory: clawhub-source - run: bun install --frozen-lockfile + run: bash "$GITHUB_WORKSPACE/scripts/install-clawhub-cli-deps.sh" - name: Bootstrap ClawHub CLI run: | @@ -392,3 +528,31 @@ jobs: PACKAGE_TAG: ${{ matrix.plugin.publishTag }} PACKAGE_DIR: ${{ matrix.plugin.packageDir }} run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}" + + verify_plugins_clawhub: + needs: [preview_plugins_clawhub, collect_preview_results, publish_plugins_clawhub] + if: always() && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + ref: ${{ github.ref }} + fetch-depth: 1 + + - name: Verify expected ClawHub versions are published + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + PLAN_JSON: ${{ needs.preview_plugins_clawhub.outputs.plan_json }} + PUBLISH_RESULT: ${{ needs.publish_plugins_clawhub.result }} + run: | + set -euo pipefail + mkdir -p .local + printf '%s\n' "${PLAN_JSON}" > .local/plugin-clawhub-release-plan.json + if [[ "${PUBLISH_RESULT}" != "success" ]]; then + echo "::warning::ClawHub publish job concluded with ${PUBLISH_RESULT}; verifying registry state before failing." + fi + node scripts/plugin-clawhub-verify-published.mjs .local/plugin-clawhub-release-plan.json diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index f9a0f326af6..cf20991ca93 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -82,7 +82,11 @@ the maintainer-only release runbook. artifact with the matching dist-tag as soon as plugin npm publish succeeds. ClawHub publishing may still be running while OpenClaw npm publishes, but the release publish workflow does not finish until both plugin publish paths and - the OpenClaw npm publish path have completed successfully. After publish, run + the OpenClaw npm publish path have completed successfully. The ClawHub path + retries transient CLI dependency install failures, publishes preview-passing + plugins even when one preview cell flakes, and ends with registry verification + for every expected plugin version so partial publishes remain visible and + retryable. After publish, run the post-publish package acceptance against the published `openclaw@YYYY.M.D-beta.N` or `openclaw@beta` package. If a pushed or published prerelease needs a fix, diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 0c630011d07..eedd9abdfc5 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -27,6 +27,14 @@ Child workflows use the trusted workflow ref for the harness and the input `ref` for the candidate under test. That keeps new validation logic available when validating an older release branch or tag. +Plugin publish validation is intentionally split from core package publication. +`OpenClaw Release Publish` dispatches npm plugin publishing and ClawHub +publishing in parallel, starts the core npm publish after plugin npm succeeds, +and keeps waiting for ClawHub. The ClawHub child retries transient CLI +dependency install failures, publishes preview-passing plugins when a single +preview cell flakes, and then verifies every expected package/version through +the ClawHub API so a partial publish still fails loudly and can be rerun. + By default, `release_profile=stable` runs the release-blocking lanes and skips the exhaustive live/Docker soak. Pass `run_release_soak=true` to include the soak lanes on a stable run. `release_profile=full` always enables soak lanes so diff --git a/scripts/install-clawhub-cli-deps.sh b/scripts/install-clawhub-cli-deps.sh new file mode 100755 index 00000000000..d56a867d6d1 --- /dev/null +++ b/scripts/install-clawhub-cli-deps.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +attempts="${CLAWHUB_BUN_INSTALL_ATTEMPTS:-3}" +delay_seconds="${CLAWHUB_BUN_INSTALL_RETRY_DELAY_SECONDS:-15}" + +for attempt in $(seq 1 "${attempts}"); do + if bun install --frozen-lockfile; then + exit 0 + fi + + status="$?" + if [[ "${attempt}" == "${attempts}" ]]; then + exit "${status}" + fi + + echo "::warning::ClawHub CLI bun install failed on attempt ${attempt}/${attempts}; clearing install cache before retry." + rm -rf node_modules + rm -rf "${BUN_INSTALL_CACHE_DIR:-${HOME}/.bun/install/cache}" + find "${TMPDIR:-/tmp}" -maxdepth 1 -type d -name 'bun-*' -prune -exec rm -rf {} + 2>/dev/null || true + sleep "$((delay_seconds * attempt))" +done diff --git a/scripts/plugin-clawhub-verify-published.mjs b/scripts/plugin-clawhub-verify-published.mjs new file mode 100755 index 00000000000..8cf7e106a1e --- /dev/null +++ b/scripts/plugin-clawhub-verify-published.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import process from "node:process"; + +const DEFAULT_REGISTRY = "https://clawhub.ai"; +const RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); + +function usage() { + return "usage: node scripts/plugin-clawhub-verify-published.mjs "; +} + +function parsePlan(planPath) { + const plan = JSON.parse(readFileSync(planPath, "utf8")); + const entries = [...(plan.candidates ?? []), ...(plan.skippedPublished ?? [])]; + return entries + .filter((entry) => typeof entry?.packageName === "string" && typeof entry?.version === "string") + .map((entry) => ({ + packageName: entry.packageName, + version: entry.version, + })); +} + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchVersionStatus({ registry, packageName, version }) { + const encodedName = encodeURIComponent(packageName); + const encodedVersion = encodeURIComponent(version); + const url = `${registry.replace(/\/+$/, "")}/api/v1/packages/${encodedName}/versions/${encodedVersion}`; + + let lastStatus = 0; + let lastError = ""; + for (let attempt = 1; attempt <= 8; attempt += 1) { + try { + const response = await fetch(url, { method: "GET" }); + lastStatus = response.status; + if (response.ok) { + return { ok: true, status: response.status, url }; + } + if (!RETRY_STATUSES.has(response.status)) { + break; + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await wait(5000 * attempt); + } + + return { ok: false, status: lastStatus, error: lastError, url }; +} + +export async function verifyClawHubPublished(argv) { + const planPath = argv[0]; + if (!planPath) { + throw new Error(usage()); + } + + const registry = process.env.CLAWHUB_REGISTRY || DEFAULT_REGISTRY; + const expected = parsePlan(planPath); + if (expected.length === 0) { + console.log("No ClawHub package versions to verify."); + return; + } + + const failures = []; + for (const entry of expected) { + const result = await fetchVersionStatus({ registry, ...entry }); + if (result.ok) { + console.log(`Verified ClawHub package ${entry.packageName}@${entry.version}`); + continue; + } + failures.push({ ...entry, ...result }); + } + + if (failures.length > 0) { + throw new Error( + `Missing or unavailable ClawHub package version(s):\n${failures + .map((failure) => { + const suffix = failure.error + ? ` (${failure.error})` + : failure.status + ? ` (HTTP ${failure.status})` + : ""; + return `- ${failure.packageName}@${failure.version}${suffix}`; + }) + .join("\n")}`, + ); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { + await verifyClawHubPublished(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +}