ci: orchestrate plugin release publishing

This commit is contained in:
Peter Steinberger
2026-05-02 07:23:56 +01:00
parent a3e0231252
commit cdd8e81075
8 changed files with 397 additions and 27 deletions

View File

@@ -0,0 +1,257 @@
name: OpenClaw Release Publish
on:
workflow_dispatch:
inputs:
tag:
description: Release tag to publish, for example v2026.5.1-beta.1
required: true
type: string
preflight_run_id:
description: Successful OpenClaw NPM Release preflight run id, required when publish_openclaw_npm=true
required: false
type: string
npm_dist_tag:
description: npm dist-tag for the OpenClaw package
required: true
default: beta
type: choice
options:
- beta
- latest
plugin_publish_scope:
description: Plugin publish scope to run before OpenClaw publish
required: true
default: all-publishable
type: choice
options:
- selected
- all-publishable
plugins:
description: Comma-separated plugin package names when plugin_publish_scope=selected
required: false
type: string
publish_openclaw_npm:
description: Publish the OpenClaw npm package after plugin npm and ClawHub publish complete
required: true
default: true
type: boolean
permissions:
actions: write
contents: read
concurrency:
group: openclaw-release-publish-${{ inputs.tag }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.32.1"
jobs:
resolve_release_target:
name: Resolve release target
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
sha: ${{ steps.ref.outputs.sha }}
steps:
- name: Validate inputs
env:
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag: ${RELEASE_TAG}" >&2
exit 1
fi
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Beta prerelease tags must publish OpenClaw to npm dist-tag beta." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && -z "${PREFLIGHT_RUN_ID}" ]]; then
echo "publish_openclaw_npm=true requires preflight_run_id." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
echo "publish_openclaw_npm=true requires dispatching this workflow from main or release/YYYY.M.D." >&2
exit 1
fi
if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then
echo "plugin_publish_scope=selected requires plugins." >&2
exit 1
fi
if [[ "${PLUGIN_PUBLISH_SCOPE}" == "all-publishable" && -n "${PLUGINS}" ]]; then
echo "plugin_publish_scope=all-publishable must not include plugins." >&2
exit 1
fi
- name: Checkout release tag
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
- name: Resolve checked-out release ref
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate release tag is reachable from main or release branch
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
while IFS= read -r release_ref; do
if git merge-base --is-ancestor HEAD "${release_ref}"; then
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
echo "Release tag must point to a commit reachable from main or release/*." >&2
exit 1
- name: Verify plugin versions were synced for this release
run: pnpm plugins:sync:check
- name: Summarize release target
env:
RELEASE_TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ steps.ref.outputs.sha }}
run: |
{
echo "### Release target"
echo
echo "- Tag: \`${RELEASE_TAG}\`"
echo "- SHA: \`${TARGET_SHA}\`"
} >> "$GITHUB_STEP_SUMMARY"
publish:
name: Publish plugins, then OpenClaw
needs: [resolve_release_target]
runs-on: ubuntu-latest
timeout-minutes: 360
steps:
- name: Dispatch publish workflows
env:
GH_TOKEN: ${{ github.token }}
TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
run: |
set -euo pipefail
dispatch_and_wait() {
local workflow="$1"
shift
local before_json dispatch_output run_id status conclusion url
before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
printf '%s\n' "$dispatch_output"
run_id="$(
printf '%s\n' "$dispatch_output" |
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
tail -n 1
)"
if [[ -z "$run_id" ]]; then
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
cancel_child() {
if [[ -n "${run_id:-}" ]]; then
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
gh run cancel "$run_id" >/dev/null 2>&1 || true
fi
}
trap cancel_child EXIT INT TERM
while true; do
status="$(gh run view "$run_id" --json status --jq '.status')"
if [[ "$status" == "completed" ]]; then
break
fi
sleep 30
done
trap - EXIT INT TERM
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
url="$(gh run view "$run_id" --json url --jq '.url')"
echo "${workflow} finished with ${conclusion}: ${url}"
{
echo "- ${workflow}: ${conclusion} (${url})"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
{
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
} >> "$GITHUB_STEP_SUMMARY"
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
if [[ -n "${PLUGINS}" ]]; then
npm_args+=(-f plugins="${PLUGINS}")
clawhub_args+=(-f plugins="${PLUGINS}")
fi
dispatch_and_wait plugin-npm-release.yml "${npm_args[@]}"
dispatch_and_wait plugin-clawhub-release.yml "${clawhub_args[@]}"
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
dispatch_and_wait openclaw-npm-release.yml \
-f tag="${RELEASE_TAG}" \
-f preflight_only=false \
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}"
else
echo "- OpenClaw npm publish: skipped by input" >> "$GITHUB_STEP_SUMMARY"
fi

