diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml new file mode 100644 index 00000000000..c1d44144b3b --- /dev/null +++ b/.github/workflows/openclaw-release-publish.yml @@ -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 diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index 9a3892060dc..ab041fa5544 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -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: diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index 87fade32691..1dab78d08ea 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -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: diff --git a/docs/ci.md b/docs/ci.md index 78562743cb9..8b7ec9b17f2 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -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=`: diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 187da06dfd3..8e681d9fea9 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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 diff --git a/package.json b/package.json index c08cea4455f..93dacfeca7a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/sync-plugin-versions.ts b/scripts/sync-plugin-versions.ts index 24c7d5976ba..803659806c7 100644 --- a/scripts/sync-plugin-versions.ts +++ b/scripts/sync-plugin-versions.ts @@ -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); + } } diff --git a/src/scripts/sync-plugin-versions.test.ts b/src/scripts/sync-plugin-versions.test.ts index 5fb9c0597ab..e5dfd465948 100644 --- a/src/scripts/sync-plugin-versions.test.ts +++ b/src/scripts/sync-plugin-versions.test.ts @@ -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; + 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"); + }); });