diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index 775f1feb40ac..1f792ec5aea0 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -24,6 +24,11 @@ on: description: Approved OpenClaw Release Publish workflow run id required: false type: string + dry_run: + description: Validate the full ClawHub artifact handoff without publishing. + required: false + default: false + type: boolean concurrency: group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} @@ -35,7 +40,7 @@ env: CLAWHUB_REGISTRY: "https://clawhub.ai" CLAWHUB_REPOSITORY: "openclaw/clawhub" # Pinned to a reviewed ClawHub commit so release behavior stays reproducible. - CLAWHUB_REF: "facf20ceb6cc459e2872d941e71335a784bbc55c" + CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854" jobs: preview_plugins_clawhub: @@ -326,15 +331,12 @@ jobs: PACKAGE_DIR: ${{ matrix.plugin.packageDir }} run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}" - publish_plugins_clawhub: + pack_plugins_clawhub_artifacts: needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval] if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' runs-on: ubuntu-latest - environment: clawhub-plugin-release permissions: - actions: read contents: read - id-token: write strategy: fail-fast: false max-parallel: 32 @@ -407,73 +409,7 @@ jobs: chmod +x "$RUNNER_TEMP/clawhub" echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - - name: Write ClawHub token config - env: - CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} - CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} - run: | - set -euo pipefail - if [[ -z "${CLAWHUB_TOKEN}" ]]; then - echo "No CLAWHUB_TOKEN secret configured; publish will rely on GitHub OIDC trusted publishing." - exit 0 - fi - node --input-type=module <<'EOF' - import { writeFileSync } from "node:fs"; - import { join } from "node:path"; - - const path = join(process.env.RUNNER_TEMP, "clawhub-config.json"); - writeFileSync( - path, - `${JSON.stringify( - { - registry: process.env.CLAWHUB_REGISTRY, - token: process.env.CLAWHUB_TOKEN, - }, - null, - 2, - )}\n`, - ); - console.log(path); - EOF - echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV" - - - name: Check ClawHub package version - id: clawhub_package_version - env: - PACKAGE_NAME: ${{ matrix.plugin.packageName }} - PACKAGE_VERSION: ${{ matrix.plugin.version }} - CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} - run: | - set -euo pipefail - 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="" - 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." - echo "already_published=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${status}" != "404" ]]; then - echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}." - exit 1 - fi - echo "already_published=false" >> "$GITHUB_OUTPUT" - - - name: Publish - if: steps.clawhub_package_version.outputs.already_published != 'true' + - name: Pack ClawHub package artifact env: CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} SOURCE_REPO: ${{ github.repository }} @@ -481,8 +417,65 @@ jobs: SOURCE_REF: ${{ github.ref }} PACKAGE_TAG: ${{ matrix.plugin.publishTag }} PACKAGE_DIR: ${{ matrix.plugin.packageDir }} - run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}" + OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: ${{ runner.temp }}/clawhub-package-artifact + run: bash scripts/plugin-clawhub-publish.sh --pack "${PACKAGE_DIR}" + - name: Upload ClawHub package artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.plugin.artifactName }} + path: ${{ runner.temp }}/clawhub-package-artifact/*.tgz + if-no-files-found: error + retention-days: 7 + + approve_plugin_clawhub_release: + needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts] + if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + environment: clawhub-plugin-release + permissions: {} + steps: + - name: Approve ClawHub package publish + run: echo "ClawHub package publish approved." + + publish_plugins_clawhub: + needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release] + if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' + permissions: + actions: read + contents: read + id-token: write + strategy: + fail-fast: false + max-parallel: 32 + matrix: + plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} + uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854 + with: + dry_run: ${{ inputs.dry_run }} + json: true + package_artifact_name: ${{ matrix.plugin.artifactName }} + registry: https://clawhub.ai + site: https://clawhub.ai + source_repo: ${{ github.repository }} + source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} + source_ref: ${{ github.ref }} + tags: ${{ matrix.plugin.publishTag }} + secrets: + clawhub_token: ${{ secrets.CLAWHUB_TOKEN }} + + verify_published_clawhub_package: + needs: [preview_plugins_clawhub, publish_plugins_clawhub] + if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 32 + matrix: + plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} + steps: - name: Verify published ClawHub package env: CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index 3cd227ff7f78..3cae2408440a 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -55,6 +55,7 @@ export type PublishablePluginPackage = { type PluginReleasePlanItem = PublishablePluginPackage & { alreadyPublished: boolean; + artifactName: string; }; type PluginReleasePlan = { @@ -103,6 +104,16 @@ function getRegistryBaseUrl(explicit?: string) { ); } +function formatClawHubPackageArtifactName( + plugin: Pick, +) { + const safeName = plugin.packageName + .replace(/^@/u, "") + .replace(/[^A-Za-z0-9_.-]+/gu, "-") + .replace(/^-+|-+$/gu, ""); + return `clawhub-package-${safeName}-${plugin.version}`; +} + async function readClawHubPackageOwnerDetail( response: Response, packageName: string, @@ -467,6 +478,7 @@ export async function collectPluginClawHubReleasePlan(params?: { plugin.version, { registryBaseUrl: params?.registryBaseUrl, fetchImpl: params?.fetchImpl }, ), + artifactName: formatClawHubPackageArtifactName(plugin), }), ), ); diff --git a/scripts/plugin-clawhub-publish.sh b/scripts/plugin-clawhub-publish.sh index 7961e60ec840..bb6616d6cdf7 100644 --- a/scripts/plugin-clawhub-publish.sh +++ b/scripts/plugin-clawhub-publish.sh @@ -8,8 +8,8 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" invocation_root="$(pwd)" -if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" ]]; then - echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish] " >&2 +if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" && "${mode}" != "--pack" ]]; then + echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish|--pack] " >&2 exit 2 fi @@ -119,6 +119,21 @@ if [[ ! -f "${pack_path}" ]]; then exit 1 fi +echo "Resolved ClawPack: ${pack_path}" + +if [[ "${mode}" == "--pack" ]]; then + output_dir="${OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR:-}" + if [[ -z "${output_dir}" ]]; then + echo "OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR is required for --pack" >&2 + exit 2 + fi + mkdir -p "${output_dir}" + output_path="${output_dir}/$(basename "${pack_path}")" + cp "${pack_path}" "${output_path}" + echo "Packed ClawPack: ${output_path}" + exit 0 +fi + publish_cmd=( clawhub --workdir @@ -143,8 +158,6 @@ if [[ -n "${source_ref}" ]]; then ) fi -echo "Resolved ClawPack: ${pack_path}" - printf 'Publish command: CLAWHUB_WORKDIR=%q' "${clawhub_workdir}" printf ' %q' "${publish_cmd[@]}" printf '\n' diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index 99094c13abc5..a0cbfc157d68 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -1,6 +1,13 @@ // Plugin ClawHub release tests validate plugin release metadata and artifacts. import { execFileSync } from "node:child_process"; -import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + writeFileSync, +} from "node:fs"; import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -328,6 +335,7 @@ describe("collectPluginClawHubReleasePlan", () => { expect(plan.skippedPublished).toHaveLength(1); expect(plan.skippedPublished[0]).toEqual({ alreadyPublished: true, + artifactName: "clawhub-package-openclaw-demo-plugin-2026.4.1", channel: "stable", extensionId: "demo-plugin", packageDir: "extensions/demo-plugin", @@ -367,6 +375,9 @@ describe("collectPluginClawHubReleasePlan", () => { }); expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]); + expect(plan.candidates.map((plugin) => plugin.artifactName)).toEqual([ + "clawhub-package-openclaw-demo-plugin-2026.4.1", + ]); }); }); @@ -510,6 +521,70 @@ exit 0 expect(invocations).toContain(".tgz --tags latest"); expect(invocations).toContain("--dry-run"); }); + + it("packs a reusable workflow artifact without publishing", () => { + const repoDir = createTempPluginRepo(); + const binDir = join(repoDir, "bin"); + const markerPath = join(repoDir, "clawhub-invoked"); + const outputDir = join(repoDir, "clawhub-artifacts"); + mkdirSync(binDir, { recursive: true }); + const clawhubPath = join(binDir, "clawhub"); + writeFileSync( + clawhubPath, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> ${JSON.stringify(markerPath)} +if [[ "\${1:-}" == "--workdir" ]]; then + shift 2 +fi +if [[ "\${1:-}" == "package" && "\${2:-}" == "pack" ]]; then + pack_destination="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --pack-destination) + pack_destination="\${2:-}" + shift 2 + ;; + *) + shift + ;; + esac + done + mkdir -p "$pack_destination" + pack_path="$pack_destination/openclaw-demo-plugin-2026.4.1.tgz" + printf 'fake tgz\\n' > "$pack_path" + printf '{"path":"%s","name":"@openclaw/demo-plugin","version":"2026.4.1"}\\n' "$pack_path" +fi +exit 0 +`, + ); + chmodSync(clawhubPath, 0o755); + + const output = execFileSync( + "bash", + [ + join(process.cwd(), "scripts/plugin-clawhub-publish.sh"), + "--pack", + "extensions/demo-plugin", + ], + { + cwd: repoDir, + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: outputDir, + OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0", + PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`, + }, + }, + ); + + expect(output).toContain("Packed ClawPack:"); + expect(existsSync(join(outputDir, "openclaw-demo-plugin-2026.4.1.tgz"))).toBe(true); + const invocations = readFileSync(markerPath, "utf8"); + expect(invocations).toContain("package pack "); + expect(invocations).not.toContain("package publish "); + }); }); describe("collectPluginClawHubReleasePathsFromGitRange", () => { diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index c0900d1ba9e9..2e04f19cee25 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1564,7 +1564,25 @@ describe("package artifact reuse", () => { expect(packageJson.scripts?.["release:fast-pretag-check"]).toBe( "bash scripts/release-fast-pretag-check.sh", ); + expect(clawHubWorkflow).toContain('CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"'); + expect(clawHubWorkflow).toContain("pack_plugins_clawhub_artifacts:"); + expect(clawHubWorkflow).toContain("Pack ClawHub package artifact"); + expect(clawHubWorkflow).toContain("Upload ClawHub package artifact"); + expect(clawHubWorkflow).toContain("dry_run:"); + expect(clawHubWorkflow).toContain("default: false"); + expect(clawHubWorkflow).toContain("approve_plugin_clawhub_release:"); + expect(clawHubWorkflow).toContain("Approve ClawHub package publish"); + expect(clawHubWorkflow).toContain( + "uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854", + ); + expect(clawHubWorkflow).toContain("dry_run: ${{ inputs.dry_run }}"); + expect(clawHubWorkflow).toContain("package_artifact_name: ${{ matrix.plugin.artifactName }}"); + expect(clawHubWorkflow).toContain("clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}"); + expect(clawHubWorkflow).toContain("verify_published_clawhub_package:"); + expect(clawHubWorkflow).toContain("inputs.dry_run != true"); expect(clawHubWorkflow).toContain("Verify published ClawHub package"); + expect(clawHubWorkflow).not.toContain("bash scripts/plugin-clawhub-publish.sh --publish"); + expect(clawHubWorkflow).not.toContain("Write ClawHub token config"); expect(clawHubWorkflow).toContain("bun install failed while preparing ClawHub CLI; retrying"); expect(clawHubWorkflow).toContain("max-parallel: 32"); expect(clawHubResolveRefIndex).toBeGreaterThanOrEqual(0); @@ -1601,11 +1619,6 @@ describe("package artifact reuse", () => { expect(pluginNpmWorkflow).toContain( "steps.npm_package_version.outputs.already_published != 'true'", ); - expect(clawHubWorkflow).toContain("Check ClawHub package version"); - expect(clawHubWorkflow).toContain("already_published=true"); - expect(clawHubWorkflow).toContain( - "steps.clawhub_package_version.outputs.already_published != 'true'", - ); expect(pluginNpmWorkflow).toContain("Direct Plugin NPM Release dispatch"); expect(clawHubWorkflow).toContain("Direct Plugin ClawHub Release dispatch"); expect(openclawNpmWorkflow).toContain("Direct OpenClaw npm publish");