fix(release): publish ClawHub bootstrap artifacts immutably (#103809)

* fix(release): harden ClawHub bootstrap

* fix(release): centralize publication artifact verification

* fix(release): harden publication artifact verification

* fix(release): lock ClawHub bootstrap toolchain

* test(release): assert locked ClawHub binary

* fix(release): pack with trusted ClawHub harness

* fix(release): bound beta verifier artifact reads

* fix(release): bind target and tooling identities

* fix(release): stabilize signed package ordering

* fix(release): bind ClawHub pretag proof

* fix(release): bind ClawHub OIDC readback

* fix(release): bind ClawHub OIDC readback
This commit is contained in:
Vincent Koc
2026-07-10 13:33:20 -07:00
committed by GitHub
parent 941bbec619
commit 54cc90b1b1
31 changed files with 11251 additions and 471 deletions
@@ -551,6 +551,33 @@ pnpm test:install:smoke
`npm view <package-name> version dist-tags --json --prefer-online`; a 404 for
a package newly added to the release is a release-prep blocker, not something
to discover from the publish job.
- Bootstrap a new ClawHub package only from the trusted workflow source:
`gh workflow run plugin-clawhub-new.yml --ref main -f plugins=@openclaw/name -f ref=<full-release-sha> -f pretag_validation=true -f dry_run=true`.
The workflow source stays on `main`; `ref` is the exact release target. A
pre-tag dry run rejects tag/parent-approval inputs and requires the target to be
reachable from `main` or `release/*`. It must still resolve the live registry
plan, pack every candidate, upload and download the exact artifact ID, rehash
the inventory, reject ambiguous TAR paths locally with the pinned CLI's USTAR
canonicalization, and validate each tarball with the pinned CLI publish
dry-run. It never loads credentials or changes package/trusted-publisher
state. Approve the `clawhub-plugin-bootstrap` environment only after the
secretless pack jobs finish; the protected validation job itself has no
credentials or mutation commands. For an
existing version missing trusted-publisher configuration, pack the target
bytes too and require its tag plus exact registry byte/metadata equality
before allowing configuration-only repair. The credential-job prefilter
enforces the ClawHub 120 MiB compressed and 50 MiB total-payload limits, plus
64 MiB expanded-TAR and 10,000-TAR-entry parser-safety limits. A mismatch
requires a new version; never bless unrelated immutable bytes. A real run
publishes the exact downloaded tarball, bounds each CLI attempt, and records
byte-identical registry readback. Final release verification must consume the
unique terminal readback artifact and bind its main-only workflow SHA/attempt,
target SHA, requested packages, package artifact ID/name/digest, and
per-package SHA-256/size/npm integrity metadata. The parent approval attests a
separate exact trusted-main child workflow SHA; the child run and protected
approval must match it. Rerun-failed recovery may reuse a prior package
artifact only when the exact producer job succeeded. Final evidence must also
preserve the locked ClawHub version, lock SHA-256, and npm integrity.
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
`otel-trace-smoke`, and checks span names plus content/identifier redaction
@@ -752,8 +779,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
and release proof manually. Never rerun the publish workflow for that
already-published version.
- npm validation-only preflight may still be dispatched from ordinary branches
when testing workflow changes before merge. Release checks and real publish
use only `main` or `release/YYYY.M.PATCH`.
when testing workflow changes before merge. Regular beta and stable release
checks and publish orchestration use trusted `main` against the exact target
tag; Tideclaw alpha keeps its matching alpha branch.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the release-ops repo. It still rebuilds the JS outputs needed for
@@ -777,10 +805,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
Full Release Validation must pass before npm publish unless the operator
explicitly waives the full gate; mac beta validation is still only required
when requested.
- Real publish runs may be dispatched from `main` or from a
`release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained
in that release branch, and the real publish must reuse a successful preflight
from the same branch.
- Dispatch regular beta and stable `OpenClaw Release Publish` runs from trusted
`main`; the tag still selects the exact release commit, including a commit on
`release/YYYY.M.PATCH`. Tideclaw alpha publish runs remain on their matching
alpha branch. Reuse the successful preflight for that exact release SHA.
- The release workflows stay tag-based; rely on the documented release sequence
rather than workflow-level SHA pinning.
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
@@ -906,8 +934,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
where npm did not publish the beta version, delete/recreate the same beta
tag and any accidental draft/incomplete prerelease at the fixed commit
instead of skipping a prerelease number.
22. Start `.github/workflows/openclaw-release-publish.yml` from the same branch with
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
22. Start `.github/workflows/openclaw-release-publish.yml` from trusted `main`
with the same tag for the real beta or stable publish, choose `npm_dist_tag` (`beta` default,
`latest` only when you intentionally want direct stable publish), keep it
the same as the preflight run, and pass the successful npm
`preflight_run_id` plus the successful `full_release_validation_run_id` and
+481
View File
@@ -0,0 +1,481 @@
{
"name": "openclaw-release-clawhub-cli",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openclaw-release-clawhub-cli",
"version": "1.0.0",
"dependencies": {
"clawhub": "0.23.1"
}
},
"node_modules/@ark/schema": {
"version": "0.56.0",
"resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.0.tgz",
"integrity": "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==",
"license": "MIT",
"dependencies": {
"@ark/util": "0.56.0"
}
},
"node_modules/@ark/util": {
"version": "0.56.0",
"resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.0.tgz",
"integrity": "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==",
"license": "MIT"
},
"node_modules/@clack/core": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.2.tgz",
"integrity": "sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==",
"license": "MIT",
"dependencies": {
"fast-wrap-ansi": "^0.2.0",
"sisteransi": "^1.0.5"
},
"engines": {
"node": ">= 20.12.0"
}
},
"node_modules/@clack/prompts": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.6.0.tgz",
"integrity": "sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.4.2",
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
"sisteransi": "^1.0.5"
},
"engines": {
"node": ">= 20.12.0"
}
},
"node_modules/@openclaw/plugin-inspector": {
"version": "0.3.17",
"resolved": "https://registry.npmjs.org/@openclaw/plugin-inspector/-/plugin-inspector-0.3.17.tgz",
"integrity": "sha512-JPPHPhiXMsIvrV8UR8RQjhflMjRZX/uIhy9meE81dup7MMSnRJcsTGOXYACohv6e4z2P95z2QuE7nZkWT6Ysuw==",
"license": "MIT",
"bin": {
"plugin-inspector": "src/cli.js"
},
"engines": {
"node": ">=22"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/arkregex": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.6.tgz",
"integrity": "sha512-9mvuMKQuibfWhBrsNYhsKhNb6k9oEHoAJ/FvDiqe8h+E9Siwe0/cro1WVOGgpajXQ9ZHd24yCOf2k35Q/QqUQw==",
"license": "MIT",
"dependencies": {
"@ark/util": "0.56.0"
}
},
"node_modules/arktype": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.1.tgz",
"integrity": "sha512-CWPJxNoSxrS+NYGB3ufwc/blFonESEW5vBQyYPVS0rf4STu8VWoAWfKJSl5vVVm56h4yxpwbODeYwy6XFKvojA==",
"license": "MIT",
"dependencies": {
"@ark/schema": "0.56.0",
"@ark/util": "0.56.0",
"arkregex": "0.0.6"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clawhub": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/clawhub/-/clawhub-0.23.1.tgz",
"integrity": "sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA==",
"license": "MIT",
"dependencies": {
"@clack/prompts": "1.6.0",
"@openclaw/plugin-inspector": "0.3.17",
"arktype": "2.2.1",
"commander": "15.0.0",
"fflate": "0.8.3",
"ignore": "7.0.5",
"json5": "2.2.3",
"mime": "4.1.0",
"ora": "9.4.1",
"p-retry": "8.0.0",
"semver": "7.8.5",
"undici": "7.28.0"
},
"bin": {
"clawdhub": "bin/clawdhub.js",
"clawhub": "bin/clawdhub.js"
},
"engines": {
"node": ">=22"
}
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-spinners": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz",
"integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==",
"license": "MIT",
"engines": {
"node": ">=18.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/fast-string-truncated-width": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
"integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
"license": "MIT"
},
"node_modules/fast-string-width": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
"integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
"license": "MIT",
"dependencies": {
"fast-string-truncated-width": "^3.0.2"
}
},
"node_modules/fast-wrap-ansi": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
"integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
"license": "MIT",
"dependencies": {
"fast-string-width": "^3.0.2"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/is-interactive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-network-error": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz",
"integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/log-symbols": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
"integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0",
"yoctocolors": "^2.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz",
"integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==",
"license": "MIT",
"dependencies": {
"chalk": "^5.6.2",
"cli-cursor": "^5.0.0",
"cli-spinners": "^3.2.0",
"is-interactive": "^2.0.0",
"is-unicode-supported": "^2.1.0",
"log-symbols": "^7.0.1",
"stdin-discarder": "^0.3.2",
"string-width": "^8.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz",
"integrity": "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.3.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/stdin-discarder": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
"integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/yoctocolors": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
"integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "openclaw-release-clawhub-cli",
"private": true,
"version": "1.0.0",
"dependencies": {
"clawhub": "0.23.1"
}
}
+246 -39
View File
@@ -161,8 +161,8 @@ jobs:
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_publish=true
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]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases." >&2
if [[ "${WORKFLOW_REF}" != "refs/heads/main" && "${tideclaw_alpha_publish}" != "true" ]]; then
echo "OpenClaw Release Publish must use trusted main workflow tooling, except for a matching Tideclaw alpha branch." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then
@@ -534,6 +534,14 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Checkout trusted release tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.sha }}
path: .release-harness
fetch-depth: 1
persist-credentials: false
- name: Download full release validation manifest
if: ${{ inputs.publish_openclaw_npm }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
@@ -549,6 +557,106 @@ jobs:
with:
install-bun: "false"
- name: Resolve ClawHub release plan
id: clawhub_plan
env:
TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
run: |
set -euo pipefail
plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json"
bootstrap_workflow_sha="$(
gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \
--jq '.object.sha | select(test("^[a-f0-9]{40}$"))'
)"
plan_args=(
--bootstrap-workflow-sha "${bootstrap_workflow_sha}"
--release-tag "${RELEASE_TAG}"
--release-sha "${TARGET_SHA}"
--release-publish-branch "${CHILD_WORKFLOW_REF}"
--release-publish-run-attempt "${GITHUB_RUN_ATTEMPT}"
--release-publish-run-id "${GITHUB_RUN_ID}"
--plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}"
)
if [[ -n "${PLUGINS// }" ]]; then
plan_args+=(--plugins "${PLUGINS}")
fi
CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \
node --import tsx \
"${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-release-clawhub-plan.ts" \
"${plan_args[@]}" > "${plan_path}"
cat "${plan_path}"
bootstrap_should_dispatch="$(jq -er '.bootstrap.shouldDispatch' "${plan_path}")"
echo "bootstrap_should_dispatch=${bootstrap_should_dispatch}" >> "${GITHUB_OUTPUT}"
if [[ "${bootstrap_should_dispatch}" != "true" ]]; then
exit 0
fi
jq -e \
--arg branch "${CHILD_WORKFLOW_REF}" \
--arg bootstrap_workflow_sha "${bootstrap_workflow_sha}" \
--arg attempt "${GITHUB_RUN_ATTEMPT}" \
--arg run_id "${GITHUB_RUN_ID}" \
--arg tag "${RELEASE_TAG}" \
--arg target "${TARGET_SHA}" \
'.bootstrapWorkflowSha == $bootstrap_workflow_sha and
.bootstrap.ref == "main" and
.bootstrap.inputs.ref == $target and
.bootstrap.inputs.bootstrap_workflow_sha == $bootstrap_workflow_sha and
.bootstrap.inputs.release_tag == $tag and
.bootstrap.inputs.release_publish_branch == $branch and
.bootstrap.inputs.release_publish_run_attempt == $attempt and
.bootstrap.inputs.release_publish_run_id == $run_id' \
"${plan_path}" >/dev/null
approval_dir="${RUNNER_TEMP}/clawhub-bootstrap-approval"
mkdir -p "${approval_dir}"
PLAN_PATH="${plan_path}" APPROVAL_PATH="${approval_dir}/approval.json" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
const plan = JSON.parse(readFileSync(process.env.PLAN_PATH, "utf8"));
const packages = [...plan.bootstrap.packages].toSorted((a, b) =>
a < b ? -1 : a > b ? 1 : 0,
);
const approval = {
version: 2,
kind: "clawhub-bootstrap",
repository: process.env.GITHUB_REPOSITORY,
workflow: "OpenClaw Release Publish",
parentRunId: process.env.GITHUB_RUN_ID,
parentRunAttempt: Number(process.env.GITHUB_RUN_ATTEMPT),
workflowBranch: process.env.CHILD_WORKFLOW_REF,
parentWorkflowSha: process.env.GITHUB_SHA,
bootstrapWorkflowSha: plan.bootstrapWorkflowSha,
releaseTag: process.env.RELEASE_TAG,
targetSha: process.env.TARGET_SHA,
packages,
};
writeFileSync(process.env.APPROVAL_PATH, `${JSON.stringify(approval, null, 2)}\n`);
NODE
- name: Attest ClawHub bootstrap approval
if: ${{ steps.clawhub_plan.outputs.bootstrap_should_dispatch == 'true' }}
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-path: ${{ runner.temp }}/clawhub-bootstrap-approval/approval.json
- name: Upload ClawHub bootstrap approval
if: ${{ steps.clawhub_plan.outputs.bootstrap_should_dispatch == 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: clawhub-bootstrap-approval-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/clawhub-bootstrap-approval/approval.json
if-no-files-found: error
retention-days: 30
- name: Prepare GitHub release notes
if: ${{ inputs.publish_openclaw_npm }}
env:
@@ -632,6 +740,7 @@ jobs:
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }}
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
FULL_RELEASE_VALIDATION_MANIFEST_DIR: ${{ runner.temp }}/full-release-validation-manifest
CLAWHUB_PLAN_PATH: ${{ runner.temp }}/openclaw-release-clawhub-plan.json
run: |
set -euo pipefail
@@ -649,7 +758,8 @@ jobs:
local workflow="$1"
shift
local dispatch_body dispatch_response field key run_id run_url value
local dispatch_body dispatch_response expected_head_sha field key run_head_sha run_id run_url value
expected_head_sha="${EXPECTED_WORKFLOW_HEAD_SHA:-}"
local inputs_json='{}'
while (( $# > 0 )); do
if [[ "$1" != "-f" && "$1" != "--raw-field" && "$1" != "-F" && "$1" != "--field" ]]; then
@@ -685,6 +795,14 @@ jobs:
--input -)"
run_id="$(printf '%s' "$dispatch_response" | jq -er '.workflow_run_id')"
run_url="$(printf '%s' "$dispatch_response" | jq -er '.html_url')"
run_head_sha="$(
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" \
--jq '.head_sha | select(test("^[a-f0-9]{40}$"))'
)"
if [[ -n "${expected_head_sha}" && "${run_head_sha}" != "${expected_head_sha}" ]]; then
echo "${workflow}: dispatched run head ${run_head_sha} does not match approved workflow SHA ${expected_head_sha}." >&2
exit 1
fi
echo "Dispatched ${workflow}: ${run_url}" >&2
{
@@ -697,6 +815,20 @@ jobs:
dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@"
}
verify_bootstrap_workflow_sha() {
local approved_sha current_sha
approved_sha="$(jq -er '.bootstrapWorkflowSha | select(test("^[a-f0-9]{40}$"))' "${CLAWHUB_PLAN_PATH}")"
current_sha="$(
gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \
--jq '.object.sha | select(test("^[a-f0-9]{40}$"))'
)"
[[ "${current_sha}" == "${approved_sha}" ]] || {
echo "Trusted main moved from approved ClawHub bootstrap workflow SHA ${approved_sha} to ${current_sha}; rerun release approval." >&2
exit 1
}
printf '%s\n' "${approved_sha}"
}
print_pending_deployments() {
local workflow="$1"
local run_id="$2"
@@ -714,12 +846,31 @@ jobs:
done < <(printf '%s' "${pending_json}" | jq -r '.[] | [.environment.id, .environment.name, .current_user_can_approve] | @tsv')
}
# Returns 0 after an approval, 1 when no gate is ready yet, and 2 for
# identity or mutation failures that must not be retried as "pending".
approve_pending_deployments() {
local workflow="$1"
local run_id="$2"
local pending_json approved
local expected_head_sha="${3:-}"
local pending_json approved run_head_sha
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
if [[ -n "${expected_head_sha}" ]]; then
if ! run_head_sha="$(
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" \
--jq '.head_sha | select(test("^[a-f0-9]{40}$"))'
)"; then
echo "${workflow}: unable to read the child workflow SHA before environment approval." >&2
return 2
fi
[[ "${run_head_sha}" == "${expected_head_sha}" ]] || {
echo "${workflow}: refusing environment approval because run head ${run_head_sha} does not match approved workflow SHA ${expected_head_sha}." >&2
return 2
}
fi
if ! pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null)"; then
return 1
fi
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
return 1
fi
@@ -730,10 +881,13 @@ jobs:
continue
fi
echo "${workflow}: approving pending environment ${env_name} (${env_id})"
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
if ! gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
-F "environment_ids[]=${env_id}" \
-f state=approved \
-f comment="Approve child release gate after parent release approval" >/dev/null
-f comment="Approve child release gate after parent release approval" >/dev/null; then
echo "${workflow}: failed to approve pending environment ${env_name} (${env_id})." >&2
return 2
fi
approved=1
done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv')
@@ -769,7 +923,17 @@ jobs:
wait_for_run() {
local workflow="$1"
local run_id="$2"
local status conclusion url updated_at created_at duration_seconds duration_label last_state failed_json
local status conclusion url updated_at created_at duration_seconds duration_label last_state failed_json expected_head_sha approval_status
expected_head_sha=""
if [[ "${workflow}" == "plugin-clawhub-new.yml" ]]; then
if ! expected_head_sha="$(
jq -er '.bootstrapWorkflowSha | select(test("^[a-f0-9]{40}$"))' "${CLAWHUB_PLAN_PATH}"
)"; then
echo "${workflow}: approved bootstrap workflow SHA is missing or invalid." >&2
return 1
fi
fi
last_state=""
while true; do
@@ -792,7 +956,12 @@ jobs:
if [[ "$state" != "$last_state" ]]; then
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
print_pending_deployments "${workflow}" "${run_id}"
approve_pending_deployments "${workflow}" "${run_id}" || true
approval_status=0
approve_pending_deployments "${workflow}" "${run_id}" "${expected_head_sha}" ||
approval_status=$?
if (( approval_status > 1 )); then
return 1
fi
last_state="$state"
fi
sleep 30
@@ -889,17 +1058,38 @@ jobs:
approve_child_publish_environment() {
local workflow="$1"
local run_id="$2"
local run_json status conclusion deadline
local run_json status conclusion deadline expected_head_sha approval_status
deadline=$((SECONDS + 900))
expected_head_sha=""
if [[ "${workflow}" == "plugin-clawhub-new.yml" ]]; then
if ! expected_head_sha="$(
jq -er '.bootstrapWorkflowSha | select(test("^[a-f0-9]{40}$"))' "${CLAWHUB_PLAN_PATH}"
)"; then
echo "${workflow}: approved bootstrap workflow SHA is missing or invalid." >&2
return 2
fi
fi
while true; do
if approve_pending_deployments "${workflow}" "${run_id}"; then
approval_status=0
approve_pending_deployments "${workflow}" "${run_id}" "${expected_head_sha}" ||
approval_status=$?
if (( approval_status == 0 )); then
echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"
status="$(printf '%s' "$run_json" | jq -r '.status')"
conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion // ""')"
if (( approval_status > 1 )); then
return "${approval_status}"
fi
if ! run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"; then
sleep 10
continue
fi
if ! status="$(printf '%s' "$run_json" | jq -er '.status | select(type == "string" and length > 0)')" ||
! conclusion="$(printf '%s' "$run_json" | jq -er '(.conclusion // "") | select(type == "string")')"; then
echo "${workflow}: invalid run state while waiting for environment approval." >&2
return 2
fi
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" ]]; then
echo "${workflow}: completed before child environment approval was needed"
@@ -918,6 +1108,21 @@ jobs:
done
}
approve_clawhub_bootstrap_environments() {
local run_id="$1"
wait_for_job_success \
plugin-clawhub-new.yml \
"${run_id}" \
"Validate release publish approval" || return 1
approve_child_publish_environment plugin-clawhub-new.yml "${run_id}" || return 1
wait_for_job_success \
plugin-clawhub-new.yml \
"${run_id}" \
"Validate immutable bootstrap handoff" || return 1
approve_child_publish_environment plugin-clawhub-new.yml "${run_id}" || return 1
}
guard_existing_public_release() {
local release_version asset_name release_json is_draft has_sha has_proof has_asset has_canonical_body release_url release_body release_body_file
@@ -1024,21 +1229,8 @@ jobs:
}
resolve_clawhub_release_plan() {
local -a plan_args
clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json"
plan_args=(
--release-tag "${RELEASE_TAG}"
--release-publish-branch "${CHILD_WORKFLOW_REF}"
--release-publish-run-id "${GITHUB_RUN_ID}"
--plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}"
)
if [[ -n "${PLUGINS// }" ]]; then
plan_args+=(--plugins "${PLUGINS}")
fi
CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \
node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}"
clawhub_plan_path="${CLAWHUB_PLAN_PATH}"
test -s "${clawhub_plan_path}"
echo "Resolved OpenClaw release ClawHub dispatch plan:"
cat "${clawhub_plan_path}"
@@ -1079,7 +1271,8 @@ jobs:
write_clawhub_runtime_state() {
local force_skip_clawhub="$1"
local output_path="$2"
node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \
node --import tsx \
"${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-release-clawhub-runtime-state.ts" \
--repository "${GITHUB_REPOSITORY}" \
--wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \
--force-skip-clawhub "${force_skip_clawhub}" \
@@ -1507,7 +1700,7 @@ jobs:
}
verify_published_release() {
local release_version evidence_path skip_clawhub clawhub_runtime_state_path
local release_version evidence_path skip_clawhub clawhub_runtime_state_path bootstrap_run_arg_present
local validation_manifest validation_run_attempt validation_run_id
local validation_target_sha validation_url validation_workflow_ref
local -a verify_args
@@ -1519,12 +1712,11 @@ jobs:
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
verify_args=(
release:verify-beta
--
"${release_version}"
--tag "${RELEASE_TAG}"
--dist-tag "${RELEASE_NPM_DIST_TAG}"
--repo "${GITHUB_REPOSITORY}"
--release-sha "${TARGET_SHA}"
--workflow-ref "${CHILD_WORKFLOW_REF}"
--clawhub-workflow-ref "${clawhub_workflow_ref}"
--plugin-npm-run "${plugin_npm_run_id}"
@@ -1541,14 +1733,24 @@ jobs:
while IFS= read -r arg; do
verify_args+=("${arg}")
done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}")
bootstrap_run_arg_present="$(
jq -r \
'.verifierArgs | index("--plugin-clawhub-bootstrap-run") != null' \
"${clawhub_runtime_state_path}"
)"
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
fi
if [[ -n "${bootstrap_plugins// }" && "${bootstrap_run_arg_present}" == "true" ]]; then
verify_args+=(--clawhub-bootstrap-plugins "${bootstrap_plugins}")
fi
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}")
fi
pnpm "${verify_args[@]}"
node --import tsx \
"${GITHUB_WORKSPACE}/.release-harness/scripts/release-verify-beta.ts" \
"${verify_args[@]}"
# Resolve already validated this exact v3 run before mutation. Append
# its immutable tuple here so frozen targets need no new CLI option.
@@ -1686,7 +1888,8 @@ jobs:
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
echo "- Normal ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
echo "- ClawHub bootstrap workflow ref: trusted \`main\`, target \`${TARGET_SHA}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
@@ -1722,6 +1925,11 @@ jobs:
resolve_openclaw_npm_publish_state
resolve_clawhub_release_plan
bootstrap_workflow_sha=""
if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
bootstrap_workflow_sha="$(verify_bootstrap_workflow_sha)"
fi
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
if [[ -n "${PLUGINS}" ]]; then
npm_args+=(-f plugins="${PLUGINS}")
@@ -1744,7 +1952,7 @@ jobs:
if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
clawhub_dispatch_args=()
append_clawhub_dispatch_args bootstrap
plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \
plugin_clawhub_bootstrap_run_id="$(EXPECTED_WORKFLOW_HEAD_SHA="${bootstrap_workflow_sha}" dispatch_workflow_at_ref \
"$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \
"$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
@@ -1840,11 +2048,10 @@ jobs:
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
else
wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
if approve_clawhub_bootstrap_environments "${plugin_clawhub_bootstrap_run_id}"; then
:
else
echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
echo "- plugin-clawhub-new.yml: child environment gates not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
File diff suppressed because it is too large Load Diff
+29 -54
View File
@@ -415,7 +415,9 @@ jobs:
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
timeout-minutes: 45
permissions:
actions: read
contents: read
strategy:
fail-fast: false
@@ -423,6 +425,27 @@ jobs:
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Checkout verification tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
install-deps: "false"
use-actions-cache: "false"
- name: Download published package input
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ matrix.plugin.artifactName }}
path: ${{ runner.temp }}/clawhub-package-artifact
- name: Verify published ClawHub package
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
@@ -431,57 +454,9 @@ jobs:
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
run: |
set -euo pipefail
node --input-type=module <<'EOF'
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
const packageName = process.env.PACKAGE_NAME;
const packageVersion = process.env.PACKAGE_VERSION;
const packageTag = process.env.PACKAGE_TAG;
if (!packageName || !packageVersion || !packageTag) {
throw new Error("Missing ClawHub package verification env.");
}
const encodedName = encodeURIComponent(packageName);
const encodedVersion = encodeURIComponent(packageVersion);
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
const artifactUrl = `${versionUrl}/artifact/download`;
async function fetchWithRetry(url, options = {}) {
let lastStatus = "unknown";
for (let attempt = 1; attempt <= 12; attempt += 1) {
try {
const response = await fetch(url, { redirect: "manual", ...options });
lastStatus = response.status;
if (response.status !== 429 && response.status < 500) {
return response;
}
} catch (error) {
lastStatus = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
}
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
}
const detailResponse = await fetchWithRetry(detailUrl, {
headers: { accept: "application/json" },
});
if (!detailResponse.ok) {
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
}
const detail = await detailResponse.json();
const tags = detail?.package?.tags ?? {};
if (tags[packageTag] !== packageVersion) {
throw new Error(
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
);
}
const versionResponse = await fetchWithRetry(versionUrl);
if (!versionResponse.ok) {
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
}
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
}
console.log(`${packageName}@${packageVersion} verified on ClawHub.`);
EOF
node scripts/verify-clawhub-published-artifact.mjs \
--expected-artifact-dir "${RUNNER_TEMP}/clawhub-package-artifact" \
--package-name "${PACKAGE_NAME}" \
--package-version "${PACKAGE_VERSION}" \
--publish-tag "${PACKAGE_TAG}" \
--registry "${CLAWHUB_REGISTRY}"
+1
View File
@@ -225,6 +225,7 @@ USER.md
/local/
/client_secret_*.json
package-lock.json
!.github/release/clawhub-cli/package-lock.json
!src/commands/copilot-sdk-install-manifest/package-lock.json
.claude/
.agent/
+8 -5
View File
@@ -243,10 +243,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.PATCH` or `main` after the release tag exists and after the
OpenClaw npm preflight has succeeded (the preflight runs `pnpm plugins:sync:check`
among its checks). It requires the saved `preflight_run_id` and a successful
`OpenClaw Release Publish` is the manual mutating release workflow. Dispatch
regular beta and stable publishes from trusted `main` after the release tag
exists and after the OpenClaw npm preflight has succeeded (the preflight runs
`pnpm plugins:sync:check` among its checks). The tag still selects the exact
release commit, including a commit on `release/YYYY.M.PATCH`; Tideclaw alpha
publishes keep using their matching alpha branch. It requires the saved
`preflight_run_id` and a successful
`full_release_validation_run_id` and its exact
`full_release_validation_run_attempt`, dispatches `Plugin NPM Release` for all
publishable plugin packages, dispatches `Plugin ClawHub Release` for the same
@@ -259,7 +262,7 @@ and checksum contract before publishing the GitHub release draft.
```bash
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
--ref main \
-f tag=vYYYY.M.PATCH-beta.N \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
+57 -8
View File
@@ -168,9 +168,9 @@ This checklist is the public shape of the release flow. Private credentials, sig
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. The preflight generates dependency release evidence for the exact checked-out dependency graph and stores it in the npm preflight artifact. Save the successful `preflight_run_id`.
7. Kick off all pre-release tests with `Full Release Validation` for the release branch, tag, or full commit SHA. This is the one manual entrypoint for the four big release test boxes: Vitest, Docker, QA Lab, and Package. Save the `full_release_validation_run_id` and exact `full_release_validation_run_attempt`; both are required inputs for `OpenClaw NPM Release` and `OpenClaw Release Publish`.
8. If validation fails, fix on the release branch and rerun the smallest failed 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 a tagged beta candidate, run `pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. For stable, also pass the required Windows source release: `pnpm release:candidate -- --tag vYYYY.M.PATCH --windows-node-tag vX.Y.Z`. Before it dispatches the full validation matrix, the helper deterministically renders the exact tag's GitHub release body and rejects a missing version heading, an over-limit body that cannot use the canonical compact form, or contribution-record base/target provenance that is not reachable from the tag. It also validates any explicit shipped-baseline exclusion metadata against the referenced cumulative tag records. It then runs the local generated-release checks, dispatches or verifies full release validation and npm preflight evidence, runs Parallels fresh/update proof against the exact prepared tarball plus Telegram package proof, records plugin npm and ClawHub plans, and prints the exact `OpenClaw Release Publish` command only after the evidence bundle is green.
9. For a tagged beta candidate, run `pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. For stable, also pass the required Windows source release: `pnpm release:candidate -- --tag vYYYY.M.PATCH --windows-node-tag vX.Y.Z`. The helper uses trusted `main` as the workflow source while each workflow targets the exact tag. Before it dispatches the full validation matrix, the helper deterministically renders the exact tag's GitHub release body and rejects a missing version heading, an over-limit body that cannot use the canonical compact form, or contribution-record base/target provenance that is not reachable from the tag. It also validates any explicit shipped-baseline exclusion metadata against the referenced cumulative tag records. It then runs the local generated-release checks, dispatches or verifies full release validation and npm preflight evidence, runs Parallels fresh/update proof against the exact prepared tarball plus Telegram package proof, records plugin npm and ClawHub plans, and prints the exact `OpenClaw Release Publish` command only after the evidence bundle is green.
`OpenClaw Release Publish` dispatches the selected or all-publishable plugin packages to npm and the same set to ClawHub in parallel, then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag once plugin npm publish succeeds. Before any publish child starts, it renders and caches the exact GitHub release body. When the complete matching `CHANGELOG.md` section fits GitHub's 125,000-character limit and the renderer's matching 125,000-byte safety ceiling, the page contains that exact `## YYYY.M.PATCH` section including its heading. When the source section does not fit, the page keeps the exact grouped editorial notes and replaces the oversized contribution record with a stable link to the full record in the tag-pinned `CHANGELOG.md`; partial records and truncated bullets are never published. The workflow chooses that full or compact body before adding `### Release verification`; if the proof tail would exceed the limit, it keeps the canonical body and relies on the immutable attached evidence instead. Stable releases published to npm `latest` become the GitHub latest release, while stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence, the full-validation manifest, and postpublish registry verification evidence to the GitHub release for post-release incident response. It prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes failed child jobs with log tails, creates the draft GitHub release page up front and promotes Windows and Android assets concurrently with the OpenClaw npm publish, closes out the release page and dependency evidence once those stages succeed, waits for ClawHub whenever OpenClaw npm is being published, then runs `pnpm release:verify-beta` and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, selected ClawHub packages, child workflow run IDs, and optional NPM Telegram run ID. The ClawHub path retries transient CLI dependency install failures, publishes preview-passing plugins even when one preview cell flakes, and ends with registry verification for every expected plugin version so partial publishes stay visible and retryable.
`OpenClaw Release Publish` dispatches the selected or all-publishable plugin packages to npm and the same set to ClawHub in parallel, then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag once plugin npm publish succeeds. The release checkout remains the product/data root, while planning and final verification execute from the exact trusted workflow-source checkout so an older release commit cannot silently use obsolete release tooling. Before any publish child starts, it renders and caches the exact GitHub release body. When the complete matching `CHANGELOG.md` section fits GitHub's 125,000-character limit and the renderer's matching 125,000-byte safety ceiling, the page contains that exact `## YYYY.M.PATCH` section including its heading. When the source section does not fit, the page keeps the exact grouped editorial notes and replaces the oversized contribution record with a stable link to the full record in the tag-pinned `CHANGELOG.md`; partial records and truncated bullets are never published. The workflow chooses that full or compact body before adding `### Release verification`; if the proof tail would exceed the limit, it keeps the canonical body and relies on the immutable attached evidence instead. Stable releases published to npm `latest` become the GitHub latest release, while stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence, the full-validation manifest, and postpublish registry verification evidence to the GitHub release for post-release incident response. It prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes failed child jobs with log tails, creates the draft GitHub release page up front and promotes Windows and Android assets concurrently with the OpenClaw npm publish, closes out the release page and dependency evidence once those stages succeed, waits for ClawHub whenever OpenClaw npm is being published, then runs the trusted-main beta verifier and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, selected ClawHub packages, child workflow run IDs, and optional NPM Telegram run ID. The ClawHub bootstrap verifier requires the exact trusted-main workflow path and SHA, producer and terminal run attempts, release SHA, requested package set, immutable package artifact tuple, and terminal registry readback artifact; a successful legacy release-ref run is not accepted.
Then run the post-publish package acceptance against the published `openclaw@YYYY.M.PATCH-beta.N` or `openclaw@beta` package. If a pushed or published prerelease needs a fix, cut the next matching prerelease number; never delete or rewrite the old one.
@@ -238,7 +238,7 @@ A legacy fallback correction tag may reuse base-package evidence only when the c
- Run `pnpm qa:observability:smoke` for the source-checkout OpenTelemetry and Prometheus smoke lanes back to back.
- Run `pnpm release:check` before every tagged release.
- `OpenClaw NPM Release` preflight generates dependency release evidence before it packs the npm tarball. The npm advisory vulnerability gate is release-blocking. The transitive manifest risk, dependency ownership/install surface, and dependency change reports are release evidence only. The dependency change report compares the release candidate with the previous reachable release tag. The preflight uploads dependency evidence as `openclaw-release-dependency-evidence-<tag>` and also embeds it under `dependency-evidence/` inside the prepared npm preflight artifact. The real publish path reuses that preflight artifact, then attaches the same evidence to the GitHub release as `openclaw-<version>-dependency-evidence.zip`.
- Run `OpenClaw Release Publish` for the mutating publish sequence after the tag exists. Dispatch it from `release/YYYY.M.PATCH` (or `main` when publishing a main-reachable tag), pass the release tag, successful OpenClaw npm `preflight_run_id`, successful `full_release_validation_run_id`, and exact `full_release_validation_run_attempt`, 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; Windows and Android promotion runs concurrently with the core npm publish against the draft release page. Publish reruns are resumable: an already-published core npm version skips the core dispatch after the workflow proves the registry tarball matches the tag's preflight artifact, and Windows/Android promotion is skipped when the release already carries the verified asset contract, so a retry only redoes the failed stages.
- Run `OpenClaw Release Publish` for the mutating publish sequence after the tag exists. Dispatch regular beta and stable publishes from trusted `main`; the release tag still selects the exact target commit and may point into `release/YYYY.M.PATCH`. Tideclaw alpha publishes remain on their matching alpha branch. Pass the successful OpenClaw npm `preflight_run_id`, successful `full_release_validation_run_id`, and exact `full_release_validation_run_attempt`, 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; Windows and Android promotion runs concurrently with the core npm publish against the draft release page. Publish reruns are resumable: an already-published core npm version skips the core dispatch after the workflow proves the registry tarball matches the tag's preflight artifact, and Windows/Android promotion is skipped when the release already carries the verified asset contract, so a retry only redoes the failed stages.
- Stable `OpenClaw Release Publish` requires an exact `windows_node_tag` after the matching non-prerelease `openclaw/openclaw-windows-node` release exists, plus the candidate-approved `windows_node_installer_digests` map. Before dispatching any publish child, it verifies that source release is published, non-prerelease, contains the required x64/ARM64 installers, and still matches that approved map. It then dispatches `Windows Node Release` while the OpenClaw release is still a draft, carrying the pinned installer digest map unchanged. The child workflow downloads the signed Windows Hub installers from that exact tag, matches them against the pinned digests, verifies their Authenticode signatures use the expected OpenClaw Foundation signer on a Windows runner, writes a SHA-256 manifest, and uploads the installers plus manifest onto the canonical OpenClaw GitHub release, then re-downloads the promoted assets and verifies manifest membership and hashes. The parent verifies the current x64, ARM64, and checksum asset contract before publication. Direct recovery rejects unexpected `OpenClawCompanion-*` asset names before replacing the expected contract assets with the pinned source bytes.
Manually dispatch `Windows Node Release` only for recovery, and always pass an exact tag, never `latest`, plus the explicit `expected_installer_digests` JSON map from the approved source release. Website download links should target exact OpenClaw release asset URLs for the current stable release, or `releases/latest/download/...` only after verifying GitHub's latest redirect points at that same release; do not link only to the companion repo release page.
@@ -259,7 +259,7 @@ A legacy fallback correction tag may reuse base-package evidence only when the c
- Maintainers can run the same post-publish check from GitHub Actions via the manual `NPM Telegram Beta E2E` workflow. It is intentionally manual-only and does not run on every merge.
- Maintainer release automation uses preflight-then-promote:
- Real npm publish must pass a successful npm `preflight_run_id`.
- Real publish must be dispatched from the same `main` or `release/YYYY.M.PATCH` branch as the successful preflight run (a Tideclaw alpha branch is allowed for alpha prereleases).
- Regular beta and stable publish orchestration and preflight use trusted `main` against the exact target tag. Tideclaw alpha publish and preflight use the matching alpha branch.
- Stable npm releases default to `beta`; stable npm publish can target `latest` explicitly via workflow input.
- Token-based npm dist-tag mutation lives in `openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` because `npm dist-tag add` still needs `NPM_TOKEN` while the source repo keeps OIDC-only publish.
- Public `macOS Release` is validation-only; when a tag lives only on a release branch but the workflow is dispatched from `main`, set `public_release_branch=release/YYYY.M.PATCH`.
@@ -470,7 +470,7 @@ Beta publish example:
```bash
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
--ref main \
-f tag=vYYYY.M.PATCH-beta.N \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
@@ -482,7 +482,7 @@ Stable publish to the default beta dist-tag:
```bash
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
--ref main \
-f tag=vYYYY.M.PATCH \
-f windows_node_tag=vX.Y.Z \
-f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:<approved-x64-sha256>","OpenClawCompanion-Setup-arm64.exe":"sha256:<approved-arm64-sha256>"}' \
@@ -496,7 +496,7 @@ Stable promotion directly to `latest` is explicit:
```bash
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
--ref main \
-f tag=vYYYY.M.PATCH \
-f windows_node_tag=vX.Y.Z \
-f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:<approved-x64-sha256>","OpenClawCompanion-Setup-arm64.exe":"sha256:<approved-arm64-sha256>"}' \
@@ -508,6 +508,55 @@ gh workflow run openclaw-release-publish.yml \
Use the lower-level `Plugin NPM Release` and `Plugin ClawHub Release` workflows only for focused repair or republish work. `OpenClaw Release Publish` rejects `plugin_publish_scope=selected` when `publish_openclaw_npm=true` so the core package cannot ship without every publishable official plugin, including `@openclaw/diffs-language-pack`. For a selected plugin repair, set `publish_openclaw_npm=false` with `plugin_publish_scope=selected` and `plugins=@openclaw/name`, or dispatch the child workflow directly.
First-publish ClawHub bootstrap is the exception: dispatch `Plugin ClawHub New`
from trusted `main` and pass the full target release SHA through `ref`.
Never run the bootstrap workflow itself from the release tag or branch:
```bash
gh workflow run plugin-clawhub-new.yml \
--ref main \
-f plugins=@openclaw/name \
-f ref=<full-40-character-release-sha> \
-f pretag_validation=true \
-f dry_run=true
```
Pre-tag validation requires `dry_run=true`, rejects release-tag and parent-run
inputs, and accepts only an exact target reachable from `main` or `release/*`.
It does not load ClawHub credentials, publish package bytes, or change trusted
publisher configuration. The workflow still resolves the live registry plan,
checks out and packs the target only in a secretless job, materializes the
locked ClawHub toolchain, and validates the immutable artifact and package
slug/identity before the release tag exists. Approve the
`clawhub-plugin-bootstrap` environment only after the secretless pack jobs
finish; this protected validation job has no credentials or mutation commands.
An approved dry run or real bootstrap after tagging must include the exact
release tag plus the parent `OpenClaw Release Publish` run id, attempt, and
branch. The parent attests its own workflow SHA and a separate exact trusted
`main` SHA for `Plugin ClawHub New`; the child run and every protected
environment approval must match that approved child SHA. The release tag is
rechecked before every publish attempt and trusted-publisher mutation.
The pack job
uploads one immutable artifact whose name, Actions artifact ID/digest,
producer run/attempt, target SHA, and per-package tarball SHA-256/size are
carried into the validation and protected jobs. The protected job checks out trusted `main`
tooling only, validates the artifact tuple through the GitHub API, downloads
by exact artifact ID, rehashes every tarball, and validates local TAR paths and
package identity with the pinned CLI's USTAR canonicalization rules. Every
candidate then passes the pinned CLI publish dry-run, which returns before
registry lookup or auth. The credential-job prefilter caps compressed ClawPacks
at 120 MiB, total file payload at 50 MiB, expanded TAR data at 64 MiB, and
TAR entry count at 10,000. Existing-package trusted-publisher repair remains
configure-only, but it still packs the target and requires the requested tag
plus exact registry byte and metadata equality before changing trusted-publisher
configuration. Post-publish verification downloads the ClawHub artifact and
requires the same SHA-256 and size. A rerun-failed recovery may reuse an earlier
attempt's package artifact only when the exact producer job completed
successfully. Final evidence also binds the locked ClawHub version, lock
SHA-256, and npm integrity. A mismatch requires a new package version.
## NPM workflow inputs
`OpenClaw NPM Release` accepts these operator-controlled inputs:
@@ -573,7 +622,7 @@ When cutting a regular orchestrated stable release:
3. Run `Full Release Validation` on the release branch, release tag, or full commit SHA when you want normal CI plus live prompt cache, Docker, QA Lab, Matrix, and Telegram coverage from one manual workflow. If you intentionally only need the deterministic normal test graph, run the manual `CI` workflow on the release ref instead.
4. Select the exact non-prerelease `openclaw/openclaw-windows-node` release tag whose signed x64 and ARM64 installers should ship. Save it as `windows_node_tag`, and save their validated digest map as `windows_node_installer_digests`. The release-candidate helper records both and includes them in its generated publish command.
5. Save the successful `preflight_run_id`, `full_release_validation_run_id`, and exact `full_release_validation_run_attempt`.
6. Run `OpenClaw Release Publish` with the same `tag`, the same `npm_dist_tag`, the selected `windows_node_tag`, its saved `windows_node_installer_digests`, the saved `preflight_run_id`, `full_release_validation_run_id`, and `full_release_validation_run_attempt`. It publishes externalized plugins to npm and ClawHub before promoting the OpenClaw npm package.
6. Run `OpenClaw Release Publish` from trusted `main` with the same `tag`, the same `npm_dist_tag`, the selected `windows_node_tag`, its saved `windows_node_installer_digests`, the saved `preflight_run_id`, `full_release_validation_run_id`, and `full_release_validation_run_attempt`. It publishes externalized plugins to npm and ClawHub before promoting the OpenClaw npm package.
7. If the release landed on `beta`, use the `openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow to promote that stable version from `beta` to `latest`.
8. If the release intentionally published directly to `latest` and `beta` should follow the same stable build immediately, use that same release workflow to point both dist-tags at the stable version, or let its scheduled self-healing sync move `beta` later.
+994
View File
@@ -0,0 +1,994 @@
import { createHash } from "node:crypto";
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
import { basename } from "node:path";
import { inflateRawSync } from "node:zlib";
export const ACTIONS_ARTIFACT_API_VERSION = "2026-03-10";
export const DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES = 256 * 1024 * 1024;
export const DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES = 512 * 1024 * 1024;
const DEFAULT_MAX_JSON_BYTES = 2 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 60_000;
const ARTIFACT_DIGEST_RE = /^sha256:[0-9a-f]{64}$/u;
const ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
const COMMIT_SHA_RE = /^[0-9a-f]{40}$/u;
const REPOSITORY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
const SUPPORTED_ZIP_FLAGS = 0x0808;
const ZIP_DATA_DESCRIPTOR_FLAG = 0x0008;
const ZIP_UTF8_FLAG = 0x0800;
const ZIP_LOCAL_HEADER_SIGNATURE = 0x04034b50;
const ZIP_CENTRAL_HEADER_SIGNATURE = 0x02014b50;
const ZIP_DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
const ZIP_EOCD_SIGNATURE = 0x06054b50;
const ZIP_EOCD_BYTES = 22;
const ZIP_DATA_DESCRIPTOR_BYTES = 16;
const ZIP_MAX_COMMENT_BYTES = 65_535;
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
function assertPositiveInteger(value, label) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive safe integer.`);
}
return value;
}
function assertTrimmedString(value, label) {
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
throw new Error(`${label} must be a non-empty trimmed string.`);
}
return value;
}
function hasControlCharacters(value) {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint <= 0x1f || codePoint === 0x7f) {
return true;
}
}
return false;
}
function boundedLimit(value, fallback, label) {
if (value === undefined) {
return fallback;
}
if (!Number.isSafeInteger(value) || value <= 0 || value > fallback) {
throw new Error(`${label} must be a positive safe integer no larger than ${fallback}.`);
}
return value;
}
function asBuffer(bytes, label) {
if (!(bytes instanceof Uint8Array)) {
throw new Error(`${label} must be a Uint8Array.`);
}
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
function decodeUtf8Exact(bytes, label) {
let value;
try {
value = UTF8_DECODER.decode(bytes);
} catch {
throw new Error(`${label} is not valid UTF-8.`);
}
if (!Buffer.from(value, "utf8").equals(bytes)) {
throw new Error(`${label} is not canonically encoded UTF-8.`);
}
return value;
}
function assertSafeArchivePath(value, label) {
if (
typeof value !== "string" ||
value.length === 0 ||
value.startsWith("/") ||
value.endsWith("/") ||
value.includes("\\") ||
value.includes("\0") ||
value.normalize("NFC") !== value ||
hasControlCharacters(value)
) {
throw new Error(`Unsafe ${label}: ${JSON.stringify(value)}`);
}
const parts = value.split("/");
if (parts.some((part) => part.length === 0 || part === "." || part === "..")) {
throw new Error(`Unsafe ${label}: ${JSON.stringify(value)}`);
}
return value;
}
function assertArtifactName(value) {
const name = assertTrimmedString(value, "Actions artifact name");
if (!ARTIFACT_NAME_RE.test(name)) {
throw new Error(`Invalid Actions artifact name: ${name}`);
}
return name;
}
function assertArtifactDigest(value) {
const digest = assertTrimmedString(value, "Actions artifact digest");
if (!ARTIFACT_DIGEST_RE.test(digest)) {
throw new Error("Actions artifact digest must be sha256:<64 lowercase hex>.");
}
return digest;
}
function assertCommitSha(value, label) {
const sha = assertTrimmedString(value, label);
if (!COMMIT_SHA_RE.test(sha)) {
throw new Error(`${label} must be a full lowercase commit SHA.`);
}
return sha;
}
function assertWorkflowPath(value) {
const workflowPath = assertTrimmedString(value, "workflow path");
if (
!/^\.github\/workflows\/[A-Za-z0-9][A-Za-z0-9_.-]*\.ya?ml$/u.test(workflowPath) ||
hasControlCharacters(workflowPath)
) {
throw new Error(`Invalid workflow path: ${workflowPath}`);
}
return workflowPath;
}
function assertRepository(value) {
const repository = assertTrimmedString(value, "GitHub repository");
if (!REPOSITORY_RE.test(repository)) {
throw new Error("GitHub repository must be owner/name.");
}
return repository;
}
export function sha256Digest(bytes) {
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}
function compareCodeUnits(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
export function describeActionsArtifactFiles(files) {
if (!(files instanceof Map)) {
throw new Error("Actions artifact files must be a Map.");
}
return [...files.entries()]
.map(([path, bytes]) => {
const safePath = assertSafeArchivePath(path, "Actions artifact file path");
const content = asBuffer(bytes, `Actions artifact file ${safePath}`);
return {
path: safePath,
sha256: sha256Digest(content).slice("sha256:".length),
sizeBytes: content.byteLength,
};
})
.toSorted((left, right) => compareCodeUnits(left.path, right.path));
}
export function readBoundedRegularFile(path, params) {
if (!Number.isSafeInteger(params.maxBytes) || params.maxBytes <= 0) {
throw new Error(`${params.label} byte limit must be a positive safe integer.`);
}
let descriptor;
try {
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const before = fstatSync(descriptor);
if (!before.isFile()) {
throw new Error(`${params.label} must be a regular file.`);
}
if (before.size === 0 || before.size > params.maxBytes) {
throw new Error(`${params.label} size is outside the allowed range: ${before.size}.`);
}
const bytes = Buffer.allocUnsafe(before.size);
let offset = 0;
while (offset < bytes.length) {
const bytesRead = readSync(descriptor, bytes, offset, bytes.length - offset, offset);
if (bytesRead === 0) {
throw new Error(`${params.label} changed while it was being read.`);
}
offset += bytesRead;
}
const extra = Buffer.allocUnsafe(1);
const extraBytes = readSync(descriptor, extra, 0, 1, before.size);
const after = fstatSync(descriptor);
if (extraBytes !== 0 || after.size !== before.size) {
throw new Error(`${params.label} changed while it was being read.`);
}
return bytes;
} catch (error) {
if (error && typeof error === "object" && error.code === "ELOOP") {
throw new Error(`${params.label} must be a regular file.`, { cause: error });
}
throw error;
} finally {
if (descriptor !== undefined) {
closeSync(descriptor);
}
}
}
function findEndOfCentralDirectory(bytes) {
const minOffset = Math.max(0, bytes.length - ZIP_EOCD_BYTES - ZIP_MAX_COMMENT_BYTES);
for (let offset = bytes.length - ZIP_EOCD_BYTES; offset >= minOffset; offset -= 1) {
if (bytes.readUInt32LE(offset) !== ZIP_EOCD_SIGNATURE) {
continue;
}
const commentLength = bytes.readUInt16LE(offset + 20);
if (offset + ZIP_EOCD_BYTES + commentLength === bytes.length) {
if (commentLength !== 0) {
throw new Error("Actions artifact ZIP comments are not supported.");
}
return offset;
}
}
throw new Error(
"Actions artifact ZIP is missing an exact terminal end-of-central-directory record.",
);
}
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function normalizeArchivePolicy(policy) {
if (!policy || typeof policy !== "object") {
throw new Error("Actions artifact ZIP policy is required.");
}
const maxArchiveBytes = boundedLimit(
policy.maxArchiveBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES,
"Actions artifact ZIP byte limit",
);
const maxExpandedBytes = boundedLimit(
policy.maxExpandedBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES,
"Actions artifact ZIP expanded-byte limit",
);
let expectedEntries;
if (policy.expectedEntries !== undefined) {
if (
!Array.isArray(policy.expectedEntries) ||
policy.expectedEntries.length === 0 ||
policy.expectedEntries.length > 1024
) {
throw new Error("Actions artifact ZIP expected inventory is invalid.");
}
expectedEntries = policy.expectedEntries.map((name) =>
assertSafeArchivePath(name, "expected ZIP entry path"),
);
if (new Set(expectedEntries).size !== expectedEntries.length) {
throw new Error("Actions artifact ZIP expected inventory contains duplicates.");
}
}
const minEntries = expectedEntries?.length ?? policy.minEntries;
const maxEntries = expectedEntries?.length ?? policy.maxEntries;
if (
!Number.isSafeInteger(minEntries) ||
!Number.isSafeInteger(maxEntries) ||
minEntries <= 0 ||
maxEntries < minEntries ||
maxEntries > 1024
) {
throw new Error("Actions artifact ZIP entry-count policy is invalid.");
}
if (!expectedEntries && typeof policy.allowPath !== "function") {
throw new Error("Actions artifact ZIP path policy is required.");
}
if (typeof policy.maxEntryBytes !== "function") {
throw new Error("Actions artifact ZIP per-entry expanded-byte policy is required.");
}
if (
policy.maxCompressedEntryBytes !== undefined &&
typeof policy.maxCompressedEntryBytes !== "function"
) {
throw new Error("Actions artifact ZIP per-entry compressed-byte policy is invalid.");
}
return {
expectedEntries,
expectedEntrySet: expectedEntries ? new Set(expectedEntries) : undefined,
maxArchiveBytes,
maxEntries,
maxExpandedBytes,
minEntries,
rejectCaseFoldAliases: policy.rejectCaseFoldAliases !== false,
allowPath: expectedEntries
? (name) => new Set(expectedEntries).has(name)
: (name) => policy.allowPath(name),
maxCompressedEntryBytes: (name) =>
boundedLimit(
policy.maxCompressedEntryBytes?.(name),
maxArchiveBytes,
`Actions artifact ZIP compressed entry limit for ${name}`,
),
maxEntryBytes: (name) =>
boundedLimit(
policy.maxEntryBytes(name),
maxExpandedBytes,
`Actions artifact ZIP expanded entry limit for ${name}`,
),
};
}
function inspectCentralDirectory(bytes, eocd, policy) {
const disk = bytes.readUInt16LE(eocd + 4);
const centralDisk = bytes.readUInt16LE(eocd + 6);
const diskEntries = bytes.readUInt16LE(eocd + 8);
const totalEntries = bytes.readUInt16LE(eocd + 10);
const centralSize = bytes.readUInt32LE(eocd + 12);
const centralOffset = bytes.readUInt32LE(eocd + 16);
if (
disk !== 0 ||
centralDisk !== 0 ||
diskEntries !== totalEntries ||
totalEntries === 0xffff ||
centralSize === 0xffffffff ||
centralOffset === 0xffffffff
) {
throw new Error("Multi-disk and ZIP64 Actions artifacts are not supported.");
}
if (
totalEntries < policy.minEntries ||
totalEntries > policy.maxEntries ||
centralOffset + centralSize !== eocd
) {
throw new Error(
`Actions artifact ZIP must contain between ${policy.minEntries} and ${policy.maxEntries} exact files with a contiguous central directory.`,
);
}
const records = [];
const names = new Set();
const aliases = new Set();
let offset = centralOffset;
let declaredExpandedBytes = 0;
for (let index = 0; index < totalEntries; index += 1) {
if (offset + 46 > eocd || bytes.readUInt32LE(offset) !== ZIP_CENTRAL_HEADER_SIGNATURE) {
throw new Error("Invalid Actions artifact ZIP central-directory entry.");
}
const versionNeeded = bytes.readUInt16LE(offset + 6);
const flags = bytes.readUInt16LE(offset + 8);
const compression = bytes.readUInt16LE(offset + 10);
const expectedCrc = bytes.readUInt32LE(offset + 16);
const compressedSize = bytes.readUInt32LE(offset + 20);
const expandedSize = bytes.readUInt32LE(offset + 24);
const nameLength = bytes.readUInt16LE(offset + 28);
const extraLength = bytes.readUInt16LE(offset + 30);
const commentLength = bytes.readUInt16LE(offset + 32);
const startDisk = bytes.readUInt16LE(offset + 34);
const externalAttributes = bytes.readUInt32LE(offset + 38);
const localOffset = bytes.readUInt32LE(offset + 42);
const nameStart = offset + 46;
const nameEnd = nameStart + nameLength;
const nextOffset = nameEnd + extraLength + commentLength;
if (nextOffset > eocd) {
throw new Error("Actions artifact ZIP central directory exceeds its declared boundary.");
}
if (
versionNeeded > 20 ||
startDisk !== 0 ||
nameLength === 0 ||
extraLength !== 0 ||
commentLength !== 0
) {
throw new Error("Actions artifact ZIP central-directory encoding is not canonical.");
}
if ((flags & ~SUPPORTED_ZIP_FLAGS) !== 0 || (flags & 0x0001) !== 0) {
throw new Error(`Unsupported Actions artifact ZIP flags 0x${flags.toString(16)}.`);
}
if (compression !== 0 && compression !== 8) {
throw new Error(`Unsupported Actions artifact ZIP compression method ${compression}.`);
}
if (
compressedSize === 0xffffffff ||
expandedSize === 0xffffffff ||
localOffset === 0xffffffff
) {
throw new Error("ZIP64 Actions artifact entries are not supported.");
}
const rawName = Buffer.from(bytes.subarray(nameStart, nameEnd));
if ((flags & ZIP_UTF8_FLAG) === 0 && rawName.some((byte) => byte >= 0x80)) {
throw new Error(
"Actions artifact ZIP non-ASCII entry names must set the UTF-8 language flag.",
);
}
const name = assertSafeArchivePath(
decodeUtf8Exact(rawName, "Actions artifact ZIP entry path"),
"ZIP entry path",
);
const alias = name.toLocaleLowerCase("en-US");
if (
names.has(name) ||
(policy.rejectCaseFoldAliases && aliases.has(alias)) ||
!policy.allowPath(name)
) {
throw new Error(`Unexpected, duplicate, or aliased Actions artifact ZIP entry: ${name}`);
}
names.add(name);
aliases.add(alias);
const unixMode = (externalAttributes >>> 16) & 0xffff;
const unixType = unixMode & 0o170000;
const dosAttributes = externalAttributes & 0xffff;
if ((unixType !== 0 && unixType !== 0o100000) || (dosAttributes & 0x10) !== 0) {
throw new Error(`Actions artifact ZIP entry is not a regular file: ${name}`);
}
const maxCompressedBytes = policy.maxCompressedEntryBytes(name);
const maxEntryBytes = policy.maxEntryBytes(name);
if (
compressedSize > maxCompressedBytes ||
expandedSize > maxEntryBytes ||
declaredExpandedBytes + expandedSize > policy.maxExpandedBytes
) {
throw new Error(`Actions artifact ZIP entry is too large: ${name}`);
}
if (compression === 0 && compressedSize !== expandedSize) {
throw new Error(`Stored Actions artifact ZIP entry size mismatch for ${name}.`);
}
declaredExpandedBytes += expandedSize;
records.push({
compressedSize,
compression,
expandedSize,
expectedCrc,
flags,
localOffset,
maxEntryBytes,
name,
rawName,
versionNeeded,
});
offset = nextOffset;
}
if (offset !== eocd) {
throw new Error("Actions artifact ZIP central-directory size mismatch.");
}
return { centralOffset, records };
}
function expandZipEntry(compressed, record, remainingExpandedBytes) {
const outputLimit = Math.min(record.maxEntryBytes, remainingExpandedBytes);
try {
if (record.compression === 0) {
return compressed;
}
const result = inflateRawSync(compressed, {
info: true,
maxOutputLength: Math.max(1, outputLimit),
});
if (result.engine.bytesWritten !== compressed.length) {
throw new Error("deflate stream does not consume its full compressed member");
}
return result.buffer;
} catch (error) {
throw new Error(
`Actions artifact ZIP entry expansion exceeds its allowed range: ${record.name}`,
{ cause: error },
);
}
}
function inspectLocalRecords(bytes, centralOffset, records, policy) {
const files = new Map();
const ordered = records.toSorted((left, right) => left.localOffset - right.localOffset);
let expectedOffset = 0;
let totalExpandedBytes = 0;
for (const [index, record] of ordered.entries()) {
if (
record.localOffset !== expectedOffset ||
record.localOffset + 30 > centralOffset ||
bytes.readUInt32LE(record.localOffset) !== ZIP_LOCAL_HEADER_SIGNATURE
) {
throw new Error(
`Non-contiguous or invalid Actions artifact ZIP local record: ${record.name}`,
);
}
const localVersionNeeded = bytes.readUInt16LE(record.localOffset + 4);
const localFlags = bytes.readUInt16LE(record.localOffset + 6);
const localCompression = bytes.readUInt16LE(record.localOffset + 8);
const localCrc = bytes.readUInt32LE(record.localOffset + 14);
const localCompressedSize = bytes.readUInt32LE(record.localOffset + 18);
const localExpandedSize = bytes.readUInt32LE(record.localOffset + 22);
const localNameLength = bytes.readUInt16LE(record.localOffset + 26);
const localExtraLength = bytes.readUInt16LE(record.localOffset + 28);
const localNameStart = record.localOffset + 30;
const localNameEnd = localNameStart + localNameLength;
if (
localNameEnd > centralOffset ||
localVersionNeeded !== record.versionNeeded ||
localFlags !== record.flags ||
localCompression !== record.compression ||
localExtraLength !== 0
) {
throw new Error(`Actions artifact ZIP local header mismatch for ${record.name}.`);
}
const rawLocalName = bytes.subarray(localNameStart, localNameEnd);
if (!record.rawName.equals(rawLocalName)) {
throw new Error(`Actions artifact ZIP local and central names differ for ${record.name}.`);
}
decodeUtf8Exact(rawLocalName, `Actions artifact ZIP local entry path for ${record.name}`);
const dataStart = localNameEnd;
const dataEnd = dataStart + record.compressedSize;
if (dataEnd > centralOffset) {
throw new Error(`Actions artifact ZIP data exceeds its boundary: ${record.name}`);
}
let recordEnd = dataEnd;
if ((record.flags & ZIP_DATA_DESCRIPTOR_FLAG) !== 0) {
if (localCrc !== 0 || localCompressedSize !== 0 || localExpandedSize !== 0) {
throw new Error(
`Actions artifact ZIP descriptor-backed local sizes must be zero: ${record.name}`,
);
}
if (
dataEnd + ZIP_DATA_DESCRIPTOR_BYTES > centralOffset ||
bytes.readUInt32LE(dataEnd) !== ZIP_DATA_DESCRIPTOR_SIGNATURE ||
bytes.readUInt32LE(dataEnd + 4) !== record.expectedCrc ||
bytes.readUInt32LE(dataEnd + 8) !== record.compressedSize ||
bytes.readUInt32LE(dataEnd + 12) !== record.expandedSize
) {
throw new Error(`Invalid Actions artifact ZIP data descriptor for ${record.name}.`);
}
recordEnd += ZIP_DATA_DESCRIPTOR_BYTES;
} else if (
localCrc !== record.expectedCrc ||
localCompressedSize !== record.compressedSize ||
localExpandedSize !== record.expandedSize
) {
throw new Error(`Actions artifact ZIP local sizes or CRC differ for ${record.name}.`);
}
const nextOffset = ordered[index + 1]?.localOffset ?? centralOffset;
if (recordEnd !== nextOffset) {
throw new Error(`Actions artifact ZIP contains a gap or overlap after ${record.name}.`);
}
const compressed = bytes.subarray(dataStart, dataEnd);
const expanded = expandZipEntry(
compressed,
record,
policy.maxExpandedBytes - totalExpandedBytes,
);
if (
expanded.length !== record.expandedSize ||
totalExpandedBytes + expanded.length > policy.maxExpandedBytes ||
crc32(expanded) !== record.expectedCrc
) {
throw new Error(`Actions artifact ZIP checksum mismatch for ${record.name}.`);
}
totalExpandedBytes += expanded.length;
files.set(record.name, Buffer.from(expanded));
expectedOffset = recordEnd;
}
if (expectedOffset !== centralOffset) {
throw new Error("Actions artifact ZIP local records do not end at the central directory.");
}
return files;
}
export function inspectActionsArtifactZipWithPolicy(inputBytes, inputPolicy) {
const bytes = asBuffer(inputBytes, "Actions artifact ZIP");
const policy = normalizeArchivePolicy(inputPolicy);
if (bytes.length === 0 || bytes.length > policy.maxArchiveBytes) {
throw new Error(`Actions artifact ZIP size is outside the allowed range: ${bytes.length}.`);
}
const eocd = findEndOfCentralDirectory(bytes);
const { centralOffset, records } = inspectCentralDirectory(bytes, eocd, policy);
const files = inspectLocalRecords(bytes, centralOffset, records, policy);
if (policy.expectedEntries) {
const actual = [...files.keys()].toSorted(compareCodeUnits);
const expected = [...policy.expectedEntries].toSorted(compareCodeUnits);
if (
actual.length !== expected.length ||
actual.some((name, index) => name !== expected[index])
) {
throw new Error(
`Actions artifact ZIP inventory mismatch: expected ${expected.join(", ")}, found ${actual.join(", ")}.`,
);
}
}
return files;
}
export function inspectActionsArtifactZip(bytes, expectedEntries = 2, limits = {}) {
let expectedInventory;
let expectedCount;
if (Array.isArray(expectedEntries)) {
expectedInventory = expectedEntries;
expectedCount = expectedEntries.length;
} else {
expectedCount = assertPositiveInteger(expectedEntries, "Expected Actions artifact entry count");
}
const maxArchiveBytes = boundedLimit(
limits.maxArchiveBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES,
"Actions artifact ZIP byte limit",
);
const maxExpandedBytes = boundedLimit(
limits.maxExpandedBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES,
"Actions artifact ZIP expanded-byte limit",
);
const maxEntryBytes = boundedLimit(
limits.maxEntryBytes,
maxExpandedBytes,
"Actions artifact ZIP expanded entry limit",
);
const maxCompressedEntryBytes = boundedLimit(
limits.maxCompressedEntryBytes,
maxArchiveBytes,
"Actions artifact ZIP compressed entry limit",
);
return inspectActionsArtifactZipWithPolicy(bytes, {
expectedEntries: expectedInventory,
minEntries: expectedCount,
maxEntries: expectedCount,
maxArchiveBytes,
maxExpandedBytes,
allowPath: expectedInventory ? undefined : (name) => basename(name) === name,
maxCompressedEntryBytes: () => maxCompressedEntryBytes,
maxEntryBytes: () => maxEntryBytes,
});
}
function requireExpectedBinding(params) {
const expected = params.expected;
if (!expected || typeof expected !== "object") {
throw new Error("Expected Actions artifact binding is required.");
}
const repository = assertRepository(expected.repository);
const artifactId = assertPositiveInteger(expected.artifactId, "Actions artifact ID");
const artifactName = assertArtifactName(expected.artifactName);
const artifactDigest = assertArtifactDigest(expected.artifactDigest);
const artifactSizeBytes = assertPositiveInteger(
expected.artifactSizeBytes,
"Actions artifact size",
);
const runId = assertPositiveInteger(expected.runId, "workflow run ID");
const runAttempt = assertPositiveInteger(expected.runAttempt, "workflow run attempt");
const workflowSha = assertCommitSha(expected.workflowSha, "workflow SHA");
const workflowPath = assertWorkflowPath(expected.workflowPath);
const workflowEvent = assertTrimmedString(expected.workflowEvent, "workflow event");
const workflowHeadBranch = assertTrimmedString(
expected.workflowHeadBranch,
"workflow head branch",
);
const runStatePolicy = assertTrimmedString(expected.runStatePolicy, "workflow run-state policy");
if (runStatePolicy !== "completed-success" && runStatePolicy !== "same-run-producer-success") {
throw new Error(`Unsupported workflow run-state policy: ${runStatePolicy}`);
}
const consumerRunAttempt =
runStatePolicy === "same-run-producer-success"
? assertPositiveInteger(expected.consumerRunAttempt, "consumer workflow run attempt")
: undefined;
const producerJobName =
runStatePolicy === "same-run-producer-success"
? assertTrimmedString(expected.producerJobName, "producer job name")
: undefined;
if (consumerRunAttempt !== undefined && runAttempt > consumerRunAttempt) {
throw new Error("Producer workflow run attempt must not be newer than the consumer attempt.");
}
return {
artifactDigest,
artifactId,
artifactName,
artifactSizeBytes,
consumerRunAttempt,
producerJobName,
repository,
runStatePolicy,
runAttempt,
runId,
workflowEvent,
workflowHeadBranch,
workflowPath,
workflowSha,
};
}
export function validateActionsArtifactBinding(params) {
const expected = requireExpectedBinding(params);
const artifact = params.artifactMetadata;
const run = params.workflowRun;
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
throw new Error("Actions artifact metadata must be an object.");
}
if (!run || typeof run !== "object" || Array.isArray(run)) {
throw new Error("Actions workflow run metadata must be an object.");
}
if (
artifact.id !== expected.artifactId ||
artifact.name !== expected.artifactName ||
artifact.size_in_bytes !== expected.artifactSizeBytes ||
artifact.expired !== false ||
artifact.digest !== expected.artifactDigest ||
artifact.workflow_run?.id !== expected.runId ||
artifact.workflow_run?.head_sha !== expected.workflowSha
) {
throw new Error("Actions artifact metadata does not match the immutable publication tuple.");
}
if (
run.id !== expected.runId ||
run.run_attempt !== expected.runAttempt ||
run.head_sha !== expected.workflowSha ||
run.head_branch !== expected.workflowHeadBranch ||
run.event !== expected.workflowEvent ||
run.path !== expected.workflowPath ||
run.repository?.full_name !== expected.repository ||
run.head_repository?.full_name !== expected.repository
) {
throw new Error("Actions workflow run does not match the immutable publication tuple.");
}
if (expected.runStatePolicy === "completed-success") {
if (run.status !== "completed" || run.conclusion !== "success") {
throw new Error("Actions workflow run does not match the immutable publication tuple.");
}
} else if (expected.runAttempt === expected.consumerRunAttempt) {
if (run.status !== "in_progress" || run.conclusion !== null) {
throw new Error("Current producer workflow attempt must still be in progress.");
}
} else if (
run.status !== "completed" ||
typeof run.conclusion !== "string" ||
run.conclusion.length === 0
) {
throw new Error("Prior producer workflow attempt must be completed.");
}
return expected;
}
export function validateActionsArtifactProducerJob(params) {
const expected = requireExpectedBinding(params);
if (expected.runStatePolicy !== "same-run-producer-success") {
return expected;
}
const response = params.workflowJobs;
if (!response || typeof response !== "object" || Array.isArray(response)) {
throw new Error("Actions workflow jobs response must be an object.");
}
if (
!Number.isSafeInteger(response.total_count) ||
response.total_count < 0 ||
!Array.isArray(response.jobs) ||
response.total_count !== response.jobs.length
) {
throw new Error("Actions workflow jobs inventory is incomplete.");
}
const matches = response.jobs.filter((job) => job?.name === expected.producerJobName);
if (matches.length !== 1) {
throw new Error("Actions artifact producer job must be unique.");
}
const [producerJob] = matches;
if (
producerJob.run_id !== expected.runId ||
producerJob.run_attempt !== expected.runAttempt ||
producerJob.head_sha !== expected.workflowSha ||
producerJob.status !== "completed" ||
producerJob.conclusion !== "success"
) {
throw new Error("Actions artifact producer job did not complete successfully.");
}
return expected;
}
async function readBoundedResponseBody(response, params) {
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error(`${params.label} returned HTTP ${response.status}.`);
}
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
if (!/^(?:0|[1-9][0-9]*)$/u.test(contentLength)) {
await response.body?.cancel();
throw new Error(`${params.label} returned an invalid Content-Length.`);
}
const declaredBytes = Number(contentLength);
if (
!Number.isSafeInteger(declaredBytes) ||
declaredBytes > params.maxBytes ||
(params.expectedBytes !== undefined && declaredBytes !== params.expectedBytes)
) {
await response.body?.cancel();
throw new Error(`${params.label} Content-Length is outside the approved range.`);
}
}
const chunks = [];
let totalBytes = 0;
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
totalBytes += value.byteLength;
if (
totalBytes > params.maxBytes ||
(params.expectedBytes !== undefined && totalBytes > params.expectedBytes)
) {
await reader.cancel();
throw new Error(`${params.label} exceeded its approved byte count.`);
}
chunks.push(Buffer.from(value));
}
} catch (error) {
await reader.cancel().catch(() => undefined);
throw error;
} finally {
reader.releaseLock();
}
if (totalBytes === 0) {
throw new Error(`${params.label} returned an empty body.`);
}
if (params.expectedBytes !== undefined && totalBytes !== params.expectedBytes) {
throw new Error(`${params.label} size does not match metadata.`);
}
return Buffer.concat(chunks, totalBytes);
}
async function runBoundedRetry(label, operation, params) {
let lastError;
for (let attempt = 1; attempt <= params.attempts; attempt += 1) {
try {
return await operation(attempt);
} catch (error) {
lastError = error;
if (attempt === params.attempts) {
break;
}
await new Promise((resolvePromise) => {
setTimeout(resolvePromise, params.delayMs);
});
}
}
throw new Error(
`${label} failed after ${params.attempts} attempts: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
{ cause: lastError },
);
}
async function fetchBoundedJson(url, request, params) {
const response = await request.fetchImpl(url, {
headers: request.headers,
redirect: "follow",
signal: AbortSignal.timeout(request.timeoutMs),
});
const bytes = await readBoundedResponseBody(response, {
label: params.label,
maxBytes: params.maxBytes,
});
let value;
try {
value = JSON.parse(decodeUtf8Exact(bytes, `${params.label} body`));
} catch (error) {
throw new Error(
`${params.label} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${params.label} JSON must be an object.`);
}
return value;
}
export async function downloadActionsArtifactArchive(params) {
const expected = requireExpectedBinding(params);
const token = assertTrimmedString(params.token, "GitHub token");
const timeoutMs = boundedLimit(params.timeoutMs, DEFAULT_TIMEOUT_MS, "GitHub request timeout");
const retryAttempts =
params.retryAttempts === undefined
? 3
: boundedLimit(params.retryAttempts, 5, "GitHub request retry count");
const retryDelayMs =
params.retryDelayMs === undefined
? 250
: boundedLimit(params.retryDelayMs, 5_000, "GitHub retry delay");
const maxArchiveBytes = boundedLimit(
params.maxArchiveBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES,
"Actions artifact ZIP byte limit",
);
if (expected.artifactSizeBytes > maxArchiveBytes) {
throw new Error("Actions artifact size exceeds the configured archive limit.");
}
const fetchImpl = params.fetchImpl ?? fetch;
const headers = {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "openclaw-publication-artifact",
"x-github-api-version": ACTIONS_ARTIFACT_API_VERSION,
};
const apiRoot = `https://api.github.com/repos/${expected.repository}`;
const request = { fetchImpl, headers, timeoutMs };
const retry = {
attempts: retryAttempts,
delayMs: retryDelayMs,
};
const artifactMetadata = await runBoundedRetry(
"GitHub Actions artifact metadata",
() =>
fetchBoundedJson(`${apiRoot}/actions/artifacts/${expected.artifactId}`, request, {
label: "GitHub Actions artifact metadata",
maxBytes: DEFAULT_MAX_JSON_BYTES,
}),
retry,
);
const workflowRun = await runBoundedRetry(
"GitHub Actions workflow attempt",
() =>
fetchBoundedJson(
`${apiRoot}/actions/runs/${expected.runId}/attempts/${expected.runAttempt}`,
request,
{
label: "GitHub Actions workflow attempt",
maxBytes: DEFAULT_MAX_JSON_BYTES,
},
),
retry,
);
validateActionsArtifactBinding({ artifactMetadata, expected, workflowRun });
let workflowJobs;
if (expected.runStatePolicy === "same-run-producer-success") {
workflowJobs = await runBoundedRetry(
"GitHub Actions producer jobs",
() =>
fetchBoundedJson(
`${apiRoot}/actions/runs/${expected.runId}/attempts/${expected.runAttempt}/jobs?per_page=100`,
request,
{
label: "GitHub Actions producer jobs",
maxBytes: DEFAULT_MAX_JSON_BYTES,
},
),
retry,
);
validateActionsArtifactProducerJob({ expected, workflowJobs });
}
const archiveBytes = await runBoundedRetry(
"GitHub Actions artifact download",
async () => {
const response = await fetchImpl(`${apiRoot}/actions/artifacts/${expected.artifactId}/zip`, {
headers,
redirect: "follow",
signal: AbortSignal.timeout(timeoutMs),
});
const bytes = await readBoundedResponseBody(response, {
expectedBytes: expected.artifactSizeBytes,
label: "GitHub Actions artifact download",
maxBytes: maxArchiveBytes,
});
const actualDigest = sha256Digest(bytes);
if (actualDigest !== expected.artifactDigest) {
throw new Error(
`GitHub Actions artifact digest ${actualDigest} does not match ${expected.artifactDigest}.`,
);
}
return bytes;
},
retry,
);
return { archiveBytes, artifactMetadata, binding: expected, workflowJobs, workflowRun };
}
export async function readPublicationArtifactArchive(params) {
const downloaded = await downloadActionsArtifactArchive(params);
const files = inspectActionsArtifactZipWithPolicy(downloaded.archiveBytes, params.archivePolicy);
return { ...downloaded, files };
}
+803
View File
@@ -0,0 +1,803 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { lstat, mkdir, readdir, realpath, writeFile } from "node:fs/promises";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { pathToFileURL } from "node:url";
import {
CLAWHUB_PUBLICATION_TAR_LIMITS,
inspectPackageTarballBytes,
validatePluginPackageManifest,
} from "../plugin-publication-artifact.mjs";
import {
describeActionsArtifactFiles,
readBoundedRegularFile,
readPublicationArtifactArchive,
} from "./actions-artifact-archive.mjs";
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
const SHA512_INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]{86}==$/u;
const COMMIT_PATTERN = /^[a-f0-9]{40}$/u;
const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
const PACKAGE_NAME_PATTERN = /^@openclaw\/[a-z0-9][a-z0-9._-]*$/u;
const PACKAGE_DIR_PATTERN = /^extensions\/[a-z0-9][a-z0-9._-]*$/u;
const TAG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u;
const VERSION_PATTERN =
/^[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*|-[1-9][0-9]*)?$/u;
const TOOLCHAIN_VERSION_PATTERN = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u;
const MAX_BOOTSTRAP_ARCHIVE_BYTES = 256 * 1024 * 1024;
const MAX_BOOTSTRAP_ARCHIVE_FILES = 128;
const MAX_BOOTSTRAP_PACKAGES = MAX_BOOTSTRAP_ARCHIVE_FILES - 1;
const MAX_BOOTSTRAP_MANIFEST_BYTES = 2 * 1024 * 1024;
// The compressed and total-payload limits match ClawHub's ClawPack contract.
// The expanded TAR and entry-count ceilings bound this credential-job parser.
const MAX_CLAWPACK_BYTES = CLAWHUB_PUBLICATION_TAR_LIMITS.maxArchiveBytes;
function fail(message) {
throw new Error(message);
}
function compareCodeUnits(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
function requireString(value, label) {
if (typeof value !== "string" || value.trim() === "") {
fail(`${label} is required.`);
}
return value.trim();
}
function requirePattern(value, pattern, label) {
const result = requireString(value, label);
if (!pattern.test(result)) {
fail(`${label} is invalid.`);
}
return result;
}
function requireBoolean(value, label) {
if (typeof value !== "boolean") {
fail(`${label} must be a boolean.`);
}
return value;
}
function parsePlugins(value) {
const plugins = requireString(value, "plugins")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const unique = [...new Set(plugins)].toSorted(compareCodeUnits);
if (unique.length !== plugins.length) {
fail("plugins must not contain duplicates.");
}
for (const plugin of unique) {
requirePattern(plugin, PACKAGE_NAME_PATTERN, `plugin ${plugin}`);
}
return unique;
}
function packageSlug(packageName) {
return packageName.slice("@openclaw/".length);
}
function normalizePlanEntry(value, index) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
fail(`matrix[${index}] must be an object.`);
}
const packageName = requirePattern(
value.packageName,
PACKAGE_NAME_PATTERN,
`matrix[${index}].packageName`,
);
const packageDir = requirePattern(
value.packageDir,
PACKAGE_DIR_PATTERN,
`matrix[${index}].packageDir`,
);
const publishTag = requirePattern(value.publishTag, TAG_PATTERN, `matrix[${index}].publishTag`);
const version = requirePattern(value.version, VERSION_PATTERN, `matrix[${index}].version`);
const bootstrapMode = requireString(value.bootstrapMode, `matrix[${index}].bootstrapMode`);
if (bootstrapMode !== "publish" && bootstrapMode !== "configure-only") {
fail(`matrix[${index}].bootstrapMode is invalid.`);
}
const requiresManualOverride = requireBoolean(
value.requiresManualOverride,
`matrix[${index}].requiresManualOverride`,
);
if (bootstrapMode === "configure-only" && !requiresManualOverride) {
fail(`matrix[${index}] configure-only entries must require the manual override.`);
}
return {
packageName,
version,
packageDir,
publishTag,
bootstrapMode,
requiresManualOverride,
};
}
function hashBytes(bytes) {
return {
sha256: createHash("sha256").update(bytes).digest("hex"),
size: bytes.byteLength,
};
}
async function hashFile(path) {
return hashBytes(
readBoundedRegularFile(path, {
label: "Packed ClawHub artifact",
maxBytes: MAX_CLAWPACK_BYTES,
}),
);
}
export async function verifyClawHubPackedArtifactIdentity(options) {
const artifactPath = resolve(requireString(options.artifactPath, "artifactPath"));
const expectedSha256 = requirePattern(options.expectedSha256, SHA256_PATTERN, "expectedSha256");
const expectedSize = requirePattern(
options.expectedSize,
POSITIVE_INTEGER_PATTERN,
"expectedSize",
);
const expectedName = requirePattern(options.expectedName, PACKAGE_NAME_PATTERN, "expectedName");
const expectedVersion = requireString(options.expectedVersion, "expectedVersion");
const expectedDir = requirePattern(options.expectedDir, PACKAGE_DIR_PATTERN, "expectedDir");
const artifactStat = await lstat(artifactPath);
if (!artifactStat.isFile() || artifactStat.isSymbolicLink()) {
fail("Packed ClawHub artifact must be a regular file.");
}
if (artifactStat.size > MAX_CLAWPACK_BYTES) {
fail(`Packed ClawHub artifact exceeds ${MAX_CLAWPACK_BYTES} bytes.`);
}
if (String(artifactStat.size) !== expectedSize) {
fail("Packed ClawHub artifact hash or size mismatch.");
}
const bytes = readBoundedRegularFile(artifactPath, {
label: "Packed ClawHub artifact",
maxBytes: MAX_CLAWPACK_BYTES,
});
const identity = hashBytes(bytes);
if (identity.sha256 !== expectedSha256 || String(identity.size) !== expectedSize) {
fail("Packed ClawHub artifact hash or size mismatch.");
}
const inspection = inspectPackageTarballBytes(bytes, CLAWHUB_PUBLICATION_TAR_LIMITS);
validatePluginPackageManifest(
{
packageDir: expectedDir,
packageName: expectedName,
route: "clawhub-token-bootstrap",
version: expectedVersion,
},
inspection.packageManifest,
);
const packageName = inspection.packageManifest.name;
const packageVersion = inspection.packageManifest.version;
return {
...identity,
inventory: inspection.inventory,
packageJsonSha256: inspection.packageManifestSha256,
packageName,
packageVersion,
pluginManifestSha256: inspection.pluginManifestSha256,
};
}
async function listFiles(root) {
const result = [];
let visitedEntries = 0;
let totalPathBytes = 0;
async function visit(directory, depth) {
if (depth > 4) {
fail("Artifact inventory exceeds the supported directory depth.");
}
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
visitedEntries += 1;
if (visitedEntries > MAX_BOOTSTRAP_ARCHIVE_FILES * 4) {
fail("Artifact inventory contains too many filesystem entries.");
}
if (entry.isSymbolicLink()) {
fail(`Artifact inventory contains a symlink: ${relative(root, path)}`);
}
if (entry.isDirectory()) {
await visit(path, depth + 1);
} else if (entry.isFile()) {
const artifactPath = relative(root, path).split(sep).join("/");
totalPathBytes += Buffer.byteLength(artifactPath, "utf8");
if (
result.length >= MAX_BOOTSTRAP_ARCHIVE_FILES ||
totalPathBytes > MAX_BOOTSTRAP_MANIFEST_BYTES
) {
fail("Artifact inventory exceeds its file or path-byte limit.");
}
result.push(artifactPath);
} else {
fail(`Artifact inventory contains a non-regular entry: ${relative(root, path)}`);
}
}
}
await visit(root, 0);
return result.toSorted(compareCodeUnits);
}
function readPositiveInteger(value, label) {
const raw = requirePattern(value, POSITIVE_INTEGER_PATTERN, label);
const result = Number(raw);
if (!Number.isSafeInteger(result)) {
fail(`${label} is outside the supported range.`);
}
return result;
}
function requireExactKeys(value, expected, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
fail(`${label} must be an object.`);
}
const actual = Object.keys(value).toSorted(compareCodeUnits);
if (JSON.stringify(actual) !== JSON.stringify([...expected].toSorted(compareCodeUnits))) {
fail(`${label} keys are invalid: ${actual.join(",")}.`);
}
}
function normalizeBootstrapManifestEntry(value, index) {
requireExactKeys(
value,
[
"artifactPath",
"bootstrapMode",
"packageDir",
"packageName",
"publishTag",
"requiresManualOverride",
"sha256",
"size",
"version",
],
`manifest.entries[${index}]`,
);
const entry = normalizePlanEntry(value, index);
const expectedPrefix = `packages/${packageSlug(entry.packageName)}/`;
const artifactPath = requireString(value.artifactPath, `manifest.entries[${index}].artifactPath`);
if (
artifactPath !== `${expectedPrefix}${basename(artifactPath)}` ||
!/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(basename(artifactPath))
) {
fail(`Bootstrap Actions artifact path is invalid: ${artifactPath}`);
}
const sha256 = requirePattern(value.sha256, SHA256_PATTERN, `manifest.entries[${index}].sha256`);
if (!Number.isSafeInteger(value.size) || value.size <= 0 || value.size > MAX_CLAWPACK_BYTES) {
fail(`manifest.entries[${index}].size is invalid.`);
}
return { ...entry, artifactPath, sha256, size: value.size };
}
export function parseClawHubBootstrapManifestBytes(inputBytes) {
const manifestBytes = Buffer.isBuffer(inputBytes) ? inputBytes : Buffer.from(inputBytes);
if (manifestBytes.byteLength === 0 || manifestBytes.byteLength > MAX_BOOTSTRAP_MANIFEST_BYTES) {
fail(`ClawHub bootstrap manifest must be 1-${MAX_BOOTSTRAP_MANIFEST_BYTES} bytes.`);
}
let manifest;
try {
manifest = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(manifestBytes));
} catch {
fail("ClawHub bootstrap manifest is not valid UTF-8 JSON.");
}
requireExactKeys(
manifest,
[
"artifactName",
"clawhubToolchainIntegrity",
"clawhubToolchainSha256",
"clawhubToolchainVersion",
"entries",
"repository",
"requestedPlugins",
"runAttempt",
"runId",
"schemaVersion",
"targetSha",
"workflowSha",
],
"ClawHub bootstrap manifest",
);
if (manifest.schemaVersion !== 1) {
fail(`Unsupported ClawHub bootstrap manifest schema: ${String(manifest.schemaVersion)}.`);
}
const repository = requirePattern(manifest.repository, REPOSITORY_PATTERN, "manifest.repository");
const targetSha = requirePattern(manifest.targetSha, COMMIT_PATTERN, "manifest.targetSha");
const workflowSha = requirePattern(manifest.workflowSha, COMMIT_PATTERN, "manifest.workflowSha");
const runId = requirePattern(manifest.runId, POSITIVE_INTEGER_PATTERN, "manifest.runId");
const runAttempt = requirePattern(
manifest.runAttempt,
POSITIVE_INTEGER_PATTERN,
"manifest.runAttempt",
);
const artifactName = requireString(manifest.artifactName, "manifest.artifactName");
const clawhubToolchainSha256 = requirePattern(
manifest.clawhubToolchainSha256,
SHA256_PATTERN,
"manifest.clawhubToolchainSha256",
);
const clawhubToolchainVersion = requirePattern(
manifest.clawhubToolchainVersion,
TOOLCHAIN_VERSION_PATTERN,
"manifest.clawhubToolchainVersion",
);
const clawhubToolchainIntegrity = requirePattern(
manifest.clawhubToolchainIntegrity,
SHA512_INTEGRITY_PATTERN,
"manifest.clawhubToolchainIntegrity",
);
if (
!Array.isArray(manifest.requestedPlugins) ||
manifest.requestedPlugins.length === 0 ||
manifest.requestedPlugins.length > MAX_BOOTSTRAP_PACKAGES ||
manifest.requestedPlugins.some((entry) => typeof entry !== "string")
) {
fail("ClawHub bootstrap manifest requestedPlugins is invalid.");
}
const requestedPlugins = parsePlugins(manifest.requestedPlugins.join(","));
if (JSON.stringify(requestedPlugins) !== JSON.stringify(manifest.requestedPlugins)) {
fail("ClawHub bootstrap manifest requestedPlugins is not canonical.");
}
if (
!Array.isArray(manifest.entries) ||
manifest.entries.length === 0 ||
manifest.entries.length > MAX_BOOTSTRAP_PACKAGES
) {
fail("ClawHub bootstrap manifest entries are invalid.");
}
const entries = manifest.entries.map(normalizeBootstrapManifestEntry);
if (new Set(entries.map((entry) => entry.packageName)).size !== entries.length) {
fail("ClawHub bootstrap manifest contains duplicate package names.");
}
assertExactPackageSet(entries, requestedPlugins);
const entryNames = entries.map((entry) => entry.packageName);
if (JSON.stringify(entryNames) !== JSON.stringify(entryNames.toSorted(compareCodeUnits))) {
fail("ClawHub bootstrap manifest entries are not canonical.");
}
return {
artifactName,
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
entries,
repository,
requestedPlugins,
runAttempt,
runId,
schemaVersion: 1,
targetSha,
workflowSha,
};
}
export function readClawHubBootstrapManifest(path) {
return parseClawHubBootstrapManifestBytes(
readBoundedRegularFile(path, {
label: "ClawHub bootstrap manifest",
maxBytes: MAX_BOOTSTRAP_MANIFEST_BYTES,
}),
);
}
function validateBootstrapArchiveInventory(files, expectedBinding) {
const manifestBytes = files.get("manifest.json");
if (!manifestBytes) {
fail("Bootstrap Actions artifact must contain manifest.json.");
}
const manifest = parseClawHubBootstrapManifestBytes(manifestBytes);
for (const [key, expected] of Object.entries(expectedBinding)) {
if (manifest[key] !== String(expected)) {
fail(`Bootstrap Actions artifact manifest ${key} mismatch.`);
}
}
const { entries, requestedPlugins } = manifest;
const expected = new Set(["manifest.json"]);
for (const entry of entries) {
const artifactPath = entry.artifactPath;
if (expected.has(artifactPath)) {
fail(`Bootstrap Actions artifact path is duplicated: ${artifactPath}`);
}
const bytes = files.get(artifactPath);
if (!bytes || bytes.byteLength !== entry.size || hashBytes(bytes).sha256 !== entry.sha256) {
fail(`Bootstrap Actions artifact bytes do not match manifest: ${artifactPath}`);
}
expected.add(artifactPath);
}
const actual = new Set(files.keys());
if (actual.size !== expected.size || [...actual].some((path) => !expected.has(path))) {
fail(
`Bootstrap Actions artifact inventory mismatch: expected ${[...expected].toSorted(compareCodeUnits).join(",")}, found ${[...actual].toSorted(compareCodeUnits).join(",")}.`,
);
}
return { entries, manifest, requestedPlugins };
}
export async function downloadClawHubBootstrapArtifact(options) {
const artifactId = readPositiveInteger(options.artifactId, "artifactId");
const artifactSizeBytes = readPositiveInteger(options.artifactSize, "artifactSize");
const runId = readPositiveInteger(options.runId, "runId");
const runAttempt = readPositiveInteger(options.runAttempt, "runAttempt");
const consumerRunAttempt = readPositiveInteger(options.consumerRunAttempt, "consumerRunAttempt");
const producerJobName = requireString(options.producerJobName, "producerJobName");
const targetSha = requirePattern(options.targetSha, COMMIT_PATTERN, "targetSha");
const workflowSha = requirePattern(options.workflowSha, COMMIT_PATTERN, "workflowSha");
const artifactDigest = requirePattern(options.artifactDigest, SHA256_PATTERN, "artifactDigest");
const artifactName = requireString(options.artifactName, "artifactName");
const repository = requirePattern(options.repository, REPOSITORY_PATTERN, "repository");
const clawhubToolchainSha256 = requirePattern(
options.clawhubToolchainSha256,
SHA256_PATTERN,
"clawhubToolchainSha256",
);
const clawhubToolchainVersion = requirePattern(
options.clawhubToolchainVersion,
TOOLCHAIN_VERSION_PATTERN,
"clawhubToolchainVersion",
);
const clawhubToolchainIntegrity = requirePattern(
options.clawhubToolchainIntegrity,
SHA512_INTEGRITY_PATTERN,
"clawhubToolchainIntegrity",
);
const expectedName = `clawhub-bootstrap-${targetSha.slice(0, 12)}-${runId}-${runAttempt}`;
if (artifactName !== expectedName) {
fail("ClawHub bootstrap artifact name does not bind the target and producer attempt.");
}
const outputRoot = resolve(requireString(options.outputRoot, "outputRoot"));
try {
await lstat(outputRoot);
fail("ClawHub bootstrap artifact output directory must not already exist.");
} catch (error) {
if (!error || typeof error !== "object" || error.code !== "ENOENT") {
throw error;
}
}
const result = await readPublicationArtifactArchive({
archivePolicy: {
minEntries: 2,
maxEntries: MAX_BOOTSTRAP_ARCHIVE_FILES,
maxArchiveBytes: MAX_BOOTSTRAP_ARCHIVE_BYTES,
maxExpandedBytes: MAX_BOOTSTRAP_ARCHIVE_BYTES,
allowPath: (path) =>
path === "manifest.json" ||
/^packages\/[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(path),
maxCompressedEntryBytes: (path) =>
path === "manifest.json" ? MAX_BOOTSTRAP_MANIFEST_BYTES : MAX_CLAWPACK_BYTES,
maxEntryBytes: (path) =>
path === "manifest.json" ? MAX_BOOTSTRAP_MANIFEST_BYTES : MAX_CLAWPACK_BYTES,
},
expected: {
artifactDigest: `sha256:${artifactDigest}`,
artifactId,
artifactName,
artifactSizeBytes,
consumerRunAttempt,
producerJobName,
repository,
runStatePolicy: "same-run-producer-success",
runAttempt,
runId,
workflowEvent: "workflow_dispatch",
workflowHeadBranch: "main",
workflowPath: ".github/workflows/plugin-clawhub-new.yml",
workflowSha,
},
maxArchiveBytes: MAX_BOOTSTRAP_ARCHIVE_BYTES,
fetchImpl: options.fetchImpl,
retryAttempts: options.retryAttempts,
retryDelayMs: options.retryDelayMs,
token: requireString(options.token, "token"),
});
const validated = validateBootstrapArchiveInventory(result.files, {
artifactName,
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
repository,
runAttempt,
runId,
targetSha,
workflowSha,
});
await mkdir(dirname(outputRoot), { mode: 0o700, recursive: true });
try {
await mkdir(outputRoot, { mode: 0o700 });
} catch (error) {
if (error && typeof error === "object" && error.code === "EEXIST") {
fail("ClawHub bootstrap artifact output directory must not already exist.");
}
throw error;
}
const outputRootStat = await lstat(outputRoot);
if (!outputRootStat.isDirectory() || outputRootStat.isSymbolicLink()) {
fail("ClawHub bootstrap artifact output directory must be newly created.");
}
for (const [path, bytes] of result.files) {
const destination = join(outputRoot, path);
await mkdir(dirname(destination), { mode: 0o700, recursive: true });
await writeFile(destination, bytes, { flag: "wx", mode: 0o600 });
}
return {
artifactDigest,
artifactId,
artifactName,
artifactSizeBytes,
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
inventory: describeActionsArtifactFiles(result.files),
packages: validated.entries,
runAttempt,
runId,
};
}
async function resolveRegularArtifactFile(root, artifactPath) {
if (
typeof artifactPath !== "string" ||
artifactPath.startsWith("/") ||
artifactPath.includes("\\") ||
artifactPath.split("/").some((part) => part === "" || part === "." || part === "..")
) {
fail(`Unsafe artifact path: ${String(artifactPath)}`);
}
const rootReal = await realpath(root);
const candidate = resolve(root, artifactPath);
const candidateReal = await realpath(candidate);
if (candidateReal !== rootReal && !candidateReal.startsWith(`${rootReal}${sep}`)) {
fail(`Artifact path escapes the artifact root: ${artifactPath}`);
}
const fileStat = await lstat(candidate);
if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
fail(`Artifact path is not a regular file: ${artifactPath}`);
}
return candidate;
}
function assertExactPackageSet(entries, expectedPlugins) {
const actual = entries.map((entry) => entry.packageName).toSorted(compareCodeUnits);
if (JSON.stringify(actual) !== JSON.stringify(expectedPlugins)) {
fail(
`Artifact package set does not match requested plugins: expected ${expectedPlugins.join(",")}, found ${actual.join(",")}.`,
);
}
}
export async function createClawHubBootstrapArtifactManifest(options) {
const artifactRoot = resolve(options.artifactRoot);
const matrix = JSON.parse(
readBoundedRegularFile(options.matrixPath, {
label: "ClawHub bootstrap matrix",
maxBytes: MAX_BOOTSTRAP_MANIFEST_BYTES,
}).toString("utf8"),
);
if (!Array.isArray(matrix) || matrix.length === 0 || matrix.length > MAX_BOOTSTRAP_PACKAGES) {
fail("matrix must be a non-empty array.");
}
const entries = matrix.map(normalizePlanEntry);
const expectedPlugins = parsePlugins(options.plugins);
if (new Set(entries.map((entry) => entry.packageName)).size !== entries.length) {
fail("matrix must not contain duplicate package names.");
}
assertExactPackageSet(entries, expectedPlugins);
const manifestEntries = [];
for (const entry of entries.toSorted((a, b) => compareCodeUnits(a.packageName, b.packageName))) {
const packageDirectory = join(artifactRoot, "packages", packageSlug(entry.packageName));
const files = (await readdir(packageDirectory)).filter((name) => name.endsWith(".tgz"));
if (files.length !== 1) {
fail(`${entry.packageName} must have exactly one packed .tgz artifact.`);
}
const artifactPath = `packages/${packageSlug(entry.packageName)}/${files[0]}`;
const filePath = await resolveRegularArtifactFile(artifactRoot, artifactPath);
const identity = await hashFile(filePath);
manifestEntries.push({ ...entry, artifactPath, ...identity });
}
const manifest = {
schemaVersion: 1,
repository: requirePattern(options.repository, REPOSITORY_PATTERN, "repository"),
targetSha: requirePattern(options.targetSha, COMMIT_PATTERN, "targetSha"),
workflowSha: requirePattern(options.workflowSha, COMMIT_PATTERN, "workflowSha"),
runId: requirePattern(options.runId, POSITIVE_INTEGER_PATTERN, "runId"),
runAttempt: requirePattern(options.runAttempt, POSITIVE_INTEGER_PATTERN, "runAttempt"),
artifactName: requireString(options.artifactName, "artifactName"),
clawhubToolchainIntegrity: requirePattern(
options.clawhubToolchainIntegrity,
SHA512_INTEGRITY_PATTERN,
"clawhubToolchainIntegrity",
),
clawhubToolchainSha256: requirePattern(
options.clawhubToolchainSha256,
SHA256_PATTERN,
"clawhubToolchainSha256",
),
clawhubToolchainVersion: requirePattern(
options.clawhubToolchainVersion,
TOOLCHAIN_VERSION_PATTERN,
"clawhubToolchainVersion",
),
requestedPlugins: expectedPlugins,
entries: manifestEntries,
};
await mkdir(dirname(options.outputPath), { recursive: true });
await writeFile(options.outputPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
return manifest;
}
export async function verifyClawHubBootstrapArtifactManifest(options) {
const artifactRoot = resolve(options.artifactRoot);
const manifest = readClawHubBootstrapManifest(options.manifestPath);
const expected = {
repository: requirePattern(options.repository, REPOSITORY_PATTERN, "repository"),
targetSha: requirePattern(options.targetSha, COMMIT_PATTERN, "targetSha"),
workflowSha: requirePattern(options.workflowSha, COMMIT_PATTERN, "workflowSha"),
runId: requirePattern(options.runId, POSITIVE_INTEGER_PATTERN, "runId"),
runAttempt: requirePattern(options.runAttempt, POSITIVE_INTEGER_PATTERN, "runAttempt"),
artifactName: requireString(options.artifactName, "artifactName"),
clawhubToolchainIntegrity: requirePattern(
options.clawhubToolchainIntegrity,
SHA512_INTEGRITY_PATTERN,
"clawhubToolchainIntegrity",
),
clawhubToolchainSha256: requirePattern(
options.clawhubToolchainSha256,
SHA256_PATTERN,
"clawhubToolchainSha256",
),
clawhubToolchainVersion: requirePattern(
options.clawhubToolchainVersion,
TOOLCHAIN_VERSION_PATTERN,
"clawhubToolchainVersion",
),
};
for (const [key, value] of Object.entries(expected)) {
if (manifest[key] !== value) {
fail(`Bootstrap artifact manifest ${key} mismatch.`);
}
}
const expectedPlugins = parsePlugins(options.plugins);
if (!Array.isArray(manifest.requestedPlugins)) {
fail("Bootstrap artifact manifest requestedPlugins must be an array.");
}
if (JSON.stringify(manifest.requestedPlugins) !== JSON.stringify(expectedPlugins)) {
fail("Bootstrap artifact manifest requestedPlugins mismatch.");
}
if (
!Array.isArray(manifest.entries) ||
manifest.entries.length === 0 ||
manifest.entries.length > MAX_BOOTSTRAP_PACKAGES
) {
fail("Bootstrap artifact manifest entries must be a non-empty array.");
}
const entries = [];
const allowedFiles = new Set([relative(artifactRoot, options.manifestPath).split(sep).join("/")]);
for (const [index, rawEntry] of manifest.entries.entries()) {
const entry = normalizeBootstrapManifestEntry(rawEntry, index);
const { artifactPath } = entry;
const filePath = await resolveRegularArtifactFile(artifactRoot, artifactPath);
const identity = await hashFile(filePath);
if (identity.sha256 !== entry.sha256 || identity.size !== entry.size) {
fail(`${entry.packageName} packed artifact hash or size mismatch.`);
}
allowedFiles.add(artifactPath);
entries.push({ ...entry, artifactPath, ...identity });
}
if (new Set(entries.map((entry) => entry.packageName)).size !== entries.length) {
fail("Bootstrap artifact manifest must not contain duplicate package names.");
}
assertExactPackageSet(entries, expectedPlugins);
const inventory = await listFiles(artifactRoot);
const expectedInventory = [...allowedFiles].toSorted(compareCodeUnits);
if (JSON.stringify(inventory) !== JSON.stringify(expectedInventory)) {
fail(
`Bootstrap artifact inventory mismatch: expected ${expectedInventory.join(",")}, found ${inventory.join(",")}.`,
);
}
return { ...manifest, entries };
}
function parseArgs(argv) {
const values = [...argv];
const command = values.shift();
const result = { command };
while (values.length > 0) {
const key = values.shift();
const value = values.shift();
if (!key?.startsWith("--") || value === undefined) {
fail(`Invalid argument: ${String(key)}`);
}
result[key.slice(2).replaceAll("-", "_")] = value;
}
return result;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.command === "download") {
const result = await downloadClawHubBootstrapArtifact({
artifactDigest: args.artifact_digest,
artifactId: args.artifact_id,
artifactName: args.artifact_name,
artifactSize: args.artifact_size,
consumerRunAttempt: args.consumer_run_attempt,
clawhubToolchainIntegrity: args.clawhub_toolchain_integrity,
clawhubToolchainSha256: args.clawhub_toolchain_sha256,
clawhubToolchainVersion: args.clawhub_toolchain_version,
outputRoot: args.output_root,
producerJobName: args.producer_job_name,
repository: args.repository,
runAttempt: args.run_attempt,
runId: args.run_id,
targetSha: args.target_sha,
token: process.env.GH_TOKEN,
workflowSha: args.workflow_sha,
});
process.stdout.write(`${JSON.stringify(result)}\n`);
return;
}
if (args.command === "verify-packed") {
const identity = await verifyClawHubPackedArtifactIdentity({
artifactPath: args.path,
expectedDir: args.expected_dir,
expectedSha256: args.expected_sha256,
expectedSize: args.expected_size,
expectedName: args.expected_name,
expectedVersion: args.expected_version,
});
process.stdout.write(`${JSON.stringify(identity)}\n`);
return;
}
const common = {
artifactRoot: args.artifact_root,
artifactName: args.artifact_name,
clawhubToolchainIntegrity: args.clawhub_toolchain_integrity,
clawhubToolchainSha256: args.clawhub_toolchain_sha256,
clawhubToolchainVersion: args.clawhub_toolchain_version,
repository: args.repository,
targetSha: args.target_sha,
workflowSha: args.workflow_sha,
runId: args.run_id,
runAttempt: args.run_attempt,
plugins: args.plugins,
};
if (args.command === "create") {
await createClawHubBootstrapArtifactManifest({
...common,
matrixPath: args.matrix,
outputPath: args.output,
});
return;
}
if (args.command === "verify") {
const manifest = await verifyClawHubBootstrapArtifactManifest({
...common,
manifestPath: args.manifest,
});
if (args.output) {
await mkdir(dirname(args.output), { recursive: true });
await writeFile(args.output, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
} else {
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
}
return;
}
fail("Usage: clawhub-bootstrap-artifact.mjs <create|download|verify|verify-packed> [options]");
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main();
}
+62 -1
View File
@@ -23,14 +23,18 @@ type ClawHubDispatchTarget = {
};
type OpenClawReleaseClawHubPlanArgs = {
bootstrapWorkflowSha: string;
releaseTag: string;
releaseSha: string;
releasePublishBranch: string;
releasePublishRunAttempt: string;
releasePublishRunId: string;
pluginPublishScope: PluginReleaseSelectionMode;
plugins: string[];
};
type OpenClawReleaseClawHubPlan = {
bootstrapWorkflowSha: string;
clawHubWorkflowRef: string;
releasePublishBranch: string;
normal: ClawHubDispatchTarget;
@@ -86,6 +90,22 @@ function optionalArg(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined;
}
function requireCommitSha(value: string | undefined, label: string): string {
const sha = requireArg(value, label);
if (!/^[a-f0-9]{40}$/u.test(sha)) {
throw new Error(`${label} must be a full 40-character lowercase commit SHA.`);
}
return sha;
}
function requirePositiveInteger(value: string | undefined, label: string): string {
const result = requireArg(value, label);
if (!/^[1-9][0-9]*$/u.test(result)) {
throw new Error(`${label} must be a positive integer.`);
}
return result;
}
function runUrl(repository: string, runId: string): string {
return `https://github.com/${repository}/actions/runs/${runId}`;
}
@@ -110,6 +130,10 @@ function createDispatchTarget(params: {
releasePublishRunId: string;
releasePublishBranch: string;
includePublishScope: boolean;
bootstrapWorkflowSha?: string;
releaseTag?: string;
releasePublishRunAttempt?: string;
targetRef?: string;
}): ClawHubDispatchTarget {
if (params.packages.length === 0) {
return {
@@ -129,6 +153,14 @@ function createDispatchTarget(params: {
packages: [...params.packages],
inputs: {
...(params.includePublishScope ? { publish_scope: "selected" } : {}),
...(params.targetRef ? { ref: params.targetRef } : {}),
...(params.bootstrapWorkflowSha
? { bootstrap_workflow_sha: params.bootstrapWorkflowSha }
: {}),
...(params.releaseTag ? { release_tag: params.releaseTag } : {}),
...(params.releasePublishRunAttempt
? { release_publish_run_attempt: params.releasePublishRunAttempt }
: {}),
plugins,
release_publish_run_id: params.releasePublishRunId,
release_publish_branch: params.releasePublishBranch,
@@ -194,7 +226,10 @@ export function parseOpenClawReleaseClawHubPlanArgs(
}
let releaseTag: string | undefined;
let releaseSha: string | undefined;
let bootstrapWorkflowSha: string | undefined;
let releasePublishBranch: string | undefined;
let releasePublishRunAttempt: string | undefined;
let releasePublishRunId: string | undefined;
let pluginPublishScope: PluginReleaseSelectionMode | undefined;
let plugins: string[] = [];
@@ -212,12 +247,21 @@ export function parseOpenClawReleaseClawHubPlanArgs(
};
switch (arg) {
case "--bootstrap-workflow-sha":
bootstrapWorkflowSha = next();
break;
case "--release-tag":
releaseTag = next();
break;
case "--release-sha":
releaseSha = next();
break;
case "--release-publish-branch":
releasePublishBranch = next();
break;
case "--release-publish-run-attempt":
releasePublishRunAttempt = next();
break;
case "--release-publish-run-id":
releasePublishRunId = next();
break;
@@ -245,8 +289,14 @@ export function parseOpenClawReleaseClawHubPlanArgs(
}
return {
bootstrapWorkflowSha: requireCommitSha(bootstrapWorkflowSha, "--bootstrap-workflow-sha"),
releaseTag: requireArg(releaseTag, "--release-tag"),
releaseSha: requireCommitSha(releaseSha, "--release-sha"),
releasePublishBranch: requireArg(releasePublishBranch, "--release-publish-branch"),
releasePublishRunAttempt: requirePositiveInteger(
releasePublishRunAttempt,
"--release-publish-run-attempt",
),
releasePublishRunId: requireArg(releasePublishRunId, "--release-publish-run-id"),
pluginPublishScope: resolvedPluginPublishScope,
plugins,
@@ -261,8 +311,14 @@ export async function buildOpenClawReleaseClawHubPlan(
registryBaseUrl?: string;
} = {},
): Promise<OpenClawReleaseClawHubPlan> {
const bootstrapWorkflowSha = requireCommitSha(args.bootstrapWorkflowSha, "bootstrapWorkflowSha");
const releaseTag = requireArg(args.releaseTag, "releaseTag");
const releaseSha = requireCommitSha(args.releaseSha, "releaseSha");
const releasePublishBranch = requireArg(args.releasePublishBranch, "releasePublishBranch");
const releasePublishRunAttempt = requirePositiveInteger(
args.releasePublishRunAttempt,
"releasePublishRunAttempt",
);
const releasePublishRunId = requireArg(args.releasePublishRunId, "releasePublishRunId");
const plan = await collectPluginClawHubReleasePlan({
rootDir: options.rootDir ?? resolve("."),
@@ -281,6 +337,7 @@ export async function buildOpenClawReleaseClawHubPlan(
assertNoPackageOverlap(normalPackages, bootstrapPackages);
return {
bootstrapWorkflowSha,
clawHubWorkflowRef: releaseTag,
releasePublishBranch,
normal: createDispatchTarget({
@@ -293,11 +350,15 @@ export async function buildOpenClawReleaseClawHubPlan(
}),
bootstrap: createDispatchTarget({
workflow: "plugin-clawhub-new.yml",
ref: releaseTag,
ref: "main",
packages: bootstrapPackages,
releasePublishRunId,
releasePublishBranch,
includePublishScope: false,
bootstrapWorkflowSha,
releaseTag,
releasePublishRunAttempt,
targetRef: releaseSha,
}),
summary: {
normalCount: normalPackages.length,
+5 -2
View File
@@ -5,8 +5,11 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js";
import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts";
import { parseReleaseVersion } from "../openclaw-npm-release-check.ts";
import { collectReleaseVersionFloorErrors, resolveNpmPublishPlan } from "./npm-publish-plan.mjs";
import {
collectReleaseVersionFloorErrors,
parseReleaseVersion,
resolveNpmPublishPlan,
} from "./npm-publish-plan.mjs";
export type PluginPackageJson = {
name?: string;
+716 -18
View File
@@ -1,7 +1,10 @@
// Release Beta Verifier script supports OpenClaw repository automation.
import { execFileSync } from "node:child_process";
// Release Beta Verifier script supports OpenClaw repository automation.
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { readPublicationArtifactArchive, sha256Digest } from "./actions-artifact-archive.mjs";
import { readBoundedResponseText } from "./bounded-response.ts";
import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts";
import {
@@ -17,9 +20,11 @@ type ReleaseVerifyBetaArgs = {
distTag: string;
repo: string;
registry: string;
releaseSha?: string;
workflowRef?: string;
clawHubWorkflowRef?: string;
pluginSelection: string[];
clawHubBootstrapPlugins: string[];
evidenceOut?: string;
skipPostpublish: boolean;
skipGitHubRelease: boolean;
@@ -52,17 +57,44 @@ type WorkflowRunSummary = {
label: string;
url?: string;
durationSeconds?: number;
bootstrapEvidence?: {
targetSha: string;
workflowSha: string;
workflowPath: string;
producerRunAttempt: string;
terminalRunAttempt: string;
readbackArtifactId: string;
readbackArtifactDigest: string;
packageArtifactId: string;
packageArtifactDigest: string;
packageCount: number;
clawhubToolchainIntegrity: string;
clawhubToolchainSha256: string;
clawhubToolchainVersion: string;
};
};
const DEFAULT_REPO = "openclaw/openclaw";
const DEFAULT_CLAWHUB_REGISTRY = "https://clawhub.ai";
const CLAWHUB_BOOTSTRAP_WORKFLOW_PATH = ".github/workflows/plugin-clawhub-new.yml";
const CLAWHUB_BOOTSTRAP_READBACK_FILE = "clawhub-bootstrap-readback.json";
const CLAWHUB_REQUEST_TIMEOUT_MS = 20_000;
const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 1024 * 1024;
const CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES = 2 * 1024 * 1024;
const COMMIT_SHA_PATTERN = /^[a-f0-9]{40}$/u;
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
const SHA512_INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]+={0,2}$/u;
const TRUSTED_TOOLING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
// Trusted publish can finish before npm registry metadata converges. Keep the
// verifier on the same release train instead of forcing a republish/correction.
const NPM_VIEW_ATTEMPTS = 30;
const NPM_VIEW_RETRY_MAX_DELAY_MS = 10_000;
function compareCodeUnits(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -79,6 +111,35 @@ function requireString(value: unknown, label: string): string {
return stringValue;
}
function readTrustedClawHubToolchainIdentity(): {
clawhubToolchainIntegrity: string;
clawhubToolchainSha256: string;
clawhubToolchainVersion: string;
} {
const lockPath = resolve(TRUSTED_TOOLING_ROOT, ".github/release/clawhub-cli/package-lock.json");
const lockBytes = readFileSync(lockPath);
const lock = parseJson(lockBytes.toString("utf8"), "trusted ClawHub CLI package-lock.json");
if (!isRecord(lock) || !isRecord(lock.packages)) {
throw new Error("Trusted ClawHub CLI package-lock.json is invalid.");
}
const clawhub = lock.packages["node_modules/clawhub"];
if (!isRecord(clawhub)) {
throw new Error("Trusted ClawHub CLI package-lock.json is missing clawhub.");
}
const clawhubToolchainIntegrity = requireString(
clawhub.integrity,
"trusted ClawHub CLI integrity",
);
if (!SHA512_INTEGRITY_PATTERN.test(clawhubToolchainIntegrity)) {
throw new Error("Trusted ClawHub CLI integrity is invalid.");
}
return {
clawhubToolchainIntegrity,
clawhubToolchainSha256: createHash("sha256").update(lockBytes).digest("hex"),
clawhubToolchainVersion: requireString(clawhub.version, "trusted ClawHub CLI version"),
};
}
function runCommand(command: string, args: string[], options: { cwd?: string } = {}): string {
return execFileSync(command, args, {
cwd: options.cwd,
@@ -165,7 +226,7 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
const version = values.shift();
if (!version || version.startsWith("-")) {
throw new Error(
"Usage: pnpm release:verify-beta -- <version> [--workflow-ref REF] [--clawhub-workflow-ref REF] [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--plugin-clawhub-bootstrap-run ID] [--npm-telegram-run ID] [--skip-github-release] [--skip-clawhub]",
"Usage: pnpm release:verify-beta -- <version> [--release-sha SHA] [--workflow-ref REF] [--clawhub-workflow-ref REF] [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--plugin-clawhub-bootstrap-run ID --clawhub-bootstrap-plugins NAMES] [--npm-telegram-run ID] [--skip-github-release] [--skip-clawhub]",
);
}
@@ -175,9 +236,11 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
distTag: "beta",
repo: DEFAULT_REPO,
registry: DEFAULT_CLAWHUB_REGISTRY,
releaseSha: undefined,
workflowRef: undefined,
clawHubWorkflowRef: undefined,
pluginSelection: [],
clawHubBootstrapPlugins: [],
evidenceOut: undefined,
skipPostpublish: false,
skipGitHubRelease: false,
@@ -210,6 +273,12 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
case "--registry":
parsed.registry = next();
break;
case "--release-sha":
parsed.releaseSha = next();
if (!COMMIT_SHA_PATTERN.test(parsed.releaseSha)) {
throw new Error("--release-sha must be a full 40-character lowercase commit SHA.");
}
break;
case "--workflow-ref":
parsed.workflowRef = next();
break;
@@ -222,6 +291,12 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
throw new Error("--plugins requires at least one plugin package name.");
}
break;
case "--clawhub-bootstrap-plugins":
parsed.clawHubBootstrapPlugins = parsePluginReleaseSelection(next());
if (parsed.clawHubBootstrapPlugins.length === 0) {
throw new Error("--clawhub-bootstrap-plugins requires at least one package name.");
}
break;
case "--evidence-out":
parsed.evidenceOut = next();
break;
@@ -260,6 +335,17 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
}
}
if (parsed.workflowRuns.pluginClawHubBootstrap !== undefined) {
if (parsed.releaseSha === undefined) {
throw new Error("--plugin-clawhub-bootstrap-run requires --release-sha.");
}
if (parsed.clawHubBootstrapPlugins.length === 0) {
throw new Error("--plugin-clawhub-bootstrap-run requires --clawhub-bootstrap-plugins.");
}
} else if (parsed.clawHubBootstrapPlugins.length > 0) {
throw new Error("--clawhub-bootstrap-plugins requires --plugin-clawhub-bootstrap-run.");
}
return parsed;
}
@@ -298,16 +384,66 @@ async function cancelResponseBody(response: Response): Promise<void> {
await response.body?.cancel().catch(() => undefined);
}
async function fetchJsonWithRetry(url: string): Promise<unknown> {
const { response, signal } = await fetchWithRetry(
url,
{ headers: { accept: "application/json" } },
5,
);
if (!response.ok) {
throw new Error(`${url} returned HTTP ${response.status}.`);
export async function fetchJsonWithRetry(
url: string,
options: {
attempts?: number;
delay?: (delayMs: number) => Promise<void>;
fetchImpl?: typeof fetch;
timeoutMs?: number;
} = {},
): Promise<unknown> {
const attempts = options.attempts ?? 5;
const delay =
options.delay ??
((delayMs: number) =>
new Promise((resolveDelay) => {
setTimeout(resolveDelay, delayMs);
}));
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? CLAWHUB_REQUEST_TIMEOUT_MS;
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
let response: Response | undefined;
let attemptError: unknown;
try {
const signal = AbortSignal.timeout(timeoutMs);
response = await fetchImpl(url, {
headers: { accept: "application/json" },
signal,
});
if (response.status !== 429 && response.status < 500) {
if (!response.ok) {
await cancelResponseBody(response);
throw new Error(`${url} returned HTTP ${response.status}.`);
}
return await readBoundedJsonResponse(response, url, undefined, { signal });
}
attemptError = new Error(`HTTP ${response.status}`);
lastError = attemptError;
} catch (error) {
if (
response !== undefined &&
response.status !== 429 &&
response.status < 500 &&
!response.ok
) {
throw error;
}
attemptError = error;
lastError = error;
} finally {
if (response !== undefined && attemptError !== undefined) {
await cancelResponseBody(response);
}
}
if (attempt < attempts) {
await delay(attempt * 1000);
}
}
return await readBoundedJsonResponse(response, url, undefined, { signal });
const message = lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(`${url} did not return stable JSON: ${message}`);
}
export async function readBoundedJsonResponse(
@@ -514,6 +650,562 @@ function verifyWorkflowRun(params: {
};
}
function requirePositiveIntegerString(value: unknown, label: string): string {
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
return String(value);
}
const stringValue = readString(value);
if (stringValue === undefined || !POSITIVE_INTEGER_PATTERN.test(stringValue)) {
throw new Error(`${label} must be a positive integer.`);
}
return stringValue;
}
function requirePositiveSafeInteger(value: unknown, label: string): number {
const stringValue = requirePositiveIntegerString(value, label);
const numberValue = Number(stringValue);
if (!Number.isSafeInteger(numberValue)) {
throw new Error(`${label} must be a positive safe integer.`);
}
return numberValue;
}
function requireCommitSha(value: unknown, label: string): string {
const sha = requireString(value, label);
if (!COMMIT_SHA_PATTERN.test(sha)) {
throw new Error(`${label} must be a full 40-character lowercase commit SHA.`);
}
return sha;
}
function requireSha256(value: unknown, label: string): string {
const sha = requireString(value, label);
if (!SHA256_PATTERN.test(sha)) {
throw new Error(`${label} must be a lowercase SHA-256 digest.`);
}
return sha;
}
function requireArtifactDigest(value: unknown, label: string): string {
const digest = requireString(value, label);
const match = /^sha256:([a-f0-9]{64})$/u.exec(digest);
if (!match?.[1]) {
throw new Error(`${label} must be a sha256 artifact digest.`);
}
return match[1];
}
function requireStringArray(value: unknown, label: string): string[] {
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
throw new Error(`${label} must be a string array.`);
}
return value
.map((entry) => entry.trim())
.filter(Boolean)
.toSorted(compareCodeUnits);
}
function requireArtifactWorkflowRun(
artifact: JsonRecord,
params: { label: string; runId: string; headSha: string },
): void {
if (artifact.expired !== false) {
throw new Error(`${params.label} is expired or missing its immutable state.`);
}
const workflowRun = artifact.workflow_run;
if (!isRecord(workflowRun)) {
throw new Error(`${params.label} is missing workflow_run metadata.`);
}
if (
requirePositiveIntegerString(workflowRun.id, `${params.label} workflow run id`) !== params.runId
) {
throw new Error(`${params.label} belongs to a different workflow run.`);
}
if (
requireCommitSha(workflowRun.head_sha, `${params.label} workflow head SHA`) !== params.headSha
) {
throw new Error(`${params.label} belongs to a different workflow head SHA.`);
}
if (requireString(workflowRun.head_branch, `${params.label} workflow head branch`) !== "main") {
throw new Error(`${params.label} was not produced by trusted main.`);
}
}
function requireClawHubBootstrapRunBinding(
run: unknown,
expectedRunId: string,
): {
headSha: string;
run: JsonRecord;
runAttempt: number;
runId: number;
terminalRunAttempt: string;
workflowPath: string;
} {
if (!isRecord(run)) {
throw new Error("Plugin ClawHub New run metadata is invalid.");
}
const runId = requirePositiveSafeInteger(run.id, "Plugin ClawHub New run id");
if (String(runId) !== expectedRunId) {
throw new Error(`Plugin ClawHub New run id is ${runId}, expected ${expectedRunId}.`);
}
if (run.name !== "Plugin ClawHub New") {
throw new Error("Plugin ClawHub New run has an unexpected workflow name.");
}
if (run.event !== "workflow_dispatch") {
throw new Error("Plugin ClawHub New run was not workflow_dispatch.");
}
if (run.head_branch !== "main") {
throw new Error("Plugin ClawHub New run was not dispatched from trusted main.");
}
const headSha = requireCommitSha(run.head_sha, "Plugin ClawHub New head SHA");
const runAttempt = requirePositiveSafeInteger(run.run_attempt, "Plugin ClawHub New run attempt");
const workflowPath = requireString(run.path, "Plugin ClawHub New workflow path").replace(
/@.*$/u,
"",
);
if (workflowPath !== CLAWHUB_BOOTSTRAP_WORKFLOW_PATH) {
throw new Error("Plugin ClawHub New run has an unexpected workflow path.");
}
if (run.status !== "completed" || run.conclusion !== "success") {
throw new Error("Plugin ClawHub New run is not completed/success.");
}
return {
headSha,
run,
runAttempt,
runId,
terminalRunAttempt: String(runAttempt),
workflowPath,
};
}
function requireClawHubReadbackArtifactBinding(
artifact: unknown,
run: ReturnType<typeof requireClawHubBootstrapRunBinding>,
): {
artifactDigest: string;
artifactId: number;
artifactName: string;
artifactSizeBytes: number;
} {
if (!isRecord(artifact)) {
throw new Error("Plugin ClawHub New readback artifact metadata is invalid.");
}
const artifactName = `clawhub-bootstrap-readback-${run.runId}-${run.terminalRunAttempt}`;
if (artifact.name !== artifactName) {
throw new Error("Plugin ClawHub New readback artifact name does not bind the run attempt.");
}
requireArtifactWorkflowRun(artifact, {
label: "Plugin ClawHub New readback artifact",
runId: String(run.runId),
headSha: run.headSha,
});
return {
artifactDigest: `sha256:${requireArtifactDigest(
artifact.digest,
"Plugin ClawHub New readback artifact digest",
)}`,
artifactId: requirePositiveSafeInteger(artifact.id, "Plugin ClawHub New readback artifact id"),
artifactName,
artifactSizeBytes: requirePositiveSafeInteger(
artifact.size_in_bytes,
"Plugin ClawHub New readback artifact size",
),
};
}
function validateBootstrapPackageEvidence(
value: unknown,
params: { packageName: string; version: string },
): void {
if (!isRecord(value)) {
throw new Error(`${params.packageName} bootstrap evidence is invalid.`);
}
if (
requireString(value.packageName, `${params.packageName} package name`) !== params.packageName
) {
throw new Error(`${params.packageName} bootstrap evidence package mismatch.`);
}
if (requireString(value.version, `${params.packageName} version`) !== params.version) {
throw new Error(`${params.packageName} bootstrap evidence version mismatch.`);
}
const expectedSha256 = requireSha256(
value.expectedSha256,
`${params.packageName} expected sha256`,
);
if (
requireSha256(value.registrySha256, `${params.packageName} registry sha256`) !== expectedSha256
) {
throw new Error(
`${params.packageName} registry artifact digest differs from the packed artifact.`,
);
}
const expectedSize = value.expectedSize;
const registrySize = value.registrySize;
if (!Number.isSafeInteger(expectedSize) || expectedSize <= 0 || registrySize !== expectedSize) {
throw new Error(
`${params.packageName} registry artifact size differs from the packed artifact.`,
);
}
const npmIntegrity = requireString(value.npmIntegrity, `${params.packageName} npm integrity`);
const npmShasum = requireString(value.npmShasum, `${params.packageName} npm shasum`);
const metadata = value.artifactMetadata;
if (!isRecord(metadata)) {
throw new Error(`${params.packageName} artifact metadata evidence is invalid.`);
}
if (metadata.kind !== "npm-pack") {
throw new Error(`${params.packageName} artifact metadata is not npm-pack.`);
}
if (
requireString(metadata.packageName, `${params.packageName} metadata package name`) !==
params.packageName ||
requireString(metadata.version, `${params.packageName} metadata version`) !== params.version
) {
throw new Error(`${params.packageName} artifact metadata identity mismatch.`);
}
if (
requireSha256(metadata.sha256, `${params.packageName} metadata sha256`) !== expectedSha256 ||
metadata.size !== expectedSize ||
metadata.npmIntegrity !== npmIntegrity ||
metadata.npmShasum !== npmShasum
) {
throw new Error(`${params.packageName} artifact metadata does not match downloaded bytes.`);
}
}
export function validateClawHubBootstrapEvidence(params: {
repo: string;
runId: string;
releaseSha: string;
expectedVersion: string;
expectedPackages: string[];
run: unknown;
readbackArtifact: unknown;
readbackArchiveSha256: string;
packageArtifact: unknown;
evidence: unknown;
}): WorkflowRunSummary {
const runBinding = requireClawHubBootstrapRunBinding(params.run, params.runId);
const { headSha, terminalRunAttempt, workflowPath } = runBinding;
const runId = String(runBinding.runId);
const readbackBinding = requireClawHubReadbackArtifactBinding(
params.readbackArtifact,
runBinding,
);
const readbackArtifactId = String(readbackBinding.artifactId);
const readbackArtifactDigest = requireArtifactDigest(
readbackBinding.artifactDigest,
"Plugin ClawHub New readback artifact digest",
);
if (
requireSha256(params.readbackArchiveSha256, "Downloaded readback artifact sha256") !==
readbackArtifactDigest
) {
throw new Error("Downloaded Plugin ClawHub New readback artifact digest mismatch.");
}
if (!isRecord(params.evidence)) {
throw new Error("Plugin ClawHub New readback evidence is invalid.");
}
if (params.evidence.schemaVersion !== 2 || params.evidence.verificationMode !== "postpublish") {
throw new Error("Plugin ClawHub New readback evidence schema or mode is invalid.");
}
if (params.evidence.repository !== params.repo) {
throw new Error("Plugin ClawHub New readback evidence repository mismatch.");
}
if (
requireCommitSha(params.evidence.targetSha, "Plugin ClawHub New target SHA") !==
params.releaseSha
) {
throw new Error("Plugin ClawHub New readback evidence target SHA mismatch.");
}
if (
requireCommitSha(params.evidence.workflowSha, "Plugin ClawHub New workflow SHA") !== headSha
) {
throw new Error("Plugin ClawHub New readback evidence workflow SHA mismatch.");
}
const evidenceRunId = requirePositiveIntegerString(
params.evidence.runId,
"Plugin ClawHub New evidence run id",
);
const producerRunAttempt = requirePositiveIntegerString(
params.evidence.producerRunAttempt,
"Plugin ClawHub New evidence producer run attempt",
);
const evidenceTerminalRunAttempt = requirePositiveIntegerString(
params.evidence.terminalRunAttempt,
"Plugin ClawHub New evidence terminal run attempt",
);
if (evidenceRunId !== runId || evidenceTerminalRunAttempt !== terminalRunAttempt) {
throw new Error("Plugin ClawHub New readback evidence run tuple mismatch.");
}
if (BigInt(producerRunAttempt) > BigInt(terminalRunAttempt)) {
throw new Error("Plugin ClawHub New producer attempt is newer than its terminal attempt.");
}
const expectedToolchain = readTrustedClawHubToolchainIdentity();
for (const [key, expected] of Object.entries(expectedToolchain)) {
if (params.evidence[key] !== expected) {
throw new Error(`Plugin ClawHub New ${key} mismatch.`);
}
}
const expectedPackages = [...new Set(params.expectedPackages)].toSorted(compareCodeUnits);
if (expectedPackages.length === 0) {
throw new Error("Plugin ClawHub New expected package set is empty.");
}
if (
JSON.stringify(
requireStringArray(params.evidence.requestedPlugins, "requested bootstrap plugins"),
) !== JSON.stringify(expectedPackages)
) {
throw new Error("Plugin ClawHub New requested package set mismatch.");
}
if (!Array.isArray(params.evidence.packages)) {
throw new Error("Plugin ClawHub New package evidence is invalid.");
}
const evidencePackages = params.evidence.packages
.map((entry) =>
isRecord(entry) ? requireString(entry.packageName, "bootstrap package name") : "",
)
.toSorted(compareCodeUnits);
if (JSON.stringify(evidencePackages) !== JSON.stringify(expectedPackages)) {
throw new Error("Plugin ClawHub New terminal package set mismatch.");
}
for (const packageName of expectedPackages) {
validateBootstrapPackageEvidence(
params.evidence.packages.find(
(entry) => isRecord(entry) && entry.packageName === packageName,
),
{ packageName, version: params.expectedVersion },
);
}
if (!isRecord(params.packageArtifact)) {
throw new Error("Plugin ClawHub New package artifact metadata is invalid.");
}
const packageArtifactId = requirePositiveIntegerString(
params.packageArtifact.id,
"Plugin ClawHub New package artifact id",
);
if (
packageArtifactId !==
requirePositiveIntegerString(
params.evidence.artifactId,
"Plugin ClawHub New evidence package artifact id",
)
) {
throw new Error("Plugin ClawHub New package artifact id mismatch.");
}
if (
params.packageArtifact.name !==
requireString(params.evidence.artifactName, "Plugin ClawHub New package artifact name")
) {
throw new Error("Plugin ClawHub New package artifact name mismatch.");
}
requireArtifactWorkflowRun(params.packageArtifact, {
label: "Plugin ClawHub New package artifact",
runId,
headSha,
});
const packageArtifactDigest = requireArtifactDigest(
params.packageArtifact.digest,
"Plugin ClawHub New package artifact digest",
);
if (
packageArtifactDigest !==
requireSha256(
params.evidence.artifactDigest,
"Plugin ClawHub New evidence package artifact digest",
)
) {
throw new Error("Plugin ClawHub New package artifact digest mismatch.");
}
const expectedPackageArtifactName = `clawhub-bootstrap-${params.releaseSha.slice(0, 12)}-${runId}-${producerRunAttempt}`;
if (params.packageArtifact.name !== expectedPackageArtifactName) {
throw new Error(
"Plugin ClawHub New package artifact name does not bind the target and attempt.",
);
}
const createdAt = readString(runBinding.run.created_at);
const updatedAt = readString(runBinding.run.updated_at);
const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt);
const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt);
return {
id: runId,
label: "Plugin ClawHub New",
url: readString(runBinding.run.html_url),
durationSeconds:
Number.isFinite(createdMs) && Number.isFinite(updatedMs)
? Math.max(0, Math.round((updatedMs - createdMs) / 1000))
: undefined,
bootstrapEvidence: {
targetSha: params.releaseSha,
workflowSha: headSha,
workflowPath,
producerRunAttempt,
terminalRunAttempt,
readbackArtifactId,
readbackArtifactDigest,
packageArtifactId,
packageArtifactDigest,
packageCount: expectedPackages.length,
...expectedToolchain,
},
};
}
function readGitHubApiJson(repo: string, endpoint: string, label: string): unknown {
return parseJson(runCommand("gh", ["api", `repos/${repo}/${endpoint}`]), label);
}
function readGitHubToken(): string {
return requireString(
process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? runCommand("gh", ["auth", "token"]),
"GitHub token",
);
}
function decodeUtf8Exact(bytes: Uint8Array, label: string): string {
let value: string;
try {
value = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (error) {
throw new Error(`${label} is not valid UTF-8.`, { cause: error });
}
if (!Buffer.from(value, "utf8").equals(bytes)) {
throw new Error(`${label} is not canonically encoded UTF-8.`);
}
return value;
}
export async function downloadClawHubBootstrapReadback(params: {
repo: string;
runId: string;
run: unknown;
readbackArtifact: unknown;
token: string;
fetchImpl?: typeof fetch;
retryAttempts?: number;
retryDelayMs?: number;
timeoutMs?: number;
}): Promise<{ value: unknown; archiveSha256: string }> {
const runBinding = requireClawHubBootstrapRunBinding(params.run, params.runId);
const artifactBinding = requireClawHubReadbackArtifactBinding(
params.readbackArtifact,
runBinding,
);
const downloaded = await readPublicationArtifactArchive({
archivePolicy: {
expectedEntries: [CLAWHUB_BOOTSTRAP_READBACK_FILE],
maxArchiveBytes: CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES,
maxExpandedBytes: CLAWHUB_RESPONSE_BODY_MAX_BYTES,
maxCompressedEntryBytes: () => CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES,
maxEntryBytes: () => CLAWHUB_RESPONSE_BODY_MAX_BYTES,
},
expected: {
artifactDigest: artifactBinding.artifactDigest,
artifactId: artifactBinding.artifactId,
artifactName: artifactBinding.artifactName,
artifactSizeBytes: artifactBinding.artifactSizeBytes,
repository: params.repo,
runStatePolicy: "completed-success",
runAttempt: runBinding.runAttempt,
runId: runBinding.runId,
workflowEvent: "workflow_dispatch",
workflowHeadBranch: "main",
workflowPath: runBinding.workflowPath,
workflowSha: runBinding.headSha,
},
fetchImpl: params.fetchImpl,
maxArchiveBytes: CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES,
retryAttempts: params.retryAttempts,
retryDelayMs: params.retryDelayMs,
timeoutMs: params.timeoutMs,
token: params.token,
});
const bytes = downloaded.files.get(CLAWHUB_BOOTSTRAP_READBACK_FILE);
if (!bytes) {
throw new Error("Plugin ClawHub New readback artifact is missing its evidence file.");
}
return {
value: parseJson(
decodeUtf8Exact(bytes, "Plugin ClawHub New readback artifact"),
"Plugin ClawHub New readback artifact",
),
archiveSha256: requireArtifactDigest(
sha256Digest(downloaded.archiveBytes),
"Downloaded Plugin ClawHub New readback artifact digest",
),
};
}
async function verifyClawHubBootstrapRun(params: {
repo: string;
runId: string;
releaseSha: string;
version: string;
expectedPackages: string[];
}): Promise<WorkflowRunSummary> {
const run = readGitHubApiJson(
params.repo,
`actions/runs/${params.runId}`,
"Plugin ClawHub New run",
);
const runBinding = requireClawHubBootstrapRunBinding(run, params.runId);
const terminalRunAttempt = runBinding.terminalRunAttempt;
const readbackName = `clawhub-bootstrap-readback-${params.runId}-${terminalRunAttempt}`;
const artifactList = readGitHubApiJson(
params.repo,
`actions/runs/${params.runId}/artifacts?per_page=100&name=${encodeURIComponent(readbackName)}`,
"Plugin ClawHub New readback artifact list",
);
if (!isRecord(artifactList) || !Array.isArray(artifactList.artifacts)) {
throw new Error("Plugin ClawHub New readback artifact list is invalid.");
}
const readbackArtifacts = artifactList.artifacts.filter(
(artifact) => isRecord(artifact) && artifact.name === readbackName,
);
if (readbackArtifacts.length !== 1 || !isRecord(readbackArtifacts[0])) {
throw new Error(
`Plugin ClawHub New run must have exactly one ${readbackName} artifact; found ${readbackArtifacts.length}.`,
);
}
const readbackArtifact = readbackArtifacts[0];
const downloaded = await downloadClawHubBootstrapReadback({
repo: params.repo,
runId: params.runId,
run,
readbackArtifact,
token: readGitHubToken(),
});
if (!isRecord(downloaded.value)) {
throw new Error("Plugin ClawHub New readback evidence is invalid.");
}
const packageArtifactId = requirePositiveIntegerString(
downloaded.value.artifactId,
"Plugin ClawHub New package artifact id",
);
const packageArtifact = readGitHubApiJson(
params.repo,
`actions/artifacts/${packageArtifactId}`,
"Plugin ClawHub New package artifact",
);
return validateClawHubBootstrapEvidence({
repo: params.repo,
runId: params.runId,
releaseSha: params.releaseSha,
expectedVersion: params.version,
expectedPackages: params.expectedPackages,
run,
readbackArtifact,
readbackArchiveSha256: downloaded.archiveSha256,
packageArtifact,
evidence: downloaded.value,
});
}
function readRootPackageVersion(rootDir: string): string {
const packageJson = parseJson(
readFileSync(resolve(rootDir, "package.json"), "utf8"),
@@ -558,6 +1250,12 @@ export async function verifyBetaRelease(
if (rootVersion !== args.version) {
throw new Error(`package.json version is ${rootVersion}; expected ${args.version}.`);
}
if (args.releaseSha !== undefined) {
const checkedOutSha = runCommand("git", ["rev-parse", "HEAD"], { cwd: rootDir });
if (checkedOutSha !== args.releaseSha) {
throw new Error(`release checkout SHA is ${checkedOutSha}; expected ${args.releaseSha}.`);
}
}
const lines: string[] = [];
const releaseUrl = args.skipGitHubRelease ? undefined : verifyGitHubRelease(args);
@@ -656,15 +1354,13 @@ export async function verifyBetaRelease(
);
}
if (args.workflowRuns.pluginClawHubBootstrap !== undefined) {
const clawHubWorkflowRef = args.clawHubWorkflowRef ?? args.workflowRef;
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.pluginClawHubBootstrap,
label: "Plugin ClawHub New",
await verifyClawHubBootstrapRun({
repo: args.repo,
expectedWorkflowName: "Plugin ClawHub New",
expectedHeadBranch: clawHubWorkflowRef,
rerunFailed: false,
runId: args.workflowRuns.pluginClawHubBootstrap,
releaseSha: requireCommitSha(args.releaseSha, "release SHA"),
version: args.version,
expectedPackages: args.clawHubBootstrapPlugins,
}),
);
}
@@ -718,6 +1414,8 @@ export async function verifyBetaRelease(
pluginNpmPackageCount: npmPlugins.length,
clawHubPackageCount: clawHubPlugins.length,
workflowRuns,
clawHubBootstrapEvidence:
workflowRuns.find((run) => run.bootstrapEvidence)?.bootstrapEvidence ?? null,
},
null,
2,
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
source_root="${1:?trusted ClawHub CLI source root is required}"
destination="${2:?ClawHub CLI destination is required}"
github_output="${3:-}"
package_json="${source_root}/package.json"
package_lock="${source_root}/package-lock.json"
expected_lock_sha256="f44f670d70f13a8cde566a174cae5be682ad98456ec7a85aafd497f7d8c71816"
expected_clawhub_integrity="sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA=="
test -f "${package_json}"
test -f "${package_lock}"
if [[ -e "${destination}" || -L "${destination}" ]]; then
echo "ClawHub CLI destination must not already exist: ${destination}" >&2
exit 1
fi
install -d -m 0700 "${destination}"
install -m 0600 "${package_json}" "${destination}/package.json"
install -m 0600 "${package_lock}" "${destination}/package-lock.json"
lock_sha256="$(
CLAWHUB_CLI_LOCK="${package_lock}" \
node -e "const { createHash } = require('node:crypto'); const { readFileSync } = require('node:fs'); process.stdout.write(createHash('sha256').update(readFileSync(process.env.CLAWHUB_CLI_LOCK)).digest('hex'));"
)"
[[ "${lock_sha256}" == "${expected_lock_sha256}" ]] || {
echo "Pinned ClawHub CLI lock SHA-256 mismatch." >&2
exit 1
}
clawhub_integrity="$(
CLAWHUB_CLI_LOCK="${package_lock}" \
node -p "require(require('node:path').resolve(process.env.CLAWHUB_CLI_LOCK)).packages['node_modules/clawhub'].integrity"
)"
[[ "${clawhub_integrity}" == "${expected_clawhub_integrity}" ]] || {
echo "Pinned ClawHub CLI integrity mismatch." >&2
exit 1
}
npm ci \
--prefix "${destination}" \
--ignore-scripts \
--no-audit \
--no-fund \
--omit=dev
clawhub_version="$(
CLAWHUB_CLI_ROOT="${destination}" \
node -p "require(require('node:path').join(process.env.CLAWHUB_CLI_ROOT, 'node_modules/clawhub/package.json')).version"
)"
[[ "${clawhub_version}" == "0.23.1" ]] || {
echo "Pinned ClawHub CLI version mismatch: ${clawhub_version}" >&2
exit 1
}
test -x "${destination}/node_modules/.bin/clawhub"
clawhub_cli="${destination}/node_modules/.bin/clawhub"
echo "Materialized clawhub@${clawhub_version} from lock ${lock_sha256}."
if [[ -n "${github_output}" ]]; then
{
echo "cli=${clawhub_cli}"
echo "integrity=${clawhub_integrity}"
echo "lock_sha256=${lock_sha256}"
echo "version=${clawhub_version}"
} >> "${github_output}"
fi
+228 -42
View File
@@ -4,6 +4,7 @@ set -euo pipefail
usage() {
echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish|--pack] <package-dir>"
echo " bash scripts/plugin-clawhub-publish.sh [--validate-packed|--publish-packed] <clawpack.tgz>"
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
@@ -16,7 +17,8 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/.." && pwd)"
invocation_root="$(pwd)"
if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" && "${mode}" != "--pack" ]]; then
if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" && "${mode}" != "--pack" &&
"${mode}" != "--validate-packed" && "${mode}" != "--publish-packed" ]]; then
usage >&2
exit 2
fi
@@ -25,15 +27,15 @@ shift
if [[ "${1:-}" == "--" ]]; then
shift
fi
package_dir=""
input_path=""
if [[ "$#" -gt 0 ]]; then
case "$1" in
-*) echo "unexpected plugin ClawHub package-dir option: $1" >&2; exit 2 ;;
*) package_dir="$1"; shift ;;
*) input_path="$1"; shift ;;
esac
fi
if [[ -z "${package_dir}" ]]; then
echo "missing package dir" >&2
if [[ -z "${input_path}" ]]; then
echo "missing package dir or ClawPack path" >&2
exit 2
fi
if [[ "$#" -gt 0 ]]; then
@@ -41,6 +43,19 @@ if [[ "$#" -gt 0 ]]; then
exit 2
fi
packed_mode=false
if [[ "${mode}" == "--validate-packed" || "${mode}" == "--publish-packed" ]]; then
packed_mode=true
fi
package_dir="${PACKAGE_DIR:-}"
clawpack_path=""
if [[ "${packed_mode}" == "true" ]]; then
clawpack_path="$(cd "$(dirname "${input_path}")" && pwd)/$(basename "${input_path}")"
else
package_dir="${input_path}"
fi
if [[ ! "${package_dir}" =~ ^extensions/[a-z0-9][a-z0-9._-]*$ ]]; then
echo "invalid package dir: ${package_dir}" >&2
exit 2
@@ -48,24 +63,69 @@ fi
package_source="${invocation_root}/${package_dir}"
if [[ ! -f "${package_source}/package.json" ]]; then
if [[ "${packed_mode}" == "false" && ! -f "${package_source}/package.json" ]]; then
echo "package.json not found under ${package_dir}" >&2
exit 2
fi
if ! command -v clawhub >/dev/null 2>&1; then
echo "clawhub CLI is required on PATH" >&2
exit 1
if [[ "${packed_mode}" == "true" && ! -f "${clawpack_path}" ]]; then
echo "ClawPack tarball not found: ${clawpack_path}" >&2
exit 2
fi
package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_source}")"
package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_source}")"
clawhub_cli="${OPENCLAW_CLAWHUB_CLI:-}"
if [[ -n "${clawhub_cli}" ]]; then
if [[ "${clawhub_cli}" != /* || ! -x "${clawhub_cli}" ]]; then
echo "OPENCLAW_CLAWHUB_CLI must be an absolute executable path" >&2
exit 1
fi
else
clawhub_cli="$(command -v clawhub 2>/dev/null || true)"
if [[ -z "${clawhub_cli}" ]]; then
echo "clawhub CLI is required on PATH" >&2
exit 1
fi
fi
if [[ "${packed_mode}" == "true" ]]; then
package_name="${EXPECTED_CLAWHUB_PACKAGE_NAME:-}"
package_version="${EXPECTED_CLAWHUB_PACKAGE_VERSION:-}"
if [[ ! "${package_name}" =~ ^@openclaw/[a-z0-9][a-z0-9._-]*$ ]]; then
echo "EXPECTED_CLAWHUB_PACKAGE_NAME is invalid." >&2
exit 2
fi
if [[ -z "${package_version}" ]]; then
echo "EXPECTED_CLAWHUB_PACKAGE_VERSION is required." >&2
exit 2
fi
else
package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_source}")"
package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_source}")"
fi
publish_tag="${PACKAGE_TAG:-latest}"
source_repo="${SOURCE_REPO:-${GITHUB_REPOSITORY:-openclaw/openclaw}}"
source_commit="${SOURCE_COMMIT:-$(git -C "${invocation_root}" rev-parse HEAD)}"
source_ref="${SOURCE_REF:-$(git -C "${invocation_root}" symbolic-ref -q HEAD || true)}"
clawhub_workdir="${CLAWDHUB_WORKDIR:-${CLAWHUB_WORKDIR:-${invocation_root}}}"
manual_override_reason="${OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON:-}"
release_git_dir="${OPENCLAW_CLAWHUB_RELEASE_GIT_DIR:-}"
release_tag="${OPENCLAW_CLAWHUB_RELEASE_TAG:-}"
release_target_sha="${OPENCLAW_CLAWHUB_TARGET_SHA:-}"
release_binding_count=0
for release_binding_value in "${release_git_dir}" "${release_tag}" "${release_target_sha}"; do
if [[ -n "${release_binding_value}" ]]; then
release_binding_count=$((release_binding_count + 1))
fi
done
if [[ "${release_binding_count}" != "0" && "${release_binding_count}" != "3" ]]; then
echo "OPENCLAW_CLAWHUB_RELEASE_GIT_DIR, OPENCLAW_CLAWHUB_RELEASE_TAG, and OPENCLAW_CLAWHUB_TARGET_SHA must be provided together." >&2
exit 2
fi
if [[ "${release_binding_count}" == "3" ]]; then
if [[ ! -d "${release_git_dir}" || ! "${release_target_sha}" =~ ^[a-f0-9]{40}$ ]]; then
echo "ClawHub release tag binding is invalid." >&2
exit 2
fi
fi
pack_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-clawhub-pack.XXXXXX")"
cleanup() {
@@ -74,7 +134,7 @@ cleanup() {
trap cleanup EXIT
pack_cmd=(
clawhub
"${clawhub_cli}"
--workdir
"${clawhub_workdir}"
package
@@ -103,23 +163,24 @@ echo "Resolved source repo: ${source_repo}"
echo "Resolved source commit: ${source_commit}"
echo "Resolved source ref: ${source_ref:-<missing>}"
echo "Resolved ClawHub workdir: ${clawhub_workdir}"
echo "Publish auth: GitHub Actions OIDC via ClawHub short-lived token"
echo "Publish auth: ${OPENCLAW_CLAWHUB_AUTH_LABEL:-GitHub Actions OIDC via ClawHub short-lived token}"
printf 'Pack command: CLAWHUB_WORKDIR=%q' "${clawhub_workdir}"
printf ' %q' "${pack_cmd[@]}"
printf '\n'
if [[ "${packed_mode}" == "false" ]]; then
printf 'Pack command: CLAWHUB_WORKDIR=%q' "${clawhub_workdir}"
printf ' %q' "${pack_cmd[@]}"
printf '\n'
build_package_runtime
build_package_runtime
pack_json="${pack_dir}/pack.json"
CLAWHUB_WORKDIR="${clawhub_workdir}" \
node "${repo_root}/scripts/lib/plugin-npm-package-manifest.mjs" --run "${package_dir}" -- \
"${pack_cmd[@]}" > "${pack_json}"
pack_output="$(cat "${pack_json}")"
printf '%s\n' "${pack_output}"
pack_json="${pack_dir}/pack.json"
CLAWHUB_WORKDIR="${clawhub_workdir}" \
node "${repo_root}/scripts/lib/plugin-npm-package-manifest.mjs" --run "${package_dir}" -- \
"${pack_cmd[@]}" > "${pack_json}"
pack_output="$(cat "${pack_json}")"
printf '%s\n' "${pack_output}"
pack_path="$(
PACK_OUTPUT="${pack_output}" node --input-type=module <<'EOF'
pack_path="$(
PACK_OUTPUT="${pack_output}" node --input-type=module <<'EOF'
import { resolve } from "node:path";
const raw = process.env.PACK_OUTPUT ?? "";
@@ -136,14 +197,17 @@ if (!parsed || typeof parsed.path !== "string" || parsed.path.trim() === "") {
}
console.log(resolve(parsed.path));
EOF
)"
)"
if [[ ! -f "${pack_path}" ]]; then
echo "ClawPack tarball not found: ${pack_path}" >&2
exit 1
if [[ ! -f "${pack_path}" ]]; then
echo "ClawPack tarball not found: ${pack_path}" >&2
exit 1
fi
clawpack_path="${pack_path}"
fi
echo "Resolved ClawPack: ${pack_path}"
echo "Resolved ClawPack: ${clawpack_path}"
if [[ "${mode}" == "--pack" ]]; then
output_dir="${OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR:-}"
@@ -152,19 +216,101 @@ if [[ "${mode}" == "--pack" ]]; then
exit 2
fi
mkdir -p "${output_dir}"
output_path="${output_dir}/$(basename "${pack_path}")"
cp "${pack_path}" "${output_path}"
output_path="${output_dir}/$(basename "${clawpack_path}")"
cp "${clawpack_path}" "${output_path}"
echo "Packed ClawPack: ${output_path}"
exit 0
fi
verify_packed_identity() {
local expected_sha="${EXPECTED_CLAWHUB_ARTIFACT_SHA256:-}"
local expected_size="${EXPECTED_CLAWHUB_ARTIFACT_SIZE:-}"
if [[ ! "${expected_sha}" =~ ^[a-f0-9]{64}$ ]]; then
echo "EXPECTED_CLAWHUB_ARTIFACT_SHA256 is invalid." >&2
exit 2
fi
if [[ ! "${expected_size}" =~ ^[1-9][0-9]*$ ]]; then
echo "EXPECTED_CLAWHUB_ARTIFACT_SIZE is invalid." >&2
exit 2
fi
node "${repo_root}/scripts/lib/clawhub-bootstrap-artifact.mjs" verify-packed \
--path "${clawpack_path}" \
--expected-sha256 "${expected_sha}" \
--expected-size "${expected_size}" \
--expected-dir "${package_dir}" \
--expected-name "${package_name}" \
--expected-version "${package_version}"
}
if [[ "${packed_mode}" == "true" ]]; then
verify_packed_identity
fi
clawhub_timeout_seconds="${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPT_TIMEOUT_SECONDS:-300}"
if [[ ! "${clawhub_timeout_seconds}" =~ ^[1-9][0-9]*$ || "${clawhub_timeout_seconds}" -gt 900 ]]; then
echo "OPENCLAW_CLAWHUB_PUBLISH_ATTEMPT_TIMEOUT_SECONDS must be an integer from 1 through 900." >&2
exit 2
fi
timeout_bin=""
for timeout_candidate in timeout gtimeout; do
timeout_candidate_path="$(command -v "${timeout_candidate}" 2>/dev/null || true)"
if [[ -n "${timeout_candidate_path}" ]] &&
"${timeout_candidate_path}" --signal=TERM --kill-after=1s 1s true >/dev/null 2>&1; then
timeout_bin="${timeout_candidate_path}"
break
fi
done
if [[ -z "${timeout_bin}" ]]; then
echo "GNU timeout or gtimeout with --signal and --kill-after support is required for bounded ClawHub CLI calls." >&2
exit 1
fi
clawhub_timeout=(
"${timeout_bin}"
--signal=TERM
--kill-after=10s
"${clawhub_timeout_seconds}s"
)
validate_packed_publish() {
local dry_run_json
dry_run_json="$(
CLAWHUB_WORKDIR="${clawhub_workdir}" "${clawhub_timeout[@]}" "${clawhub_cli}" \
--workdir "${clawhub_workdir}" \
package publish "${clawpack_path}" \
--tags "${publish_tag}" \
--source-repo "${source_repo}" \
--source-commit "${source_commit}" \
--source-path "${package_dir}" \
--dry-run \
--json
)"
printf '%s\n' "${dry_run_json}"
DRY_RUN_JSON="${dry_run_json}" EXPECTED_NAME="${package_name}" EXPECTED_VERSION="${package_version}" \
node --input-type=module <<'NODE'
const output = JSON.parse(process.env.DRY_RUN_JSON ?? "");
if (output.name !== process.env.EXPECTED_NAME || output.version !== process.env.EXPECTED_VERSION) {
throw new Error(
`Packed ClawHub identity mismatch: expected ${process.env.EXPECTED_NAME}@${process.env.EXPECTED_VERSION}, found ${String(output.name)}@${String(output.version)}.`,
);
}
NODE
}
if [[ "${packed_mode}" == "true" ]]; then
validate_packed_publish
if [[ "${mode}" == "--validate-packed" ]]; then
exit 0
fi
fi
publish_cmd=(
clawhub
"${clawhub_cli}"
--workdir
"${clawhub_workdir}"
package
publish
"${pack_path}"
"${clawpack_path}"
--tags
"${publish_tag}"
--source-repo
@@ -194,21 +340,61 @@ printf ' %q' "${publish_cmd[@]}"
printf '\n'
if [[ "${mode}" == "--dry-run" ]]; then
CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}" --dry-run
CLAWHUB_WORKDIR="${clawhub_workdir}" "${clawhub_timeout[@]}" "${publish_cmd[@]}" --dry-run
exit 0
fi
publish_attempts="${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8}"
publish_retry_delay="${OPENCLAW_CLAWHUB_PUBLISH_RETRY_DELAY_SECONDS:-60}"
if [[ ! "${publish_attempts}" =~ ^[1-9][0-9]*$ || "${publish_attempts}" -gt 12 ]]; then
echo "OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS must be an integer from 1 through 12." >&2
exit 2
fi
if [[ ! "${publish_retry_delay}" =~ ^[1-9][0-9]*$ || "${publish_retry_delay}" -gt 300 ]]; then
echo "OPENCLAW_CLAWHUB_PUBLISH_RETRY_DELAY_SECONDS must be an integer from 1 through 300." >&2
exit 2
fi
publish_log="${pack_dir}/publish.log"
for attempt in $(seq 1 "${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8}"); do
if CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}" 2>&1 | tee "${publish_log}"; then
verify_release_tag_target() {
if [[ "${release_binding_count}" == "0" ]]; then
return 0
fi
git -C "${release_git_dir}" fetch --force --no-tags origin \
"+refs/tags/${release_tag}:refs/tags/${release_tag}"
local tag_sha
tag_sha="$(git -C "${release_git_dir}" rev-parse "${release_tag}^{commit}")"
[[ "${tag_sha}" == "${release_target_sha}" ]] || {
echo "ClawHub publish target ${release_target_sha} no longer matches ${release_tag} (${tag_sha})." >&2
exit 1
}
}
for attempt in $(seq 1 "${publish_attempts}"); do
verify_release_tag_target
set +e
CLAWHUB_WORKDIR="${clawhub_workdir}" \
"${clawhub_timeout[@]}" "${publish_cmd[@]}" 2>&1 | tee "${publish_log}"
pipeline_status=("${PIPESTATUS[@]}")
set -e
publish_status="${pipeline_status[0]}"
tee_status="${pipeline_status[1]}"
if [[ "${tee_status}" != "0" ]]; then
echo "Failed to capture ClawHub publish output." >&2
exit "${tee_status}"
fi
if [[ "${publish_status}" == "0" ]]; then
exit 0
fi
if ! grep -Eqi "rate limit|too many requests|\\b429\\b" "${publish_log}"; then
if [[ "${publish_status}" != "124" && "${publish_status}" != "137" ]] &&
! grep -Eqi "rate limit|too many requests|\\b(408|425|429|5[0-9]{2})\\b|ECONNRESET|ETIMEDOUT|fetch failed|socket hang up|network error|temporarily unavailable" "${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}"
if [[ "${attempt}" -lt "${publish_attempts}" ]]; then
echo "ClawHub publish hit a transient failure; retrying (${attempt}/${publish_attempts})." >&2
sleep "${publish_retry_delay}"
fi
done
echo "ClawHub publish failed after ${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8} attempts." >&2
echo "ClawHub publish failed after ${publish_attempts} attempts." >&2
exit 1
File diff suppressed because it is too large Load Diff
+129 -19
View File
@@ -2,7 +2,8 @@
// Coordinates release-candidate validation runs and emits the publish command
// only after required local, CI, npm, plugin, and E2E evidence is green.
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mjs";
@@ -31,6 +32,9 @@ const DEFAULT_TELEGRAM_PROVIDER_MODE = "mock-openai";
const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000;
const DEFAULT_GITHUB_API_RESPONSE_BODY_MAX_BYTES = 16 * 1024 * 1024;
const COMMAND_CAPTURE_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
const TOOLING_ROOT = fileURLToPath(new URL("../", import.meta.url));
const TIDECLAW_ALPHA_WORKFLOW_REF_PATTERN =
/^tideclaw\/alpha\/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$/u;
const WINDOWS_NODE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u;
const WINDOWS_NODE_REPO = "openclaw/openclaw-windows-node";
const WINDOWS_NODE_REQUIRED_ASSETS = [
@@ -48,7 +52,7 @@ OpenClaw Release Publish command only after everything is green.
Options:
--tag <tag> Release tag to validate.
--workflow-ref <ref> Workflow branch/ref. Default: current branch.
--workflow-ref <ref> Trusted workflow ref. Default: main; matching Tideclaw branch required for alpha.
--repo <owner/repo> GitHub repo. Default: ${DEFAULT_REPO}
--full-release-run <id> Reuse successful Full Release Validation run.
--npm-preflight-run <id> Reuse successful OpenClaw NPM Release preflight run.
@@ -180,6 +184,18 @@ export function parseArgs(argv) {
if (!options.tag) {
throw new Error("--tag is required");
}
if (options.tag.includes("-alpha.")) {
if (!TIDECLAW_ALPHA_WORKFLOW_REF_PATTERN.test(options.workflowRef)) {
throw new Error(
"--workflow-ref must be the matching tideclaw/alpha/YYYY-MM-DD-HHMMZ branch for alpha release candidates",
);
}
} else {
options.workflowRef ||= "main";
}
if (!options.tag.includes("-alpha.") && options.workflowRef !== "main") {
throw new Error("--workflow-ref must be main for regular beta and stable release candidates");
}
options.releaseProfile ||=
options.tag.includes("-alpha.") || options.tag.includes("-beta.") ? "beta" : "stable";
if (!["beta", "stable", "full"].includes(options.releaseProfile)) {
@@ -348,24 +364,100 @@ export async function validateWindowsSourceRelease(tag, options = {}) {
};
}
function currentBranch() {
return run("git", ["branch", "--show-current"], { capture: true }).trim();
function gitRevParse(ref, cwd) {
return run("git", ["rev-parse", ref], { capture: true, cwd }).trim();
}
function gitRevParse(ref) {
return run("git", ["rev-parse", ref], { capture: true }).trim();
function gitTopLevel(cwd) {
return run("git", ["rev-parse", "--show-toplevel"], { capture: true, cwd }).trim();
}
export function validateCandidateCheckout({ targetSha, headSha, trackedStatus }) {
if (headSha !== targetSha) {
throw new Error(`release candidate tag resolves to ${targetSha}, but HEAD is ${headSha}`);
function gitTrackedStatus(cwd) {
return run("git", ["status", "--porcelain=v1", "--untracked-files=no"], {
capture: true,
cwd,
});
}
function fetchTrustedWorkflowSha(workflowRef, toolingRoot) {
const remoteRef = `refs/remotes/origin/${workflowRef}`;
run("git", ["fetch", "--no-tags", "origin", `+refs/heads/${workflowRef}:${remoteRef}`], {
cwd: toolingRoot,
});
return gitRevParse(`${remoteRef}^{commit}`, toolingRoot);
}
function runFromTrustedTooling(argv, { targetRoot, workflowRef }) {
const trustedToolingSha = fetchTrustedWorkflowSha(workflowRef, targetRoot);
const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-release-tooling-"));
const toolingRoot = join(tempRoot, "checkout");
let worktreeAdded = false;
try {
run("git", ["worktree", "add", "--detach", toolingRoot, trustedToolingSha], {
cwd: targetRoot,
});
worktreeAdded = true;
const result = spawnSync(
process.execPath,
[join(toolingRoot, "scripts/release-candidate-checklist.mjs"), ...argv],
{
cwd: targetRoot,
env: process.env,
stdio: "inherit",
},
);
if (result.status !== 0) {
throw new Error(
`trusted release candidate tooling failed with ${result.status ?? result.signal}`,
);
}
} finally {
if (worktreeAdded) {
const cleanup = spawnSync("git", ["worktree", "remove", "--force", toolingRoot], {
cwd: targetRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (cleanup.status !== 0) {
console.warn(
`could not remove temporary trusted tooling worktree: ${cleanup.stderr?.trim() || cleanup.signal || cleanup.status}`,
);
}
}
rmSync(tempRoot, { force: true, recursive: true });
}
if (trackedStatus.trim()) {
}
export function validateCandidateCheckout({
targetSha,
targetHeadSha,
targetTrackedStatus,
toolingSha,
trustedToolingSha,
toolingTrackedStatus,
workflowRef,
}) {
if (targetHeadSha !== targetSha) {
throw new Error(
"release candidate validation requires a clean tracked worktree so the checked tooling matches the tag",
`release candidate tag resolves to ${targetSha}, but target worktree HEAD is ${targetHeadSha}`,
);
}
return { status: "passed", targetSha };
if (targetTrackedStatus.trim()) {
throw new Error(
"release candidate validation requires a clean tracked target worktree at the release tag",
);
}
if (toolingSha !== trustedToolingSha) {
throw new Error(
`release candidate tooling HEAD ${toolingSha} does not match trusted ${workflowRef} ${trustedToolingSha}`,
);
}
if (toolingTrackedStatus.trim()) {
throw new Error(
"release candidate validation requires a clean tracked tooling checkout at the trusted workflow ref",
);
}
return { status: "passed", targetSha, toolingSha, workflowRef };
}
function gitIsAncestor(ancestor, target) {
@@ -857,6 +949,12 @@ function shellQuote(value) {
* Builds the final release publish workflow command once validation evidence is ready.
*/
export function buildPublishCommand(options) {
const workflowRef = options.tag.includes("-alpha.") ? options.workflowRef : "main";
if (options.tag.includes("-alpha.") && !TIDECLAW_ALPHA_WORKFLOW_REF_PATTERN.test(workflowRef)) {
throw new Error(
"alpha release publish requires a matching tideclaw/alpha/YYYY-MM-DD-HHMMZ workflow ref",
);
}
const fields = [
["tag", options.tag],
["preflight_run_id", options.npmPreflightRunId],
@@ -888,7 +986,7 @@ export function buildPublishCommand(options) {
"--repo",
options.repo,
"--ref",
options.workflowRef,
workflowRef,
...fields.flatMap(([key, value]) => ["-f", `${key}=${value}`]),
]
.map(shellQuote)
@@ -1030,15 +1128,27 @@ async function runTelegramIfNeeded(options, artifactName) {
async function main() {
const options = parseArgs(process.argv.slice(2));
options.workflowRef ||= currentBranch();
const targetRoot = gitTopLevel(process.cwd());
const toolingRoot = gitTopLevel(TOOLING_ROOT);
if (targetRoot === toolingRoot) {
runFromTrustedTooling(process.argv.slice(2), {
targetRoot,
workflowRef: options.workflowRef,
});
return;
}
options.outputDir ||= join(".artifacts", "release-candidate", options.tag);
const targetSha = gitRevParse(`${options.tag}^{}`);
const targetSha = gitRevParse(`${options.tag}^{}`, targetRoot);
const toolingSha = gitRevParse("HEAD", TOOLING_ROOT);
const trustedToolingSha = fetchTrustedWorkflowSha(options.workflowRef, TOOLING_ROOT);
validateCandidateCheckout({
targetSha,
headSha: gitRevParse("HEAD"),
trackedStatus: run("git", ["status", "--porcelain=v1", "--untracked-files=no"], {
capture: true,
}),
targetHeadSha: gitRevParse("HEAD", targetRoot),
targetTrackedStatus: gitTrackedStatus(targetRoot),
toolingSha,
trustedToolingSha,
toolingTrackedStatus: gitTrackedStatus(TOOLING_ROOT),
workflowRef: options.workflowRef,
});
const releaseChangelog = run("git", ["show", `${targetSha}:CHANGELOG.md`], { capture: true });
const releaseNotesVersion = releaseNotesVersionForTag(options.tag);
+43 -1
View File
@@ -605,7 +605,10 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([
],
[
".github/workflows/plugin-clawhub-new.yml",
["test/scripts/package-acceptance-workflow.test.ts"],
[
"test/scripts/package-acceptance-workflow.test.ts",
"test/scripts/plugin-clawhub-new-workflow.test.ts",
],
],
[
".github/workflows/plugin-clawhub-release.yml",
@@ -1326,6 +1329,45 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["test/release-check.test.ts", "test/scripts/test-install-sh-docker.test.ts"],
],
["scripts/lib/openclaw-release-clawhub-plan.ts", ["test/plugin-clawhub-release.test.ts"]],
[
"scripts/lib/actions-artifact-archive.mjs",
["test/scripts/plugin-publication-artifact.test.ts"],
],
[
"scripts/lib/clawhub-bootstrap-artifact.mjs",
[
"test/scripts/clawhub-bootstrap-artifact.test.ts",
"test/scripts/verify-clawhub-published-artifact.test.ts",
],
],
["scripts/plugin-publication-artifact.mjs", ["test/scripts/plugin-publication-artifact.test.ts"]],
[
"scripts/materialize-clawhub-cli.sh",
[
"test/scripts/package-acceptance-workflow.test.ts",
"test/scripts/plugin-clawhub-new-workflow.test.ts",
],
],
[
".github/release/clawhub-cli/package.json",
[
"test/scripts/package-acceptance-workflow.test.ts",
"test/scripts/plugin-clawhub-new-workflow.test.ts",
],
],
[
".github/release/clawhub-cli/package-lock.json",
[
"test/scripts/package-acceptance-workflow.test.ts",
"test/scripts/plugin-clawhub-new-workflow.test.ts",
],
],
[
"scripts/verify-clawhub-published-artifact.mjs",
["test/scripts/verify-clawhub-published-artifact.test.ts"],
],
["scripts/plugin-clawhub-publish.sh", ["test/plugin-clawhub-release.test.ts"]],
["scripts/lib/release-beta-verifier.ts", ["test/scripts/release-beta-verifier.test.ts"]],
[
"scripts/lib/plugin-clawhub-release.ts",
["test/plugin-clawhub-release.test.ts", "test/plugin-npm-release.test.ts"],
+81 -19
View File
@@ -8,21 +8,81 @@ const releasePublishRunId = process.env.RELEASE_PUBLISH_RUN_ID ?? "";
const expectedBranch = process.env.EXPECTED_WORKFLOW_BRANCH ?? "";
const directRecovery = process.env.DIRECT_RELEASE_RECOVERY === "true";
const approvalPath = process.env.APPROVAL_PATH ?? "";
const approvalKind = process.env.RELEASE_APPROVAL_KIND ?? "android";
const expectedRunAttempt = process.env.EXPECTED_RUN_ATTEMPT ?? "";
const childWorkflowSha = process.env.CHILD_WORKFLOW_SHA ?? "";
function fail(message) {
console.error(message);
process.exit(1);
}
function canonicalPackages(value) {
const packages = value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
if (
packages.length === 0 ||
new Set(packages).size !== packages.length ||
packages.some((entry) => !/^@openclaw\/[a-z0-9][a-z0-9._-]*$/u.test(entry))
) {
fail("ClawHub bootstrap approval requires a unique @openclaw/* package set.");
}
return packages.toSorted((a, b) => (a < b ? -1 : a > b ? 1 : 0));
}
function positiveRunAttempt(value) {
if (!/^[1-9][0-9]*$/u.test(value)) {
fail("Expected release publish run attempt must be a positive integer.");
}
return Number(value);
}
if (approvalKind === "clawhub-bootstrap" && !approvalPath) {
fail("ClawHub bootstrap approval requires an attested approval artifact.");
}
if (approvalPath) {
const approval = JSON.parse(fs.readFileSync(approvalPath, "utf8"));
const expectedApproval = {
version: 1,
repository: process.env.GITHUB_REPOSITORY,
workflow: "OpenClaw Release Publish",
parentRunId: releasePublishRunId,
workflowBranch: expectedBranch,
releaseTag: process.env.RELEASE_TAG,
targetSha: process.env.RELEASE_TARGET_SHA,
};
let expectedApproval;
let mismatchMessage;
if (approvalKind === "android") {
expectedApproval = {
version: 1,
repository: process.env.GITHUB_REPOSITORY,
workflow: "OpenClaw Release Publish",
parentRunId: releasePublishRunId,
workflowBranch: expectedBranch,
releaseTag: process.env.RELEASE_TAG,
targetSha: process.env.RELEASE_TARGET_SHA,
};
mismatchMessage = "Attested Android release approval does not match this run request.";
} else if (approvalKind === "clawhub-bootstrap") {
if (!/^[a-f0-9]{40}$/u.test(childWorkflowSha)) {
fail("Plugin ClawHub New workflow SHA must be a full lowercase commit SHA.");
}
expectedApproval = {
version: 2,
kind: "clawhub-bootstrap",
repository: process.env.GITHUB_REPOSITORY,
workflow: "OpenClaw Release Publish",
parentRunId: releasePublishRunId,
parentRunAttempt: positiveRunAttempt(expectedRunAttempt),
workflowBranch: expectedBranch,
parentWorkflowSha: run.headSha,
bootstrapWorkflowSha: childWorkflowSha,
releaseTag: process.env.RELEASE_TAG,
targetSha: process.env.RELEASE_TARGET_SHA,
packages: canonicalPackages(process.env.RELEASE_PACKAGES ?? ""),
};
mismatchMessage =
"Attested ClawHub bootstrap approval does not match this release target and package set.";
} else {
fail(`Unsupported release approval kind: ${approvalKind}`);
}
if (JSON.stringify(approval) !== JSON.stringify(expectedApproval)) {
console.error("Attested Android release approval does not match this run request.");
process.exit(1);
fail(mismatchMessage);
}
}
@@ -34,25 +94,28 @@ const checks = [
for (const [key, expected] of checks) {
if (run[key] !== expected) {
console.error(
fail(
`Referenced release publish run ${releasePublishRunId} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`,
);
process.exit(1);
}
}
if (expectedRunAttempt && run.runAttempt !== positiveRunAttempt(expectedRunAttempt)) {
fail(
`Referenced release publish run ${releasePublishRunId} must use attempt ${expectedRunAttempt}, got ${run.runAttempt ?? "<missing>"}.`,
);
}
if (!directRecovery) {
if (run.status !== "in_progress") {
console.error(
fail(
`Referenced release publish run ${releasePublishRunId} must still be in_progress, got ${run.status ?? "<missing>"}.`,
);
process.exit(1);
}
if (run.conclusion) {
console.error(
fail(
`Referenced release publish run ${releasePublishRunId} already concluded ${run.conclusion}.`,
);
process.exit(1);
}
console.log(`Using release publish approval run ${releasePublishRunId}: ${run.url}`);
process.exit(0);
@@ -70,7 +133,6 @@ if (run.status === "completed" && ["success", "failure"].includes(run.conclusion
process.exit(0);
}
console.error(
fail(
`Direct release recovery run ${releasePublishRunId} must be in_progress or completed with success/failure, got status=${run.status ?? "<missing>"} conclusion=${run.conclusion ?? "<missing>"}.`,
);
process.exit(1);
@@ -0,0 +1,559 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readdirSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { readClawHubBootstrapManifest } from "./lib/clawhub-bootstrap-artifact.mjs";
import { readBoundedRegularFile } from "./plugin-publication-artifact.mjs";
const DEFAULT_ATTEMPTS = 12;
const DEFAULT_DELAY_MS = 5_000;
const DEFAULT_ATTEMPT_TIMEOUT_MS = 120_000;
const MAX_ATTEMPTS = 12;
const MAX_DELAY_MS = 60_000;
const MAX_ARTIFACT_BYTES = 130 * 1024 * 1024;
const MAX_JSON_BYTES = 1024 * 1024;
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
const PACKAGE_VERSION_PATTERN =
/^[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*|-[1-9][0-9]*)?$/u;
const PUBLISH_TAG_PATTERN = /^(?:alpha|beta|latest)$/u;
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
const SHA512_INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]{86}==$/u;
const TOOLCHAIN_VERSION_PATTERN = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u;
class PermanentReadbackError extends Error {}
class RetryableReadbackError extends Error {
constructor(message, requestedDelayMs) {
super(message);
this.retryAfterMs = requestedDelayMs;
}
}
function fail(message) {
throw new Error(message);
}
function positiveInteger(value, fallback, label, maximum = Number.MAX_SAFE_INTEGER) {
const raw = value === undefined ? fallback : value;
const text = raw === undefined ? "" : String(raw);
if (!/^[1-9][0-9]*$/u.test(text)) {
fail(`${label} must be an integer from 1 through ${maximum}.`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > maximum) {
fail(`${label} must be an integer from 1 through ${maximum}.`);
}
return parsed;
}
function requiredPattern(value, pattern, label) {
if (typeof value !== "string" || !pattern.test(value)) {
fail(`${label} is invalid.`);
}
return value;
}
function requiredString(value, label) {
if (typeof value !== "string" || value.trim() !== value || value.length === 0) {
fail(`${label} is invalid.`);
}
return value;
}
function retryAfterMs(headers) {
const retryAfter = headers?.get("retry-after")?.trim();
if (!retryAfter) {
return undefined;
}
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.min(MAX_DELAY_MS, Math.max(1, Math.round(seconds * 1_000)));
}
const dateMs = Date.parse(retryAfter);
if (Number.isFinite(dateMs)) {
return Math.min(MAX_DELAY_MS, Math.max(1, dateMs - Date.now()));
}
return undefined;
}
function retryableStatus(status) {
return status === 404 || status === 408 || status === 425 || status === 429 || status >= 500;
}
async function cancelResponse(response) {
await response.body?.cancel().catch(() => undefined);
}
async function readBoundedBytes(response, label, maximumBytes) {
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const parsedLength = Number(contentLength);
if (Number.isFinite(parsedLength) && parsedLength > maximumBytes) {
await cancelResponse(response);
throw new PermanentReadbackError(`${label} exceeded ${maximumBytes} bytes.`);
}
}
if (!response.body) {
throw new RetryableReadbackError(`${label} returned no response body.`);
}
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
total += value.byteLength;
if (total > maximumBytes) {
await reader.cancel();
throw new PermanentReadbackError(`${label} exceeded ${maximumBytes} bytes.`);
}
chunks.push(value);
}
} catch (error) {
await reader.cancel().catch(() => undefined);
throw error;
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
async function fetchResponse(url, options, context) {
let response;
try {
response = await context.fetchImpl(url, {
...options,
redirect: "follow",
signal: context.signal,
});
} catch (error) {
throw new RetryableReadbackError(
`${url} request failed: ${error instanceof Error ? error.message : String(error)}.`,
);
}
if (retryableStatus(response.status)) {
const delay = retryAfterMs(response.headers);
await cancelResponse(response);
throw new RetryableReadbackError(`${url} returned HTTP ${response.status}.`, delay);
}
if (!response.ok) {
await cancelResponse(response);
throw new PermanentReadbackError(`${url} returned HTTP ${response.status}.`);
}
return response;
}
async function fetchJson(url, context) {
const response = await fetchResponse(url, { headers: { accept: "application/json" } }, context);
let bytes;
try {
bytes = await readBoundedBytes(response, url, MAX_JSON_BYTES);
} catch (error) {
if (error instanceof PermanentReadbackError) {
throw error;
}
throw new RetryableReadbackError(
`${url} body read failed: ${error instanceof Error ? error.message : String(error)}.`,
);
}
try {
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
} catch (error) {
throw new RetryableReadbackError(
`${url} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}.`,
);
}
}
async function fetchArtifact(url, context) {
const response = await fetchResponse(url, {}, context);
let bytes;
try {
bytes = await readBoundedBytes(response, url, MAX_ARTIFACT_BYTES);
} catch (error) {
if (error instanceof PermanentReadbackError) {
throw error;
}
throw new RetryableReadbackError(
`${url} body read failed: ${error instanceof Error ? error.message : String(error)}.`,
);
}
return { bytes, headers: response.headers };
}
function requireObject(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new RetryableReadbackError(`${label} is missing or invalid.`);
}
return value;
}
function requireExact(value, expected, label) {
if (value !== expected) {
throw new RetryableReadbackError(
`${label} mismatch: expected ${String(expected)}, found ${String(value)}.`,
);
}
}
function artifactIdentity(bytes) {
return {
sha256: createHash("sha256").update(bytes).digest("hex"),
size: bytes.byteLength,
npmIntegrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}`,
npmShasum: createHash("sha1").update(bytes).digest("hex"),
};
}
function readExpectedPackageArtifact(directory) {
const artifactDirectory = requiredString(directory, "expectedArtifactDir");
const entries = readdirSync(artifactDirectory, { withFileTypes: true });
if (entries.length !== 1 || !entries[0].isFile() || !entries[0].name.endsWith(".tgz")) {
fail("Expected artifact directory must contain exactly one root .tgz regular file.");
}
const fileName = entries[0].name;
const bytes = readBoundedRegularFile(join(artifactDirectory, fileName), {
label: "Expected ClawHub package artifact",
maxBytes: MAX_ARTIFACT_BYTES,
});
return { bytes, fileName };
}
function validateArtifactMetadata(entry, metadata, identity, headers) {
const packageDetail = requireObject(metadata.package, `${entry.packageName} artifact package`);
const artifact = requireObject(metadata.artifact, `${entry.packageName} artifact metadata`);
requireExact(packageDetail.name, entry.packageName, `${entry.packageName} artifact package name`);
requireExact(metadata.version, entry.version, `${entry.packageName} artifact version`);
requireExact(artifact.kind, "npm-pack", `${entry.packageName} artifact kind`);
requireExact(artifact.sha256, identity.sha256, `${entry.packageName} artifact sha256`);
requireExact(artifact.size, identity.size, `${entry.packageName} artifact size`);
requireExact(
artifact.npmIntegrity,
identity.npmIntegrity,
`${entry.packageName} artifact npmIntegrity`,
);
requireExact(artifact.npmShasum, identity.npmShasum, `${entry.packageName} artifact npmShasum`);
const headerSha256 = headers.get("x-clawhub-artifact-sha256");
const headerIntegrity = headers.get("x-clawhub-npm-integrity");
const headerShasum = headers.get("x-clawhub-npm-shasum");
requireExact(headerSha256, identity.sha256, `${entry.packageName} download sha256 header`);
requireExact(
headerIntegrity,
identity.npmIntegrity,
`${entry.packageName} download npm integrity header`,
);
requireExact(headerShasum, identity.npmShasum, `${entry.packageName} download shasum header`);
return {
kind: artifact.kind,
sha256: artifact.sha256,
size: artifact.size,
npmIntegrity: artifact.npmIntegrity,
npmShasum: artifact.npmShasum,
packageName: packageDetail.name,
version: metadata.version,
};
}
async function verifyEntryOnce(entry, options, context) {
const registry = options.registry;
const encodedName = encodeURIComponent(entry.packageName);
const encodedVersion = encodeURIComponent(entry.version);
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
const metadataUrl = `${versionUrl}/artifact`;
const artifactUrl = `${metadataUrl}/download`;
const detail = await fetchJson(detailUrl, context);
requireExact(
detail?.package?.tags?.[entry.publishTag],
entry.version,
`${entry.packageName} ClawHub tag ${entry.publishTag}`,
);
if (options.mode === "postpublish") {
const trustedPublisher = (await fetchJson(`${detailUrl}/trusted-publisher`, context))
?.trustedPublisher;
requireExact(
trustedPublisher?.provider,
"github-actions",
`${entry.packageName} trusted publisher provider`,
);
requireExact(
trustedPublisher?.repository,
"openclaw/openclaw",
`${entry.packageName} trusted publisher repository`,
);
requireExact(
trustedPublisher?.workflowFilename,
"plugin-clawhub-release.yml",
`${entry.packageName} trusted publisher workflow`,
);
requireExact(
trustedPublisher?.environment ?? null,
null,
`${entry.packageName} trusted publisher environment`,
);
}
const metadata = await fetchJson(metadataUrl, context);
const { bytes, headers } = await fetchArtifact(artifactUrl, context);
const identity = artifactIdentity(bytes);
requireExact(identity.sha256, entry.sha256, `${entry.packageName} registry artifact sha256`);
requireExact(identity.size, entry.size, `${entry.packageName} registry artifact size`);
const artifactMetadata = validateArtifactMetadata(entry, metadata, identity, headers);
return {
packageName: entry.packageName,
version: entry.version,
publishTag: entry.publishTag,
bootstrapMode: entry.bootstrapMode,
expectedSha256: entry.sha256,
expectedSize: entry.size,
registrySha256: identity.sha256,
registrySize: identity.size,
npmIntegrity: identity.npmIntegrity,
npmShasum: identity.npmShasum,
artifactMetadata,
};
}
async function runBoundedRetry(label, operation, retryOptions = {}) {
const attempts = positiveInteger(
retryOptions.attempts,
DEFAULT_ATTEMPTS,
"attempts",
MAX_ATTEMPTS,
);
const delayMs = positiveInteger(retryOptions.delayMs, DEFAULT_DELAY_MS, "delayMs", MAX_DELAY_MS);
const timeoutMs = positiveInteger(
retryOptions.timeoutMs,
DEFAULT_ATTEMPT_TIMEOUT_MS,
"timeoutMs",
);
const sleep =
retryOptions.sleep ??
((milliseconds) =>
new Promise((resolveDelay) => {
setTimeout(resolveDelay, milliseconds);
}));
const fetchImpl = retryOptions.fetchImpl ?? fetch;
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const signal = AbortSignal.timeout(timeoutMs);
try {
return await operation({ fetchImpl, signal });
} catch (error) {
if (error instanceof PermanentReadbackError) {
throw error;
}
lastError = error;
if (attempt < attempts) {
const requestedDelay =
error instanceof RetryableReadbackError ? error.retryAfterMs : undefined;
await sleep(requestedDelay ?? Math.min(MAX_DELAY_MS, delayMs * attempt));
}
}
}
const detail = lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(`${label} did not stabilize after ${attempts} attempts; last failure ${detail}`);
}
export async function verifyPublishedClawHubArtifacts(options) {
const registry = String(options.registry ?? "https://clawhub.ai").replace(/\/+$/u, "");
const manifest = readClawHubBootstrapManifest(options.manifestPath);
const expectedToolchain = {
clawhubToolchainIntegrity: requiredPattern(
options.clawhubToolchainIntegrity,
SHA512_INTEGRITY_PATTERN,
"clawhubToolchainIntegrity",
),
clawhubToolchainSha256: requiredPattern(
options.clawhubToolchainSha256,
SHA256_PATTERN,
"clawhubToolchainSha256",
),
clawhubToolchainVersion: requiredPattern(
options.clawhubToolchainVersion,
TOOLCHAIN_VERSION_PATTERN,
"clawhubToolchainVersion",
),
};
for (const [key, expected] of Object.entries(expectedToolchain)) {
if (manifest[key] !== expected) {
fail(`Validated ClawHub bootstrap manifest ${key} mismatch.`);
}
}
const mode = options.mode ?? "postpublish";
if (mode !== "postpublish" && mode !== "configure-only-preflight") {
fail(`Unsupported ClawHub artifact verification mode: ${String(mode)}.`);
}
const producerRunAttempt = positiveInteger(manifest.runAttempt, undefined, "manifest runAttempt");
const terminalRunAttempt = positiveInteger(
options.terminalRunAttempt,
undefined,
"terminalRunAttempt",
);
if (terminalRunAttempt < producerRunAttempt) {
fail("terminalRunAttempt must be greater than or equal to the producer run attempt.");
}
const artifactId = String(positiveInteger(options.artifactId, undefined, "artifactId"));
const artifactDigest = requiredPattern(options.artifactDigest, SHA256_PATTERN, "artifactDigest");
const entries =
mode === "configure-only-preflight"
? manifest.entries.filter((entry) => entry.bootstrapMode === "configure-only")
: manifest.entries;
const results = [];
for (const entry of entries) {
results.push(
await runBoundedRetry(
`${entry.packageName}@${entry.version} ClawHub artifact`,
(context) => verifyEntryOnce(entry, { registry, mode }, context),
options.retryOptions,
),
);
}
return {
schemaVersion: 2,
repository: manifest.repository,
targetSha: manifest.targetSha,
workflowSha: manifest.workflowSha,
runId: manifest.runId,
producerRunAttempt: String(producerRunAttempt),
terminalRunAttempt: String(terminalRunAttempt),
artifactName: manifest.artifactName,
artifactId,
artifactDigest,
clawhubToolchainIntegrity: manifest.clawhubToolchainIntegrity,
clawhubToolchainSha256: manifest.clawhubToolchainSha256,
clawhubToolchainVersion: manifest.clawhubToolchainVersion,
requestedPlugins: manifest.requestedPlugins,
verificationMode: mode,
packages: results,
};
}
export async function verifyPublishedClawHubPackage(options) {
const registry = String(options.registry ?? "https://clawhub.ai").replace(/\/+$/u, "");
const packageName = requiredPattern(options.packageName, PACKAGE_NAME_PATTERN, "packageName");
const version = requiredPattern(
options.packageVersion,
PACKAGE_VERSION_PATTERN,
"packageVersion",
);
const publishTag = requiredPattern(options.publishTag, PUBLISH_TAG_PATTERN, "publishTag");
const { bytes, fileName } = readExpectedPackageArtifact(options.expectedArtifactDir);
const expected = artifactIdentity(bytes);
const entry = {
bootstrapMode: null,
packageName,
publishTag,
sha256: expected.sha256,
size: expected.size,
version,
};
const result = await runBoundedRetry(
`${packageName}@${version} ClawHub artifact`,
(context) => verifyEntryOnce(entry, { registry, mode: "postpublish" }, context),
options.retryOptions,
);
return {
schemaVersion: 1,
verificationMode: "oidc-postpublish",
expectedArtifact: {
fileName,
...expected,
},
package: result,
};
}
function parseArgs(argv) {
const result = {};
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index];
const value = argv[index + 1];
if (!key?.startsWith("--") || value === undefined) {
fail(`Invalid argument: ${String(key)}`);
}
result[key.slice(2).replaceAll("-", "_")] = value;
}
return result;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const retryOptions = {
attempts: positiveInteger(
process.env.OPENCLAW_CLAWHUB_VERIFY_ATTEMPTS,
DEFAULT_ATTEMPTS,
"OPENCLAW_CLAWHUB_VERIFY_ATTEMPTS",
MAX_ATTEMPTS,
),
delayMs: positiveInteger(
process.env.OPENCLAW_CLAWHUB_VERIFY_DELAY_MS,
DEFAULT_DELAY_MS,
"OPENCLAW_CLAWHUB_VERIFY_DELAY_MS",
MAX_DELAY_MS,
),
timeoutMs: positiveInteger(
process.env.OPENCLAW_CLAWHUB_VERIFY_ATTEMPT_TIMEOUT_MS,
DEFAULT_ATTEMPT_TIMEOUT_MS,
"OPENCLAW_CLAWHUB_VERIFY_ATTEMPT_TIMEOUT_MS",
),
};
const directMode = [
args.expected_artifact_dir,
args.package_name,
args.package_version,
args.publish_tag,
].some((value) => value !== undefined);
if (directMode && args.manifest !== undefined) {
fail("Direct package verification and bootstrap manifest verification are mutually exclusive.");
}
if (!directMode && args.manifest === undefined) {
fail("Expected --manifest or --expected-artifact-dir.");
}
const evidence = directMode
? await verifyPublishedClawHubPackage({
expectedArtifactDir: args.expected_artifact_dir,
packageName: args.package_name,
packageVersion: args.package_version,
publishTag: args.publish_tag,
registry: args.registry,
retryOptions,
})
: await verifyPublishedClawHubArtifacts({
registry: args.registry,
manifestPath: args.manifest,
artifactId: args.artifact_id,
artifactDigest: args.artifact_digest,
clawhubToolchainIntegrity: args.clawhub_toolchain_integrity,
clawhubToolchainSha256: args.clawhub_toolchain_sha256,
clawhubToolchainVersion: args.clawhub_toolchain_version,
mode: args.mode,
terminalRunAttempt: args.terminal_run_attempt,
retryOptions,
});
if (args.output) {
await mkdir(dirname(args.output), { recursive: true });
await writeFile(args.output, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
}
process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main();
}
+317 -6
View File
@@ -1,5 +1,6 @@
// Plugin ClawHub release tests validate plugin release metadata and artifacts.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
existsSync,
@@ -9,6 +10,7 @@ import {
writeFileSync,
} from "node:fs";
import { delimiter, join } from "node:path";
import { gzipSync } from "node:zlib";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildOpenClawReleaseClawHubPlan,
@@ -37,6 +39,62 @@ afterEach(() => {
cleanupTempDirs(tempDirs);
});
function writeTarField(header: Buffer, offset: number, length: number, value: string) {
const bytes = Buffer.from(value);
if (bytes.byteLength > length) {
throw new Error(`tar field exceeds ${length} bytes`);
}
bytes.copy(header, offset);
}
function writeTarOctal(header: Buffer, offset: number, length: number, value: number) {
writeTarField(header, offset, length, `${value.toString(8).padStart(length - 2, "0")} \0`);
}
function createClawPackBytes(
packageName: string,
version: string,
options: { duplicateNormalizedPackageJson?: boolean } = {},
) {
function entry(name: string, contents: string, prefix = "") {
const bytes = Buffer.from(contents);
const header = Buffer.alloc(512);
writeTarField(header, 0, 100, name);
writeTarOctal(header, 100, 8, 0o644);
writeTarOctal(header, 108, 8, 0);
writeTarOctal(header, 116, 8, 0);
writeTarOctal(header, 124, 12, bytes.byteLength);
writeTarOctal(header, 136, 12, 0);
header[156] = "0".charCodeAt(0);
writeTarField(header, 257, 6, "ustar\0");
writeTarField(header, 263, 2, "00");
writeTarOctal(header, 329, 8, 0);
writeTarOctal(header, 337, 8, 0);
writeTarField(header, 345, 155, prefix);
header.fill(0x20, 148, 156);
const checksum = header.reduce((total, byte) => total + byte, 0);
writeTarOctal(header, 148, 8, checksum);
const padding = Buffer.alloc((512 - (bytes.byteLength % 512)) % 512);
return Buffer.concat([header, bytes, padding]);
}
const packageJson = JSON.stringify({
name: packageName,
version,
openclaw: { release: { publishToClawHub: true } },
});
const packageJsonEntries = options.duplicateNormalizedPackageJson
? [entry("package/package.json", packageJson), entry("package/package.json", packageJson)]
: [entry("package/package.json", packageJson)];
return gzipSync(
Buffer.concat([
...packageJsonEntries,
entry("package/openclaw.plugin.json", JSON.stringify({ id: "demo-plugin" })),
Buffer.alloc(1024),
]),
);
}
describe("resolveChangedClawHubPublishablePluginPackages", () => {
const publishablePlugins: PublishablePluginPackage[] = [
{
@@ -426,9 +484,7 @@ describe("collectPluginClawHubReleasePlan", () => {
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual([
"@openclaw/demo-plugin",
]);
expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]);
});
it("fails closed when npm latest cannot be resolved", async () => {
@@ -1014,8 +1070,11 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
const plan = await buildOpenClawReleaseClawHubPlan(
{
bootstrapWorkflowSha: "d".repeat(40),
releaseTag: "v2026.4.1-beta.1",
releaseSha: "a".repeat(40),
releasePublishBranch: "main",
releasePublishRunAttempt: "2",
releasePublishRunId: "12345",
pluginPublishScope: "all-publishable",
plugins: [],
@@ -1028,6 +1087,7 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
);
expect(plan.clawHubWorkflowRef).toBe("v2026.4.1-beta.1");
expect(plan.bootstrapWorkflowSha).toBe("d".repeat(40));
expect(plan.releasePublishBranch).toBe("main");
expect(plan.normal).toEqual({
workflow: "plugin-clawhub-release.yml",
@@ -1043,11 +1103,15 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
});
expect(plan.bootstrap).toEqual({
workflow: "plugin-clawhub-new.yml",
ref: "v2026.4.1-beta.1",
ref: "main",
shouldDispatch: true,
packages: ["@openclaw/demo-two", "@openclaw/demo-three"],
inputs: {
bootstrap_workflow_sha: "d".repeat(40),
ref: "a".repeat(40),
release_tag: "v2026.4.1-beta.1",
plugins: "@openclaw/demo-two,@openclaw/demo-three",
release_publish_run_attempt: "2",
release_publish_run_id: "12345",
release_publish_branch: "main",
},
@@ -1093,8 +1157,11 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
const plan = await buildOpenClawReleaseClawHubPlan(
{
bootstrapWorkflowSha: "d".repeat(40),
releaseTag: "v2026.4.1-beta.1",
releaseSha: "b".repeat(40),
releasePublishBranch: "release/2026.4.1",
releasePublishRunAttempt: "3",
releasePublishRunId: "12345",
pluginPublishScope: "selected",
plugins: ["@openclaw/demo-plugin"],
@@ -1109,11 +1176,15 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
expect(plan.normal.shouldDispatch).toBe(false);
expect(plan.bootstrap).toMatchObject({
workflow: "plugin-clawhub-new.yml",
ref: "v2026.4.1-beta.1",
ref: "main",
shouldDispatch: true,
packages: ["@openclaw/demo-plugin"],
inputs: {
bootstrap_workflow_sha: "d".repeat(40),
ref: "b".repeat(40),
release_tag: "v2026.4.1-beta.1",
plugins: "@openclaw/demo-plugin",
release_publish_run_attempt: "3",
release_publish_run_id: "12345",
release_publish_branch: "release/2026.4.1",
},
@@ -1130,10 +1201,16 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
it("rejects incompatible all-publishable plugin selection args", () => {
expect(() =>
parseOpenClawReleaseClawHubPlanArgs([
"--bootstrap-workflow-sha",
"d".repeat(40),
"--release-tag",
"v2026.4.1-beta.1",
"--release-sha",
"c".repeat(40),
"--release-publish-branch",
"main",
"--release-publish-run-attempt",
"1",
"--release-publish-run-id",
"12345",
"--plugin-publish-scope",
@@ -1143,6 +1220,48 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
]),
).toThrow("plugin-publish-scope=all-publishable must not be combined with --plugins.");
});
it("requires an exact lowercase release SHA for bootstrap targeting", () => {
const baseArgs = [
"--bootstrap-workflow-sha",
"d".repeat(40),
"--release-tag",
"v2026.4.1-beta.1",
"--release-publish-branch",
"release/2026.4.1",
"--release-publish-run-attempt",
"1",
"--release-publish-run-id",
"12345",
];
expect(() => parseOpenClawReleaseClawHubPlanArgs(baseArgs)).toThrow(
"--release-sha is required.",
);
expect(() =>
parseOpenClawReleaseClawHubPlanArgs([...baseArgs, "--release-sha", "ABCDEF"]),
).toThrow("--release-sha must be a full 40-character lowercase commit SHA.");
});
it("requires an exact parent release run attempt for bootstrap approval binding", () => {
const args = [
"--bootstrap-workflow-sha",
"d".repeat(40),
"--release-tag",
"v2026.4.1-beta.1",
"--release-sha",
"c".repeat(40),
"--release-publish-branch",
"main",
"--release-publish-run-id",
"12345",
];
expect(() => parseOpenClawReleaseClawHubPlanArgs(args)).toThrow(
"--release-publish-run-attempt is required.",
);
expect(() =>
parseOpenClawReleaseClawHubPlanArgs([...args, "--release-publish-run-attempt", "0"]),
).toThrow("--release-publish-run-attempt must be a positive integer.");
});
});
describe("runPluginClawHubReleaseCheck", () => {
@@ -1291,6 +1410,25 @@ describe("buildOpenClawReleaseClawHubRuntimeState", () => {
});
describe("plugin-clawhub-publish.sh", () => {
it("rejects ambiguous packed identities before invoking the pinned ClawHub CLI", () => {
const source = readFileSync("scripts/plugin-clawhub-publish.sh", "utf8");
const localIdentityIndex = source.indexOf("clawhub-bootstrap-artifact.mjs");
const clawHubDryRunIndex = source.indexOf("local dry_run_json");
expect(localIdentityIndex).toBeGreaterThan(0);
expect(localIdentityIndex).toBeLessThan(clawHubDryRunIndex);
});
it("probes GNU timeout capabilities and leaves pack-only mode portable", () => {
const source = readFileSync("scripts/plugin-clawhub-publish.sh", "utf8");
const packExitIndex = source.indexOf('if [[ "${mode}" == "--pack" ]]');
const timeoutProbeIndex = source.indexOf("for timeout_candidate in timeout gtimeout");
expect(timeoutProbeIndex).toBeGreaterThan(packExitIndex);
expect(source).toContain("--signal=TERM --kill-after=1s 1s true");
expect(source).toContain("with --signal and --kill-after support is required");
});
it("prints help before package or ClawHub checks", () => {
const output = execFileSync(
"bash",
@@ -1301,7 +1439,10 @@ describe("plugin-clawhub-publish.sh", () => {
);
expect(output.trim()).toBe(
"usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish|--pack] <package-dir>",
[
"usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish|--pack] <package-dir>",
" bash scripts/plugin-clawhub-publish.sh [--validate-packed|--publish-packed] <clawpack.tgz>",
].join("\n"),
);
});
@@ -1526,6 +1667,176 @@ exit 0
expect(invocations).toContain("package pack ");
expect(invocations).not.toContain("package publish ");
});
it("rejects duplicate normalized paths before invoking the ClawHub CLI", () => {
const repoDir = createTempPluginRepo();
const binDir = join(repoDir, "bin");
const markerPath = join(repoDir, "clawhub-invoked");
const tgzPath = join(repoDir, "ambiguous.tgz");
const tgzBytes = createClawPackBytes("@openclaw/demo-plugin", "2026.4.1", {
duplicateNormalizedPackageJson: true,
});
mkdirSync(binDir, { recursive: true });
writeFileSync(tgzPath, tgzBytes);
writeFileSync(
join(binDir, "clawhub"),
`#!/usr/bin/env bash
set -euo pipefail
touch ${JSON.stringify(markerPath)}
exit 99
`,
);
chmodSync(join(binDir, "clawhub"), 0o755);
expect(() =>
execFileSync(
"bash",
[join(process.cwd(), "scripts/plugin-clawhub-publish.sh"), "--validate-packed", tgzPath],
{
cwd: repoDir,
encoding: "utf8",
env: {
...process.env,
EXPECTED_CLAWHUB_ARTIFACT_SHA256: createHash("sha256").update(tgzBytes).digest("hex"),
EXPECTED_CLAWHUB_ARTIFACT_SIZE: String(tgzBytes.byteLength),
EXPECTED_CLAWHUB_PACKAGE_NAME: "@openclaw/demo-plugin",
EXPECTED_CLAWHUB_PACKAGE_VERSION: "2026.4.1",
PACKAGE_DIR: "extensions/demo-plugin",
PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`,
},
},
),
).toThrow("Duplicate or aliased plugin tar entry: package/package.json");
expect(existsSync(markerPath)).toBe(false);
});
it("publishes the exact validated tgz and retries transient failures", () => {
const repoDir = createTempPluginRepo();
const binDir = join(repoDir, "bin");
const markerPath = join(repoDir, "clawhub-invoked");
const attemptsPath = join(repoDir, "publish-attempts");
const tgzPath = join(repoDir, "immutable.tgz");
const tgzBytes = createClawPackBytes("@openclaw/demo-plugin", "2026.4.1");
mkdirSync(binDir, { recursive: true });
writeFileSync(tgzPath, tgzBytes);
writeFileSync(
join(binDir, "sleep"),
`#!/usr/bin/env bash
exit 0
`,
);
chmodSync(join(binDir, "sleep"), 0o755);
writeFileSync(
join(binDir, "clawhub"),
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> ${JSON.stringify(markerPath)}
if [[ "\${1:-}" == "--workdir" ]]; then
shift 2
fi
if [[ " $* " == *" --dry-run "* ]]; then
printf '{"name":"@openclaw/demo-plugin","version":"2026.4.1"}\\n'
exit 0
fi
attempts=0
if [[ -f ${JSON.stringify(attemptsPath)} ]]; then
attempts="$(cat ${JSON.stringify(attemptsPath)})"
fi
attempts=$((attempts + 1))
printf '%s' "$attempts" > ${JSON.stringify(attemptsPath)}
if [[ "$attempts" == "1" ]]; then
echo "HTTP 503 temporarily unavailable" >&2
exit 1
fi
exit 0
`,
);
chmodSync(join(binDir, "clawhub"), 0o755);
execFileSync(
"bash",
[join(process.cwd(), "scripts/plugin-clawhub-publish.sh"), "--publish-packed", tgzPath],
{
cwd: repoDir,
encoding: "utf8",
env: {
...process.env,
EXPECTED_CLAWHUB_ARTIFACT_SHA256: createHash("sha256").update(tgzBytes).digest("hex"),
EXPECTED_CLAWHUB_ARTIFACT_SIZE: String(tgzBytes.byteLength),
EXPECTED_CLAWHUB_PACKAGE_NAME: "@openclaw/demo-plugin",
EXPECTED_CLAWHUB_PACKAGE_VERSION: "2026.4.1",
OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS: "2",
OPENCLAW_CLAWHUB_PUBLISH_RETRY_DELAY_SECONDS: "1",
PACKAGE_DIR: "extensions/demo-plugin",
PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`,
},
},
);
const invocations = readFileSync(markerPath, "utf8");
expect(invocations).not.toContain("package pack");
expect(invocations.match(/immutable\.tgz/gu)).toHaveLength(3);
expect(readFileSync(attemptsPath, "utf8")).toBe("2");
});
it("bounds each packed publish attempt and retries a timed-out CLI", () => {
const repoDir = createTempPluginRepo();
const binDir = join(repoDir, "bin");
const attemptsPath = join(repoDir, "publish-attempts");
const tgzPath = join(repoDir, "immutable.tgz");
const tgzBytes = createClawPackBytes("@openclaw/demo-plugin", "2026.4.1");
mkdirSync(binDir, { recursive: true });
writeFileSync(tgzPath, tgzBytes);
writeFileSync(
join(binDir, "clawhub"),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "\${1:-}" == "--workdir" ]]; then
shift 2
fi
if [[ " $* " == *" --dry-run "* ]]; then
printf '{"name":"@openclaw/demo-plugin","version":"2026.4.1"}\\n'
exit 0
fi
attempts=0
if [[ -f ${JSON.stringify(attemptsPath)} ]]; then
attempts="$(cat ${JSON.stringify(attemptsPath)})"
fi
attempts=$((attempts + 1))
printf '%s' "$attempts" > ${JSON.stringify(attemptsPath)}
if [[ "$attempts" == "1" ]]; then
sleep 60
fi
exit 0
`,
);
chmodSync(join(binDir, "clawhub"), 0o755);
const startedAt = Date.now();
execFileSync(
"bash",
[join(process.cwd(), "scripts/plugin-clawhub-publish.sh"), "--publish-packed", tgzPath],
{
cwd: repoDir,
encoding: "utf8",
env: {
...process.env,
EXPECTED_CLAWHUB_ARTIFACT_SHA256: createHash("sha256").update(tgzBytes).digest("hex"),
EXPECTED_CLAWHUB_ARTIFACT_SIZE: String(tgzBytes.byteLength),
EXPECTED_CLAWHUB_PACKAGE_NAME: "@openclaw/demo-plugin",
EXPECTED_CLAWHUB_PACKAGE_VERSION: "2026.4.1",
OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS: "2",
OPENCLAW_CLAWHUB_PUBLISH_ATTEMPT_TIMEOUT_SECONDS: "1",
OPENCLAW_CLAWHUB_PUBLISH_RETRY_DELAY_SECONDS: "1",
PACKAGE_DIR: "extensions/demo-plugin",
PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`,
},
},
);
expect(Date.now() - startedAt).toBeLessThan(5_000);
expect(readFileSync(attemptsPath, "utf8")).toBe("2");
});
});
describe("collectPluginClawHubReleasePathsFromGitRange", () => {
@@ -0,0 +1,537 @@
import { createHash } from "node:crypto";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
truncateSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import {
createClawHubBootstrapArtifactManifest,
downloadClawHubBootstrapArtifact,
verifyClawHubPackedArtifactIdentity,
verifyClawHubBootstrapArtifactManifest,
} from "../../scripts/lib/clawhub-bootstrap-artifact.mjs";
const tempDirs: string[] = [];
const targetSha = "a".repeat(40);
const workflowSha = "b".repeat(40);
const clawhubToolchainSha256 = "c".repeat(64);
const clawhubToolchainVersion = "0.23.1";
const clawhubToolchainIntegrity =
"sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA==";
afterEach(() => {
for (const directory of tempDirs.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function fixture() {
const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-bootstrap-"));
tempDirs.push(root);
const artifactRoot = join(root, "artifact");
const packageRoot = join(artifactRoot, "packages", "meta");
const existingPackageRoot = join(artifactRoot, "packages", "existing");
mkdirSync(packageRoot, { recursive: true });
mkdirSync(existingPackageRoot, { recursive: true });
writeFileSync(join(packageRoot, "openclaw-meta-2026.7.1-beta.3.tgz"), "packed meta");
writeFileSync(
join(existingPackageRoot, "openclaw-existing-2026.7.1-beta.3.tgz"),
"packed existing",
);
const matrixPath = join(root, "matrix.json");
writeFileSync(
matrixPath,
JSON.stringify([
{
packageName: "@openclaw/meta",
version: "2026.7.1-beta.3",
packageDir: "extensions/meta",
publishTag: "beta",
bootstrapMode: "publish",
requiresManualOverride: false,
},
{
packageName: "@openclaw/existing",
version: "2026.7.1-beta.3",
packageDir: "extensions/existing",
publishTag: "beta",
bootstrapMode: "configure-only",
requiresManualOverride: true,
},
]),
);
return {
artifactRoot,
matrixPath,
manifestPath: join(artifactRoot, "manifest.json"),
};
}
function common(paths: ReturnType<typeof fixture>) {
return {
artifactRoot: paths.artifactRoot,
artifactName: `clawhub-bootstrap-${targetSha.slice(0, 12)}-123-2`,
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
plugins: "@openclaw/meta,@openclaw/existing",
repository: "openclaw/openclaw",
runAttempt: "2",
runId: "123",
targetSha,
workflowSha,
};
}
function writeTarField(header: Buffer, offset: number, length: number, value: string) {
const bytes = Buffer.from(value);
if (bytes.byteLength > length) {
throw new Error(`tar field exceeds ${length} bytes`);
}
bytes.copy(header, offset);
}
function writeTarOctal(header: Buffer, offset: number, length: number, value: number) {
writeTarField(header, offset, length, `${value.toString(8).padStart(length - 2, "0")} \0`);
}
function tarEntry(
name: string,
prefix: string,
contents: string | Uint8Array,
type: "0" | "5" = "0",
) {
const bytes = Buffer.from(contents);
const header = Buffer.alloc(512);
writeTarField(header, 0, 100, name);
writeTarOctal(header, 100, 8, type === "5" ? 0o755 : 0o644);
writeTarOctal(header, 108, 8, 0);
writeTarOctal(header, 116, 8, 0);
writeTarOctal(header, 124, 12, bytes.byteLength);
writeTarOctal(header, 136, 12, 0);
header.fill(0x20, 148, 156);
header[156] = type.charCodeAt(0);
writeTarField(header, 257, 6, "ustar\0");
writeTarField(header, 263, 2, "00");
writeTarOctal(header, 329, 8, 0);
writeTarOctal(header, 337, 8, 0);
writeTarField(header, 345, 155, prefix);
let checksum = 0;
for (const byte of header) {
checksum += byte;
}
writeTarOctal(header, 148, 8, checksum);
const padding = Buffer.alloc((512 - (bytes.byteLength % 512)) % 512);
return Buffer.concat([header, bytes, padding]);
}
function writeClawPack(
entries: Array<{
name: string;
prefix?: string;
contents: string | Uint8Array;
type?: "0" | "5";
}>,
) {
const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-packed-"));
tempDirs.push(root);
const bytes = gzipSync(
Buffer.concat([
...entries.map((entry) =>
tarEntry(entry.name, entry.prefix ?? "", entry.contents, entry.type),
),
Buffer.alloc(1024),
]),
);
const artifactPath = join(root, "package.tgz");
writeFileSync(artifactPath, bytes);
return {
artifactPath,
bytes,
sha256: createHash("sha256").update(bytes).digest("hex"),
};
}
describe("ClawHub bootstrap artifact manifest", () => {
it("binds the exact package set and packed file identity", async () => {
const paths = fixture();
const created = await createClawHubBootstrapArtifactManifest({
...common(paths),
matrixPath: paths.matrixPath,
outputPath: paths.manifestPath,
});
const meta = created.entries.find((entry) => entry.packageName === "@openclaw/meta");
expect(meta).toMatchObject({
artifactPath: "packages/meta/openclaw-meta-2026.7.1-beta.3.tgz",
size: 11,
});
expect(meta?.sha256).toMatch(/^[a-f0-9]{64}$/u);
expect(created.clawhubToolchainIntegrity).toBe(clawhubToolchainIntegrity);
expect(created.clawhubToolchainSha256).toBe(clawhubToolchainSha256);
expect(created.clawhubToolchainVersion).toBe(clawhubToolchainVersion);
await expect(
verifyClawHubBootstrapArtifactManifest({
...common(paths),
manifestPath: paths.manifestPath,
}),
).resolves.toEqual(created);
});
it("rejects changed bytes and extra artifact files", async () => {
const paths = fixture();
await createClawHubBootstrapArtifactManifest({
...common(paths),
matrixPath: paths.matrixPath,
outputPath: paths.manifestPath,
});
writeFileSync(
join(paths.artifactRoot, "packages", "meta", "openclaw-meta-2026.7.1-beta.3.tgz"),
"changed",
);
await expect(
verifyClawHubBootstrapArtifactManifest({
...common(paths),
manifestPath: paths.manifestPath,
}),
).rejects.toThrow("packed artifact hash or size mismatch");
const manifest = JSON.parse(readFileSync(paths.manifestPath, "utf8"));
writeFileSync(
join(paths.artifactRoot, "packages", "meta", "openclaw-meta-2026.7.1-beta.3.tgz"),
"packed meta",
);
writeFileSync(join(paths.artifactRoot, "unexpected.txt"), "unexpected");
await expect(
verifyClawHubBootstrapArtifactManifest({
...common(paths),
manifestPath: paths.manifestPath,
}),
).rejects.toThrow("artifact inventory mismatch");
expect(manifest.entries).toHaveLength(2);
});
it("uses one exact fatal-UTF8 manifest schema for local and archive consumers", async () => {
const paths = fixture();
const created = await createClawHubBootstrapArtifactManifest({
...common(paths),
matrixPath: paths.matrixPath,
outputPath: paths.manifestPath,
});
writeFileSync(paths.manifestPath, JSON.stringify({ ...created, unexpected: true }));
await expect(
verifyClawHubBootstrapArtifactManifest({
...common(paths),
manifestPath: paths.manifestPath,
}),
).rejects.toThrow("keys are invalid");
writeFileSync(paths.manifestPath, Buffer.from([0xff]));
await expect(
verifyClawHubBootstrapArtifactManifest({
...common(paths),
manifestPath: paths.manifestPath,
}),
).rejects.toThrow("not valid UTF-8 JSON");
writeFileSync(paths.manifestPath, JSON.stringify(created));
await expect(
verifyClawHubBootstrapArtifactManifest({
...common(paths),
clawhubToolchainSha256: "d".repeat(64),
manifestPath: paths.manifestPath,
}),
).rejects.toThrow("clawhubToolchainSha256 mismatch");
writeFileSync(
paths.manifestPath,
JSON.stringify({
...created,
entries: [null, ...created.entries.slice(1)],
}),
);
await expect(
verifyClawHubBootstrapArtifactManifest({
...common(paths),
manifestPath: paths.manifestPath,
}),
).rejects.toThrow("manifest.entries[0] must be an object");
});
it("binds exact target bytes to configure-only repairs", async () => {
const paths = fixture();
const manifest = await createClawHubBootstrapArtifactManifest({
...common(paths),
matrixPath: paths.matrixPath,
outputPath: paths.manifestPath,
});
const existing = manifest.entries.find((entry) => entry.packageName === "@openclaw/existing");
expect(existing).toMatchObject({
artifactPath: "packages/existing/openclaw-existing-2026.7.1-beta.3.tgz",
size: 15,
});
expect(existing?.sha256).toMatch(/^[a-f0-9]{64}$/u);
});
it("rejects preexisting and symlinked download output roots before fetching", async () => {
const paths = fixture();
const downloadOptions = {
artifactDigest: "d".repeat(64),
artifactId: "456",
artifactName: `clawhub-bootstrap-${targetSha.slice(0, 12)}-123-2`,
artifactSize: "1",
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
consumerRunAttempt: "2",
repository: "openclaw/openclaw",
producerJobName: "Pack immutable ClawHub bootstrap artifacts",
runAttempt: "2",
runId: "123",
targetSha,
token: "test-token",
workflowSha,
fetchImpl: (() => {
throw new Error("fetch must not run");
}) as typeof fetch,
};
const existingRoot = join(paths.artifactRoot, "existing-output");
mkdirSync(existingRoot);
await expect(
downloadClawHubBootstrapArtifact({
...downloadOptions,
outputRoot: existingRoot,
}),
).rejects.toThrow("output directory must not already exist");
const symlinkTarget = join(paths.artifactRoot, "symlink-target");
const symlinkRoot = join(paths.artifactRoot, "symlink-output");
mkdirSync(symlinkTarget);
symlinkSync(symlinkTarget, symlinkRoot);
await expect(
downloadClawHubBootstrapArtifact({
...downloadOptions,
outputRoot: symlinkRoot,
}),
).rejects.toThrow("output directory must not already exist");
});
});
describe("ClawHub packed artifact identity", () => {
const expectedIdentity = {
expectedDir: "extensions/meta",
expectedName: "@openclaw/meta-provider",
expectedVersion: "2026.7.1-beta.3",
};
it("accepts one canonical package identity and plugin manifest", async () => {
const pack = writeClawPack([
{
name: "package.json",
prefix: "package",
contents: JSON.stringify({
name: "@openclaw/meta-provider",
version: "2026.7.1-beta.3",
openclaw: {
release: {
publishToClawHub: true,
publishToNpm: true,
},
},
}),
},
{
name: "openclaw.plugin.json",
prefix: "package",
contents: JSON.stringify({ id: "meta" }),
},
]);
await expect(
verifyClawHubPackedArtifactIdentity({
artifactPath: pack.artifactPath,
expectedSha256: pack.sha256,
expectedSize: String(pack.bytes.byteLength),
expectedDir: "extensions/meta",
expectedName: "@openclaw/meta-provider",
expectedVersion: "2026.7.1-beta.3",
}),
).resolves.toMatchObject({
packageName: "@openclaw/meta-provider",
packageVersion: "2026.7.1-beta.3",
sha256: pack.sha256,
size: pack.bytes.byteLength,
});
});
it("rejects a whitespace-bearing alias before a later package.json", async () => {
const pack = writeClawPack([
{
name: " package.json ",
prefix: " package ",
contents: JSON.stringify({
name: "@openclaw/meta-provider",
version: "2026.7.1-beta.3",
}),
},
{
name: "package/package.json",
contents: JSON.stringify({
name: "@openclaw/other",
version: "9.9.9",
}),
},
{
name: "package/openclaw.plugin.json",
contents: JSON.stringify({ id: "meta" }),
},
]);
await expect(
verifyClawHubPackedArtifactIdentity({
artifactPath: pack.artifactPath,
expectedSha256: pack.sha256,
expectedSize: String(pack.bytes.byteLength),
expectedDir: "extensions/meta",
expectedName: "@openclaw/meta-provider",
expectedVersion: "2026.7.1-beta.3",
}),
).rejects.toThrow("changes under the pinned ClawHub path normalization");
});
it("rejects a whitespace-bearing alias after a canonical package.json", async () => {
const pack = writeClawPack([
{
name: "package/package.json",
contents: JSON.stringify({
name: "@openclaw/meta-provider",
version: "2026.7.1-beta.3",
}),
},
{
name: " package.json ",
prefix: " package ",
contents: JSON.stringify({
name: "@openclaw/other",
version: "9.9.9",
}),
},
]);
await expect(
verifyClawHubPackedArtifactIdentity({
artifactPath: pack.artifactPath,
expectedSha256: pack.sha256,
expectedSize: String(pack.bytes.byteLength),
...expectedIdentity,
}),
).rejects.toThrow("changes under the pinned ClawHub path normalization");
});
it("rejects a compressed artifact above the ClawHub package limit before reading it", async () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-packed-limit-"));
tempDirs.push(root);
const artifactPath = join(root, "oversized.tgz");
writeFileSync(artifactPath, "");
truncateSync(artifactPath, 120 * 1024 * 1024 + 1);
await expect(
verifyClawHubPackedArtifactIdentity({
artifactPath,
expectedSha256: "a".repeat(64),
expectedSize: "1",
...expectedIdentity,
}),
).rejects.toThrow("exceeds 125829120 bytes");
});
it("bounds expanded tar bytes", async () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-expanded-limit-"));
tempDirs.push(root);
const artifactPath = join(root, "expanded.tgz");
const bytes = gzipSync(Buffer.alloc(64 * 1024 * 1024 + 1));
writeFileSync(artifactPath, bytes);
await expect(
verifyClawHubPackedArtifactIdentity({
artifactPath,
expectedSha256: createHash("sha256").update(bytes).digest("hex"),
expectedSize: String(bytes.byteLength),
...expectedIdentity,
}),
).rejects.toThrow("expands beyond 67108864 bytes");
});
it("bounds individual file payloads", async () => {
const pack = writeClawPack([
{
name: "package/large.bin",
contents: Buffer.alloc(50 * 1024 * 1024 + 1),
},
]);
await expect(
verifyClawHubPackedArtifactIdentity({
artifactPath: pack.artifactPath,
expectedSha256: pack.sha256,
expectedSize: String(pack.bytes.byteLength),
...expectedIdentity,
}),
).rejects.toThrow("entry package/large.bin exceeds 52428800 bytes");
});
it("bounds total file payload bytes", async () => {
const pack = writeClawPack(
Array.from({ length: 6 }, (_, index) => ({
name: `package/chunk-${index}.bin`,
contents: Buffer.alloc(9 * 1024 * 1024),
})),
);
await expect(
verifyClawHubPackedArtifactIdentity({
artifactPath: pack.artifactPath,
expectedSha256: pack.sha256,
expectedSize: String(pack.bytes.byteLength),
...expectedIdentity,
}),
).rejects.toThrow("file payload exceeds 52428800 bytes");
});
it("bounds the total number of TAR entries", async () => {
const entries: Array<{
name: string;
contents: string;
type: "0" | "5";
}> = Array.from({ length: 10_000 }, (_, index) => ({
name: `package/dir-${index}/`,
contents: "",
type: "5",
}));
entries.push({
name: "package/package.json",
contents: "{}",
type: "0",
});
const pack = writeClawPack(entries);
await expect(
verifyClawHubPackedArtifactIdentity({
artifactPath: pack.artifactPath,
expectedSha256: pack.sha256,
expectedSize: String(pack.bytes.byteLength),
...expectedIdentity,
}),
).rejects.toThrow("exceeds the 10000 entry limit");
});
});
+264 -22
View File
@@ -28,6 +28,7 @@ const DOCKER_E2E_PLAN_ACTION = ".github/actions/docker-e2e-plan/action.yml";
const RELEASE_CHECKS_WORKFLOW = ".github/workflows/openclaw-release-checks.yml";
const RELEASE_TELEGRAM_QA_WORKFLOW = ".github/workflows/openclaw-release-telegram-qa.yml";
const RELEASE_PUBLISH_WORKFLOW = ".github/workflows/openclaw-release-publish.yml";
const PLUGIN_CLAWHUB_RELEASE_WORKFLOW = ".github/workflows/plugin-clawhub-release.yml";
const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml";
const STABLE_MAIN_CLOSEOUT_WORKFLOW = ".github/workflows/openclaw-stable-main-closeout.yml";
const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml";
@@ -129,6 +130,20 @@ function workflowStep(job: WorkflowJob, stepName: string): WorkflowStep {
return step;
}
function shellFunctionSource(source: string, functionName: string): string {
const startMarker = `${functionName}() {`;
const endMarker = "\n}\n";
const start = source.indexOf(startMarker);
if (start < 0) {
throw new Error(`Expected shell function ${functionName}`);
}
const end = source.indexOf(endMarker, start);
if (end < 0) {
throw new Error(`Expected shell function terminator for ${functionName}`);
}
return source.slice(start, end + endMarker.length);
}
function workflowMatrixEntry(path: string, jobName: string, suiteId: string): WorkflowMatrixEntry {
const entry = workflowJob(path, jobName).strategy?.matrix?.include?.find(
(candidate) => candidate.suite_id === suiteId,
@@ -2769,7 +2784,9 @@ describe("package artifact reuse", () => {
);
expect(publishOrchestration.run).not.toContain("--full-release-validation-workflow-ref");
expect(publishOrchestration.run).not.toContain("--full-release-validation-run");
expect(publishOrchestration.run).toContain('pnpm "${verify_args[@]}"');
expect(publishOrchestration.run).toContain(
'"${GITHUB_WORKSPACE}/.release-harness/scripts/release-verify-beta.ts"',
);
expect(publishOrchestration.run).toContain(".workflowRuns += [");
expect(publishOrchestration.run).toContain('label: "Full Release Validation"');
expect(publishOrchestration.run).toContain('"${validation_target_sha}" != "${TARGET_SHA}"');
@@ -3145,6 +3162,17 @@ describe("package artifact reuse", () => {
const clawHubMetadataIndex = clawHubWorkflow.indexOf(
"- name: Validate publishable plugin metadata",
);
const releasePublishJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish");
const releaseInputGuard =
workflowStep(
workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target"),
"Validate inputs",
).run ?? "";
const trustedReleaseToolingCheckout = workflowStep(
releasePublishJob,
"Checkout trusted release tooling",
);
const trustedClawHubPlan = workflowStep(releasePublishJob, "Resolve ClawHub release plan");
expect(packageJson.scripts?.["release:verify-beta"]).toBe(
"node --import tsx scripts/release-verify-beta.ts",
@@ -3237,6 +3265,31 @@ describe("package artifact reuse", () => {
expect(clawHubWorkflow).toContain("verify_published_clawhub_package:");
expect(clawHubWorkflow).toContain("inputs.dry_run != true");
expect(clawHubWorkflow).toContain("Verify published ClawHub package");
const clawHubVerifier = workflowJob(
PLUGIN_CLAWHUB_RELEASE_WORKFLOW,
"verify_published_clawhub_package",
);
expect(clawHubVerifier["timeout-minutes"]).toBe(45);
expect(clawHubVerifier.permissions).toMatchObject({ actions: "read", contents: "read" });
expect(workflowStep(clawHubVerifier, "Download published package input")).toMatchObject({
uses: DOWNLOAD_ARTIFACT_V8,
with: {
name: "${{ matrix.plugin.artifactName }}",
path: "${{ runner.temp }}/clawhub-package-artifact",
},
});
const clawHubVerifyRun = workflowStep(clawHubVerifier, "Verify published ClawHub package").run;
expectTextToIncludeAll(clawHubVerifyRun, [
"scripts/verify-clawhub-published-artifact.mjs",
'--expected-artifact-dir "${RUNNER_TEMP}/clawhub-package-artifact"',
'--package-name "${PACKAGE_NAME}"',
'--package-version "${PACKAGE_VERSION}"',
'--publish-tag "${PACKAGE_TAG}"',
'--registry "${CLAWHUB_REGISTRY}"',
]);
expect(clawHubVerifyRun).not.toContain("fetchWithRetry");
expect(clawHubVerifyRun).not.toContain(".json()");
expect(clawHubVerifyRun).not.toContain('method: "HEAD"');
expect(clawHubWorkflow).not.toContain("bash scripts/plugin-clawhub-publish.sh --publish");
expect(clawHubWorkflow).not.toContain("Write ClawHub token config");
expect(clawHubWorkflow).not.toContain("Checkout ClawHub CLI source");
@@ -3257,6 +3310,7 @@ describe("package artifact reuse", () => {
expect(releaseWorkflow).not.toContain("return_run_details: true");
expect(releaseWorkflow).toContain("'.workflow_run_id'");
expect(releaseWorkflow).toContain("'.html_url'");
expect(releaseWorkflow).toContain('gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}"');
expect(releaseWorkflow).not.toContain("BEFORE_IDS=");
expect(releaseWorkflow).not.toContain("before_json");
expect(releaseWorkflow).toContain("plugin-clawhub-new.yml");
@@ -3266,6 +3320,42 @@ describe("package artifact reuse", () => {
expect(isExecutable("scripts/openclaw-release-clawhub-plan.ts")).toBe(true);
expect(isExecutable("scripts/openclaw-release-clawhub-runtime-state.ts")).toBe(true);
expect(releaseWorkflow).toContain("openclaw-release-clawhub-plan.json");
expect(trustedReleaseToolingCheckout.with).toMatchObject({
ref: "${{ github.sha }}",
path: ".release-harness",
"fetch-depth": 1,
"persist-credentials": false,
});
expect(releaseWorkflow).toContain(
'"${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-release-clawhub-plan.ts"',
);
expect(trustedClawHubPlan.run).toContain(
'"${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-release-clawhub-plan.ts"',
);
expect(trustedClawHubPlan.run).toContain('--release-sha "${TARGET_SHA}"');
expect(trustedClawHubPlan.run).toContain(
'--release-publish-run-attempt "${GITHUB_RUN_ATTEMPT}"',
);
expect(trustedClawHubPlan.run).toContain(
'--bootstrap-workflow-sha "${bootstrap_workflow_sha}"',
);
expect(trustedClawHubPlan.run).toContain(
'gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main"',
);
expect(trustedClawHubPlan.run).not.toContain("cd .release-harness");
expect(releaseWorkflow).toContain("Attest ClawHub bootstrap approval");
expect(releaseWorkflow).toContain("Upload ClawHub bootstrap approval");
expect(releaseWorkflow).toContain(
"clawhub-bootstrap-approval-${{ github.run_id }}-${{ github.run_attempt }}",
);
expect(releaseWorkflow).toContain("parentWorkflowSha: process.env.GITHUB_SHA");
expect(releaseWorkflow).toContain("bootstrapWorkflowSha: plan.bootstrapWorkflowSha");
expect(releaseWorkflow).toContain(
'"${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-release-clawhub-runtime-state.ts"',
);
expect(releaseWorkflow).toContain(
'"${GITHUB_WORKSPACE}/.release-harness/scripts/release-verify-beta.ts"',
);
expect(releaseWorkflow).toContain("openclaw-release-clawhub-runtime-state");
expect(releaseWorkflow).toContain("bootstrap_plugins");
expect(releaseWorkflow).toContain("missing_trusted_plugins");
@@ -3282,6 +3372,17 @@ describe("package artifact reuse", () => {
expect(releaseWorkflow).toContain(
"Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish.",
);
expect(releaseWorkflow).toContain(
"refusing environment approval because run head ${run_head_sha} does not match approved workflow SHA ${expected_head_sha}",
);
const verifyBootstrapWorkflowIndex = releaseWorkflow.indexOf(
'bootstrap_workflow_sha="$(verify_bootstrap_workflow_sha)"',
);
const dispatchPluginNpmIndex = releaseWorkflow.indexOf(
'plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml',
);
expect(verifyBootstrapWorkflowIndex).toBeGreaterThan(-1);
expect(dispatchPluginNpmIndex).toBeGreaterThan(verifyBootstrapWorkflowIndex);
expect(releaseWorkflow).toContain("OpenClaw npm run ID");
expect(releaseWorkflow).toContain("npm_telegram_run_id");
expect(releaseWorkflow).toContain('release_publish_run_id="${GITHUB_RUN_ID}"');
@@ -3314,10 +3415,40 @@ describe("package artifact reuse", () => {
expect(clawHubReleasePlanScript).toContain("not awaited by this proof");
expect(releaseWorkflow).toContain("wait_for_job_success");
expect(releaseWorkflow).toContain("Validate release publish approval");
expect(releaseWorkflow).toContain("approve_clawhub_bootstrap_environments");
expect(releaseWorkflow).toContain('"Validate release publish approval" || return 1');
expect(releaseWorkflow).toContain(
'approve_child_publish_environment plugin-clawhub-new.yml "${run_id}" || return 1',
);
expect(releaseWorkflow).toContain('"Validate immutable bootstrap handoff" || return 1');
const firstBootstrapApproval = releaseWorkflow.indexOf(
'approve_child_publish_environment plugin-clawhub-new.yml "${run_id}"',
);
const protectedBootstrapValidation = releaseWorkflow.indexOf(
'"Validate immutable bootstrap handoff"',
firstBootstrapApproval,
);
const secondBootstrapApproval = releaseWorkflow.indexOf(
'approve_child_publish_environment plugin-clawhub-new.yml "${run_id}"',
firstBootstrapApproval + 1,
);
expect(firstBootstrapApproval).toBeGreaterThan(-1);
expect(protectedBootstrapValidation).toBeGreaterThan(firstBootstrapApproval);
expect(secondBootstrapApproval).toBeGreaterThan(protectedBootstrapValidation);
expect(releaseWorkflow).toContain('conclusion" == "skipped"');
expect(releaseWorkflow).toContain("approve_child_publish_environment");
expect(releaseWorkflow).toContain("Approve child release gate after parent release approval");
expect(releaseWorkflow).toContain("release:verify-beta");
expect(releaseWorkflow).toContain('"${verify_args[@]}"');
expect(releaseWorkflow).toContain(
"OpenClaw Release Publish must use trusted main workflow tooling",
);
expect(releaseInputGuard).toContain(
'[[ "${WORKFLOW_REF}" != "refs/heads/main" && "${tideclaw_alpha_publish}" != "true" ]]',
);
expect(releaseInputGuard).not.toContain("refs/heads/release/");
expect(releaseInputGuard).toContain(
'"${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha"',
);
expect(releaseWorkflow).toContain('--workflow-ref "${CHILD_WORKFLOW_REF}"');
expect(releaseWorkflow).toContain("--skip-github-release");
expect(clawHubReleasePlanScript).toContain("--plugin-clawhub-bootstrap-run");
@@ -3363,7 +3494,7 @@ describe("package artifact reuse", () => {
expect(pluginNpmWorkflow).toContain("environment: npm-release");
expect(clawHubWorkflow.match(/environment: clawhub-plugin-release/g)?.length).toBe(1);
expect(clawHubNewWorkflow).toContain("name: Plugin ClawHub New");
expect(clawHubNewWorkflow).toContain('CLAWHUB_CLI_PACKAGE: "clawhub@0.23.1"');
expect(clawHubNewWorkflow).not.toContain("CLAWHUB_CLI_PACKAGE:");
expect(clawHubNewWorkflow).not.toContain("CLAWHUB_REPOSITORY:");
expect(clawHubNewWorkflow).not.toContain("CLAWHUB_REF:");
expect(clawHubNewWorkflow).toContain("environment: clawhub-plugin-bootstrap");
@@ -3373,7 +3504,13 @@ describe("package artifact reuse", () => {
);
expect(clawHubNewWorkflow).not.toContain("clawhub_token:");
expect(clawHubNewWorkflow).toContain("Validate pinned ClawHub trusted publisher CLI support");
expect(clawHubNewWorkflow).toContain('npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}"');
expect(clawHubNewWorkflow).toContain("Materialize locked ClawHub CLI");
expect(clawHubNewWorkflow).toContain("scripts/materialize-clawhub-cli.sh");
expect(clawHubNewWorkflow).not.toContain("npm exec");
expect(clawHubNewWorkflow).not.toContain("npm install");
expect(clawHubNewWorkflow).toContain("--clawhub-toolchain-integrity");
expect(clawHubNewWorkflow).toContain("--clawhub-toolchain-sha256");
expect(clawHubNewWorkflow).toContain("--clawhub-toolchain-version");
expect(clawHubNewWorkflow).toContain(
"CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows",
);
@@ -3387,40 +3524,64 @@ describe("package artifact reuse", () => {
"CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.",
);
expect(clawHubNewWorkflow).toContain("JSON.stringify({ registry, token }, null, 2)");
expect(clawHubNewWorkflow).toContain("Publish ClawHub bootstrap package");
expect(clawHubNewWorkflow).toContain("bash scripts/plugin-clawhub-publish.sh --publish");
expect(clawHubNewWorkflow).toContain("bootstrapMode");
expect(clawHubNewWorkflow).toContain("BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}");
expect(clawHubNewWorkflow).toContain("requiresManualOverride");
expect(clawHubNewWorkflow).toContain("Pack immutable ClawHub bootstrap artifacts");
expect(clawHubNewWorkflow).toContain("Upload immutable ClawHub bootstrap artifact");
expect(clawHubNewWorkflow).toContain("Validate immutable bootstrap handoff");
expect(clawHubNewWorkflow).toContain("Upload immutable bootstrap validation evidence");
expect(clawHubNewWorkflow).toContain(
'OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"',
"Download and verify immutable ClawHub bootstrap artifact",
);
expect(clawHubNewWorkflow).toContain("Rehash immutable ClawHub bootstrap artifacts");
expect(clawHubNewWorkflow).toContain("Download parent ClawHub bootstrap approval");
expect(clawHubNewWorkflow).toContain("RELEASE_APPROVAL_KIND: clawhub-bootstrap");
expect(clawHubNewWorkflow).toContain("gh attestation verify");
expect(clawHubNewWorkflow).toContain(
"actions/runs/${RELEASE_PUBLISH_RUN_ID}/attempts/${EXPECTED_RUN_ATTEMPT}",
);
expect(clawHubNewWorkflow).toContain('--source-digest "${EXPECTED_WORKFLOW_SHA}"');
expect(clawHubNewWorkflow).toContain('git rev-parse "${RELEASE_TAG}^{commit}"');
expect(clawHubNewWorkflow).toContain("refs/remotes/origin/release");
expect(clawHubNewWorkflow).toContain("Require configure-only registry bytes to match target");
expect(clawHubNewWorkflow).toContain(
"Reconfirm configure-only registry bytes before credentials",
);
expect(clawHubNewWorkflow).toContain("--mode configure-only-preflight");
expect(clawHubNewWorkflow).toContain("--validate-packed");
expect(clawHubNewWorkflow).toContain("--publish-packed");
expect(clawHubNewWorkflow).toContain(
"GitHub Actions trusted publisher repair before OIDC migration",
);
expect(clawHubNewWorkflow).toContain("GitHub Actions immutable bootstrap retry");
expect(clawHubNewWorkflow).toContain("configure-only");
expect(clawHubNewWorkflow).toContain(
"version is already present on ClawHub; configuring trusted publisher only",
"EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch }}",
);
expect(clawHubNewWorkflow).toContain(
"EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}",
"TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch }}",
);
expect(clawHubNewWorkflow).toContain(
"TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}",
);
expect(clawHubNewWorkflow).toContain('OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"');
expect(clawHubNewWorkflow).toContain("trusted-publisher set");
expect(clawHubNewWorkflow).toContain("--workflow-filename plugin-clawhub-release.yml");
expect(clawHubNewWorkflow).not.toContain("--environment clawhub-plugin-release");
expect(clawHubNewWorkflow).toContain("trustedPublisher?.environment != null");
expect(clawHubNewWorkflow).toContain("without an environment pin");
expect(clawHubNewWorkflow).not.toContain("Checkout ClawHub CLI source");
expect(clawHubNewWorkflow).not.toContain("packages/clawhub/src/cli.ts");
expect(clawHubNewWorkflow).toContain("verify_bootstrap_clawhub_package:");
expect(clawHubNewWorkflow).toContain("Verify bootstrap ClawHub package and trusted publisher");
expect(clawHubNewWorkflow).toContain("/trusted-publisher");
expect(clawHubNewWorkflow).toContain('trustedPublisher?.repository !== "openclaw/openclaw"');
expect(clawHubNewWorkflow).toContain("Verify exact ClawHub registry artifact bytes");
expect(clawHubNewWorkflow).toContain("verify-clawhub-published-artifact.mjs");
expect(openclawNpmWorkflow).toContain("environment: npm-release");
expect(releaseWorkflow).toContain("default: from-validation");
expect(releaseWorkflow).toContain('--release-publish-branch "${CHILD_WORKFLOW_REF}"');
expect(releaseWorkflow).toContain('--release-publish-run-attempt "${GITHUB_RUN_ATTEMPT}"');
expect(releaseWorkflow).toContain('--release-publish-run-id "${GITHUB_RUN_ID}"');
expect(releaseWorkflow).toContain('--release-sha "${TARGET_SHA}"');
expect(releaseWorkflow).toContain(
'.verifierArgs | index("--plugin-clawhub-bootstrap-run") != null',
);
expect(releaseWorkflow).not.toContain(
"jq -er \\\n '.verifierArgs | index(\"--plugin-clawhub-bootstrap-run\") != null'",
);
expect(releaseWorkflow).toContain(
'[[ -n "${bootstrap_plugins// }" && "${bootstrap_run_arg_present}" == "true" ]]',
);
expect(releaseWorkflow).toContain('--clawhub-bootstrap-plugins "${bootstrap_plugins}"');
expect(releaseWorkflow).toContain("jq -r '.normal.ref' \"${clawhub_plan_path}\"");
expect(releaseWorkflow).toContain("jq -r '.normal.workflow' \"${clawhub_plan_path}\"");
expect(releaseWorkflow).toContain("jq -r '.bootstrap.ref' \"${clawhub_plan_path}\"");
@@ -3447,6 +3608,86 @@ describe("package artifact reuse", () => {
expect(releaseWorkflow).toContain("finished with ${conclusion} in ${duration_label}");
});
it("fails closed when child environment identity or approval mutation fails", () => {
const publishRun =
workflowStep(workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish"), "Dispatch publish workflows")
.run ?? "";
const approvePending = shellFunctionSource(publishRun, "approve_pending_deployments");
const approveChild = shellFunctionSource(publishRun, "approve_child_publish_environment");
const waitForRun = shellFunctionSource(publishRun, "wait_for_run");
const expectedSha = "a".repeat(40);
const mutationFailure = spawnSync(
"bash",
[
"-c",
`
set -uo pipefail
GITHUB_REPOSITORY=openclaw/openclaw
gh() {
if [[ "$1" == "api" && "$2" == "repos/openclaw/openclaw/actions/runs/123" ]]; then
printf '%s\\n' "${expectedSha}"
return 0
fi
if [[ "$1" == "api" && "$2" == "-X" && "$3" == "GET" ]]; then
printf '%s\\n' '[{"environment":{"id":7,"name":"clawhub-plugin-bootstrap"},"current_user_can_approve":true}]'
return 0
fi
if [[ "$1" == "api" && "$2" == "-X" && "$3" == "POST" ]]; then
return 42
fi
return 99
}
${approvePending}
status=0
approve_pending_deployments plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
[[ "$status" -eq 2 ]]
`,
],
{ encoding: "utf8" },
);
expect(mutationFailure.status, mutationFailure.stderr).toBe(0);
const missingApprovedSha = spawnSync(
"bash",
[
"-c",
`
set -uo pipefail
CLAWHUB_PLAN_PATH=/dev/null
GITHUB_REPOSITORY=openclaw/openclaw
GITHUB_STEP_SUMMARY=/dev/null
gh() { return 99; }
print_failed_run_summary() { :; }
${approvePending}
${approveChild}
status=0
approve_child_publish_environment plugin-clawhub-new.yml 123 || status=$?
[[ "$status" -eq 2 ]]
`,
],
{ encoding: "utf8" },
);
expect(missingApprovedSha.status, missingApprovedSha.stderr).toBe(0);
const missingWaitSha = spawnSync(
"bash",
[
"-c",
`
set -uo pipefail
CLAWHUB_PLAN_PATH=/dev/null
${waitForRun}
status=0
wait_for_run plugin-clawhub-new.yml 123 || status=$?
[[ "$status" -eq 1 ]]
`,
],
{ encoding: "utf8" },
);
expect(missingWaitSha.status, missingWaitSha.stderr).toBe(0);
});
it("keeps release workflow setup and timeout budgets bounded", () => {
const fullRelease = readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW);
const releaseChecks = readWorkflow(RELEASE_CHECKS_WORKFLOW);
@@ -3517,6 +3758,7 @@ describe("package artifact reuse", () => {
it("keeps tracked sync metadata and QA Mantis sources visible to remote full syncs", () => {
for (const path of [
".github/release/clawhub-cli/package-lock.json",
".gitignore",
"apps/android/.gitignore",
"extensions/qa-lab/src/mantis/cli.ts",
@@ -0,0 +1,356 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
type Step = {
env?: Record<string, string>;
if?: string;
name?: string;
run?: string;
uses?: string;
with?: Record<string, boolean | string>;
};
type Job = {
environment?: string;
if?: string;
name?: string;
outputs?: Record<string, string>;
permissions?: Record<string, string>;
steps?: Step[];
"timeout-minutes"?: number;
};
type Workflow = {
jobs?: Record<string, Job>;
on?: {
workflow_dispatch?: {
inputs?: Record<string, { required?: boolean }>;
};
};
};
const source = readFileSync(".github/workflows/plugin-clawhub-new.yml", "utf8");
const workflow = parse(source) as Workflow;
const jobs = workflow.jobs ?? {};
const materializerSource = readFileSync("scripts/materialize-clawhub-cli.sh", "utf8");
const clawhubCliPackage = JSON.parse(
readFileSync(".github/release/clawhub-cli/package.json", "utf8"),
) as { dependencies?: Record<string, string> };
const clawhubCliLock = JSON.parse(
readFileSync(".github/release/clawhub-cli/package-lock.json", "utf8"),
) as {
packages?: Record<string, { integrity?: string; version?: string }>;
};
function job(name: string): Job {
const value = jobs[name];
expect(value, `missing ${name}`).toBeDefined();
return value ?? {};
}
function step(jobValue: Job, name: string): Step {
const value = jobValue.steps?.find((entry) => entry.name === name);
expect(value, `missing step ${name}`).toBeDefined();
return value ?? {};
}
describe("Plugin ClawHub New workflow", () => {
it("binds trusted-main workflow code to an exact release target SHA", () => {
expect(workflow.on?.workflow_dispatch?.inputs?.ref?.required).toBe(true);
for (const input of [
"bootstrap_workflow_sha",
"release_tag",
"release_publish_run_id",
"release_publish_run_attempt",
"release_publish_branch",
"pretag_validation",
]) {
expect(workflow.on?.workflow_dispatch?.inputs?.[input]?.required, input).toBe(false);
}
const resolve = job("resolve_bootstrap_plan");
const checkout = step(resolve, "Checkout");
expect(checkout.with?.ref).toBe("${{ github.sha }}");
const guard = step(resolve, "Require trusted main workflow source").run ?? "";
expect(guard).toContain('WORKFLOW_REF}" == "refs/heads/main"');
expect(guard).toContain(
"Plugin ClawHub New workflow SHA does not match the parent-approved trusted-main SHA.",
);
const target = step(resolve, "Resolve checked-out ref").run ?? "";
expect(target).toContain('[[ "${TARGET_REF}" =~ ^[a-f0-9]{40}$ ]]');
expect(target).toContain('git rev-parse "${RELEASE_TAG}^{commit}"');
expect(target).toContain(
"Plugin ClawHub bootstrap target ${TARGET_REF} does not match ${RELEASE_TAG} (${tag_sha}).",
);
expect(target).toContain("refs/remotes/origin/release");
});
it("supports a secretless pre-tag validation mode without tag or parent approval", () => {
const resolveRun = step(job("resolve_bootstrap_plan"), "Resolve checked-out ref").run ?? "";
expect(resolveRun).toContain('[[ "${PRETAG_VALIDATION}" == "true" ]]');
expect(resolveRun).toContain(
"Plugin ClawHub pre-tag validation must not include a release tag or parent approval tuple.",
);
expect(resolveRun).toContain(
"Plugin ClawHub pre-tag validation target must be reachable from main or release/*.",
);
expect(resolveRun).toContain("Plugin ClawHub pre-tag validation requires dry_run=true.");
const approval = job("validate_release_publish_approval");
expect(approval.if).toContain("inputs.pretag_validation != true");
for (const jobName of ["validate_bootstrap_trusted_publisher_cli", "pack_bootstrap_plugins"]) {
const validationJob = job(jobName);
expect(validationJob.if).toContain("inputs.pretag_validation == true");
expect(validationJob.environment).toBeUndefined();
expect(JSON.stringify(validationJob)).not.toContain("secrets.");
expect(JSON.stringify(validationJob)).not.toContain("--publish-packed");
}
const protectedValidation = job("validate_bootstrap_artifact");
expect(protectedValidation.if).toContain("inputs.pretag_validation == true");
expect(protectedValidation.environment).toBe("clawhub-plugin-bootstrap");
expect(JSON.stringify(protectedValidation)).not.toContain("secrets.");
expect(JSON.stringify(protectedValidation)).not.toContain("--publish-packed");
expect(JSON.stringify(protectedValidation)).not.toContain("trusted-publisher set");
const publish = job("publish_bootstrap_plugins");
expect(publish.if).toContain("inputs.pretag_validation != true");
expect(publish.if).toContain("inputs.dry_run != true");
});
it("requires an exact attested parent tuple for approved dry-run validation", () => {
const approval = job("validate_release_publish_approval");
expect(approval.if).not.toContain("inputs.dry_run != true");
expect(approval.if).toContain("inputs.pretag_validation != true");
expect(approval.permissions).toEqual({
actions: "read",
attestations: "read",
contents: "read",
});
expect(step(approval, "Download parent ClawHub bootstrap approval").with).toMatchObject({
name: "clawhub-bootstrap-approval-${{ inputs.release_publish_run_id }}-${{ inputs.release_publish_run_attempt }}",
"run-id": "${{ inputs.release_publish_run_id }}",
});
const validation = step(approval, "Validate release publish approval run");
expect(validation.env).toMatchObject({
RELEASE_APPROVAL_KIND: "clawhub-bootstrap",
CHILD_WORKFLOW_SHA: "${{ github.sha }}",
RELEASE_PACKAGES: "${{ inputs.plugins }}",
RELEASE_TAG: "${{ inputs.release_tag }}",
RELEASE_TARGET_SHA: "${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}",
});
expect(validation.run).toContain("gh attestation verify");
expect(validation.run).toContain(
"actions/runs/${RELEASE_PUBLISH_RUN_ID}/attempts/${EXPECTED_RUN_ATTEMPT}",
);
expect(validation.run).toContain('--source-digest "${EXPECTED_WORKFLOW_SHA}"');
});
it("requires the child workflow SHA to match the separately attested bootstrap tooling SHA", () => {
const validation = step(
job("validate_release_publish_approval"),
"Validate release publish approval run",
);
expect(validation.env?.CHILD_WORKFLOW_SHA).toBe("${{ github.sha }}");
expect(readFileSync("scripts/validate-release-publish-approval.mjs", "utf8")).toContain(
"bootstrapWorkflowSha: childWorkflowSha",
);
});
it("packs target code only in the secretless producer", () => {
const pack = job("pack_bootstrap_plugins");
expect(pack.name).toBe("Pack immutable ClawHub bootstrap artifacts");
expect(pack.environment).toBeUndefined();
expect(pack.permissions).toEqual({ actions: "read", contents: "read" });
const serialized = JSON.stringify(pack);
expect(serialized).not.toContain("secrets.");
expect(pack.outputs).toMatchObject({
artifact_digest: "${{ steps.upload.outputs.artifact-digest }}",
artifact_id: "${{ steps.upload.outputs.artifact-id }}",
artifact_name: "${{ steps.artifact.outputs.name }}",
artifact_run_attempt: "${{ github.run_attempt }}",
artifact_run_id: "${{ github.run_id }}",
artifact_size: "${{ steps.upload_binding.outputs.size }}",
clawhub_toolchain_sha256: "${{ steps.clawhub_cli.outputs.lock_sha256 }}",
});
expect(step(pack, "Upload immutable ClawHub bootstrap artifact").with).toMatchObject({
archive: true,
name: "${{ steps.artifact.outputs.name }}",
path: "${{ runner.temp }}/clawhub-bootstrap-artifact",
});
const packRun = step(pack, "Pack immutable ClawHub bootstrap artifacts").run ?? "";
expect(packRun).not.toContain('mode}" == "configure-only"');
expect(packRun).toContain("bash .release-harness/scripts/plugin-clawhub-publish.sh --pack");
expect(packRun).not.toContain("bash scripts/plugin-clawhub-publish.sh --pack");
expect(packRun).toContain("--validate-packed");
expect(packRun).toContain("--clawhub-toolchain-integrity");
expect(packRun).toContain("--clawhub-toolchain-sha256");
expect(packRun).toContain("--clawhub-toolchain-version");
});
it("always validates the immutable handoff without credentials, including dry runs", () => {
const validate = job("validate_bootstrap_artifact");
expect(validate.environment).toBe("clawhub-plugin-bootstrap");
expect(validate.permissions).toEqual({ actions: "read", contents: "read" });
expect(validate.if).not.toContain("inputs.dry_run != true");
expect(JSON.stringify(validate)).not.toContain("secrets.");
expect(validate["timeout-minutes"]).toBe(45);
const binding =
step(validate, "Download and verify immutable ClawHub bootstrap artifact").run ?? "";
expect(binding).toContain("clawhub-bootstrap-artifact.mjs download");
expect(binding).toContain('--artifact-size "${ARTIFACT_SIZE}"');
expect(binding).toContain('--run-attempt "${ARTIFACT_RUN_ATTEMPT}"');
expect(binding).toContain('--consumer-run-attempt "${GITHUB_RUN_ATTEMPT}"');
expect(binding).toContain('--producer-job-name "Pack immutable ClawHub bootstrap artifacts"');
expect(binding).toContain("--clawhub-toolchain-integrity");
expect(binding).toContain("--clawhub-toolchain-sha256");
expect(binding).toContain("--clawhub-toolchain-version");
expect(step(validate, "Validate packed ClawHub package identities").run).toContain(
"--validate-packed",
);
expect(step(validate, "Require configure-only registry bytes to match target").run).toContain(
"--mode configure-only-preflight",
);
expect(step(validate, "Require configure-only registry bytes to match target").run).toContain(
'--terminal-run-attempt "${GITHUB_RUN_ATTEMPT}"',
);
expect(step(validate, "Upload immutable bootstrap validation evidence").with?.name).toBe(
"clawhub-bootstrap-validation-${{ github.run_id }}-${{ github.run_attempt }}",
);
});
it("uses a fresh trusted-main credential job after immutable validation", () => {
const publish = job("publish_bootstrap_plugins");
expect(publish.environment).toBe("clawhub-plugin-bootstrap");
expect(publish.permissions).toEqual({ actions: "read", contents: "read" });
expect(publish.if).toContain("inputs.dry_run != true");
expect(publish.if).toContain("inputs.pretag_validation != true");
expect(publish.if).toContain("needs.validate_bootstrap_artifact.result == 'success'");
expect(publish["timeout-minutes"]).toBe(120);
const checkout = step(publish, "Checkout trusted workflow tooling");
expect(checkout.with).toMatchObject({
ref: "${{ github.sha }}",
path: ".release-harness",
"persist-credentials": false,
});
const uses = (publish.steps ?? []).flatMap((entry) => (entry.uses ? [entry.uses] : []));
expect(uses).toEqual([
"actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10",
"actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e",
"actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a",
]);
const binding =
step(publish, "Download and verify immutable ClawHub bootstrap artifact").run ?? "";
expect(binding).toContain("clawhub-bootstrap-artifact.mjs download");
expect(binding).toContain('--artifact-size "${ARTIFACT_SIZE}"');
expect(binding).toContain('--run-attempt "${ARTIFACT_RUN_ATTEMPT}"');
expect(binding).toContain('--consumer-run-attempt "${GITHUB_RUN_ATTEMPT}"');
expect(binding).toContain('--producer-job-name "Pack immutable ClawHub bootstrap artifacts"');
expect(binding).toContain("--clawhub-toolchain-integrity");
expect(binding).toContain("--clawhub-toolchain-sha256");
expect(binding).toContain("--clawhub-toolchain-version");
});
it("rehashes and validates tgz identity before exposing the token", () => {
const publish = job("publish_bootstrap_plugins");
const names = (publish.steps ?? []).map((entry) => entry.name);
expect(names.indexOf("Rehash immutable ClawHub bootstrap artifacts")).toBeLessThan(
names.indexOf("Write ClawHub token config"),
);
expect(
names.indexOf("Validate packed ClawHub package identities before credentials"),
).toBeLessThan(names.indexOf("Write ClawHub token config"));
expect(names.indexOf("Materialize locked ClawHub CLI")).toBeLessThan(
names.indexOf("Write ClawHub token config"),
);
expect(
names.indexOf("Reconfirm configure-only registry bytes before credentials"),
).toBeLessThan(names.indexOf("Write ClawHub token config"));
expect(names.indexOf("Reconfirm release tag before credentials")).toBeLessThan(
names.indexOf("Write ClawHub token config"),
);
expect(step(publish, "Rehash immutable ClawHub bootstrap artifacts").run).toContain(
".release-harness/scripts/lib/clawhub-bootstrap-artifact.mjs verify",
);
expect(
step(publish, "Validate packed ClawHub package identities before credentials").run,
).toContain("--validate-packed");
expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain(
"--publish-packed",
);
expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain(
"verify_release_tag_target",
);
expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain(
"OPENCLAW_CLAWHUB_RELEASE_GIT_DIR",
);
expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain(
"OPENCLAW_CLAWHUB_RELEASE_TAG",
);
expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain(
"OPENCLAW_CLAWHUB_TARGET_SHA",
);
});
it("preserves configure-only repair and exact registry byte readback", () => {
const publish = job("publish_bootstrap_plugins");
const publishRun = step(publish, "Publish exact ClawHub bootstrap artifacts").run ?? "";
expect(publishRun).toContain('mode}" == "publish"');
expect(publishRun).toContain("GitHub Actions immutable bootstrap retry");
expect(publishRun).toContain("GitHub Actions trusted publisher repair before OIDC migration");
expect(publishRun).toContain('"${OPENCLAW_CLAWHUB_CLI}" package trusted-publisher set');
expect(publishRun).toContain("timeout --signal=TERM --kill-after=10s 300s");
expect(publishRun).toContain("--repository openclaw/openclaw");
expect(publishRun).toContain("--workflow-filename plugin-clawhub-release.yml");
expect(publishRun).not.toContain("--environment");
expect(step(publish, "Verify exact ClawHub registry artifact bytes").run).toContain(
".release-harness/scripts/verify-clawhub-published-artifact.mjs",
);
expect(step(publish, "Verify exact ClawHub registry artifact bytes").run).toContain(
'--terminal-run-attempt "${GITHUB_RUN_ATTEMPT}"',
);
expect(step(publish, "Upload ClawHub bootstrap readback evidence").with?.name).toBe(
"clawhub-bootstrap-readback-${{ github.run_id }}-${{ github.run_attempt }}",
);
expect(
step(publish, "Reconfirm configure-only registry bytes before credentials").run,
).toContain("--mode configure-only-preflight");
});
it("uses one lockfile-only ClawHub CLI graph and absolute binary path", () => {
expect(clawhubCliPackage.dependencies).toEqual({ clawhub: "0.23.1" });
expect(clawhubCliLock.packages?.["node_modules/clawhub"]).toMatchObject({
integrity:
"sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA==",
version: "0.23.1",
});
expect(materializerSource).toContain("npm ci");
expect(materializerSource).toContain("--ignore-scripts");
expect(materializerSource).toContain("--omit=dev");
expect(materializerSource).toContain(
"f44f670d70f13a8cde566a174cae5be682ad98456ec7a85aafd497f7d8c71816",
);
expect(materializerSource).toContain("lock_sha256=");
expect(materializerSource).toContain("integrity=${clawhub_integrity}");
expect(materializerSource).toContain("cli=${clawhub_cli}");
expect(source).not.toContain("npm exec");
expect(source).not.toContain("npm install");
expect(source).not.toContain("CLAWHUB_CLI_PACKAGE");
expect(source).toContain("OPENCLAW_CLAWHUB_CLI: ${{ steps.clawhub_cli.outputs.cli }}");
expect(source).toContain('"${OPENCLAW_CLAWHUB_CLI}" package trusted-publisher set');
});
it("bounds every job and keeps secretless validation active in dry-run mode", () => {
expect(job("resolve_bootstrap_plan")["timeout-minutes"]).toBe(30);
expect(job("validate_release_publish_approval")["timeout-minutes"]).toBe(20);
expect(job("validate_bootstrap_trusted_publisher_cli")["timeout-minutes"]).toBe(10);
expect(job("validate_bootstrap_trusted_publisher_cli").if).not.toContain(
"inputs.dry_run != true",
);
expect(job("validate_release_publish_approval").if).toContain(
"inputs.pretag_validation != true",
);
expect(job("pack_bootstrap_plugins")["timeout-minutes"]).toBe(60);
});
});
File diff suppressed because it is too large Load Diff
+494
View File
@@ -1,13 +1,75 @@
// Release Beta Verifier tests cover release beta verifier script behavior.
import { createHash } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
downloadClawHubBootstrapReadback,
fetchJsonWithRetry,
fetchStatusWithRetry,
parseNpmViewFields,
parseReleaseVerifyBetaArgs,
readBoundedJsonResponse,
runNpmViewWithRetry,
validateClawHubBootstrapEvidence,
} from "../../scripts/lib/release-beta-verifier.ts";
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createStoredZip(files: Array<{ name: string; bytes: Buffer }>): Buffer {
const localParts: Buffer[] = [];
const centralParts: Buffer[] = [];
let localOffset = 0;
for (const file of files) {
const name = Buffer.from(file.name, "utf8");
const checksum = crc32(file.bytes);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(0, 8);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(file.bytes.length, 18);
local.writeUInt32LE(file.bytes.length, 22);
local.writeUInt16LE(name.length, 26);
localParts.push(local, name, file.bytes);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(0x0314, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(0, 10);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(file.bytes.length, 20);
central.writeUInt32LE(file.bytes.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE((0o100600 * 0x10000) >>> 0, 38);
central.writeUInt32LE(localOffset, 42);
centralParts.push(central, name);
localOffset += local.length + name.length + file.bytes.length;
}
const centralDirectory = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(centralDirectory.length, 12);
end.writeUInt32LE(localOffset, 16);
return Buffer.concat([...localParts, centralDirectory, end]);
}
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
@@ -21,9 +83,11 @@ describe("parseReleaseVerifyBetaArgs", () => {
distTag: "beta",
repo: "openclaw/openclaw",
registry: "https://clawhub.ai",
releaseSha: undefined,
workflowRef: undefined,
clawHubWorkflowRef: undefined,
pluginSelection: [],
clawHubBootstrapPlugins: [],
evidenceOut: undefined,
skipPostpublish: false,
skipGitHubRelease: false,
@@ -40,6 +104,8 @@ describe("parseReleaseVerifyBetaArgs", () => {
"2026.5.10-beta.3",
"--workflow-ref",
"release/2026.5.10",
"--release-sha",
"a".repeat(40),
"--clawhub-workflow-ref",
"v2026.5.10-beta.3",
"--plugins",
@@ -54,6 +120,8 @@ describe("parseReleaseVerifyBetaArgs", () => {
"33",
"--plugin-clawhub-bootstrap-run",
"34",
"--clawhub-bootstrap-plugins",
"@openclaw/plugin-b",
"--npm-telegram-run",
"44",
"--evidence-out",
@@ -69,9 +137,11 @@ describe("parseReleaseVerifyBetaArgs", () => {
distTag: "beta",
repo: "openclaw/openclaw",
registry: "https://clawhub.ai",
releaseSha: "a".repeat(40),
workflowRef: "release/2026.5.10",
clawHubWorkflowRef: "v2026.5.10-beta.3",
pluginSelection: ["@openclaw/plugin-a", "@openclaw/plugin-b"],
clawHubBootstrapPlugins: ["@openclaw/plugin-b"],
evidenceOut: ".artifacts/release-evidence.json",
skipPostpublish: true,
skipGitHubRelease: true,
@@ -87,6 +157,385 @@ describe("parseReleaseVerifyBetaArgs", () => {
},
});
});
it("requires exact target and package inputs for bootstrap run verification", () => {
expect(() =>
parseReleaseVerifyBetaArgs(["2026.5.10-beta.3", "--plugin-clawhub-bootstrap-run", "34"]),
).toThrow("--plugin-clawhub-bootstrap-run requires --release-sha");
expect(() =>
parseReleaseVerifyBetaArgs([
"2026.5.10-beta.3",
"--release-sha",
"a".repeat(40),
"--plugin-clawhub-bootstrap-run",
"34",
]),
).toThrow("--plugin-clawhub-bootstrap-run requires --clawhub-bootstrap-plugins");
expect(() =>
parseReleaseVerifyBetaArgs([
"2026.5.10-beta.3",
"--clawhub-bootstrap-plugins",
"@openclaw/plugin-b",
]),
).toThrow("--clawhub-bootstrap-plugins requires --plugin-clawhub-bootstrap-run");
});
});
describe("validateClawHubBootstrapEvidence", () => {
const clawhubToolchainIntegrity =
"sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA==";
const clawhubToolchainSha256 = "f44f670d70f13a8cde566a174cae5be682ad98456ec7a85aafd497f7d8c71816";
const clawhubToolchainVersion = "0.23.1";
const releaseSha = "a".repeat(40);
const workflowSha = "b".repeat(40);
const packageSha = "c".repeat(64);
const readbackSha = "d".repeat(64);
const run = {
id: 34,
name: "Plugin ClawHub New",
event: "workflow_dispatch",
head_branch: "main",
head_sha: workflowSha,
path: ".github/workflows/plugin-clawhub-new.yml@refs/heads/main",
run_attempt: 2,
status: "completed",
conclusion: "success",
html_url: "https://github.com/openclaw/openclaw/actions/runs/34",
created_at: "2026-07-10T00:00:00Z",
updated_at: "2026-07-10T00:02:00Z",
};
const workflowRun = {
id: 34,
head_branch: "main",
head_sha: workflowSha,
};
const readbackArtifact = {
id: 45,
name: "clawhub-bootstrap-readback-34-2",
digest: `sha256:${readbackSha}`,
size_in_bytes: 1,
expired: false,
workflow_run: workflowRun,
};
const packageArtifact = {
id: 46,
name: `clawhub-bootstrap-${releaseSha.slice(0, 12)}-34-1`,
digest: `sha256:${packageSha}`,
expired: false,
workflow_run: workflowRun,
};
const evidence = {
schemaVersion: 2,
repository: "openclaw/openclaw",
targetSha: releaseSha,
workflowSha,
runId: "34",
producerRunAttempt: "1",
terminalRunAttempt: "2",
artifactName: packageArtifact.name,
artifactId: "46",
artifactDigest: packageSha,
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
requestedPlugins: ["@openclaw/meta"],
verificationMode: "postpublish",
packages: [
{
packageName: "@openclaw/meta",
version: "2026.7.1-beta.3",
expectedSha256: packageSha,
expectedSize: 123,
registrySha256: packageSha,
registrySize: 123,
npmIntegrity: "sha512-test",
npmShasum: "1".repeat(40),
artifactMetadata: {
kind: "npm-pack",
sha256: packageSha,
size: 123,
npmIntegrity: "sha512-test",
npmShasum: "1".repeat(40),
packageName: "@openclaw/meta",
version: "2026.7.1-beta.3",
},
},
],
};
function validate(
overrides: {
run?: unknown;
readbackArtifact?: unknown;
packageArtifact?: unknown;
evidence?: unknown;
expectedPackages?: string[];
} = {},
) {
return validateClawHubBootstrapEvidence({
repo: "openclaw/openclaw",
runId: "34",
releaseSha,
expectedVersion: "2026.7.1-beta.3",
expectedPackages: overrides.expectedPackages ?? ["@openclaw/meta"],
run: overrides.run ?? run,
readbackArtifact: overrides.readbackArtifact ?? readbackArtifact,
readbackArchiveSha256: readbackSha,
packageArtifact: overrides.packageArtifact ?? packageArtifact,
evidence: overrides.evidence ?? evidence,
});
}
it("binds the exact main run, attempt, target, package set, and artifact tuple", () => {
expect(validate()).toMatchObject({
id: "34",
label: "Plugin ClawHub New",
durationSeconds: 120,
bootstrapEvidence: {
targetSha: releaseSha,
workflowSha,
workflowPath: ".github/workflows/plugin-clawhub-new.yml",
producerRunAttempt: "1",
terminalRunAttempt: "2",
readbackArtifactId: "45",
readbackArtifactDigest: readbackSha,
packageArtifactId: "46",
packageArtifactDigest: packageSha,
packageCount: 1,
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
},
});
});
it("rejects legacy release-ref runs and mismatched target/package evidence", () => {
expect(() => validate({ run: { ...run, head_branch: "release/2026.7.1" } })).toThrow(
"not dispatched from trusted main",
);
expect(() =>
validate({
run: { ...run, path: ".github/workflows/not-plugin-clawhub-new.yml" },
}),
).toThrow("unexpected workflow path");
expect(() => validate({ evidence: { ...evidence, targetSha: "e".repeat(40) } })).toThrow(
"target SHA mismatch",
);
expect(() => validate({ expectedPackages: ["@openclaw/other"] })).toThrow(
"requested package set mismatch",
);
});
it("rejects stale attempts, changed artifact bytes, and metadata drift", () => {
expect(() =>
validate({
readbackArtifact: {
...readbackArtifact,
name: "clawhub-bootstrap-readback-34-1",
},
}),
).toThrow("does not bind the run attempt");
expect(() =>
validate({
evidence: { ...evidence, terminalRunAttempt: "1" },
}),
).toThrow("readback evidence run tuple mismatch");
expect(() =>
validate({
evidence: { ...evidence, producerRunAttempt: "3" },
}),
).toThrow("producer attempt is newer than its terminal attempt");
expect(() =>
validate({
packageArtifact: {
...packageArtifact,
name: `clawhub-bootstrap-${releaseSha.slice(0, 12)}-34-2`,
},
evidence: {
...evidence,
artifactName: `clawhub-bootstrap-${releaseSha.slice(0, 12)}-34-2`,
},
}),
).toThrow("package artifact name does not bind the target and attempt");
expect(() =>
validate({
packageArtifact: {
...packageArtifact,
digest: `sha256:${"e".repeat(64)}`,
},
}),
).toThrow("package artifact digest mismatch");
expect(() =>
validate({
evidence: {
...evidence,
packages: [
{
...evidence.packages[0],
artifactMetadata: {
...evidence.packages[0].artifactMetadata,
npmIntegrity: "sha512-different",
},
},
],
},
}),
).toThrow("artifact metadata does not match downloaded bytes");
expect(() =>
validate({
evidence: {
...evidence,
clawhubToolchainSha256: "e".repeat(64),
},
}),
).toThrow("clawhubToolchainSha256 mismatch");
});
});
describe("downloadClawHubBootstrapReadback", () => {
const workflowSha = "b".repeat(40);
const run = {
id: 34,
name: "Plugin ClawHub New",
event: "workflow_dispatch",
head_branch: "main",
head_sha: workflowSha,
path: ".github/workflows/plugin-clawhub-new.yml@refs/heads/main",
run_attempt: 2,
status: "completed",
conclusion: "success",
};
const workflowAttempt = {
id: 34,
run_attempt: 2,
head_sha: workflowSha,
head_branch: "main",
event: "workflow_dispatch",
path: ".github/workflows/plugin-clawhub-new.yml",
status: "completed",
conclusion: "success",
repository: { full_name: "openclaw/openclaw" },
head_repository: { full_name: "openclaw/openclaw" },
};
function createFixture(
archive: Buffer,
overrides: {
artifactMetadata?: Record<string, unknown>;
workflowAttempt?: Record<string, unknown>;
} = {},
) {
const readbackArtifact = {
id: 45,
name: "clawhub-bootstrap-readback-34-2",
digest: `sha256:${sha256(archive)}`,
size_in_bytes: archive.length,
expired: false,
workflow_run: {
id: 34,
head_branch: "main",
head_sha: workflowSha,
},
};
const artifactMetadata = {
...readbackArtifact,
...overrides.artifactMetadata,
};
const attemptMetadata = {
...workflowAttempt,
...overrides.workflowAttempt,
};
const fetchImpl = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith("/actions/artifacts/45")) {
return Response.json(artifactMetadata);
}
if (url.endsWith("/actions/runs/34/attempts/2")) {
return Response.json(attemptMetadata);
}
if (url.endsWith("/actions/artifacts/45/zip")) {
return new Response(archive, {
headers: { "content-length": String(archive.length) },
});
}
throw new Error(`unexpected request: ${url}`);
});
return { fetchImpl, readbackArtifact };
}
async function download(
archive: Buffer,
overrides?: Parameters<typeof createFixture>[1],
): Promise<{
result: Awaited<ReturnType<typeof downloadClawHubBootstrapReadback>>;
requests: number;
}> {
const fixture = createFixture(archive, overrides);
const result = await downloadClawHubBootstrapReadback({
repo: "openclaw/openclaw",
runId: "34",
run,
readbackArtifact: fixture.readbackArtifact,
token: "test-token",
fetchImpl: fixture.fetchImpl,
retryAttempts: 1,
retryDelayMs: 1,
timeoutMs: 1_000,
});
return { result, requests: fixture.fetchImpl.mock.calls.length };
}
it("downloads one exact readback file from the bound successful main attempt", async () => {
const evidence = { schemaVersion: 2, targetSha: "a".repeat(40) };
const archive = createStoredZip([
{
name: "clawhub-bootstrap-readback.json",
bytes: Buffer.from(JSON.stringify(evidence)),
},
]);
await expect(download(archive)).resolves.toEqual({
result: {
value: evidence,
archiveSha256: sha256(archive),
},
requests: 3,
});
});
it("rejects stale live artifact and workflow-attempt metadata", async () => {
const archive = createStoredZip([
{
name: "clawhub-bootstrap-readback.json",
bytes: Buffer.from("{}"),
},
]);
await expect(
download(archive, {
artifactMetadata: { digest: `sha256:${"c".repeat(64)}` },
}),
).rejects.toThrow("artifact metadata does not match the immutable publication tuple");
await expect(
download(archive, {
workflowAttempt: { run_attempt: 1 },
}),
).rejects.toThrow("workflow run does not match the immutable publication tuple");
});
it("rejects hostile or expanded readback inventories through the shared ZIP policy", async () => {
const hostileArchives = [
createStoredZip([{ name: "../clawhub-bootstrap-readback.json", bytes: Buffer.from("{}") }]),
createStoredZip([
{ name: "clawhub-bootstrap-readback.json", bytes: Buffer.from("{}") },
{ name: "extra.json", bytes: Buffer.from("{}") },
]),
];
for (const archive of hostileArchives) {
await expect(download(archive)).rejects.toThrow(/(?:Unsafe ZIP entry|Actions artifact ZIP)/u);
}
});
});
describe("parseNpmViewFields", () => {
@@ -198,6 +647,51 @@ describe("fetchStatusWithRetry", () => {
});
});
describe("fetchJsonWithRetry", () => {
it("retries invalid and failed response bodies within the attempt budget", async () => {
const delays: number[] = [];
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(new Response("{invalid"))
.mockResolvedValueOnce(
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.error(new Error("truncated"));
},
}),
),
)
.mockResolvedValueOnce(Response.json({ ok: true }));
await expect(
fetchJsonWithRetry("https://clawhub.test/api/v1/package", {
attempts: 3,
delay: async (delayMs) => {
delays.push(delayMs);
},
fetchImpl,
}),
).resolves.toEqual({ ok: true });
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(delays).toEqual([1000, 2000]);
});
it("fails permanent client errors without retrying", async () => {
const delay = vi.fn(async () => {});
const fetchImpl = vi.fn(async () => new Response("denied", { status: 403 }));
await expect(
fetchJsonWithRetry("https://clawhub.test/api/v1/package", {
attempts: 3,
delay,
fetchImpl,
}),
).rejects.toThrow("returned HTTP 403");
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(delay).not.toHaveBeenCalled();
});
});
describe("readBoundedJsonResponse", () => {
it("parses JSON bodies within the release verifier limit", async () => {
await expect(
@@ -50,28 +50,81 @@ describe("release candidate checklist", () => {
expect(output).toHaveLength(2 * 1024 * 1024);
});
it("requires candidate tooling to come from the clean tagged checkout", () => {
it("keeps the frozen release target separate from clean trusted workflow tooling", () => {
expect(
validateCandidateCheckout({
targetSha: "a".repeat(40),
headSha: "a".repeat(40),
trackedStatus: "",
targetHeadSha: "a".repeat(40),
targetTrackedStatus: "",
toolingSha: "b".repeat(40),
trustedToolingSha: "b".repeat(40),
toolingTrackedStatus: "",
workflowRef: "main",
}),
).toEqual({ status: "passed", targetSha: "a".repeat(40) });
).toEqual({
status: "passed",
targetSha: "a".repeat(40),
toolingSha: "b".repeat(40),
workflowRef: "main",
});
expect(() =>
validateCandidateCheckout({
targetSha: "a".repeat(40),
headSha: "b".repeat(40),
trackedStatus: "",
targetHeadSha: "c".repeat(40),
targetTrackedStatus: "",
toolingSha: "b".repeat(40),
trustedToolingSha: "b".repeat(40),
toolingTrackedStatus: "",
workflowRef: "main",
}),
).toThrow("but HEAD is");
).toThrow("target worktree HEAD");
expect(() =>
validateCandidateCheckout({
targetSha: "a".repeat(40),
headSha: "a".repeat(40),
trackedStatus: " M scripts/release-candidate-checklist.mjs",
targetHeadSha: "a".repeat(40),
targetTrackedStatus: " M package.json",
toolingSha: "b".repeat(40),
trustedToolingSha: "b".repeat(40),
toolingTrackedStatus: "",
workflowRef: "main",
}),
).toThrow("requires a clean tracked worktree");
).toThrow("clean tracked target worktree");
expect(() =>
validateCandidateCheckout({
targetSha: "a".repeat(40),
targetHeadSha: "a".repeat(40),
targetTrackedStatus: "",
toolingSha: "b".repeat(40),
trustedToolingSha: "c".repeat(40),
toolingTrackedStatus: "",
workflowRef: "main",
}),
).toThrow("does not match trusted main");
expect(() =>
validateCandidateCheckout({
targetSha: "a".repeat(40),
targetHeadSha: "a".repeat(40),
targetTrackedStatus: "",
toolingSha: "b".repeat(40),
trustedToolingSha: "b".repeat(40),
toolingTrackedStatus: " M scripts/release-candidate-checklist.mjs",
workflowRef: "main",
}),
).toThrow("clean tracked tooling checkout");
const source = readFileSync("scripts/release-candidate-checklist.mjs", "utf8");
expect(source).toContain('const TOOLING_ROOT = fileURLToPath(new URL("../", import.meta.url))');
expect(source).toContain("`+refs/heads/${workflowRef}:${remoteRef}`");
expect(source).toContain('"worktree", "add", "--detach", toolingRoot, trustedToolingSha');
expect(source).toContain(
'[join(toolingRoot, "scripts/release-candidate-checklist.mjs"), ...argv]',
);
expect(source).toContain("cwd: targetRoot");
expect(source).toContain('"worktree", "remove", "--force", toolingRoot');
expect(source).toContain(
"const trustedToolingSha = fetchTrustedWorkflowSha(options.workflowRef, TOOLING_ROOT)",
);
expect(source).toContain('targetHeadSha: gitRevParse("HEAD", targetRoot)');
expect(source).toContain("toolingTrackedStatus: gitTrackedStatus(TOOLING_ROOT)");
});
it("validates the exact tag changelog before dispatching the release matrix", () => {
@@ -427,6 +480,31 @@ describe("release candidate checklist", () => {
);
});
it("uses trusted main for regular release workflow tooling", () => {
expect(parseArgs(["--tag", "v2026.5.14-beta.3"]).workflowRef).toBe("main");
expect(() =>
parseArgs(["--tag", "v2026.5.14-beta.3", "--workflow-ref", "release/2026.5.14"]),
).toThrow("--workflow-ref must be main");
});
it("preserves the matching Tideclaw alpha workflow source", () => {
const workflowRef = "tideclaw/alpha/2026-07-10-1200Z";
const options = parseArgs([
"--tag",
"v2026.7.1-alpha.3",
"--workflow-ref",
workflowRef,
"--npm-dist-tag",
"alpha",
]);
expect(options.workflowRef).toBe(workflowRef);
expect(buildPublishCommand(options)).toContain(`'--ref' '${workflowRef}'`);
expect(() => parseArgs(["--tag", "v2026.7.1-alpha.3"])).toThrow(
"--workflow-ref must be the matching tideclaw/alpha/",
);
});
it("rejects duplicate release candidate CLI options", () => {
const requiredArgs = ["--tag", "v2026.5.14-beta.3"];
const duplicateOption = (
@@ -561,14 +639,14 @@ describe("release candidate checklist", () => {
"--tag",
"v2026.5.14-beta.3",
"--workflow-ref",
"release/2026.5.14",
"main",
"--full-release-run",
"111",
"--npm-preflight-run",
"222",
"--skip-dispatch",
]),
workflowRef: "release/2026.5.14",
workflowRef: "main",
fullReleaseRunAttempt: 2,
};
@@ -578,6 +656,7 @@ describe("release candidate checklist", () => {
expect(command).toContain("'preflight_run_id=222'");
expect(command).toContain("'tag=v2026.5.14-beta.3'");
expect(command).toContain("'plugin_publish_scope=all-publishable'");
expect(command).toContain("'--ref' 'main'");
expect(command).not.toContain("windows_node_tag=");
const workflow = parse(
@@ -606,9 +685,9 @@ describe("release candidate checklist", () => {
"--windows-node-tag",
"v0.6.3",
"--workflow-ref",
"release/2026.5.14",
"main",
]),
workflowRef: "release/2026.5.14",
workflowRef: "main",
windowsNodeInstallerDigests: JSON.stringify({
"OpenClawCompanion-Setup-x64.exe": `sha256:${"a".repeat(64)}`,
"OpenClawCompanion-Setup-arm64.exe": `sha256:${"b".repeat(64)}`,
@@ -727,14 +806,14 @@ describe("release candidate checklist", () => {
"--tag",
"v2026.5.14-beta.3",
"--workflow-ref",
"release/2026.5.14",
"main",
"--full-release-run",
"111",
"--npm-preflight-run",
"222",
"--skip-dispatch",
]),
workflowRef: "release/2026.5.14",
workflowRef: "main",
npmTelegramRunId: "333",
};
+94 -4
View File
@@ -1,16 +1,47 @@
// Release wrapper script tests keep changed-target routing tied to scripts that load the wrappers.
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const UNKNOWN_PACKAGE = "@openclaw/not-a-real-release-wrapper-test-package";
const tempDirs: string[] = [];
const tsxImport = import.meta.resolve("tsx");
function runTsxScript(scriptPath: string, args: string[]) {
return spawnSync(process.execPath, ["--import", "tsx", scriptPath, ...args], {
cwd: process.cwd(),
afterEach(() => {
for (const directory of tempDirs.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function runTsxScript(scriptPath: string, args: string[], cwd = process.cwd()) {
return spawnSync(process.execPath, ["--import", tsxImport, scriptPath, ...args], {
cwd,
encoding: "utf8",
});
}
function createOldReleaseTarget() {
const root = mkdtempSync(join(tmpdir(), "openclaw-old-release-target-"));
tempDirs.push(root);
mkdirSync(join(root, "extensions"), { recursive: true });
mkdirSync(join(root, "scripts"), { recursive: true });
writeFileSync(
join(root, "package.json"),
`${JSON.stringify({ name: "openclaw", version: "2026.7.1-beta.3" }, null, 2)}\n`,
);
writeFileSync(
join(root, "scripts", "openclaw-release-clawhub-plan.ts"),
'throw new Error("old target planner invoked");\n',
);
writeFileSync(
join(root, "scripts", "release-verify-beta.ts"),
'throw new Error("old target verifier invoked");\n',
);
return root;
}
describe("release wrapper scripts", () => {
it("runs plugin release wrapper CLIs and rejects unknown explicit selections", () => {
for (const scriptPath of [
@@ -31,8 +62,14 @@ describe("release wrapper scripts", () => {
it("loads the OpenClaw ClawHub plan CLI and validates required arguments before planning", () => {
const result = runTsxScript("scripts/openclaw-release-clawhub-plan.ts", [
"--bootstrap-workflow-sha",
"b".repeat(40),
"--release-tag",
"v2026.6.21-beta.1",
"--release-sha",
"a".repeat(40),
"--release-publish-run-attempt",
"1",
"--release-publish-run-id",
"123",
]);
@@ -42,6 +79,59 @@ describe("release wrapper scripts", () => {
expect(result.stdout).toBe("");
});
it("runs trusted harness planning and verification against an old release target cwd", () => {
const oldTarget = createOldReleaseTarget();
const repositoryRoot = process.cwd();
const releaseSha = "a".repeat(40);
const plan = runTsxScript(
join(repositoryRoot, "scripts/openclaw-release-clawhub-plan.ts"),
[
"--bootstrap-workflow-sha",
"b".repeat(40),
"--release-tag",
"v2026.7.1-beta.3",
"--release-sha",
releaseSha,
"--release-publish-branch",
"main",
"--release-publish-run-attempt",
"1",
"--release-publish-run-id",
"123",
"--plugin-publish-scope",
"all-publishable",
],
oldTarget,
);
expect(plan.status).toBe(0);
expect(JSON.parse(plan.stdout)).toMatchObject({
bootstrapWorkflowSha: "b".repeat(40),
bootstrap: { ref: "main", shouldDispatch: false },
normal: { ref: "v2026.7.1-beta.3", shouldDispatch: false },
});
expect(plan.stderr).not.toContain("old target planner invoked");
const verify = runTsxScript(
join(repositoryRoot, "scripts/release-verify-beta.ts"),
[
"2026.7.1-beta.4",
"--release-sha",
releaseSha,
"--clawhub-bootstrap-plugins",
"@openclaw/meta",
"--plugin-clawhub-bootstrap-run",
"34",
],
oldTarget,
);
expect(verify.status).toBe(1);
expect(verify.stderr).toContain(
"package.json version is 2026.7.1-beta.3; expected 2026.7.1-beta.4.",
);
expect(verify.stderr).not.toContain("old target verifier invoked");
expect(verify.stderr).not.toContain("Unknown argument");
});
it("loads the beta verifier CLI and validates required version input before remote checks", () => {
const result = runTsxScript("scripts/release-verify-beta.ts", ["--skip-clawhub"]);
@@ -11,10 +11,14 @@ const tempRoots = useAutoCleanupTempDirTracker(afterEach);
function runApprovalScript(
run: Record<string, unknown>,
env: {
CHILD_WORKFLOW_SHA?: string;
DIRECT_RELEASE_RECOVERY?: string;
EXPECTED_WORKFLOW_BRANCH?: string;
EXPECTED_RUN_ATTEMPT?: string;
APPROVAL_PATH?: string;
GITHUB_REPOSITORY?: string;
RELEASE_APPROVAL_KIND?: string;
RELEASE_PACKAGES?: string;
RELEASE_TAG?: string;
RELEASE_PUBLISH_RUN_ID?: string;
RELEASE_TARGET_SHA?: string;
@@ -25,10 +29,14 @@ function runApprovalScript(
encoding: "utf8",
env: {
...process.env,
CHILD_WORKFLOW_SHA: env.CHILD_WORKFLOW_SHA ?? "b".repeat(40),
DIRECT_RELEASE_RECOVERY: env.DIRECT_RELEASE_RECOVERY ?? "false",
EXPECTED_WORKFLOW_BRANCH: env.EXPECTED_WORKFLOW_BRANCH ?? "release/2026.6.21",
EXPECTED_RUN_ATTEMPT: env.EXPECTED_RUN_ATTEMPT ?? "",
APPROVAL_PATH: env.APPROVAL_PATH ?? "",
GITHUB_REPOSITORY: env.GITHUB_REPOSITORY ?? "openclaw/openclaw",
RELEASE_APPROVAL_KIND: env.RELEASE_APPROVAL_KIND ?? "android",
RELEASE_PACKAGES: env.RELEASE_PACKAGES ?? "",
RELEASE_TAG: env.RELEASE_TAG ?? "v2026.6.21",
RELEASE_PUBLISH_RUN_ID: env.RELEASE_PUBLISH_RUN_ID ?? "123",
RELEASE_TARGET_SHA: env.RELEASE_TARGET_SHA ?? "a".repeat(40),
@@ -68,6 +76,30 @@ function approvalRun(overrides: Record<string, unknown> = {}) {
};
}
function writeClawHubApproval(overrides: Record<string, unknown> = {}) {
const tempRoot = tempRoots.make("openclaw-clawhub-bootstrap-approval-");
const approvalPath = path.join(tempRoot, "approval.json");
fs.writeFileSync(
approvalPath,
`${JSON.stringify({
version: 2,
kind: "clawhub-bootstrap",
repository: "openclaw/openclaw",
workflow: "OpenClaw Release Publish",
parentRunId: "123",
parentRunAttempt: 2,
workflowBranch: "main",
parentWorkflowSha: "d".repeat(40),
bootstrapWorkflowSha: "b".repeat(40),
releaseTag: "v2026.7.1-beta.3",
targetSha: "a".repeat(40),
packages: ["@openclaw/meta-provider", "@openclaw/voice-call"],
...overrides,
})}\n`,
);
return approvalPath;
}
describe("scripts/validate-release-publish-approval.mjs", () => {
it("accepts an in-progress release publish workflow run for approval", () => {
const result = runApprovalScript(approvalRun());
@@ -108,6 +140,131 @@ describe("scripts/validate-release-publish-approval.mjs", () => {
expect(result.stderr).toBe("");
});
it("accepts an exact attested ClawHub bootstrap parent tuple", () => {
const approvalPath = writeClawHubApproval();
const result = runApprovalScript(
approvalRun({
headBranch: "main",
headSha: "d".repeat(40),
runAttempt: 2,
}),
{
APPROVAL_PATH: approvalPath,
EXPECTED_WORKFLOW_BRANCH: "main",
EXPECTED_RUN_ATTEMPT: "2",
RELEASE_APPROVAL_KIND: "clawhub-bootstrap",
RELEASE_PACKAGES: "@openclaw/voice-call,@openclaw/meta-provider",
RELEASE_TAG: "v2026.7.1-beta.3",
},
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
});
it("accepts a child workflow SHA that differs from the approving parent tooling", () => {
const approvalPath = writeClawHubApproval();
const result = runApprovalScript(
approvalRun({
headBranch: "main",
headSha: "d".repeat(40),
runAttempt: 2,
}),
{
APPROVAL_PATH: approvalPath,
EXPECTED_WORKFLOW_BRANCH: "main",
EXPECTED_RUN_ATTEMPT: "2",
RELEASE_APPROVAL_KIND: "clawhub-bootstrap",
RELEASE_PACKAGES: "@openclaw/meta-provider,@openclaw/voice-call",
RELEASE_TAG: "v2026.7.1-beta.3",
},
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
});
it("rejects a child workflow SHA that differs from the attested bootstrap tooling", () => {
const approvalPath = writeClawHubApproval();
const result = runApprovalScript(
approvalRun({
headBranch: "main",
headSha: "d".repeat(40),
runAttempt: 2,
}),
{
APPROVAL_PATH: approvalPath,
CHILD_WORKFLOW_SHA: "c".repeat(40),
EXPECTED_WORKFLOW_BRANCH: "main",
EXPECTED_RUN_ATTEMPT: "2",
RELEASE_APPROVAL_KIND: "clawhub-bootstrap",
RELEASE_PACKAGES: "@openclaw/meta-provider,@openclaw/voice-call",
RELEASE_TAG: "v2026.7.1-beta.3",
},
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
"Attested ClawHub bootstrap approval does not match this release target and package set.",
);
});
it("rejects a ClawHub bootstrap handoff without an attested approval artifact", () => {
const result = runApprovalScript(
approvalRun({
headBranch: "main",
headSha: "d".repeat(40),
runAttempt: 2,
}),
{
EXPECTED_WORKFLOW_BRANCH: "main",
EXPECTED_RUN_ATTEMPT: "2",
RELEASE_APPROVAL_KIND: "clawhub-bootstrap",
RELEASE_PACKAGES: "@openclaw/meta-provider,@openclaw/voice-call",
RELEASE_TAG: "v2026.7.1-beta.3",
},
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
"ClawHub bootstrap approval requires an attested approval artifact.",
);
});
it.each([
["release tag", { releaseTag: "v2026.7.1-beta.2" }, {}],
["target SHA", { targetSha: "c".repeat(40) }, {}],
["package set", { packages: ["@openclaw/meta-provider"] }, {}],
["parent attempt", { parentRunAttempt: 1 }, {}],
["parent workflow SHA", { parentWorkflowSha: "c".repeat(40) }, {}],
["bootstrap workflow SHA", { bootstrapWorkflowSha: "c".repeat(40) }, {}],
["extra field", { unexpected: true }, {}],
["requested attempt", {}, { EXPECTED_RUN_ATTEMPT: "3" }],
])("rejects a ClawHub bootstrap approval for another %s", (_name, overrides, envOverrides) => {
const approvalPath = writeClawHubApproval(overrides);
const result = runApprovalScript(
approvalRun({
headBranch: "main",
headSha: "d".repeat(40),
runAttempt: 2,
}),
{
APPROVAL_PATH: approvalPath,
EXPECTED_WORKFLOW_BRANCH: "main",
EXPECTED_RUN_ATTEMPT: "2",
RELEASE_APPROVAL_KIND: "clawhub-bootstrap",
RELEASE_PACKAGES: "@openclaw/meta-provider,@openclaw/voice-call",
RELEASE_TAG: "v2026.7.1-beta.3",
...envOverrides,
},
);
expect(result.status).toBe(1);
expect(result.stderr).toMatch(
/Attested ClawHub bootstrap approval does not match|must use attempt/u,
);
});
it.each([
["parent run", { parentRunId: "999" }],
["release tag", { releaseTag: "v2026.6.22" }],
@@ -0,0 +1,666 @@
import { createHash } from "node:crypto";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
truncateSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
verifyPublishedClawHubArtifacts,
verifyPublishedClawHubPackage,
} from "../../scripts/verify-clawhub-published-artifact.mjs";
const tempDirs: string[] = [];
const clawhubToolchainSha256 = "d".repeat(64);
const clawhubToolchainVersion = "0.23.1";
const clawhubToolchainIntegrity =
"sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA==";
function immutableBinding() {
return {
artifactDigest: "c".repeat(64),
artifactId: "456",
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
};
}
afterEach(() => {
for (const directory of tempDirs.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function identity(artifact: Uint8Array) {
return {
sha256: createHash("sha256").update(artifact).digest("hex"),
size: artifact.byteLength,
npmIntegrity: `sha512-${createHash("sha512").update(artifact).digest("base64")}`,
npmShasum: createHash("sha1").update(artifact).digest("hex"),
};
}
function writeManifest(mode: "publish" | "configure-only", artifact: Uint8Array, runAttempt = "1") {
const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-readback-"));
tempDirs.push(root);
const path = join(root, "manifest.json");
const artifactIdentity = identity(artifact);
writeFileSync(
path,
JSON.stringify({
schemaVersion: 1,
repository: "openclaw/openclaw",
targetSha: "a".repeat(40),
workflowSha: "b".repeat(40),
runId: "123",
runAttempt,
artifactName: `clawhub-bootstrap-aaaaaaaaaaaa-123-${runAttempt}`,
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
requestedPlugins: ["@openclaw/meta"],
entries: [
{
packageName: "@openclaw/meta",
version: "2026.7.1-beta.3",
packageDir: "extensions/meta",
publishTag: "beta",
bootstrapMode: mode,
requiresManualOverride: mode === "configure-only",
artifactPath: "packages/meta/openclaw-meta-2026.7.1-beta.3.tgz",
sha256: artifactIdentity.sha256,
size: artifactIdentity.size,
},
],
}),
);
return path;
}
function writeExpectedArtifact(artifact: Uint8Array) {
const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-oidc-readback-"));
tempDirs.push(root);
const artifactDir = join(root, "artifact");
mkdirSync(artifactDir);
writeFileSync(join(artifactDir, "openclaw-meta-2026.7.1-beta.3.tgz"), artifact);
return artifactDir;
}
function artifactResponse(artifact: Uint8Array, body: BodyInit = artifact) {
const artifactIdentity = identity(artifact);
return new Response(body, {
headers: {
"content-length": String(artifact.byteLength),
"x-clawhub-artifact-sha256": artifactIdentity.sha256,
"x-clawhub-npm-integrity": artifactIdentity.npmIntegrity,
"x-clawhub-npm-shasum": artifactIdentity.npmShasum,
},
});
}
function metadataResponse(artifact: Uint8Array, body?: BodyInit) {
const artifactIdentity = identity(artifact);
return new Response(
body ??
JSON.stringify({
package: { name: "@openclaw/meta" },
version: "2026.7.1-beta.3",
artifact: {
kind: "npm-pack",
sha256: artifactIdentity.sha256,
size: artifactIdentity.size,
npmIntegrity: artifactIdentity.npmIntegrity,
npmShasum: artifactIdentity.npmShasum,
},
}),
{ headers: { "content-type": "application/json" } },
);
}
function registryFetch(artifact: Uint8Array) {
return vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith("/trusted-publisher")) {
return Response.json({
trustedPublisher: {
provider: "github-actions",
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
environment: null,
},
});
}
if (url.endsWith("/artifact/download")) {
return artifactResponse(artifact);
}
if (url.endsWith("/artifact")) {
return metadataResponse(artifact);
}
return Response.json({
package: { tags: { beta: "2026.7.1-beta.3" } },
});
});
}
describe("ClawHub published artifact verification", () => {
it("uses bounded streaming reads with an active attempt timeout", () => {
const source = readFileSync("scripts/verify-clawhub-published-artifact.mjs", "utf8");
expect(source).not.toContain(".arrayBuffer(");
expect(source).toContain("response.body.getReader()");
expect(source).toContain("readBoundedBytes(response, url, MAX_JSON_BYTES)");
expect(source).toContain("readBoundedBytes(response, url, MAX_ARTIFACT_BYTES)");
expect(source).toContain("AbortSignal.timeout(timeoutMs)");
});
it("verifies normal OIDC publication against the exact prepared artifact bytes", async () => {
const artifact = new TextEncoder().encode("exact oidc tgz bytes");
const fetchImpl = registryFetch(artifact);
const evidence = await verifyPublishedClawHubPackage({
expectedArtifactDir: writeExpectedArtifact(artifact),
packageName: "@openclaw/meta",
packageVersion: "2026.7.1-beta.3",
publishTag: "beta",
registry: "https://clawhub.example",
retryOptions: { fetchImpl, attempts: 1, delayMs: 1 },
});
expect(evidence).toMatchObject({
schemaVersion: 1,
verificationMode: "oidc-postpublish",
expectedArtifact: identity(artifact),
package: {
packageName: "@openclaw/meta",
registrySha256: identity(artifact).sha256,
registrySize: artifact.byteLength,
},
});
expect(fetchImpl).toHaveBeenCalledTimes(4);
});
it("rejects a non-GitHub Actions trusted publisher", async () => {
const artifact = new TextEncoder().encode("exact oidc tgz bytes");
const fetchImpl = registryFetch(artifact);
fetchImpl
.mockResolvedValueOnce(
Response.json({
package: { tags: { beta: "2026.7.1-beta.3" } },
}),
)
.mockResolvedValueOnce(
Response.json({
trustedPublisher: {
provider: "other",
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
environment: null,
},
}),
);
await expect(
verifyPublishedClawHubPackage({
expectedArtifactDir: writeExpectedArtifact(artifact),
packageName: "@openclaw/meta",
packageVersion: "2026.7.1-beta.3",
publishTag: "beta",
registry: "https://clawhub.example",
retryOptions: { fetchImpl, attempts: 1, delayMs: 1 },
}),
).rejects.toThrow("trusted publisher provider mismatch");
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("rejects ambiguous or symlinked normal OIDC artifacts before registry access", async () => {
const artifact = new TextEncoder().encode("exact oidc tgz bytes");
const fetchImpl = registryFetch(artifact);
const ambiguous = writeExpectedArtifact(artifact);
writeFileSync(join(ambiguous, "second.tgz"), artifact);
await expect(
verifyPublishedClawHubPackage({
expectedArtifactDir: ambiguous,
packageName: "@openclaw/meta",
packageVersion: "2026.7.1-beta.3",
publishTag: "beta",
retryOptions: { fetchImpl, attempts: 1, delayMs: 1 },
}),
).rejects.toThrow("exactly one root .tgz regular file");
const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-oidc-symlink-"));
tempDirs.push(root);
const artifactDir = join(root, "artifact");
const target = join(root, "target.tgz");
mkdirSync(artifactDir);
writeFileSync(target, artifact);
symlinkSync(target, join(artifactDir, "linked.tgz"));
await expect(
verifyPublishedClawHubPackage({
expectedArtifactDir: artifactDir,
packageName: "@openclaw/meta",
packageVersion: "2026.7.1-beta.3",
publishTag: "beta",
retryOptions: { fetchImpl, attempts: 1, delayMs: 1 },
}),
).rejects.toThrow("exactly one root .tgz regular file");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("requires exact bytes and complete artifact metadata", async () => {
const artifact = new TextEncoder().encode("exact tgz bytes");
const evidence = await verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: writeManifest("publish", artifact),
registry: "https://clawhub.example",
terminalRunAttempt: "2",
retryOptions: { fetchImpl: registryFetch(artifact), attempts: 1, delayMs: 1 },
});
expect(evidence).toMatchObject({
schemaVersion: 2,
producerRunAttempt: "1",
terminalRunAttempt: "2",
artifactName: "clawhub-bootstrap-aaaaaaaaaaaa-123-1",
clawhubToolchainIntegrity,
clawhubToolchainSha256,
clawhubToolchainVersion,
requestedPlugins: ["@openclaw/meta"],
verificationMode: "postpublish",
packages: [
{
packageName: "@openclaw/meta",
registrySha256: identity(artifact).sha256,
registrySize: artifact.byteLength,
npmIntegrity: identity(artifact).npmIntegrity,
npmShasum: identity(artifact).npmShasum,
artifactMetadata: {
kind: "npm-pack",
packageName: "@openclaw/meta",
version: "2026.7.1-beta.3",
},
},
],
});
});
it("proves configure-only registry bytes before trusted-publisher mutation", async () => {
const artifact = new TextEncoder().encode("historical exact bytes");
const fetchImpl = registryFetch(artifact);
const evidence = await verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: writeManifest("configure-only", artifact),
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl, attempts: 1, delayMs: 1 },
});
expect(evidence.packages[0]).toMatchObject({
bootstrapMode: "configure-only",
expectedSha256: identity(artifact).sha256,
registrySha256: identity(artifact).sha256,
});
expect(fetchImpl.mock.calls.some(([url]) => String(url).endsWith("/trusted-publisher"))).toBe(
false,
);
});
it("rejects a missing configure-only tag before artifact or publisher requests", async () => {
const artifact = new TextEncoder().encode("historical exact bytes");
const fetchImpl = vi.fn(async () =>
Response.json({
package: { tags: { beta: "2026.7.1-beta.2" } },
}),
);
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: writeManifest("configure-only", artifact),
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl, attempts: 1, delayMs: 1 },
}),
).rejects.toThrow(
"@openclaw/meta@2026.7.1-beta.3 ClawHub artifact did not stabilize after 1 attempts; last failure @openclaw/meta ClawHub tag beta mismatch",
);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl.mock.calls.some(([url]) => String(url).includes("/artifact"))).toBe(false);
expect(fetchImpl.mock.calls.some(([url]) => String(url).endsWith("/trusted-publisher"))).toBe(
false,
);
});
it("retries invalid JSON, body read failures, and eventual byte convergence", async () => {
const expected = new TextEncoder().encode("expected");
const wrong = new TextEncoder().encode("wrong");
let detailCalls = 0;
let artifactCalls = 0;
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (!url.includes("/artifact") && !url.endsWith("/trusted-publisher")) {
detailCalls += 1;
if (detailCalls === 1) {
return new Response("{invalid");
}
return Response.json({ package: { tags: { beta: "2026.7.1-beta.3" } } });
}
if (url.endsWith("/trusted-publisher")) {
return Response.json({
trustedPublisher: {
provider: "github-actions",
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
environment: null,
},
});
}
if (url.endsWith("/artifact")) {
return metadataResponse(expected);
}
artifactCalls += 1;
if (artifactCalls === 1) {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.error(new Error("truncated body"));
},
}),
);
}
if (artifactCalls === 2) {
return artifactResponse(expected, wrong);
}
return artifactResponse(expected);
});
const sleep = vi.fn(async () => {});
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: writeManifest("publish", expected),
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl, attempts: 4, delayMs: 1, sleep },
}),
).resolves.toMatchObject({ packages: [{ registrySha256: identity(expected).sha256 }] });
expect(sleep).toHaveBeenCalledTimes(3);
});
it("keeps the attempt timeout active through a stalled body", async () => {
const artifact = new TextEncoder().encode("expected");
let artifactCalls = 0;
const fetchImpl = vi.fn(
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = String(input);
if (url.endsWith("/artifact")) {
return metadataResponse(artifact);
}
if (url.endsWith("/artifact/download")) {
artifactCalls += 1;
if (artifactCalls === 1) {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
init?.signal?.addEventListener(
"abort",
() => controller.error(init.signal?.reason),
{ once: true },
);
},
}),
);
}
return artifactResponse(artifact);
}
if (!url.includes("/artifact")) {
return Response.json({
package: { tags: { beta: "2026.7.1-beta.3" } },
});
}
throw new Error(`unexpected URL ${url}`);
},
);
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: writeManifest("configure-only", artifact),
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl, attempts: 2, delayMs: 1, timeoutMs: 10 },
}),
).resolves.toMatchObject({ packages: [{ registrySize: artifact.byteLength }] });
expect(artifactCalls).toBe(2);
});
it("cancels retryable response bodies and never sleeps after the final attempt", async () => {
const artifact = new TextEncoder().encode("expected");
const canceled: string[] = [];
const sleep = vi.fn(async () => {});
const fetchImpl = vi.fn(async () => {
return new Response(
new ReadableStream<Uint8Array>({
cancel() {
canceled.push("retry");
},
}),
{ status: 503 },
);
});
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: writeManifest("configure-only", artifact),
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl, attempts: 2, delayMs: 1, sleep },
}),
).rejects.toThrow("did not stabilize after 2 attempts");
expect(canceled).toEqual(["retry", "retry"]);
expect(sleep).toHaveBeenCalledTimes(1);
});
it("fails immediately on permanent HTTP errors and explicit size limits", async () => {
const artifact = new TextEncoder().encode("expected");
const permanentFetch = vi.fn(async () => new Response("denied", { status: 403 }));
const permanentSleep = vi.fn(async () => {});
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: writeManifest("configure-only", artifact),
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: {
fetchImpl: permanentFetch,
attempts: 3,
delayMs: 1,
sleep: permanentSleep,
},
}),
).rejects.toThrow("returned HTTP 403");
expect(permanentFetch).toHaveBeenCalledTimes(1);
expect(permanentSleep).not.toHaveBeenCalled();
const oversizedFetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith("/artifact")) {
return new Response("{}", {
headers: { "content-length": String(1024 * 1024 + 1) },
});
}
return Response.json({
package: { tags: { beta: "2026.7.1-beta.3" } },
});
});
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: writeManifest("configure-only", artifact),
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl: oversizedFetch, attempts: 3, delayMs: 1 },
}),
).rejects.toThrow("exceeded 1048576 bytes");
expect(oversizedFetch).toHaveBeenCalledTimes(2);
const oversizedArtifactFetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith("/artifact/download")) {
return new Response(null, {
headers: { "content-length": String(130 * 1024 * 1024 + 1) },
});
}
if (url.endsWith("/artifact")) {
return metadataResponse(artifact);
}
return Response.json({
package: { tags: { beta: "2026.7.1-beta.3" } },
});
});
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: writeManifest("configure-only", artifact),
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl: oversizedArtifactFetch, attempts: 3, delayMs: 1 },
}),
).rejects.toThrow("exceeded 136314880 bytes");
});
it("requires a terminal attempt at or after the immutable producer attempt", async () => {
const artifact = new TextEncoder().encode("expected");
const baseOptions = {
...immutableBinding(),
manifestPath: writeManifest("configure-only", artifact),
mode: "configure-only-preflight",
registry: "https://clawhub.example",
retryOptions: { fetchImpl: registryFetch(artifact), attempts: 1, delayMs: 1 },
};
await expect(verifyPublishedClawHubArtifacts(baseOptions)).rejects.toThrow(
"terminalRunAttempt must be an integer",
);
await expect(
verifyPublishedClawHubArtifacts({ ...baseOptions, terminalRunAttempt: "0" }),
).rejects.toThrow("terminalRunAttempt must be an integer");
await expect(
verifyPublishedClawHubArtifacts({
...baseOptions,
manifestPath: writeManifest("configure-only", artifact, "2"),
terminalRunAttempt: "1",
}),
).rejects.toThrow("greater than or equal to the producer run attempt");
for (const invalid of ["1junk", "1.5", "1e2"]) {
await expect(
verifyPublishedClawHubArtifacts({
...baseOptions,
terminalRunAttempt: invalid,
}),
).rejects.toThrow("terminalRunAttempt must be an integer");
}
await expect(
verifyPublishedClawHubArtifacts({
...baseOptions,
artifactId: "1junk",
terminalRunAttempt: "1",
}),
).rejects.toThrow("artifactId must be an integer");
await expect(
verifyPublishedClawHubArtifacts({
...baseOptions,
artifactDigest: "A".repeat(64),
terminalRunAttempt: "1",
}),
).rejects.toThrow("artifactDigest is invalid");
});
it("requires the locked ClawHub toolchain identity in the validated manifest", async () => {
const artifact = new TextEncoder().encode("expected");
const manifestPath = writeManifest("configure-only", artifact);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
manifest.clawhubToolchainSha256 = "A".repeat(64);
writeFileSync(manifestPath, JSON.stringify(manifest));
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath,
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl: registryFetch(artifact), attempts: 1, delayMs: 1 },
}),
).rejects.toThrow("manifest.clawhubToolchainSha256 is invalid");
manifest.clawhubToolchainSha256 = clawhubToolchainSha256;
writeFileSync(manifestPath, JSON.stringify(manifest));
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
clawhubToolchainSha256: "e".repeat(64),
manifestPath,
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl: registryFetch(artifact), attempts: 1, delayMs: 1 },
}),
).rejects.toThrow("clawhubToolchainSha256 mismatch");
});
it("rejects noncanonical, oversized, and symlinked manifests before registry access", async () => {
const artifact = new TextEncoder().encode("expected");
const fetchImpl = registryFetch(artifact);
const manifestPath = writeManifest("configure-only", artifact);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
writeFileSync(manifestPath, JSON.stringify({ ...manifest, unexpected: true }));
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath,
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl, attempts: 1, delayMs: 1 },
}),
).rejects.toThrow("keys are invalid");
const oversizedPath = writeManifest("configure-only", artifact);
truncateSync(oversizedPath, 2 * 1024 * 1024 + 1);
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: oversizedPath,
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl, attempts: 1, delayMs: 1 },
}),
).rejects.toThrow("size is outside the allowed range: 2097153");
const targetPath = writeManifest("configure-only", artifact);
const symlinkPath = `${targetPath}.link`;
symlinkSync(targetPath, symlinkPath);
await expect(
verifyPublishedClawHubArtifacts({
...immutableBinding(),
manifestPath: symlinkPath,
mode: "configure-only-preflight",
registry: "https://clawhub.example",
terminalRunAttempt: "1",
retryOptions: { fetchImpl, attempts: 1, delayMs: 1 },
}),
).rejects.toThrow("must be a regular file");
expect(fetchImpl).not.toHaveBeenCalled();
});
});