View File

@@ -15,9 +15,14 @@ on:
description: Comma-separated plugin package names to publish when publish_scope=selected
required: false
type: string
ref:
description: Commit SHA on main or a release branch to publish from; defaults to the workflow ref
required: false
default: ""
type: string
concurrency:
group: plugin-clawhub-release-${{ github.sha }}
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
cancel-in-progress: false
env:
@@ -45,7 +50,7 @@ jobs:
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.sha }}
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
fetch-depth: 0
- name: Setup Node environment
@@ -59,11 +64,22 @@ jobs:
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on main
- name: Validate ref is on main or a release branch
run: |
set -euo pipefail
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor HEAD origin/main
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
while IFS= read -r release_ref; do
if git merge-base --is-ancestor HEAD "${release_ref}"; then
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
echo "Plugin ClawHub publishes must target a commit reachable from main or release/*." >&2
exit 1
- name: Validate publishable plugin metadata
env:

View File

@@ -24,7 +24,7 @@ on:
- selected
- all-publishable
ref:
description: Commit SHA on main to publish from (copy from the preview run)
description: Commit SHA on main or a release branch to publish from (copy from the preview run)
required: true
type: string
plugins:
@@ -70,11 +70,22 @@ jobs:
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on main
- name: Validate ref is on main or a release branch
run: |
set -euo pipefail
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor HEAD origin/main
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
while IFS= read -r release_ref; do
if git merge-base --is-ancestor HEAD "${release_ref}"; then
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
echo "Plugin npm publishes must target a commit reachable from main or release/*." >&2
exit 1
- name: Validate publishable plugin metadata
env:

View File

@@ -134,6 +134,13 @@ See [Full release validation](/reference/full-release-validation) for the
stage matrix, exact workflow job names, profile differences, artifacts, and
focused rerun handles.
`OpenClaw Release Publish` is the manual mutating release workflow. Dispatch it
from `release/YYYY.M.D` or `main` after the release tag exists and after the
OpenClaw npm preflight has succeeded. It verifies `pnpm plugins:sync:check`,
dispatches `Plugin NPM Release` for all publishable plugin packages, dispatches
`Plugin ClawHub Release` for the same release SHA, and only then dispatches
`OpenClaw NPM Release` with the saved `preflight_run_id`.
For pinned commit proof on a fast-moving branch, use the helper instead of
`gh workflow run ... --ref main -f ref=<sha>`:

View File

