mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
ci(release): harden clawhub plugin publish
This commit is contained in:
99
.github/workflows/plugin-clawhub-release.yml
vendored
99
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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<PublishablePluginPackage, "packageName">[];
|
||||
requiredOwnerHandle?: string;
|
||||
registryBaseUrl?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<string[]> {
|
||||
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}` : "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return errors.toSorted();
|
||||
}
|
||||
|
||||
export async function collectPluginClawHubReleasePlan(params?: {
|
||||
rootDir?: string;
|
||||
selection?: string[];
|
||||
|
||||
44
scripts/plugin-clawhub-owner-preflight.ts
Normal file
44
scripts/plugin-clawhub-owner-preflight.ts
Normal file
@@ -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 <release-plan.json>");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user