ci(release): add plugin npm artifact preflight (#103759)

* ci(release): add plugin npm preflight proof

* fix(release): bind plugin preflight package evidence

* fix(release): harden plugin preflight trust

* fix(release): bind plugin registry readback

* fix(release): retry npm packument bodies

* fix(release): retry malformed npm packuments

* fix(release): satisfy npm preflight lint
This commit is contained in:
Vincent Koc
2026-07-10 09:39:10 -07:00
committed by GitHub
parent 1dcbcce515
commit daa653c5cc
6 changed files with 1500 additions and 31 deletions
@@ -84,18 +84,18 @@ them.
Use this matrix for pre-release signoff. Record pass/fail, run URL/Testbox ID,
package SHA/version, and skipped-live reason.
| Surface | Proof | Preferred runner |
| --- | --- | --- |
| Package artifact | Package Acceptance `suite_profile=package` or custom lanes | GitHub Actions |
| Bundled lifecycle | 8-shard `test:docker:bundled-plugin-install-uninstall` | Testbox or release Docker |
| External plugins | `test:docker:plugins` and `plugins-offline` | Testbox/package acceptance |
| Update no-op | `test:docker:plugin-update` | Testbox/package acceptance |
| Channel runtime deps | `test:docker:bundled-channel-deps:fast` plus key channels | Testbox/package acceptance |
| Doctor/fix | seeded bad configs + `doctor --fix --non-interactive` | new Docker/Testbox harness |
| Config round-trip | `config set/get`, inspect, doctor, reload, diff hash | new Docker/Testbox harness |
| Gateway bootstrap | clean `HOME`, plugin groups enabled/disabled, status JSON | new Docker/Testbox harness |
| SDK compatibility | directory, tgz, and `file:` external plugins using SDK subpaths | `test:docker:plugins` plus new smoke |
| Live-ish | redacted provider/channel probes only for present env | Testbox live lanes |
| Surface | Proof | Preferred runner |
| -------------------- | --------------------------------------------------------------- | ------------------------------------ |
| Package artifact | Package Acceptance `suite_profile=package` or custom lanes | GitHub Actions |
| Bundled lifecycle | 8-shard `test:docker:bundled-plugin-install-uninstall` | Testbox or release Docker |
| External plugins | `test:docker:plugins` and `plugins-offline` | Testbox/package acceptance |
| Update no-op | `test:docker:plugin-update` | Testbox/package acceptance |
| Channel runtime deps | `test:docker:bundled-channel-deps:fast` plus key channels | Testbox/package acceptance |
| Doctor/fix | seeded bad configs + `doctor --fix --non-interactive` | new Docker/Testbox harness |
| Config round-trip | `config set/get`, inspect, doctor, reload, diff hash | new Docker/Testbox harness |
| Gateway bootstrap | clean `HOME`, plugin groups enabled/disabled, status JSON | new Docker/Testbox harness |
| SDK compatibility | directory, tgz, and `file:` external plugins using SDK subpaths | `test:docker:plugins` plus new smoke |
| Live-ish | redacted provider/channel probes only for present env | Testbox live lanes |
## Package Acceptance Plan
@@ -117,6 +117,40 @@ Use `source=npm -f package_spec=openclaw@beta` for published beta proof. Keep
`workflow_ref` as trusted current harness code unless the release process says
otherwise.
## Plugin npm Artifact Preflight
Use the trusted `main` workflow to prepare and read back a selected plugin npm
artifact from an exact release SHA without entering any publish approval,
environment, secret, OIDC, npm mutation, or ClawHub mutation path:
```bash
release_sha="$(git rev-parse origin/release/2026.7.1)"
ghx workflow run plugin-npm-release.yml \
--repo openclaw/openclaw \
--ref main \
-f preflight_only=true \
-f publish_scope=selected \
-f plugins=@openclaw/meta-provider \
-f ref="${release_sha}" \
-f npm_dist_tag=default
```
Do not pass `release_publish_run_id`. Require the workflow to finish
`verify_plugin_npm_preflight` successfully. Record the run URL, workflow SHA,
and source SHA. The workflow first creates the staging/readback artifact
`plugin-npm-package-source-<source-sha>-<extension-id>` containing
`npm-pack.json`, `preflight-manifest.json`, and the tarball. It then uploads the
final consumer artifact `plugin-npm-package-<extension-id>-<version>` containing
the tarball and `plugin-npm-package-evidence.json`.
Record the final artifact name and digest separately. In the v2 evidence,
`publicationArtifact` binds the staging artifact id, name, digest, source and
packed `package.json` hashes, and tarball hash. This proof is validation-only;
it does not authorize or stage publication. For an already-published version,
require npm `dist.integrity` and `dist.shasum` to match the verified tarball.
Treat only missing or provably older dist-tags as repairable; newer or
incomparable selectors are a blocker.
## New Testbox Harness Plan
If more certainty is needed, add or run a `plugin-lifecycle-matrix` Docker lane
+728 -14
View File
@@ -1,5 +1,5 @@
name: Plugin NPM Release
run-name: ${{ github.event_name == 'workflow_dispatch' && format('Plugin NPM Release [{0}] {1}', inputs.npm_dist_tag, inputs.ref) || format('Plugin NPM Release [default] {0}', github.sha) }}
run-name: ${{ github.event_name == 'workflow_dispatch' && (inputs.preflight_only && format('Plugin NPM Preflight [{0}] {1}', inputs.npm_dist_tag, inputs.ref) || format('Plugin NPM Release [{0}] {1}', inputs.npm_dist_tag, inputs.ref)) || format('Plugin NPM Release [default] {0}', github.sha) }}
on:
push:
@@ -27,7 +27,7 @@ on:
- selected
- all-publishable
ref:
description: Commit SHA on main, a release branch, the canonical extended-stable branch, or the matching Tideclaw alpha branch to publish from
description: Exact commit SHA; preflight accepts main/release ancestry, while publish mode also supports canonical extended-stable or matching Tideclaw alpha branches
required: true
type: string
plugins:
@@ -38,6 +38,11 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
preflight_only:
description: Prepare and verify immutable plugin npm artifacts without publishing
required: true
default: false
type: boolean
npm_dist_tag:
description: Optional npm dist-tag override
required: true
@@ -64,6 +69,8 @@ jobs:
ref_revision: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
has_selection: ${{ steps.plan.outputs.has_selection }}
selection_count: ${{ steps.plan.outputs.selection_count }}
matrix: ${{ steps.plan.outputs.matrix }}
all_matrix: ${{ steps.plan.outputs.all_matrix }}
steps:
@@ -74,12 +81,6 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Resolve checked-out ref
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
@@ -87,12 +88,29 @@ jobs:
- name: Validate ref is on a trusted publish branch
env:
NPM_DIST_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag || 'default' }}
PREFLIGHT_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only || false }}
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
RELEASE_PUBLISH_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_id || '' }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
WORKFLOW_REF: ${{ github.ref }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] || [[ ! "${WORKFLOW_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Plugin npm preflight must run from a trusted main workflow revision." >&2
exit 1
fi
if [[ ! "${SOURCE_REF}" =~ ^[0-9a-fA-F]{40}$ ]] || [[ "$(git rev-parse HEAD)" != "$(git rev-parse "${SOURCE_REF}^{commit}")" ]]; then
echo "Plugin npm preflight requires ref to be the exact 40-character source SHA." >&2
exit 1
fi
if [[ -n "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
echo "Plugin npm preflight must not include release_publish_run_id." >&2
exit 1
fi
fi
if [[ "${NPM_DIST_TAG}" == "extended-stable" ]]; then
if [[ "${PUBLISH_SCOPE}" != "all-publishable" || -n "${RELEASE_PLUGINS// }" ]]; then
echo "Extended-stable plugin publication requires publish_scope=all-publishable without an explicit plugin list." >&2
@@ -102,7 +120,7 @@ jobs:
echo "Extended-stable plugin publication requires ref to be the exact 40-character source SHA." >&2
exit 1
fi
package_version="$(node -p "require('./package.json').version")"
package_version="$(jq -er '.version | strings' package.json)"
if [[ ! "${package_version}" =~ ^([0-9]{4})\.([1-9]|1[0-2])\.([1-9][0-9]*)$ ]] || (( 10#${BASH_REMATCH[3]:-0} < 33 )); then
echo "Extended-stable plugin publication requires a final YYYY.M.PATCH version with PATCH >= 33." >&2
exit 1
@@ -120,6 +138,10 @@ jobs:
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
if [[ "${PREFLIGHT_ONLY}" == "true" ]] && ! git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main; then
echo "Plugin npm preflight workflow revision is not reachable from main." >&2
exit 1
fi
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
@@ -128,6 +150,10 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
echo "Plugin npm preflight target must be reachable from main or release/*." >&2
exit 1
fi
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${WORKFLOW_REF#refs/heads/}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
@@ -138,6 +164,12 @@ jobs:
echo "Plugin npm publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Validate publishable plugin metadata
env:
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
@@ -170,6 +202,7 @@ jobs:
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
HEAD_REF: ${{ steps.ref.outputs.sha }}
NPM_DIST_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag || 'default' }}
PREFLIGHT_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only || false }}
run: |
set -euo pipefail
mkdir -p .local
@@ -191,20 +224,32 @@ jobs:
cat .local/plugin-npm-release-plan.json
candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)"
selection_count="$(jq -r '.all | length' .local/plugin-npm-release-plan.json)"
has_candidates="false"
has_selection="false"
if [[ "${candidate_count}" != "0" ]]; then
has_candidates="true"
fi
if [[ "${selection_count}" != "0" ]]; then
has_selection="true"
fi
matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)"
all_matrix_json="$(jq -c '.all' .local/plugin-npm-release-plan.json)"
{
echo "candidate_count=${candidate_count}"
echo "has_candidates=${has_candidates}"
echo "selection_count=${selection_count}"
echo "has_selection=${has_selection}"
echo "matrix=${matrix_json}"
echo "all_matrix=${all_matrix_json}"
} >> "$GITHUB_OUTPUT"
if [[ "${PREFLIGHT_ONLY}" == "true" && "${has_selection}" != "true" ]]; then
echo "Plugin npm preflight resolved no selected publishable packages." >&2
exit 1
fi
echo "Plugin release candidates:"
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-npm-release-plan.json
@@ -227,7 +272,7 @@ jobs:
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_npm
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
if: github.event_name == 'workflow_dispatch' && !inputs.preflight_only && needs.preview_plugins_npm.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
@@ -263,14 +308,14 @@ jobs:
preview_plugin_pack:
needs: preview_plugins_npm
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
if: needs.preview_plugins_npm.outputs.has_candidates == 'true' || (github.event_name == 'workflow_dispatch' && inputs.preflight_only && needs.preview_plugins_npm.outputs.has_selection == 'true')
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
plugin: ${{ fromJson(github.event_name == 'workflow_dispatch' && inputs.preflight_only && needs.preview_plugins_npm.outputs.all_matrix || needs.preview_plugins_npm.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -295,9 +340,678 @@ jobs:
OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }}
run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}"
- name: Prepare immutable npm preflight artifact
id: preflight_artifact
if: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only }}
env:
ARTIFACT_NAME: plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}
EXTENSION_ID: ${{ matrix.plugin.extensionId }}
INSTALL_NPM_SPEC: ${{ matrix.plugin.installNpmSpec }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PUBLISH_TAG: ${{ matrix.plugin.publishTag }}
REPOSITORY: ${{ github.repository }}
SOURCE_REF: ${{ inputs.ref }}
SOURCE_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
WORKFLOW_PATH: .github/workflows/plugin-npm-release.yml
WORKFLOW_REF: ${{ github.workflow_ref }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }}
run: |
set -euo pipefail
artifact_dir="${RUNNER_TEMP}/${ARTIFACT_NAME}"
rm -rf "${artifact_dir}"
mkdir -p "${artifact_dir}"
pack_json="${artifact_dir}/npm-pack.json"
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD=0 \
OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR="${artifact_dir}" \
bash scripts/plugin-npm-publish.sh --pack "${PACKAGE_DIR}" > "${pack_json}"
tarball_name="$(
node - "${pack_json}" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const pack = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
if (!Array.isArray(pack) || pack.length !== 1) {
throw new Error(`Expected one npm pack result, found ${Array.isArray(pack) ? pack.length : "non-array"}.`);
}
const filename = pack[0]?.filename;
if (
typeof filename !== "string" ||
filename.length === 0 ||
filename.includes("\0") ||
filename !== path.basename(filename) ||
filename !== path.win32.basename(filename) ||
!filename.endsWith(".tgz")
) {
throw new Error(`Unsafe npm pack filename: ${JSON.stringify(filename)}.`);
}
process.stdout.write(filename);
NODE
)"
tarball_path="${artifact_dir}/${tarball_name}"
if [[ ! -f "${tarball_path}" ]]; then
echo "npm pack did not produce ${tarball_name}." >&2
exit 1
fi
packed_package_json="${RUNNER_TEMP}/${EXTENSION_ID}-packed-package.json"
packed_plugin_json="${RUNNER_TEMP}/${EXTENSION_ID}-packed-plugin.json"
tar -xOf "${tarball_path}" package/package.json > "${packed_package_json}"
tar -xOf "${tarball_path}" package/openclaw.plugin.json > "${packed_plugin_json}"
source_package_json="${PACKAGE_DIR}/package.json"
[[ -f "${source_package_json}" ]] || {
echo "Source package.json is missing: ${source_package_json}." >&2
exit 1
}
source_package_json_sha256="$(sha256sum "${source_package_json}" | awk '{print $1}')"
packed_package_json_sha256="$(sha256sum "${packed_package_json}" | awk '{print $1}')"
tarball_sha256="$(sha256sum "${tarball_path}" | awk '{print $1}')"
ARTIFACT_DIR="${artifact_dir}" \
PACK_JSON="${pack_json}" \
PACKED_PACKAGE_JSON="${packed_package_json}" \
PACKED_PLUGIN_JSON="${packed_plugin_json}" \
PACKED_PACKAGE_JSON_SHA256="${packed_package_json_sha256}" \
SOURCE_PACKAGE_JSON_SHA256="${source_package_json_sha256}" \
TARBALL_NAME="${tarball_name}" \
TARBALL_SHA256="${tarball_sha256}" \
node <<'NODE'
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
function fail(message) {
throw new Error(message);
}
const pack = JSON.parse(fs.readFileSync(process.env.PACK_JSON, "utf8"));
const packEntry = pack[0];
const packageJson = JSON.parse(fs.readFileSync(process.env.PACKED_PACKAGE_JSON, "utf8"));
const pluginManifest = JSON.parse(fs.readFileSync(process.env.PACKED_PLUGIN_JSON, "utf8"));
const tarballPath = path.join(process.env.ARTIFACT_DIR, process.env.TARBALL_NAME);
const tarball = fs.readFileSync(tarballPath);
const repositoryUrl =
typeof packageJson.repository === "string"
? packageJson.repository
: packageJson.repository?.url;
const actualIntegrity = `sha512-${crypto.createHash("sha512").update(tarball).digest("base64")}`;
const actualShasum = crypto.createHash("sha1").update(tarball).digest("hex");
if (packEntry.name !== process.env.PACKAGE_NAME || packageJson.name !== process.env.PACKAGE_NAME) {
fail(`Packed package name mismatch: expected ${process.env.PACKAGE_NAME}.`);
}
if (
packEntry.version !== process.env.PACKAGE_VERSION ||
packageJson.version !== process.env.PACKAGE_VERSION
) {
fail(`Packed package version mismatch: expected ${process.env.PACKAGE_VERSION}.`);
}
if (packageJson.openclaw?.install?.npmSpec !== process.env.INSTALL_NPM_SPEC) {
fail(`Packed npm install route mismatch: expected ${process.env.INSTALL_NPM_SPEC}.`);
}
if (repositoryUrl !== "https://github.com/openclaw/openclaw") {
fail(`Packed repository route mismatch: ${JSON.stringify(repositoryUrl)}.`);
}
if (pluginManifest.id !== process.env.EXTENSION_ID) {
fail(`Packed plugin id mismatch: expected ${process.env.EXTENSION_ID}.`);
}
if (packEntry.integrity !== actualIntegrity || packEntry.shasum !== actualShasum) {
fail("npm pack integrity metadata does not match the prepared tarball.");
}
const manifest = {
schemaVersion: 1,
kind: "openclaw-plugin-npm-preflight",
mode: "preflight-only",
repository: process.env.REPOSITORY,
workflow: {
path: process.env.WORKFLOW_PATH,
ref: process.env.WORKFLOW_REF,
sha: process.env.WORKFLOW_SHA,
runId: process.env.RUN_ID,
runAttempt: Number(process.env.RUN_ATTEMPT),
},
source: {
inputRef: process.env.SOURCE_REF,
sha: process.env.SOURCE_SHA,
trustPolicy: "workflow-main-and-target-main-or-release-ancestor",
},
package: {
extensionId: process.env.EXTENSION_ID,
packageDir: process.env.PACKAGE_DIR,
name: process.env.PACKAGE_NAME,
version: process.env.PACKAGE_VERSION,
installNpmSpec: process.env.INSTALL_NPM_SPEC,
packageJsonSha256: process.env.PACKED_PACKAGE_JSON_SHA256,
publishTag: process.env.PUBLISH_TAG,
pluginId: pluginManifest.id,
repositoryUrl,
sourcePackageJsonSha256: process.env.SOURCE_PACKAGE_JSON_SHA256,
},
artifact: {
name: process.env.ARTIFACT_NAME,
tarballName: process.env.TARBALL_NAME,
sha256: process.env.TARBALL_SHA256,
npmIntegrity: actualIntegrity,
npmShasum: actualShasum,
},
mutationCapabilities: {
npmPublish: false,
npmDistTag: false,
clawHub: false,
environmentApproval: false,
oidcWrite: false,
secretRead: false,
},
};
fs.writeFileSync(
path.join(process.env.ARTIFACT_DIR, "preflight-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
NODE
echo "dir=${artifact_dir}" >> "$GITHUB_OUTPUT"
echo "name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
echo "package_json_sha256=${packed_package_json_sha256}" >> "$GITHUB_OUTPUT"
echo "sha256=${tarball_sha256}" >> "$GITHUB_OUTPUT"
echo "source_package_json_sha256=${source_package_json_sha256}" >> "$GITHUB_OUTPUT"
- name: Upload immutable npm preflight artifact
id: upload_preflight_artifact
if: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.preflight_artifact.outputs.name }}
path: ${{ steps.preflight_artifact.outputs.dir }}
compression-level: 0
if-no-files-found: error
retention-days: 30
- name: Record npm preflight artifact attestation
if: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only }}
env:
ARTIFACT_DIGEST: ${{ steps.upload_preflight_artifact.outputs.artifact-digest }}
ARTIFACT_NAME: ${{ steps.preflight_artifact.outputs.name }}
TARBALL_SHA256: ${{ steps.preflight_artifact.outputs.sha256 }}
run: |
{
echo "- Plugin npm preflight artifact: \`${ARTIFACT_NAME}\`"
echo "- Actions artifact digest: \`${ARTIFACT_DIGEST}\`"
echo "- Packed tarball SHA-256: \`${TARBALL_SHA256}\`"
} >> "$GITHUB_STEP_SUMMARY"
verify_plugin_npm_preflight:
name: Preflight plugin npm package (${{ matrix.plugin.packageName }})
needs: [preview_plugins_npm, preview_plugin_pack]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && needs.preview_plugins_npm.outputs.has_selection == 'true' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
strategy:
fail-fast: false
matrix:
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.all_matrix) }}
steps:
- name: Checkout trusted npm preflight tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ github.workflow_sha }}
fetch-depth: 1
- name: Download immutable npm preflight artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}
path: ${{ runner.temp }}/plugin-npm-preflight-readback
- name: Validate npm preflight artifact readback
id: publication_artifact
env:
ARTIFACT_DIR: ${{ runner.temp }}/plugin-npm-preflight-readback
ARTIFACT_NAME: plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}
EXTENSION_ID: ${{ matrix.plugin.extensionId }}
GH_TOKEN: ${{ github.token }}
INSTALL_NPM_SPEC: ${{ matrix.plugin.installNpmSpec }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PUBLISH_TAG: ${{ matrix.plugin.publishTag }}
REPOSITORY: ${{ github.repository }}
SOURCE_REF: ${{ inputs.ref }}
SOURCE_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
WORKFLOW_PATH: .github/workflows/plugin-npm-release.yml
WORKFLOW_REF: ${{ github.workflow_ref }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
set -euo pipefail
[[ "${RUN_ATTEMPT}" == "1" ]] || {
echo "Plugin npm preflight requires a fresh attempt-1 workflow run." >&2
exit 1
}
git fetch --no-tags --depth=1 origin "${SOURCE_SHA}"
source_package_json="${RUNNER_TEMP}/${EXTENSION_ID}-source-package.json"
git show "${SOURCE_SHA}:${PACKAGE_DIR}/package.json" > "${source_package_json}"
artifacts_json="${RUNNER_TEMP}/${EXTENSION_ID}-artifacts.json"
gh api --paginate \
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?per_page=100" |
jq -s '{artifacts: [.[].artifacts[]]}' > "${artifacts_json}"
artifact_count="$(
jq --arg name "${ARTIFACT_NAME}" \
'[.artifacts[] | select(.name == $name and .expired == false)] | length' \
"${artifacts_json}"
)"
[[ "${artifact_count}" == "1" ]] || {
echo "Expected exactly one live package artifact named ${ARTIFACT_NAME}; found ${artifact_count}." >&2
exit 1
}
artifact_id="$(
jq -r --arg name "${ARTIFACT_NAME}" \
'.artifacts[] | select(.name == $name and .expired == false) | .id' \
"${artifacts_json}"
)"
artifact_digest="$(
jq -r --arg name "${ARTIFACT_NAME}" \
'.artifacts[] | select(.name == $name and .expired == false) | .digest' \
"${artifacts_json}"
)"
[[ "${artifact_id}" =~ ^[1-9][0-9]*$ && "${artifact_digest}" =~ ^sha256:[0-9a-f]{64}$ ]] || {
echo "Package artifact identity is invalid." >&2
exit 1
}
SOURCE_PACKAGE_JSON="${source_package_json}" \
node <<'NODE'
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
function fail(message) {
throw new Error(message);
}
const artifactDir = process.env.ARTIFACT_DIR;
const manifestPath = path.join(artifactDir, "preflight-manifest.json");
const packPath = path.join(artifactDir, "npm-pack.json");
if (!fs.existsSync(manifestPath) || !fs.existsSync(packPath)) {
fail("Plugin npm preflight artifact is missing its manifest or npm pack metadata.");
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const pack = JSON.parse(fs.readFileSync(packPath, "utf8"));
if (!Array.isArray(pack) || pack.length !== 1) {
fail("Plugin npm preflight artifact must contain exactly one npm pack result.");
}
const packEntry = pack[0];
const tarballName = manifest.artifact?.tarballName;
if (
typeof tarballName !== "string" ||
tarballName.length === 0 ||
tarballName.includes("\0") ||
tarballName !== path.basename(tarballName) ||
tarballName !== path.win32.basename(tarballName) ||
!tarballName.endsWith(".tgz")
) {
fail(`Unsafe preflight tarball name: ${JSON.stringify(tarballName)}.`);
}
const artifactFiles = fs.readdirSync(artifactDir).toSorted();
const expectedFiles = ["npm-pack.json", "preflight-manifest.json", tarballName].toSorted();
if (JSON.stringify(artifactFiles) !== JSON.stringify(expectedFiles)) {
fail(`Unexpected preflight artifact files: ${artifactFiles.join(", ")}.`);
}
const expectedManifest = {
schemaVersion: 1,
kind: "openclaw-plugin-npm-preflight",
mode: "preflight-only",
repository: process.env.REPOSITORY,
workflow: {
path: process.env.WORKFLOW_PATH,
ref: process.env.WORKFLOW_REF,
sha: process.env.WORKFLOW_SHA,
runId: process.env.RUN_ID,
runAttempt: Number(process.env.RUN_ATTEMPT),
},
source: {
inputRef: process.env.SOURCE_REF,
sha: process.env.SOURCE_SHA,
trustPolicy: "workflow-main-and-target-main-or-release-ancestor",
},
mutationCapabilities: {
npmPublish: false,
npmDistTag: false,
clawHub: false,
environmentApproval: false,
oidcWrite: false,
secretRead: false,
},
};
for (const [key, value] of Object.entries(expectedManifest)) {
if (JSON.stringify(manifest[key]) !== JSON.stringify(value)) {
fail(`Preflight manifest ${key} mismatch.`);
}
}
if (manifest.artifact?.name !== process.env.ARTIFACT_NAME) {
fail("Preflight artifact name mismatch.");
}
const tarballPath = path.join(artifactDir, tarballName);
if (!fs.existsSync(tarballPath)) {
fail(`Preflight tarball is missing: ${tarballName}.`);
}
const tarball = fs.readFileSync(tarballPath);
const sha256 = crypto.createHash("sha256").update(tarball).digest("hex");
const npmIntegrity = `sha512-${crypto.createHash("sha512").update(tarball).digest("base64")}`;
const npmShasum = crypto.createHash("sha1").update(tarball).digest("hex");
if (
manifest.artifact.sha256 !== sha256 ||
manifest.artifact.npmIntegrity !== npmIntegrity ||
manifest.artifact.npmShasum !== npmShasum ||
packEntry.integrity !== npmIntegrity ||
packEntry.shasum !== npmShasum ||
packEntry.filename !== tarballName ||
packEntry.name !== process.env.PACKAGE_NAME ||
packEntry.version !== process.env.PACKAGE_VERSION
) {
fail("Preflight tarball digest or npm integrity readback mismatch.");
}
const packedPackageJson = execFileSync("tar", [
"-xOf",
tarballPath,
"package/package.json",
]);
const sourcePackageJson = fs.readFileSync(process.env.SOURCE_PACKAGE_JSON);
const packedPackageJsonSha256 = crypto
.createHash("sha256")
.update(packedPackageJson)
.digest("hex");
const sourcePackageJsonSha256 = crypto
.createHash("sha256")
.update(sourcePackageJson)
.digest("hex");
const packageJson = JSON.parse(packedPackageJson.toString("utf8"));
const pluginManifest = JSON.parse(
execFileSync("tar", ["-xOf", tarballPath, "package/openclaw.plugin.json"], {
encoding: "utf8",
}),
);
const repositoryUrl =
typeof packageJson.repository === "string"
? packageJson.repository
: packageJson.repository?.url;
if (
manifest.package.extensionId !== process.env.EXTENSION_ID ||
manifest.package.packageDir !== process.env.PACKAGE_DIR ||
packageJson.name !== process.env.PACKAGE_NAME ||
packageJson.version !== process.env.PACKAGE_VERSION ||
manifest.package.name !== process.env.PACKAGE_NAME ||
manifest.package.version !== process.env.PACKAGE_VERSION ||
packageJson.openclaw?.install?.npmSpec !== process.env.INSTALL_NPM_SPEC ||
manifest.package.installNpmSpec !== process.env.INSTALL_NPM_SPEC ||
manifest.package.publishTag !== process.env.PUBLISH_TAG ||
repositoryUrl !== "https://github.com/openclaw/openclaw" ||
manifest.package.pluginId !== process.env.EXTENSION_ID ||
manifest.package.repositoryUrl !== repositoryUrl ||
pluginManifest.id !== process.env.EXTENSION_ID ||
manifest.package.packageJsonSha256 !== packedPackageJsonSha256 ||
manifest.package.sourcePackageJsonSha256 !== sourcePackageJsonSha256
) {
fail("Packed plugin identity, package hashes, or install route changed during artifact readback.");
}
console.log(
`Verified immutable plugin npm preflight artifact ${process.env.ARTIFACT_NAME} (${sha256}).`,
);
NODE
tarball_name="$(jq -er '.artifact.tarballName' "${ARTIFACT_DIR}/preflight-manifest.json")"
npm_integrity="$(jq -er '.artifact.npmIntegrity | strings' "${ARTIFACT_DIR}/preflight-manifest.json")"
npm_shasum="$(jq -er '.artifact.npmShasum | strings' "${ARTIFACT_DIR}/preflight-manifest.json")"
packed_package_json_sha256="$(jq -er '.package.packageJsonSha256' "${ARTIFACT_DIR}/preflight-manifest.json")"
source_package_json_sha256="$(jq -er '.package.sourcePackageJsonSha256' "${ARTIFACT_DIR}/preflight-manifest.json")"
tarball_sha256="$(jq -er '.artifact.sha256' "${ARTIFACT_DIR}/preflight-manifest.json")"
{
echo "artifact_digest=${artifact_digest}"
echo "artifact_id=${artifact_id}"
echo "artifact_name=${ARTIFACT_NAME}"
echo "npm_integrity=${npm_integrity}"
echo "npm_shasum=${npm_shasum}"
echo "package_json_sha256=${packed_package_json_sha256}"
echo "source_package_json_sha256=${source_package_json_sha256}"
echo "tarball_name=${tarball_name}"
echo "tarball_path=${ARTIFACT_DIR}/${tarball_name}"
echo "tarball_sha256=${tarball_sha256}"
} >> "$GITHUB_OUTPUT"
- name: Verify npm publication route readiness
id: publication_route
env:
EXPECTED_NPM_INTEGRITY: ${{ steps.publication_artifact.outputs.npm_integrity }}
EXPECTED_NPM_SHASUM: ${{ steps.publication_artifact.outputs.npm_shasum }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PUBLISH_TAG: ${{ matrix.plugin.publishTag }}
run: |
set -euo pipefail
node --input-type=module <<'NODE' >> "$GITHUB_OUTPUT"
import {
fetchNpmRegistryPackumentWithRetry,
resolveNpmPublishPlan,
resolvePublishedNpmVersionRoute,
} from "./scripts/lib/npm-publish-plan.mjs";
const packageName = process.env.PACKAGE_NAME;
const packageVersion = process.env.PACKAGE_VERSION;
const publishTag = process.env.PUBLISH_TAG;
const expectedIntegrity = process.env.EXPECTED_NPM_INTEGRITY;
const expectedShasum = process.env.EXPECTED_NPM_SHASUM;
if (
!/^sha512-[A-Za-z0-9+/]{86}==$/u.test(expectedIntegrity ?? "") ||
!/^[0-9a-f]{40}$/u.test(expectedShasum ?? "")
) {
throw new Error(`${packageName}: verified preflight npm identity is invalid.`);
}
const packageUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
const requestAttempts = 3;
const requestTimeoutMs = 20_000;
const observations = [];
for (let attempt = 1; attempt <= 3; attempt += 1) {
const registryFetch = await fetchNpmRegistryPackumentWithRetry({
packageName,
packageUrl,
attempts: requestAttempts,
timeoutMs: requestTimeoutMs,
});
if (registryFetch.status === 404) {
observations.push("npm-token-bootstrap");
} else if (registryFetch.ok) {
const packument = registryFetch.packument;
if (!packument || typeof packument !== "object" || Array.isArray(packument)) {
throw new Error(`${packageName}: npm registry packument is not an object.`);
}
const versions = Object.keys(packument.versions ?? {});
const targetPublished = versions.includes(packageVersion);
const priorVersions = versions.filter((version) => version !== packageVersion);
if (!targetPublished && priorVersions.length > 0) {
observations.push("npm-oidc");
} else if (targetPublished) {
const targetDist = packument.versions?.[packageVersion]?.dist;
if (
targetDist?.integrity !== expectedIntegrity ||
targetDist?.shasum !== expectedShasum
) {
throw new Error(
`${packageName}@${packageVersion}: npm registry tarball identity does not match the verified preflight artifact; refusing published-version route.`,
);
}
const publishTagOverride =
publishTag === "extended-stable" ? "extended-stable" : undefined;
const publishPlan = resolveNpmPublishPlan(
packageVersion,
packument["dist-tags"]?.beta,
publishTagOverride,
);
if (publishPlan.publishTag !== publishTag) {
throw new Error(
`${packageName}: package release plan resolved ${publishPlan.publishTag}, expected ${publishTag}.`,
);
}
observations.push(
resolvePublishedNpmVersionRoute({
packageVersion,
publishPlan,
distTags: packument["dist-tags"] ?? {},
}),
);
} else {
throw new Error(
`${packageName}: npm registry publication history is inconsistent.`,
);
}
} else {
throw new Error(
`${packageName}: npm publication-route probe returned HTTP ${registryFetch.status}.`,
);
}
if (attempt !== 3) {
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
}
}
if (new Set(observations).size !== 1) {
throw new Error(
`${packageName}: npm publication route changed during preflight: ${observations.join(", ")}.`,
);
}
console.log(`route=${observations[0]}`);
NODE
- name: Record validation-only result
id: preflight_evidence
env:
EXTENSION_ID: ${{ matrix.plugin.extensionId }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKED_PACKAGE_JSON_SHA256: ${{ steps.publication_artifact.outputs.package_json_sha256 }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PUBLICATION_ARTIFACT_DIGEST: ${{ steps.publication_artifact.outputs.artifact_digest }}
PUBLICATION_ARTIFACT_ID: ${{ steps.publication_artifact.outputs.artifact_id }}
PUBLICATION_ARTIFACT_NAME: ${{ steps.publication_artifact.outputs.artifact_name }}
PUBLISH_TAG: ${{ matrix.plugin.publishTag }}
PUBLISH_ROUTE: ${{ steps.publication_route.outputs.route }}
SOURCE_PACKAGE_JSON_SHA256: ${{ steps.publication_artifact.outputs.source_package_json_sha256 }}
TARBALL_NAME: ${{ steps.publication_artifact.outputs.tarball_name }}
TARBALL_PATH: ${{ steps.publication_artifact.outputs.tarball_path }}
TARBALL_SHA256: ${{ steps.publication_artifact.outputs.tarball_sha256 }}
TARGET_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
output_dir="${RUNNER_TEMP}/plugin-npm-preflight/evidence"
rm -rf "$output_dir"
install -d -m 0700 "$output_dir"
install -m 0600 "$TARBALL_PATH" "$output_dir/$TARBALL_NAME"
tarball_size="$(stat -c %s "$output_dir/$TARBALL_NAME")"
EVIDENCE_PATH="$output_dir/plugin-npm-package-evidence.json" \
TARBALL_SIZE="$tarball_size" \
node --input-type=module <<'NODE'
import { writeFileSync } from "node:fs";
if (
!/^[1-9][0-9]*$/u.test(process.env.PUBLICATION_ARTIFACT_ID ?? "") ||
!process.env.PUBLICATION_ARTIFACT_NAME ||
!/^sha256:[0-9a-f]{64}$/u.test(process.env.PUBLICATION_ARTIFACT_DIGEST ?? "") ||
!/^[0-9a-f]{64}$/u.test(process.env.PACKED_PACKAGE_JSON_SHA256 ?? "") ||
!/^[0-9a-f]{64}$/u.test(process.env.SOURCE_PACKAGE_JSON_SHA256 ?? "") ||
!/^[0-9a-f]{64}$/u.test(process.env.TARBALL_SHA256 ?? "") ||
!/^npm-(?:oidc|token-bootstrap|mirror|tag-repair|readback)$/u.test(
process.env.PUBLISH_ROUTE ?? "",
)
) {
throw new Error("Plugin npm preflight evidence is missing its canonical artifact tuple.");
}
const evidence = {
schema: "openclaw.plugin-npm-package-evidence/v2",
schemaVersion: 2,
workflowPath: ".github/workflows/plugin-npm-release.yml",
workflowSha: process.env.WORKFLOW_SHA,
runId: Number(process.env.GITHUB_RUN_ID),
runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT),
targetSha: process.env.TARGET_SHA,
publicationPerformed: false,
publication: {
route: process.env.PUBLISH_ROUTE,
},
publicationArtifact: {
id: Number(process.env.PUBLICATION_ARTIFACT_ID),
name: process.env.PUBLICATION_ARTIFACT_NAME,
digest: process.env.PUBLICATION_ARTIFACT_DIGEST,
packageJsonSha256: process.env.PACKED_PACKAGE_JSON_SHA256,
sourcePackageJsonSha256: process.env.SOURCE_PACKAGE_JSON_SHA256,
tarballSha256: process.env.TARBALL_SHA256,
},
package: {
extensionId: process.env.EXTENSION_ID,
packageDir: process.env.PACKAGE_DIR,
name: process.env.PACKAGE_NAME,
version: process.env.PACKAGE_VERSION,
publishTag: process.env.PUBLISH_TAG,
},
tarball: {
name: process.env.TARBALL_NAME,
sha256: process.env.TARBALL_SHA256,
size: Number(process.env.TARBALL_SIZE),
},
conclusion: "success",
};
writeFileSync(process.env.EVIDENCE_PATH, `${JSON.stringify(evidence, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
NODE
evidence_count="$(find "$output_dir" -maxdepth 1 -type f | wc -l | tr -d ' ')"
[[ "$evidence_count" == "2" ]] || {
echo "Plugin npm preflight evidence must contain exactly two files." >&2
exit 1
}
echo "artifact_path=$output_dir" >> "$GITHUB_OUTPUT"
- name: Upload immutable plugin npm preflight evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: plugin-npm-package-${{ matrix.plugin.extensionId }}-${{ matrix.plugin.version }}
path: ${{ steps.preflight_evidence.outputs.artifact_path }}/*
if-no-files-found: error
retention-days: 30
- name: Record npm preflight summary
env:
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PUBLISH_ROUTE: ${{ steps.publication_route.outputs.route }}
TARBALL_SHA256: ${{ steps.publication_artifact.outputs.tarball_sha256 }}
run: |
{
echo "## Plugin npm validation-only proof"
echo
echo "- Package: \`${PACKAGE_NAME}@${PACKAGE_VERSION}\`"
echo "- Route: \`${PUBLISH_ROUTE}\`"
echo "- Tarball SHA-256: \`${TARBALL_SHA256}\`"
echo "- Publication: **not attempted**"
} >> "$GITHUB_STEP_SUMMARY"
publish_plugins_npm:
needs: [preview_plugins_npm, preview_plugin_pack, validate_release_publish_approval]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
if: github.event_name == 'workflow_dispatch' && !inputs.preflight_only && needs.preview_plugins_npm.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: npm-release
permissions:
@@ -353,7 +1067,7 @@ jobs:
verify_plugins_npm:
needs: [preview_plugins_npm, publish_plugins_npm]
if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag == 'extended-stable' && needs.preview_plugins_npm.result == 'success' && (needs.publish_plugins_npm.result == 'success' || (needs.preview_plugins_npm.outputs.has_candidates == 'false' && needs.publish_plugins_npm.result == 'skipped')) }}
if: ${{ always() && github.event_name == 'workflow_dispatch' && !inputs.preflight_only && inputs.npm_dist_tag == 'extended-stable' && needs.preview_plugins_npm.result == 'success' && (needs.publish_plugins_npm.result == 'success' || (needs.preview_plugins_npm.outputs.has_candidates == 'false' && needs.publish_plugins_npm.result == 'skipped')) }}
runs-on: ubuntu-latest
permissions:
contents: read
+189
View File
@@ -28,6 +28,14 @@ const JUNE_2026_PATCH_FLOOR = 5;
* @property {("latest" | "alpha" | "beta")[]} mirrorDistTags
*/
/**
* @typedef {"npm-readback" | "npm-mirror" | "npm-tag-repair"} PublishedNpmVersionRoute
*/
/**
* @typedef {"match" | "missing" | "lagging" | "ahead" | "incomparable" | "conflict"} NpmDistTagVersionState
*/
/**
* @typedef {object} NpmDistTagMirrorAuth
* @property {boolean} hasAuth
@@ -38,6 +46,106 @@ const JUNE_2026_PATCH_FLOOR = 5;
* @typedef {"--dry-run" | "--publish"} NpmPublishMode
*/
/**
* @typedef {object} NpmRegistryPackumentResult
* @property {number} status
* @property {boolean} ok
* @property {unknown} packument
*/
/**
* @param {Response} response
* @returns {Promise<void>}
*/
async function cancelNpmRegistryResponseBody(response) {
await response.body?.cancel().catch(() => undefined);
}
/**
* Fetches and consumes an npm packument within one timeout per attempt. Keeping
* body transfer inside the retry loop prevents a headers-only success from
* bypassing the retry budget when the registry stream stalls or truncates.
*
* @param {{
* packageName: string;
* packageUrl: string;
* attempts?: number;
* timeoutMs?: number;
* fetchImpl?: (input: string, init: RequestInit) => Promise<Response>;
* sleep?: (delayMs: number) => Promise<void>;
* createSignal?: (timeoutMs: number) => AbortSignal;
* }} params
* @returns {Promise<NpmRegistryPackumentResult>}
*/
export async function fetchNpmRegistryPackumentWithRetry(params) {
const attempts = params.attempts ?? 3;
const timeoutMs = params.timeoutMs ?? 20_000;
const fetchImpl = params.fetchImpl ?? globalThis.fetch;
const sleep =
params.sleep ??
((delayMs) =>
new Promise((resolve) => {
setTimeout(resolve, delayMs);
}));
const createSignal = params.createSignal ?? ((delayMs) => AbortSignal.timeout(delayMs));
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
let response;
try {
response = await fetchImpl(params.packageUrl, {
headers: { accept: "application/vnd.npm.install-v1+json" },
signal: createSignal(timeoutMs),
});
} catch (error) {
lastError = error;
}
if (response) {
if (response.status === 429 || response.status >= 500) {
await cancelNpmRegistryResponseBody(response);
lastError = new Error(`HTTP ${response.status}`);
} else if (!response.ok) {
await cancelNpmRegistryResponseBody(response);
return { status: response.status, ok: false, packument: null };
} else {
let body;
try {
body = await response.text();
} catch (error) {
await cancelNpmRegistryResponseBody(response);
lastError = error;
body = undefined;
}
if (body !== undefined) {
try {
return {
status: response.status,
ok: true,
packument: JSON.parse(body),
};
} catch (error) {
await cancelNpmRegistryResponseBody(response);
const message = error instanceof Error ? error.message : String(error);
lastError = new Error(
`${params.packageName}: npm publication-route probe returned invalid JSON: ${message}.`,
);
}
}
}
}
if (attempt < attempts) {
await sleep(attempt * 1000);
}
}
const message = lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(
`${params.packageName}: npm publication-route probe did not return a stable response: ${message}.`,
);
}
/**
* @param {string} version
* @param {Record<string, string | undefined>} groups
@@ -265,6 +373,87 @@ export function resolveNpmPublishPlan(version, currentBetaVersion, publishTagOve
};
}
/**
* @param {{
* packageVersion: string;
* publishPlan: NpmPublishPlan;
* distTags: Record<string, unknown>;
* }} params
* @returns {PublishedNpmVersionRoute}
*/
export function resolvePublishedNpmVersionRoute(params) {
const primaryState = classifyNpmDistTagVersion(
params.distTags[params.publishPlan.publishTag],
params.packageVersion,
);
const needsPrimaryRepair = primaryState === "missing" || primaryState === "lagging";
if (!needsPrimaryRepair && primaryState !== "match") {
throwUnsafeNpmDistTag(
params.publishPlan.publishTag,
params.distTags[params.publishPlan.publishTag],
params.packageVersion,
primaryState,
);
}
let needsMirrorRepair = false;
for (const distTag of params.publishPlan.mirrorDistTags) {
const mirrorState = classifyNpmDistTagVersion(params.distTags[distTag], params.packageVersion);
if (mirrorState === "missing" || mirrorState === "lagging") {
needsMirrorRepair = true;
continue;
}
if (mirrorState !== "match") {
throwUnsafeNpmDistTag(distTag, params.distTags[distTag], params.packageVersion, mirrorState);
}
}
if (needsPrimaryRepair) {
return "npm-tag-repair";
}
return needsMirrorRepair ? "npm-mirror" : "npm-readback";
}
/**
* @param {unknown} currentVersion
* @param {string} targetVersion
* @returns {NpmDistTagVersionState}
*/
function classifyNpmDistTagVersion(currentVersion, targetVersion) {
if (currentVersion === undefined) {
return "missing";
}
if (typeof currentVersion !== "string") {
return "incomparable";
}
if (currentVersion === targetVersion) {
return "match";
}
const comparison = compareReleaseVersions(currentVersion, targetVersion);
if (comparison === null) {
return "incomparable";
}
if (comparison < 0) {
return "lagging";
}
if (comparison > 0) {
return "ahead";
}
return "conflict";
}
/**
* @param {string} distTag
* @param {unknown} currentVersion
* @param {string} targetVersion
* @param {NpmDistTagVersionState} state
* @returns {never}
*/
function throwUnsafeNpmDistTag(distTag, currentVersion, targetVersion, state) {
throw new Error(
`npm dist-tag "${distTag}" points to ${JSON.stringify(currentVersion)} and cannot be safely moved to "${targetVersion}" (${state}).`,
);
}
/**
* @param {{
* nodeAuthToken?: string | null | undefined;
+4 -1
View File
@@ -597,7 +597,10 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([
],
[
".github/workflows/plugin-npm-release.yml",
["test/scripts/package-acceptance-workflow.test.ts"],
[
"test/scripts/package-acceptance-workflow.test.ts",
"test/scripts/plugin-npm-extended-stable-workflow.test.ts",
],
],
[".github/workflows/plugin-prerelease.yml", ["test/scripts/plugin-prerelease-test-plan.test.ts"]],
[
+304
View File
@@ -2,11 +2,194 @@
import { describe, expect, it } from "vitest";
import {
collectReleaseVersionFloorErrors,
fetchNpmRegistryPackumentWithRetry,
resolveNpmDistTagMirrorAuth,
resolveNpmPublishPlan,
resolvePublishedNpmVersionRoute,
shouldRequireNpmDistTagMirrorAuth,
} from "../scripts/lib/npm-publish-plan.mjs";
function registryResponse(params: {
status?: number;
body?: string;
bodyError?: Error;
cancel?: () => void;
}): Response {
const status = params.status ?? 200;
return {
status,
ok: status >= 200 && status < 300,
body: {
cancel: async () => {
params.cancel?.();
},
},
text: async () => {
if (params.bodyError) {
throw params.bodyError;
}
return params.body ?? "{}";
},
} as unknown as Response;
}
describe("fetchNpmRegistryPackumentWithRetry", () => {
it("retries a failed response body before returning the parsed packument", async () => {
const waits: number[] = [];
let fetchCalls = 0;
let cancelCalls = 0;
const packument = { versions: { "2026.7.1-beta.3": {} } };
const result = await fetchNpmRegistryPackumentWithRetry({
packageName: "@openclaw/meta-provider",
packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider",
fetchImpl: async () => {
fetchCalls += 1;
return registryResponse(
fetchCalls === 1
? {
bodyError: new TypeError("terminated"),
cancel: () => {
cancelCalls += 1;
},
}
: { body: JSON.stringify(packument) },
);
},
sleep: async (delayMs) => {
waits.push(delayMs);
},
createSignal: () => new AbortController().signal,
});
expect(result).toEqual({ status: 200, ok: true, packument });
expect(fetchCalls).toBe(2);
expect(cancelCalls).toBe(1);
expect(waits).toEqual([1000]);
});
it("keeps response body failures within the bounded retry budget", async () => {
const waits: number[] = [];
let fetchCalls = 0;
let cancelCalls = 0;
await expect(
fetchNpmRegistryPackumentWithRetry({
packageName: "@openclaw/meta-provider",
packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider",
fetchImpl: async () => {
fetchCalls += 1;
return registryResponse({
bodyError: new DOMException("timed out", "AbortError"),
cancel: () => {
cancelCalls += 1;
},
});
},
sleep: async (delayMs) => {
waits.push(delayMs);
},
createSignal: () => new AbortController().signal,
}),
).rejects.toThrow("npm publication-route probe did not return a stable response");
expect(fetchCalls).toBe(3);
expect(cancelCalls).toBe(3);
expect(waits).toEqual([1000, 2000]);
});
it("retries malformed JSON before returning the parsed packument", async () => {
let fetchCalls = 0;
let cancelCalls = 0;
const waits: number[] = [];
const packument = { versions: { "2026.7.1-beta.3": {} } };
const result = await fetchNpmRegistryPackumentWithRetry({
packageName: "@openclaw/meta-provider",
packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider",
fetchImpl: async () => {
fetchCalls += 1;
return registryResponse(
fetchCalls === 1
? {
body: "{",
cancel: () => {
cancelCalls += 1;
},
}
: { body: JSON.stringify(packument) },
);
},
sleep: async (delayMs) => {
waits.push(delayMs);
},
createSignal: () => new AbortController().signal,
});
expect(result).toEqual({ status: 200, ok: true, packument });
expect(fetchCalls).toBe(2);
expect(cancelCalls).toBe(1);
expect(waits).toEqual([1000]);
});
it("keeps malformed JSON within the bounded retry budget", async () => {
let fetchCalls = 0;
let cancelCalls = 0;
const waits: number[] = [];
await expect(
fetchNpmRegistryPackumentWithRetry({
packageName: "@openclaw/meta-provider",
packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider",
fetchImpl: async () => {
fetchCalls += 1;
return registryResponse({
body: "{",
cancel: () => {
cancelCalls += 1;
},
});
},
sleep: async (delayMs) => {
waits.push(delayMs);
},
createSignal: () => new AbortController().signal,
}),
).rejects.toThrow("npm publication-route probe returned invalid JSON");
expect(fetchCalls).toBe(3);
expect(cancelCalls).toBe(3);
expect(waits).toEqual([1000, 2000]);
});
it("returns a stable missing-package status without retrying", async () => {
let fetchCalls = 0;
let cancelCalls = 0;
const result = await fetchNpmRegistryPackumentWithRetry({
packageName: "@openclaw/meta-provider",
packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider",
fetchImpl: async () => {
fetchCalls += 1;
return registryResponse({
status: 404,
cancel: () => {
cancelCalls += 1;
},
});
},
sleep: async () => {
throw new Error("stable 404 must not sleep");
},
createSignal: () => new AbortController().signal,
});
expect(result).toEqual({ status: 404, ok: false, packument: null });
expect(fetchCalls).toBe(1);
expect(cancelCalls).toBe(1);
});
});
describe("collectReleaseVersionFloorErrors", () => {
it("blocks June 2026 stable and beta release trains below the published beta floor", () => {
expect(collectReleaseVersionFloorErrors("2026.6.4")).toEqual([
@@ -24,6 +207,127 @@ describe("collectReleaseVersionFloorErrors", () => {
});
});
describe("resolvePublishedNpmVersionRoute", () => {
it.each([
{
label: "missing beta",
version: "2026.7.1-beta.3",
distTags: {},
},
{
label: "lagging beta",
version: "2026.7.1-beta.3",
distTags: { beta: "2026.7.1-beta.2" },
},
{
label: "lagging alpha",
version: "2026.7.1-alpha.3",
distTags: { alpha: "2026.7.1-alpha.2" },
},
{
label: "lagging latest with a current beta mirror",
version: "2026.7.1",
distTags: { latest: "2026.6.11", beta: "2026.7.1" },
},
])(
"requires tag repair when the primary $label selector is repairable",
({ version, distTags }) => {
expect(
resolvePublishedNpmVersionRoute({
packageVersion: version,
publishPlan: resolveNpmPublishPlan(version),
distTags,
}),
).toBe("npm-tag-repair");
},
);
it.each([
["ahead beta", "2026.7.1-beta.3", { beta: "2026.7.1-beta.4" }],
["ahead alpha", "2026.7.1-alpha.3", { alpha: "2026.7.1-alpha.4" }],
["ahead latest", "2026.7.1", { latest: "2026.8.1" }],
["incomparable beta", "2026.7.1-beta.3", { beta: "not-a-version" }],
["conflicting beta", "2026.7.1-beta.3", { beta: " 2026.7.1-beta.3 " }],
])("rejects an unsafe primary %s selector", (_label, version, distTags) => {
expect(() =>
resolvePublishedNpmVersionRoute({
packageVersion: version,
publishPlan: resolveNpmPublishPlan(version),
distTags,
}),
).toThrow("cannot be safely moved");
});
it("requires mirror repair only after the primary selector matches", () => {
const version = "2026.7.1";
expect(
resolvePublishedNpmVersionRoute({
packageVersion: version,
publishPlan: resolveNpmPublishPlan(version),
distTags: { latest: version, beta: "2026.7.1-beta.3" },
}),
).toBe("npm-mirror");
});
it("rejects an incomparable mirror instead of advertising repair", () => {
const version = "2026.7.1";
expect(() =>
resolvePublishedNpmVersionRoute({
packageVersion: version,
publishPlan: resolveNpmPublishPlan(version),
distTags: { latest: version, beta: "not-a-version" },
}),
).toThrow("cannot be safely moved");
});
it("validates unsafe mirrors before returning primary tag repair", () => {
const version = "2026.7.1";
expect(() =>
resolvePublishedNpmVersionRoute({
packageVersion: version,
publishPlan: resolveNpmPublishPlan(version),
distTags: { latest: "2026.6.11", beta: "not-a-version" },
}),
).toThrow("cannot be safely moved");
});
it("rejects an ahead mirror from an inconsistent publish plan", () => {
const version = "2026.7.1";
expect(() =>
resolvePublishedNpmVersionRoute({
packageVersion: version,
publishPlan: resolveNpmPublishPlan(version),
distTags: { latest: version, beta: "2026.8.1-beta.1" },
}),
).toThrow("cannot be safely moved");
});
it("preserves an ahead beta selector when the publish plan omits the mirror", () => {
const version = "2026.7.1";
expect(
resolvePublishedNpmVersionRoute({
packageVersion: version,
publishPlan: resolveNpmPublishPlan(version, "2026.8.1-beta.1"),
distTags: { latest: version, beta: "2026.8.1-beta.1" },
}),
).toBe("npm-readback");
});
it.each([
["beta", "2026.7.1-beta.3", { beta: "2026.7.1-beta.3" }],
["alpha", "2026.7.1-alpha.3", { alpha: "2026.7.1-alpha.3" }],
["stable", "2026.7.1", { latest: "2026.7.1", beta: "2026.7.1" }],
])("accepts complete %s registry readback", (_label, version, distTags) => {
expect(
resolvePublishedNpmVersionRoute({
packageVersion: version,
publishPlan: resolveNpmPublishPlan(version),
distTags,
}),
).toBe("npm-readback");
});
});
describe("shouldRequireNpmDistTagMirrorAuth", () => {
it("does not require npm auth for dry-run preview commands", () => {
const plan = resolveNpmPublishPlan("2026.4.1");
@@ -3,21 +3,37 @@ import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const workflowPath = ".github/workflows/plugin-npm-release.yml";
const metaPackagePath = "extensions/meta/package.json";
const metaManifestPath = "extensions/meta/openclaw.plugin.json";
type Step = { env?: Record<string, string>; name?: string; run?: string };
type Step = {
env?: Record<string, string>;
if?: string;
name?: string;
run?: string;
uses?: string;
with?: Record<string, string | number>;
};
type Job = {
environment?: string;
if?: string;
needs?: string[] | string;
permissions?: Record<string, string>;
"runs-on"?: string;
steps?: Step[];
strategy?: { matrix?: { plugin?: string } };
};
type WorkflowInput = {
default?: boolean | string;
description?: string;
options?: string[];
required?: boolean;
type?: string;
};
type Workflow = {
on?: {
workflow_dispatch?: {
inputs?: {
npm_dist_tag?: { default?: string; options?: string[]; type?: string };
};
inputs?: Record<string, WorkflowInput>;
};
};
jobs?: Record<string, Job>;
@@ -50,6 +66,19 @@ describe("plugin npm extended-stable workflow", () => {
});
});
it("exposes a closed preflight-only mode", () => {
const inputs = workflow().on?.workflow_dispatch?.inputs;
expect(inputs?.preflight_only).toEqual({
description: "Prepare and verify immutable plugin npm artifacts without publishing",
required: true,
default: false,
type: "boolean",
});
expect(inputs?.ref?.description).toBe(
"Exact commit SHA; preflight accepts main/release ancestry, while publish mode also supports canonical extended-stable or matching Tideclaw alpha branches",
);
});
it("uses one override for check, plan, preview, pack, and publish", () => {
const parsed = workflow();
const raw = readFileSync(workflowPath, "utf8");
@@ -81,6 +110,202 @@ describe("plugin npm extended-stable workflow", () => {
);
});
it("binds preflight to an exact source SHA without release-publish approval", () => {
const preview = workflow().jobs?.preview_plugins_npm;
const previewSteps = preview?.steps ?? [];
const trusted = step(preview, "Validate ref is on a trusted publish branch");
expect(previewSteps.slice(0, 4).map((candidate) => candidate.name)).toEqual([
"Checkout",
"Resolve checked-out ref",
"Validate ref is on a trusted publish branch",
"Setup Node environment",
]);
const trustedIndex = previewSteps.indexOf(trusted);
expect(trustedIndex).toBe(2);
for (const candidate of previewSteps.slice(0, trustedIndex)) {
expect(candidate.uses?.startsWith("./"), candidate.name).not.toBe(true);
expect(candidate.run ?? "", candidate.name).not.toMatch(/\b(?:bun|npm|pnpm)\b/u);
}
expect(step(preview, "Setup Node environment").uses).toBe("./.github/actions/setup-node-env");
expect(trusted.env).toMatchObject({
PREFLIGHT_ONLY:
"${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only || false }}",
RELEASE_PUBLISH_RUN_ID:
"${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_id || '' }}",
SOURCE_REF: "${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}",
WORKFLOW_REF: "${{ github.ref }}",
WORKFLOW_SHA: "${{ github.workflow_sha }}",
});
expect(trusted.run).toContain('[[ "${WORKFLOW_REF}" != "refs/heads/main" ]]');
expect(trusted.run).toContain('git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main');
expect(trusted.run).toContain('[[ ! "${SOURCE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]');
expect(trusted.run).toContain(
'[[ "$(git rev-parse HEAD)" != "$(git rev-parse "${SOURCE_REF}^{commit}")" ]]',
);
expect(trusted.run).toContain("preflight must not include release_publish_run_id");
const preflightBranchRejection = trusted.run?.indexOf(
"Plugin npm preflight target must be reachable from main or release/*.",
);
const tideclawBranch = trusted.run?.indexOf(
'if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/',
);
expect(preflightBranchRejection).toBeGreaterThan(-1);
expect(tideclawBranch).toBeGreaterThan(preflightBranchRejection ?? Number.MAX_SAFE_INTEGER);
});
it("prepares and independently reads back immutable package evidence", () => {
const parsed = workflow();
const preview = parsed.jobs?.preview_plugin_pack;
expect(preview?.if).toContain("inputs.preflight_only");
expect(preview?.strategy?.matrix?.plugin).toContain("all_matrix");
const prepare = step(preview, "Prepare immutable npm preflight artifact");
expect(prepare.env?.ARTIFACT_NAME).toBe(
"plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}",
);
expect(prepare.run).toContain('bash scripts/plugin-npm-publish.sh --pack "${PACKAGE_DIR}"');
expect(prepare.run).toContain('path.join(process.env.ARTIFACT_DIR, "preflight-manifest.json")');
expect(prepare.run).toContain('kind: "openclaw-plugin-npm-preflight"');
expect(prepare.run).toContain('mode: "preflight-only"');
expect(prepare.run).toContain("source_package_json_sha256=");
expect(prepare.run).toContain("packed_package_json_sha256=");
expect(prepare.run).toContain(
"sourcePackageJsonSha256: process.env.SOURCE_PACKAGE_JSON_SHA256",
);
expect(prepare.run).toContain("packageJsonSha256: process.env.PACKED_PACKAGE_JSON_SHA256");
expect(prepare.run).toContain("npmIntegrity: actualIntegrity");
expect(prepare.run).toContain("npmShasum: actualShasum");
expect(prepare.run).toContain(
'trustPolicy: "workflow-main-and-target-main-or-release-ancestor"',
);
expect(prepare.run).toContain("npmPublish: false");
expect(prepare.run).toContain("environmentApproval: false");
expect(prepare.run).toContain("oidcWrite: false");
const upload = step(preview, "Upload immutable npm preflight artifact");
expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a");
expect(upload.with).toMatchObject({
"compression-level": 0,
"if-no-files-found": "error",
"retention-days": 30,
});
const verify = parsed.jobs?.verify_plugin_npm_preflight;
expect(verify?.needs).toEqual(["preview_plugins_npm", "preview_plugin_pack"]);
expect(verify?.strategy?.matrix?.plugin).toContain("all_matrix");
expect(verify?.name).toBe("Preflight plugin npm package (${{ matrix.plugin.packageName }})");
const trustedCheckout = step(verify, "Checkout trusted npm preflight tooling");
expect(trustedCheckout.with?.ref).toBe("${{ github.workflow_sha }}");
const download = step(verify, "Download immutable npm preflight artifact");
expect(download.uses).toBe(
"actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c",
);
expect(download.with?.name).toBe(
"plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}",
);
const readback = step(verify, "Validate npm preflight artifact readback");
expect(readback.run).toContain('git show "${SOURCE_SHA}:${PACKAGE_DIR}/package.json"');
expect(readback.run).toContain("Expected exactly one live package artifact named");
expect(readback.run).toContain('crypto.createHash("sha256")');
expect(readback.run).toContain('crypto.createHash("sha512")');
expect(readback.run).toContain('crypto.createHash("sha1")');
expect(readback.run).toContain('echo "npm_integrity=${npm_integrity}"');
expect(readback.run).toContain('echo "npm_shasum=${npm_shasum}"');
expect(readback.run).toContain(
"Packed plugin identity, package hashes, or install route changed",
);
expect(readback.run).toContain(
'trustPolicy: "workflow-main-and-target-main-or-release-ancestor"',
);
expect(readback.run).not.toContain("target-main-release-or-tideclaw");
const route = step(verify, "Verify npm publication route readiness");
expect(route.env).toMatchObject({
EXPECTED_NPM_INTEGRITY: "${{ steps.publication_artifact.outputs.npm_integrity }}",
EXPECTED_NPM_SHASUM: "${{ steps.publication_artifact.outputs.npm_shasum }}",
});
expect(route.run).toContain("encodeURIComponent(packageName)");
expect(route.run).toContain("fetchNpmRegistryPackumentWithRetry");
expect(route.run).toContain("resolvePublishedNpmVersionRoute");
expect(route.run).toContain('distTags: packument["dist-tags"] ?? {}');
expect(route.run).toContain("const requestAttempts = 3");
expect(route.run).toContain("const requestTimeoutMs = 20_000");
expect(route.run).toContain("attempts: requestAttempts");
expect(route.run).toContain("timeoutMs: requestTimeoutMs");
expect(route.run).not.toContain("response.json()");
expect(route.run).toContain("packument.versions?.[packageVersion]?.dist");
expect(route.run).toContain("targetDist?.integrity !== expectedIntegrity");
expect(route.run).toContain("targetDist?.shasum !== expectedShasum");
expect(route.run).toContain("npm registry tarball identity does not match");
expect(route.run).toContain('observations.push("npm-token-bootstrap")');
expect(route.run).toContain('observations.push("npm-oidc")');
const evidence = step(verify, "Record validation-only result");
expect(evidence.env?.PUBLISH_ROUTE).toBe("${{ steps.publication_route.outputs.route }}");
expect(evidence.run).toContain('schema: "openclaw.plugin-npm-package-evidence/v2"');
expect(evidence.run).toContain("schemaVersion: 2");
expect(evidence.run).toContain("packageJsonSha256: process.env.PACKED_PACKAGE_JSON_SHA256");
expect(evidence.run).toContain(
"sourcePackageJsonSha256: process.env.SOURCE_PACKAGE_JSON_SHA256",
);
expect(evidence.run).toContain("id: Number(process.env.PUBLICATION_ARTIFACT_ID)");
expect(evidence.run).toContain("tarballSha256: process.env.TARBALL_SHA256");
expect(evidence.run).toContain("tag-repair");
const evidenceUpload = step(verify, "Upload immutable plugin npm preflight evidence");
expect(evidenceUpload.with?.name).toBe(
"plugin-npm-package-${{ matrix.plugin.extensionId }}-${{ matrix.plugin.version }}",
);
expect(evidenceUpload.with?.path).toBe(
"${{ steps.preflight_evidence.outputs.artifact_path }}/*",
);
});
it("makes every publication capability unreachable in preflight mode", () => {
const parsed = workflow();
for (const jobName of [
"validate_release_publish_approval",
"publish_plugins_npm",
"verify_plugins_npm",
]) {
expect(parsed.jobs?.[jobName]?.if, jobName).toContain("!inputs.preflight_only");
}
for (const jobName of [
"preview_plugins_npm",
"preview_plugin_pack",
"verify_plugin_npm_preflight",
]) {
const job = parsed.jobs?.[jobName];
expect(job?.environment, jobName).toBeUndefined();
expect(job?.permissions?.["id-token"], jobName).not.toBe("write");
const serialized = JSON.stringify(job);
expect(serialized, jobName).not.toContain("secrets.");
expect(serialized, jobName).not.toContain("--publish");
expect(serialized, jobName).not.toMatch(/\bnpm publish\b/u);
expect(serialized, jobName).not.toMatch(/\bnpm dist-tag\b/u);
expect(serialized.replaceAll("clawHub: false", ""), jobName).not.toMatch(/\bclawhub\b/iu);
expect(serialized, jobName).not.toMatch(/\b(?:android|macos|windows)\b/iu);
}
});
it("attests the canonical Meta provider package and install route", () => {
const packageJson = JSON.parse(readFileSync(metaPackagePath, "utf8")) as {
name?: string;
openclaw?: {
install?: { npmSpec?: string };
release?: { publishToClawHub?: boolean; publishToNpm?: boolean };
};
};
const pluginManifest = JSON.parse(readFileSync(metaManifestPath, "utf8")) as { id?: string };
expect(packageJson.name).toBe("@openclaw/meta-provider");
expect(packageJson.openclaw?.install?.npmSpec).toBe("@openclaw/meta-provider");
expect(packageJson.openclaw?.release).toEqual({
publishToClawHub: true,
publishToNpm: true,
});
expect(pluginManifest.id).toBe("meta");
});
it("publishes extended-stable with OIDC only and verifies every package tag", () => {
const parsed = workflow();
const publish = step(parsed.jobs?.publish_plugins_npm, "Publish");