@@ -59,10 +59,12 @@ the maintainer-only release runbook.
intentionally carried.
4. Create `release/YYYY.M.D` from current `main`; do not do normal release work
directly on `main`.
5. Bump every required version location for the intended tag, then run the
local deterministic preflight:
5. Bump every required version location for the intended tag, run
`pnpm plugins:sync` so publishable plugin packages share the release
version and compatibility metadata, then run the local deterministic preflight:
`pnpm check:test-types`, `pnpm check:architecture`,
`pnpm build && pnpm ui:build`, and `pnpm release:check`.
`pnpm build && pnpm ui:build`, `pnpm plugins:sync:check`, and
`pnpm release:check`.
6. Run `OpenClaw NPM Release` with `preflight_only=true`. Before a tag exists,
a full 40-character release-branch SHA is allowed for validation-only
preflight. Save the successful `preflight_run_id`.
@@ -73,15 +75,19 @@ the maintainer-only release runbook.
file, lane, workflow job, package profile, provider, or model allowlist that
proves the fix. Rerun the full umbrella only when the changed surface makes
prior evidence stale.
9. For beta, tag `vYYYY.M.D-beta.N`, publish with npm dist-tag `beta`, then run
post-publish package acceptance against the published `openclaw@YYYY.M.D-beta.N`
or `openclaw@beta` package. If a pushed or published beta needs a fix, cut
the next `-beta.N`; do not delete or rewrite the old beta.
9. For beta, tag `vYYYY.M.D-beta.N`, then run `OpenClaw Release Publish` from
the matching `release/YYYY.M.D` branch. It verifies `pnpm plugins:sync:check`,
publishes all publishable plugin packages to npm first, publishes the same
set to ClawHub second, and then promotes the prepared OpenClaw npm preflight
artifact with dist-tag `beta`. After publish, run post-publish package
acceptance against the published `openclaw@YYYY.M.D-beta.N` or `openclaw@beta`
package. If a pushed or published beta needs a fix, cut the next `-beta.N`;
do not delete or rewrite the old beta.
10. For stable, continue only after the vetted beta or release candidate has the
required validation evidence. Stable npm publish reuses the successful
preflight artifact via `preflight_run_id`; stable macOS release readiness
also requires the packaged `.zip`, `.dmg`, `.dSYM.zip`, and updated
`appcast.xml` on `main`.
required validation evidence. Stable npm publish also goes through
`OpenClaw Release Publish`, reusing the successful preflight artifact via
`preflight_run_id`; stable macOS release readiness also requires the
packaged `.zip`, `.dmg`, `.dSYM.zip`, and updated `appcast.xml` on `main`.
11. After publish, run the npm post-publish verifier, optional standalone
published-npm Telegram E2E when you need post-publish channel proof,
dist-tag promotion when needed, GitHub release/prerelease notes from the
@@ -143,6 +149,14 @@ the maintainer-only release runbook.
span names, bounded attributes, and content/identifier redaction without
requiring Opik, Langfuse, or another external collector.
- Run `pnpm release:check` before every tagged release
- Run `OpenClaw Release Publish` for the mutating publish sequence after the
tag exists. Dispatch it from `release/YYYY.M.D` (or `main` when publishing a
main-reachable tag), pass the release tag and successful OpenClaw npm
`preflight_run_id`, and keep the default plugin publish scope
`all-publishable` unless you are deliberately running a focused repair. The
workflow serializes plugin npm publish, plugin ClawHub publish, and OpenClaw
npm publish so the core package is not published before its externalized
plugins.
- Release checks now run in a separate manual workflow:
`OpenClaw Release Checks`
- `OpenClaw Release Checks` also runs the QA Lab mock parity gate plus the fast

View File

@@ -1412,6 +1412,7 @@
"plugins:boundary-report:json": "node --import tsx scripts/plugin-boundary-report.ts --json",
"plugins:boundary-report:summary": "node --import tsx scripts/plugin-boundary-report.ts --summary",
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
"plugins:sync:check": "node --import tsx scripts/sync-plugin-versions.ts --check",
"postinstall": "node scripts/postinstall-bundled-plugins.mjs",
"preinstall": "node scripts/preinstall-package-manager-warning.mjs",
"prepack": "node --import tsx scripts/openclaw-prepack.ts",

View File

