Skip to content

Commit e8cf6df

Browse files
feat: dogfood reusable ClawHub package publish
1 parent 9210d8f commit e8cf6df

5 files changed

Lines changed: 189 additions & 83 deletions

File tree

.github/workflows/plugin-clawhub-release.yml

Lines changed: 66 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ on:
2424
description: Approved OpenClaw Release Publish workflow run id
2525
required: false
2626
type: string
27+
dry_run:
28+
description: Validate the full ClawHub artifact handoff without publishing.
29+
required: false
30+
default: false
31+
type: boolean
2732

2833
concurrency:
2934
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
@@ -35,7 +40,7 @@ env:
3540
CLAWHUB_REGISTRY: "https://clawhub.ai"
3641
CLAWHUB_REPOSITORY: "openclaw/clawhub"
3742
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
38-
CLAWHUB_REF: "facf20ceb6cc459e2872d941e71335a784bbc55c"
43+
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
3944

4045
jobs:
4146
preview_plugins_clawhub:
@@ -326,15 +331,12 @@ jobs:
326331
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
327332
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
328333

329-
publish_plugins_clawhub:
334+
pack_plugins_clawhub_artifacts:
330335
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
331336
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
332337
runs-on: ubuntu-latest
333-
environment: clawhub-plugin-release
334338
permissions:
335-
actions: read
336339
contents: read
337-
id-token: write
338340
strategy:
339341
fail-fast: false
340342
max-parallel: 32
@@ -407,82 +409,73 @@ jobs:
407409
chmod +x "$RUNNER_TEMP/clawhub"
408410
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
409411
410-
- name: Write ClawHub token config
411-
env:
412-
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
413-
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
414-
run: |
415-
set -euo pipefail
416-
if [[ -z "${CLAWHUB_TOKEN}" ]]; then
417-
echo "No CLAWHUB_TOKEN secret configured; publish will rely on GitHub OIDC trusted publishing."
418-
exit 0
419-
fi
420-
node --input-type=module <<'EOF'
421-
import { writeFileSync } from "node:fs";
422-
import { join } from "node:path";
423-
424-
const path = join(process.env.RUNNER_TEMP, "clawhub-config.json");
425-
writeFileSync(
426-
path,
427-
`${JSON.stringify(
428-
{
429-
registry: process.env.CLAWHUB_REGISTRY,
430-
token: process.env.CLAWHUB_TOKEN,
431-
},
432-
null,
433-
2,
434-
)}\n`,
435-
);
436-
console.log(path);
437-
EOF
438-
echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV"
439-
440-
- name: Check ClawHub package version
441-
id: clawhub_package_version
442-
env:
443-
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
444-
PACKAGE_VERSION: ${{ matrix.plugin.version }}
445-
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
446-
run: |
447-
set -euo pipefail
448-
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
449-
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
450-
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
451-
status=""
452-
for attempt in $(seq 1 8); do
453-
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
454-
if [[ "${status}" == "404" || "${status}" =~ ^2 ]]; then
455-
break
456-
fi
457-
if [[ "${status}" == "429" || "${status}" =~ ^5 ]]; then
458-
echo "ClawHub availability check returned ${status} for ${PACKAGE_NAME}@${PACKAGE_VERSION}; retrying (${attempt}/8)."
459-
sleep 60
460-
continue
461-
fi
462-
break
463-
done
464-
if [[ "${status}" =~ ^2 ]]; then
465-
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
466-
echo "already_published=true" >> "$GITHUB_OUTPUT"
467-
exit 0
468-
fi
469-
if [[ "${status}" != "404" ]]; then
470-
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
471-
exit 1
472-
fi
473-
echo "already_published=false" >> "$GITHUB_OUTPUT"
474-
475-
- name: Publish
476-
if: steps.clawhub_package_version.outputs.already_published != 'true'
412+
- name: Pack ClawHub package artifact
477413
env:
478414
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
479415
SOURCE_REPO: ${{ github.repository }}
480416
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
481417
SOURCE_REF: ${{ github.ref }}
482418
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
483419
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
484-
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
420+
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: ${{ runner.temp }}/clawhub-package-artifact
421+
run: bash scripts/plugin-clawhub-publish.sh --pack "${PACKAGE_DIR}"
485422

