From b37fba7c07a769639a5d042dc8d193b39ffb092e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 10:08:09 +0100 Subject: [PATCH] ci(release): harden clawhub plugin publish --- .github/workflows/plugin-clawhub-release.yml | 99 ++++++++++++++++--- scripts/lib/plugin-clawhub-release.ts | 60 +++++++++++ scripts/plugin-clawhub-owner-preflight.ts | 44 +++++++++ scripts/plugin-clawhub-publish.sh | 15 ++- .../verify-plugin-npm-published-runtime.mjs | 7 +- test/plugin-clawhub-release.test.ts | 45 +++++++++ 6 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 scripts/plugin-clawhub-owner-preflight.ts diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index 50c2ef78cb0..52c9d017223 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -32,7 +32,7 @@ env: CLAWHUB_REGISTRY: "https://clawhub.ai" CLAWHUB_REPOSITORY: "openclaw/clawhub" # Pinned to a reviewed ClawHub commit so release behavior stays reproducible. - CLAWHUB_REF: "199e6a0cdf32471702e0503e9899e8d24f06a527" + CLAWHUB_REF: "facf20ceb6cc459e2872d941e71335a784bbc55c" jobs: preview_plugins_clawhub: @@ -50,7 +50,7 @@ jobs: uses: actions/checkout@v6 with: persist-credentials: false - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} + ref: ${{ github.ref }} fetch-depth: 0 - name: Setup Node environment @@ -62,14 +62,29 @@ jobs: - name: Resolve checked-out ref id: ref - run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - - name: Validate ref is on main or a release branch + env: + TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} run: | set -euo pipefail git fetch --no-tags origin \ +refs/heads/main:refs/remotes/origin/main \ '+refs/heads/release/*:refs/remotes/origin/release/*' + if [[ -n "${TARGET_REF}" ]]; then + if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then + target_sha="$(git rev-parse "${TARGET_REF}^{commit}")" + elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then + target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")" + else + echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2 + exit 1 + fi + git checkout --detach "${target_sha}" + fi + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Validate ref is on main or a release branch + run: | + set -euo pipefail if git merge-base --is-ancestor HEAD origin/main; then exit 0 fi @@ -153,6 +168,12 @@ jobs: echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish." exit 1 + - name: Verify OpenClaw ClawHub package ownership + if: steps.plan.outputs.has_candidates == 'true' + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json + preview_plugin_pack: needs: preview_plugins_clawhub if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true' @@ -161,7 +182,7 @@ jobs: contents: read strategy: fail-fast: false - max-parallel: 1 + max-parallel: 6 matrix: plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} steps: @@ -169,8 +190,18 @@ jobs: uses: actions/checkout@v6 with: persist-credentials: false - ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} - fetch-depth: 1 + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Checkout target revision + env: + TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} + run: | + set -euo pipefail + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + git checkout --detach "${TARGET_SHA}" - name: Setup Node environment uses: ./.github/actions/setup-node-env @@ -185,9 +216,15 @@ jobs: with: persist-credentials: false repository: ${{ env.CLAWHUB_REPOSITORY }} - ref: ${{ env.CLAWHUB_REF }} + ref: main path: clawhub-source - fetch-depth: 1 + fetch-depth: 0 + + - name: Checkout pinned ClawHub CLI revision + working-directory: clawhub-source + env: + CLAWHUB_REF: ${{ env.CLAWHUB_REF }} + run: git checkout --detach "${CLAWHUB_REF}" - name: Install ClawHub CLI dependencies working-directory: clawhub-source @@ -203,6 +240,9 @@ jobs: chmod +x "$RUNNER_TEMP/clawhub" echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + - name: Verify package-local runtime build + run: pnpm release:plugins:npm:runtime:check --package "${{ matrix.plugin.packageDir }}" + - name: Preview publish command env: CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} @@ -223,6 +263,7 @@ jobs: id-token: write strategy: fail-fast: false + max-parallel: 6 matrix: plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} steps: @@ -230,8 +271,18 @@ jobs: uses: actions/checkout@v6 with: persist-credentials: false - ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} - fetch-depth: 1 + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Checkout target revision + env: + TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} + run: | + set -euo pipefail + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + git checkout --detach "${TARGET_SHA}" - name: Setup Node environment uses: ./.github/actions/setup-node-env @@ -246,9 +297,15 @@ jobs: with: persist-credentials: false repository: ${{ env.CLAWHUB_REPOSITORY }} - ref: ${{ env.CLAWHUB_REF }} + ref: main path: clawhub-source - fetch-depth: 1 + fetch-depth: 0 + + - name: Checkout pinned ClawHub CLI revision + working-directory: clawhub-source + env: + CLAWHUB_REF: ${{ env.CLAWHUB_REF }} + run: git checkout --detach "${CLAWHUB_REF}" - name: Install ClawHub CLI dependencies working-directory: clawhub-source @@ -304,7 +361,19 @@ jobs: encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')" encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')" url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}" - status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")" + status="" + for attempt in $(seq 1 8); do + status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")" + if [[ "${status}" == "404" || "${status}" =~ ^2 ]]; then + break + fi + if [[ "${status}" == "429" || "${status}" =~ ^5 ]]; then + echo "ClawHub availability check returned ${status} for ${PACKAGE_NAME}@${PACKAGE_VERSION}; retrying (${attempt}/8)." + sleep 60 + continue + fi + break + done if [[ "${status}" =~ ^2 ]]; then echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub." exit 1 diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index 04fc5fa88c8..b08cf847270 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -60,6 +60,12 @@ type PluginReleasePlan = { skippedPublished: PluginReleasePlanItem[]; }; +type ClawHubPackageOwnerDetail = { + owner?: { + handle?: unknown; + } | null; +}; + type ClawHubPublishablePluginPackageFilters = { extensionIds?: readonly string[]; packageNames?: readonly string[]; @@ -76,6 +82,7 @@ const CLAWHUB_SHARED_RELEASE_INPUT_PATHS = [ "scripts/lib/npm-publish-plan.mjs", "scripts/lib/plugin-npm-release.ts", "scripts/lib/plugin-clawhub-release.ts", + "scripts/plugin-clawhub-owner-preflight.ts", "scripts/openclaw-npm-release-check.ts", "scripts/plugin-clawhub-publish.sh", "scripts/plugin-clawhub-release-check.ts", @@ -343,6 +350,59 @@ async function isPluginVersionPublishedOnClawHub( ); } +export async function collectClawHubOpenClawOwnerErrors(params: { + plugins: readonly Pick[]; + requiredOwnerHandle?: string; + registryBaseUrl?: string; + fetchImpl?: typeof fetch; +}): Promise { + const fetchImpl = params.fetchImpl ?? fetch; + const requiredOwnerHandle = params.requiredOwnerHandle ?? "openclaw"; + const errors: string[] = []; + + await Promise.all( + params.plugins.map(async (plugin) => { + if (!plugin.packageName.startsWith("@openclaw/")) { + return; + } + + const url = new URL( + `/api/v1/packages/${encodeURIComponent(plugin.packageName)}`, + getRegistryBaseUrl(params.registryBaseUrl), + ); + const response = await fetchImpl(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (response.status === 404) { + errors.push( + `${plugin.packageName}: ClawHub package row must already exist under @${requiredOwnerHandle} before OpenClaw release publish.`, + ); + return; + } + if (!response.ok) { + errors.push( + `${plugin.packageName}: failed to query ClawHub owner: ${response.status} ${response.statusText}`, + ); + return; + } + + const detail = (await response.json()) as ClawHubPackageOwnerDetail; + const ownerHandle = typeof detail.owner?.handle === "string" ? detail.owner.handle : null; + if (ownerHandle !== requiredOwnerHandle) { + errors.push( + `${plugin.packageName}: ClawHub package owner must be @${requiredOwnerHandle}; got ${ownerHandle ? `@${ownerHandle}` : ""}.`, + ); + } + }), + ); + + return errors.toSorted(); +} + export async function collectPluginClawHubReleasePlan(params?: { rootDir?: string; selection?: string[]; diff --git a/scripts/plugin-clawhub-owner-preflight.ts b/scripts/plugin-clawhub-owner-preflight.ts new file mode 100644 index 00000000000..6fb25d1b4e7 --- /dev/null +++ b/scripts/plugin-clawhub-owner-preflight.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env -S node --import tsx + +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { collectClawHubOpenClawOwnerErrors } from "./lib/plugin-clawhub-release.ts"; + +type ReleasePlanFile = { + candidates?: Array<{ + packageName?: unknown; + }>; +}; + +export async function runClawHubOwnerPreflight(argv: string[]) { + const planPath = argv[0]; + if (!planPath) { + throw new Error("usage: plugin-clawhub-owner-preflight.ts "); + } + + const parsed = JSON.parse(readFileSync(planPath, "utf8")) as ReleasePlanFile; + const candidates = (parsed.candidates ?? []) + .filter( + (candidate): candidate is { packageName: string } => + typeof candidate.packageName === "string", + ) + .map((candidate) => ({ packageName: candidate.packageName })); + + const errors = await collectClawHubOpenClawOwnerErrors({ plugins: candidates }); + if (errors.length > 0) { + throw new Error( + `ClawHub OpenClaw package ownership preflight failed:\n${errors.map((error) => `- ${error}`).join("\n")}`, + ); + } + + console.log(`ClawHub OpenClaw owner preflight passed for ${candidates.length} candidate(s).`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + await runClawHubOwnerPreflight(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/plugin-clawhub-publish.sh b/scripts/plugin-clawhub-publish.sh index 3beb9263765..e7c6202ba88 100644 --- a/scripts/plugin-clawhub-publish.sh +++ b/scripts/plugin-clawhub-publish.sh @@ -152,4 +152,17 @@ if [[ "${mode}" == "--dry-run" ]]; then exit 0 fi -CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}" +publish_log="${pack_dir}/publish.log" +for attempt in $(seq 1 "${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8}"); do + if CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}" > >(tee "${publish_log}") 2>&1; then + exit 0 + fi + if ! grep -Eqi "rate limit|too many requests|\\b429\\b" "${publish_log}"; then + exit 1 + fi + echo "ClawHub publish hit a rate limit; retrying (${attempt}/${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8})." >&2 + sleep "${OPENCLAW_CLAWHUB_PUBLISH_RETRY_DELAY_SECONDS:-60}" +done + +echo "ClawHub publish failed after ${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8} attempts." >&2 +exit 1 diff --git a/scripts/verify-plugin-npm-published-runtime.mjs b/scripts/verify-plugin-npm-published-runtime.mjs index 331db6b91f2..db23860042c 100644 --- a/scripts/verify-plugin-npm-published-runtime.mjs +++ b/scripts/verify-plugin-npm-published-runtime.mjs @@ -124,8 +124,8 @@ function sleep(ms) { } async function packPublishedPackage(spec, destinationDir) { - const attempts = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS ?? "6", 10); - const delayMs = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_DELAY_MS ?? "5000", 10); + const attempts = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS ?? "90", 10); + const delayMs = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_DELAY_MS ?? "10000", 10); let lastError; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { @@ -133,6 +133,9 @@ async function packPublishedPackage(spec, destinationDir) { } catch (error) { lastError = error; if (attempt < attempts) { + console.error( + `npm pack ${spec} not visible yet (attempt ${attempt}/${attempts}); retrying in ${delayMs}ms...`, + ); await sleep(delayMs); } } diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index 89eb28aed9e..18d8eb9dd3a 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -4,6 +4,7 @@ import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { collectClawHubPublishablePluginPackages, + collectClawHubOpenClawOwnerErrors, collectClawHubVersionGateErrors, collectPluginClawHubReleasePathsFromGitRange, collectPluginClawHubReleasePlan, @@ -362,6 +363,50 @@ describe("collectPluginClawHubReleasePlan", () => { }); }); +describe("collectClawHubOpenClawOwnerErrors", () => { + it("requires OpenClaw-scoped release candidates to already belong to the OpenClaw publisher", async () => { + const errors = await collectClawHubOpenClawOwnerErrors({ + plugins: [ + { packageName: "@openclaw/demo-plugin" }, + { packageName: "@openclaw/missing-plugin" }, + { packageName: "@other/safe-plugin" }, + ], + registryBaseUrl: "https://clawhub.ai", + fetchImpl: async (url) => { + const pathname = new URL(String(url)).pathname; + if (pathname.includes("%40openclaw%2Fmissing-plugin")) { + return new Response("not found", { status: 404 }); + } + return new Response( + JSON.stringify({ + owner: { handle: "steipete" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }, + }); + + expect(errors).toEqual([ + "@openclaw/demo-plugin: ClawHub package owner must be @openclaw; got @steipete.", + "@openclaw/missing-plugin: ClawHub package row must already exist under @openclaw before OpenClaw release publish.", + ]); + }); + + it("passes when OpenClaw-scoped release candidates belong to the OpenClaw publisher", async () => { + const errors = await collectClawHubOpenClawOwnerErrors({ + plugins: [{ packageName: "@openclaw/demo-plugin" }], + registryBaseUrl: "https://clawhub.ai", + fetchImpl: async () => + new Response(JSON.stringify({ owner: { handle: "openclaw" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + }); + + expect(errors).toEqual([]); + }); +}); + describe("plugin-clawhub-publish.sh", () => { it("previews the publish command through the ClawHub CLI dry-run preflight", () => { const repoDir = createTempPluginRepo();