@@ -19,6 +19,10 @@ type PackageJson = {
};
};
type SyncPluginVersionsOptions = {
write?: boolean;
};
const OPENCLAW_VERSION_RANGE_RE = /^>=\d{4}\.\d{1,2}\.\d{1,2}(?:[-.][^"\s]+)?$/u;
function syncOpenClawDependencyRange(
@@ -64,7 +68,7 @@ function syncBuildOpenClawVersion(pkg: PackageJson, targetVersion: string): bool
return true;
}
function ensureChangelogEntry(changelogPath: string, version: string): boolean {
function ensureChangelogEntry(changelogPath: string, version: string, write: boolean): boolean {
if (!existsSync(changelogPath)) {
return false;
}
@@ -75,15 +79,23 @@ function ensureChangelogEntry(changelogPath: string, version: string): boolean {
const entry = `## ${version}\n\n### Changes\n- Version alignment with core OpenClaw release numbers.\n\n`;
if (content.startsWith("# Changelog\n\n")) {
const next = content.replace("# Changelog\n\n", `# Changelog\n\n${entry}`);
writeFileSync(changelogPath, next);
if (write) {
writeFileSync(changelogPath, next);
}
return true;
}
const next = `# Changelog\n\n${entry}${content.trimStart()}`;
writeFileSync(changelogPath, `${next}\n`);
if (write) {
writeFileSync(changelogPath, `${next}\n`);
}
return true;
}
export function syncPluginVersions(rootDir = resolve(".")) {
export function syncPluginVersions(
rootDir = resolve("."),
options: SyncPluginVersionsOptions = {},
) {
const write = options.write ?? true;
const rootPackagePath = join(rootDir, "package.json");
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson;
const targetVersion = rootPackage.version;
@@ -115,7 +127,7 @@ export function syncPluginVersions(rootDir = resolve(".")) {
}
const changelogPath = join(extensionsDir, dir.name, "CHANGELOG.md");
if (ensureChangelogEntry(changelogPath, targetVersion)) {
if (ensureChangelogEntry(changelogPath, targetVersion, write)) {
changelogged.push(pkg.name);
}
@@ -140,7 +152,9 @@ export function syncPluginVersions(rootDir = resolve(".")) {
if (versionChanged) {
pkg.version = targetVersion;
}
writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`);
if (write) {
writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`);
}
updated.push(pkg.name);
}
@@ -153,8 +167,19 @@ export function syncPluginVersions(rootDir = resolve(".")) {
}
if (import.meta.main) {
const summary = syncPluginVersions();
const check = process.argv.includes("--check");
const summary = syncPluginVersions(resolve("."), { write: !check });
console.log(
`Synced plugin versions to ${summary.targetVersion}. Updated: ${summary.updated.length}. Changelogged: ${summary.changelogged.length}. Skipped: ${summary.skipped.length}.`,
);
if (check && (summary.updated.length > 0 || summary.changelogged.length > 0)) {
for (const packageName of summary.updated) {
console.error(` update required: ${packageName}`);
}
for (const packageName of summary.changelogged) {
console.error(` changelog entry required: ${packageName}`);
}
console.error("Run `pnpm plugins:sync` and commit the plugin version alignment.");
process.exit(1);
}
}

View File

@@ -73,4 +73,43 @@ describe("syncPluginVersions", () => {
expect(updatedPackage.openclaw?.compat?.pluginApi).toBe(">=2026.4.1");
expect(updatedPackage.openclaw?.build?.openclawVersion).toBe("2026.4.1");
});
it("reports pending version sync without writing in check mode", () => {
const rootDir = makeTempDir(tempDirs, "openclaw-sync-plugin-versions-check-");
writeJson(path.join(rootDir, "package.json"), {
name: "openclaw",
version: "2026.4.2",
});
writeJson(path.join(rootDir, "extensions/discord/package.json"), {
name: "@openclaw/discord",
version: "2026.4.1",
peerDependencies: {
openclaw: ">=2026.4.1",
},
openclaw: {
compat: {
pluginApi: ">=2026.4.1",
},
},
});
const summary = syncPluginVersions(rootDir, { write: false });
const unchangedPackage = JSON.parse(
fs.readFileSync(path.join(rootDir, "extensions/discord/package.json"), "utf8"),
) as {
version?: string;
peerDependencies?: Record<string, string>;
openclaw?: {
compat?: {
pluginApi?: string;
};
};
};
expect(summary.updated).toEqual(["@openclaw/discord"]);
expect(unchangedPackage.version).toBe("2026.4.1");
expect(unchangedPackage.peerDependencies?.openclaw).toBe(">=2026.4.1");
expect(unchangedPackage.openclaw?.compat?.pluginApi).toBe(">=2026.4.1");
});
});