423+
- name: Upload ClawHub package artifact
424+
uses: actions/upload-artifact@v7
425+
with:
426+
name: ${{ matrix.plugin.artifactName }}
427+
path: ${{ runner.temp }}/clawhub-package-artifact/*.tgz
428+
if-no-files-found: error
429+
retention-days: 7
430+
431+
approve_plugin_clawhub_release:
432+
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
433+
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
434+
runs-on: ubuntu-latest
435+
environment: clawhub-plugin-release
436+
permissions: {}
437+
steps:
438+
- name: Approve ClawHub package publish
439+
run: echo "ClawHub package publish approved."
440+
441+
publish_plugins_clawhub:
442+
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
443+
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
444+
permissions:
445+
actions: read
446+
contents: read
447+
id-token: write
448+
strategy:
449+
fail-fast: false
450+
max-parallel: 32
451+
matrix:
452+
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
453+
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
454+
with:
455+
dry_run: ${{ inputs.dry_run }}
456+
json: true
457+
package_artifact_name: ${{ matrix.plugin.artifactName }}
458+
registry: https://clawhub.ai
459+
site: https://clawhub.ai
460+
source_repo: ${{ github.repository }}
461+
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
462+
source_ref: ${{ github.ref }}
463+
tags: ${{ matrix.plugin.publishTag }}
464+
secrets:
465+
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
466+
467+
verify_published_clawhub_package:
468+
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
469+
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
470+
runs-on: ubuntu-latest
471+
permissions:
472+
contents: read
473+
strategy:
474+
fail-fast: false
475+
max-parallel: 32
476+
matrix:
477+
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
478+
steps:
486479
- name: Verify published ClawHub package
487480
env:
488481
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}

scripts/lib/plugin-clawhub-release.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export type PublishablePluginPackage = {
5555

5656
type PluginReleasePlanItem = PublishablePluginPackage & {
5757
alreadyPublished: boolean;
58+
artifactName: string;
5859
};
5960

6061
type PluginReleasePlan = {
@@ -103,6 +104,16 @@ function getRegistryBaseUrl(explicit?: string) {
103104
);
104105
}
105106

107+
function formatClawHubPackageArtifactName(
108+
plugin: Pick<PublishablePluginPackage, "packageName" | "version">,
109+
) {
110+
const safeName = plugin.packageName
111+
.replace(/^@/u, "")
112+
.replace(/[^A-Za-z0-9_.-]+/gu, "-")
113+
.replace(/^-+|-+$/gu, "");
114+
return `clawhub-package-${safeName}-${plugin.version}`;
115+
}
116+
106117
async function readClawHubPackageOwnerDetail(
107118
response: Response,
108119
packageName: string,
@@ -467,6 +478,7 @@ export async function collectPluginClawHubReleasePlan(params?: {
467478
plugin.version,
468479
{ registryBaseUrl: params?.registryBaseUrl, fetchImpl: params?.fetchImpl },
469480
),
481+
artifactName: formatClawHubPackageArtifactName(plugin),
470482
}),
471483
),
472484
);

scripts/plugin-clawhub-publish.sh

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
88
repo_root="$(cd "${script_dir}/.." && pwd)"
99
invocation_root="$(pwd)"
1010

11-
if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" ]]; then
12-
echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish] <package-dir>" >&2
11+
if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" && "${mode}" != "--pack" ]]; then
12+
echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish|--pack] <package-dir>" >&2
1313
exit 2
1414
fi
1515

@@ -119,6 +119,21 @@ if [[ ! -f "${pack_path}" ]]; then
119119
exit 1
120120
fi
121121

122+
echo "Resolved ClawPack: ${pack_path}"
123+
124+
if [[ "${mode}" == "--pack" ]]; then
125+
output_dir="${OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR:-}"
126+
if [[ -z "${output_dir}" ]]; then
127+
echo "OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR is required for --pack" >&2
128+
exit 2
129+
fi
130+
mkdir -p "${output_dir}"
131+
output_path="${output_dir}/$(basename "${pack_path}")"
132+
cp "${pack_path}" "${output_path}"
133+
echo "Packed ClawPack: ${output_path}"
134+
exit 0
135+
fi
136+
122137
publish_cmd=(
123138
clawhub
124139
--workdir
@@ -143,8 +158,6 @@ if [[ -n "${source_ref}" ]]; then
143158
)
144159
fi
145160

146-
echo "Resolved ClawPack: ${pack_path}"
147-
148161
printf 'Publish command: CLAWHUB_WORKDIR=%q' "${clawhub_workdir}"
149162
printf ' %q' "${publish_cmd[@]}"
150163
printf '\n'

test/plugin-clawhub-release.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
// Plugin ClawHub release tests validate plugin release metadata and artifacts.
22
import { execFileSync } from "node:child_process";
3-
import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3+
import {
4+
chmodSync,
5+
existsSync,
6+
mkdirSync,
7+
readFileSync,
8+
realpathSync,
9+
writeFileSync,
10+
} from "node:fs";
411
import { delimiter, join } from "node:path";
512
import { afterEach, describe, expect, it } from "vitest";
613
import {
@@ -328,6 +335,7 @@ describe("collectPluginClawHubReleasePlan", () => {
328335
expect(plan.skippedPublished).toHaveLength(1);
329336
expect(plan.skippedPublished[0]).toEqual({
330337
alreadyPublished: true,
338+
artifactName: "clawhub-package-openclaw-demo-plugin-2026.4.1",
331339
channel: "stable",
332340
extensionId: "demo-plugin",
333341
packageDir: "extensions/demo-plugin",
@@ -367,6 +375,9 @@ describe("collectPluginClawHubReleasePlan", () => {
367375
});
368376

369377
expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]);
378+
expect(plan.candidates.map((plugin) => plugin.artifactName)).toEqual([
379+
"clawhub-package-openclaw-demo-plugin-2026.4.1",
380+
]);
370381
});
371382
});
372383

@@ -510,6 +521,70 @@ exit 0
510521
expect(invocations).toContain(".tgz --tags latest");
511522
expect(invocations).toContain("--dry-run");
512523
});
524+
525+
it("packs a reusable workflow artifact without publishing", () => {
526+
const repoDir = createTempPluginRepo();
527+
const binDir = join(repoDir, "bin");
528+
const markerPath = join(repoDir, "clawhub-invoked");
529+
const outputDir = join(repoDir, "clawhub-artifacts");
530+
mkdirSync(binDir, { recursive: true });
531+
const clawhubPath = join(binDir, "clawhub");
532+
writeFileSync(
533+
clawhubPath,
534+
`#!/usr/bin/env bash
535+
set -euo pipefail
536+
printf '%s\\n' "$*" >> ${JSON.stringify(markerPath)}
537+
if [[ "\${1:-}" == "--workdir" ]]; then
538+
shift 2
539+
fi
540+
if [[ "\${1:-}" == "package" && "\${2:-}" == "pack" ]]; then
541+
pack_destination=""
542+
while [[ "$#" -gt 0 ]]; do
543+
case "$1" in
544+
--pack-destination)
545+
pack_destination="\${2:-}"
546+
shift 2
547+
;;
548+
*)
549+
shift
550+
;;
551+
esac
552+
done
553+
mkdir -p "$pack_destination"
554+
pack_path="$pack_destination/openclaw-demo-plugin-2026.4.1.tgz"
555+
printf 'fake tgz\\n' > "$pack_path"
556+
printf '{"path":"%s","name":"@openclaw/demo-plugin","version":"2026.4.1"}\\n' "$pack_path"
557+
fi
558+
exit 0
559+
`,
560+
);
561+
chmodSync(clawhubPath, 0o755);
562+
563+
const output = execFileSync(
564+
"bash",
565+
[
566+
join(process.cwd(), "scripts/plugin-clawhub-publish.sh"),
567+
"--pack",
568+
"extensions/demo-plugin",
569+
],
570+
{
571+
cwd: repoDir,
572+
encoding: "utf8",
573+
env: {
574+
...process.env,
575+
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: outputDir,
576+
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0",
577+
PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`,
578+
},
579+
},
580+
);
581+
582+
expect(output).toContain("Packed ClawPack:");
583+
expect(existsSync(join(outputDir, "openclaw-demo-plugin-2026.4.1.tgz"))).toBe(true);
584+
const invocations = readFileSync(markerPath, "utf8");
585+
expect(invocations).toContain("package pack ");
586+
expect(invocations).not.toContain("package publish ");
587+
});
513588
});
514589

515590
describe("collectPluginClawHubReleasePathsFromGitRange", () => {

test/scripts/package-acceptance-workflow.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,7 +1564,25 @@ describe("package artifact reuse", () => {
15641564
expect(packageJson.scripts?.["release:fast-pretag-check"]).toBe(
15651565
"bash scripts/release-fast-pretag-check.sh",
15661566
);
1567+
expect(clawHubWorkflow).toContain('CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"');
1568+
expect(clawHubWorkflow).toContain("pack_plugins_clawhub_artifacts:");
1569+
expect(clawHubWorkflow).toContain("Pack ClawHub package artifact");
1570+
expect(clawHubWorkflow).toContain("Upload ClawHub package artifact");
1571+
expect(clawHubWorkflow).toContain("dry_run:");
1572+
expect(clawHubWorkflow).toContain("default: false");
1573+
expect(clawHubWorkflow).toContain("approve_plugin_clawhub_release:");
1574+
expect(clawHubWorkflow).toContain("Approve ClawHub package publish");
1575+
expect(clawHubWorkflow).toContain(
1576+
"uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854",
1577+
);
1578+
expect(clawHubWorkflow).toContain("dry_run: ${{ inputs.dry_run }}");
1579+
expect(clawHubWorkflow).toContain("package_artifact_name: ${{ matrix.plugin.artifactName }}");
1580+
expect(clawHubWorkflow).toContain("clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}");
1581+
expect(clawHubWorkflow).toContain("verify_published_clawhub_package:");
1582+
expect(clawHubWorkflow).toContain("inputs.dry_run != true");
15671583
expect(clawHubWorkflow).toContain("Verify published ClawHub package");
1584+
expect(clawHubWorkflow).not.toContain("bash scripts/plugin-clawhub-publish.sh --publish");
1585+
expect(clawHubWorkflow).not.toContain("Write ClawHub token config");
15681586
expect(clawHubWorkflow).toContain("bun install failed while preparing ClawHub CLI; retrying");
15691587
expect(clawHubWorkflow).toContain("max-parallel: 32");
15701588
expect(clawHubResolveRefIndex).toBeGreaterThanOrEqual(0);
@@ -1601,11 +1619,6 @@ describe("package artifact reuse", () => {
16011619
expect(pluginNpmWorkflow).toContain(
16021620
"steps.npm_package_version.outputs.already_published != 'true'",
16031621
);
1604-
expect(clawHubWorkflow).toContain("Check ClawHub package version");
1605-
expect(clawHubWorkflow).toContain("already_published=true");
1606-
expect(clawHubWorkflow).toContain(
1607-
"steps.clawhub_package_version.outputs.already_published != 'true'",
1608-
);
16091622
expect(pluginNpmWorkflow).toContain("Direct Plugin NPM Release dispatch");
16101623
expect(clawHubWorkflow).toContain("Direct Plugin ClawHub Release dispatch");
16111624
expect(openclawNpmWorkflow).toContain("Direct OpenClaw npm publish");

0 commit comments

Comments
 (0)