From 022dd9dc27a590aaa2f285d0c6da2e3b16c2e1c7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 10 Jul 2026 14:50:00 -0700 Subject: [PATCH] fix(release): unblock Meta npm bootstrap publication (#103951) * fix(release): isolate Meta npm bootstrap * test(release): assert OIDC npm version guard --- .github/workflows/plugin-npm-release.yml | 383 ++++++++++++++---- scripts/plugin-publication-artifact.mjs | 15 + .../package-acceptance-workflow.test.ts | 21 +- ...lugin-npm-extended-stable-workflow.test.ts | 86 +++- .../plugin-publication-artifact.test.ts | 51 +++ 5 files changed, 444 insertions(+), 112 deletions(-) diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index 62b5c0c276c9..801fffb685f8 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -12,7 +12,9 @@ on: - "scripts/lib/npm-publish-plan.mjs" - "scripts/lib/plugin-npm-package-manifest.mjs" - "scripts/lib/plugin-npm-release.ts" + - "scripts/lib/actions-artifact-archive.mjs" - "scripts/plugin-npm-publish.sh" + - "scripts/plugin-publication-artifact.mjs" - "scripts/plugin-npm-release-check.ts" - "scripts/plugin-npm-release-plan.ts" - "scripts/verify-plugin-npm-published-runtime.mjs" @@ -342,7 +344,6 @@ jobs: - 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 }} @@ -352,7 +353,7 @@ jobs: PACKAGE_VERSION: ${{ matrix.plugin.version }} PUBLISH_TAG: ${{ matrix.plugin.publishTag }} REPOSITORY: ${{ github.repository }} - SOURCE_REF: ${{ inputs.ref }} + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} SOURCE_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }} WORKFLOW_PATH: .github/workflows/plugin-npm-release.yml WORKFLOW_REF: ${{ github.workflow_ref }} @@ -523,17 +524,16 @@ jobs: - 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 + overwrite: true 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 }} @@ -548,7 +548,7 @@ jobs: 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' }} + if: ${{ github.event_name == 'workflow_dispatch' && ((inputs.preflight_only && needs.preview_plugins_npm.outputs.has_selection == 'true') || (!inputs.preflight_only && needs.preview_plugins_npm.outputs.has_candidates == 'true')) }} runs-on: ubuntu-latest permissions: actions: read @@ -556,7 +556,7 @@ jobs: strategy: fail-fast: false matrix: - plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.all_matrix) }} + plugin: ${{ fromJson(inputs.preflight_only && needs.preview_plugins_npm.outputs.all_matrix || needs.preview_plugins_npm.outputs.matrix) }} steps: - name: Checkout trusted npm preflight tooling uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -593,10 +593,6 @@ jobs: 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}" @@ -895,100 +891,54 @@ jobs: console.log(`route=${observations[0]}`); NODE - - name: Record validation-only result + - name: Create immutable plugin npm publication evidence id: preflight_evidence env: + ARTIFACT_NAME_PREFIX: plugin-npm-package-${{ matrix.plugin.extensionId }}-${{ matrix.plugin.version }} 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 + artifact_name="${ARTIFACT_NAME_PREFIX}-${PUBLISH_ROUTE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + policy_sha256="$(sha256sum .github/workflows/plugin-npm-release.yml | awk '{print $1}')" + node scripts/plugin-publication-artifact.mjs create \ + --artifact-dir "$output_dir" \ + --artifact-name "$artifact_name" \ + --package-dir "$PACKAGE_DIR" \ + --package-name "$PACKAGE_NAME" \ + --package-version "$PACKAGE_VERSION" \ + --publication-reason "Stable npm registry preflight selected ${PUBLISH_ROUTE}." \ + --publish-tag "$PUBLISH_TAG" \ + --publisher-policy-id plugin-npm-release-workflow \ + --publisher-policy-schema openclaw.plugin-npm-publisher-policy/v1 \ + --publisher-policy-sha256 "$policy_sha256" \ + --route "$PUBLISH_ROUTE" \ + --source-package-json-sha256 "$SOURCE_PACKAGE_JSON_SHA256" \ + --target-sha "$TARGET_SHA" 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_name=$artifact_name" >> "$GITHUB_OUTPUT" 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 }} + name: ${{ steps.preflight_evidence.outputs.artifact_name }} path: ${{ steps.preflight_evidence.outputs.artifact_path }}/* if-no-files-found: error retention-days: 30 @@ -1010,7 +960,13 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" publish_plugins_npm: - needs: [preview_plugins_npm, preview_plugin_pack, validate_release_publish_approval] + needs: + [ + preview_plugins_npm, + preview_plugin_pack, + verify_plugin_npm_preflight, + validate_release_publish_approval, + ] if: github.event_name == 'workflow_dispatch' && !inputs.preflight_only && needs.preview_plugins_npm.outputs.has_candidates == 'true' runs-on: ubuntu-latest environment: npm-release @@ -1023,21 +979,206 @@ jobs: matrix: plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }} steps: - - name: Checkout + - name: Checkout trusted publication tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: ${{ github.workflow_sha }} + fetch-depth: 1 + + - name: Setup trusted Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Consume immutable npm publication evidence + id: publication_evidence + env: + ARTIFACT_NAME_PREFIX: plugin-npm-package-${{ matrix.plugin.extensionId }}-${{ matrix.plugin.version }} + EXTENSION_ID: ${{ matrix.plugin.extensionId }} + GH_TOKEN: ${{ github.token }} + PACKAGE_DIR: ${{ matrix.plugin.packageDir }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PUBLISH_TAG: ${{ matrix.plugin.publishTag }} + TARGET_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }} + WORKFLOW_HEAD_BRANCH: ${{ github.ref_name }} + WORKFLOW_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + artifacts_json="${RUNNER_TEMP}/${EXTENSION_ID}-publication-artifacts.json" + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100" | + jq -s '{artifacts: [.[].artifacts[]]}' > "$artifacts_json" + jq \ + --arg prefix "${ARTIFACT_NAME_PREFIX}-" \ + --arg run_id "$GITHUB_RUN_ID" \ + --argjson consumer_attempt "$GITHUB_RUN_ATTEMPT" \ + '[ + .artifacts[] + | select(.expired == false and (.name | startswith($prefix))) + | ( + try ( + .name + | ltrimstr($prefix) + | capture("^(?npm-(?:oidc|token-bootstrap|mirror|tag-repair|readback))-(?[1-9][0-9]*)-(?[1-9][0-9]*)$") + ) catch null + ) as $binding + | select( + $binding != null and + $binding.run_id == $run_id and + ($binding.attempt | tonumber) <= $consumer_attempt + ) + | . + { + producer_attempt: ($binding.attempt | tonumber), + publish_route: $binding.route + } + ] + | sort_by(.producer_attempt) + | if length == 0 then + [] + else + (last.producer_attempt) as $latest_attempt + | map(select(.producer_attempt == $latest_attempt)) + end' \ + "$artifacts_json" > "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json" + artifact_count="$(jq 'length' "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json")" + [[ "$artifact_count" == "1" ]] || { + echo "Expected one current or prior immutable publication artifact for ${PACKAGE_NAME}@${PACKAGE_VERSION}; found ${artifact_count}." >&2 + exit 1 + } + artifact_metadata="${RUNNER_TEMP}/${EXTENSION_ID}-publication-artifact.json" + jq '.[0] | del(.producer_attempt, .publish_route)' \ + "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json" > "$artifact_metadata" + artifact_name="$(jq -er '.name' "$artifact_metadata")" + publish_route="$(jq -er '.[0].publish_route' "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json")" + producer_attempt="$(jq -er '.[0].producer_attempt' "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json")" + [[ "$publish_route" =~ ^npm-(oidc|token-bootstrap|mirror|tag-repair|readback)$ ]] || { + echo "Unsupported immutable npm publication route: ${publish_route}." >&2 + exit 1 + } + if [[ "$publish_route" == "npm-token-bootstrap" ]]; then + [[ "$WORKFLOW_REF" == "refs/heads/main" ]] || { + echo "npm token bootstrap requires the trusted main plugin release workflow." >&2 + exit 1 + } + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main || { + echo "npm token bootstrap workflow revision is not reachable from main." >&2 + exit 1 + } + fi + artifact_id="$(jq -er '.id' "$artifact_metadata")" + artifact_digest="$(jq -er '.digest' "$artifact_metadata")" + artifact_size="$(jq -er '.size_in_bytes' "$artifact_metadata")" + [[ "$artifact_id" =~ ^[1-9][0-9]*$ && "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ && "$artifact_size" =~ ^[1-9][0-9]*$ ]] || { + echo "Immutable npm publication artifact metadata is invalid." >&2 + exit 1 + } + (( artifact_size <= 268435456 )) || { + echo "Immutable npm publication artifact exceeds 256 MiB." >&2 + exit 1 + } + + workflow_run="${RUNNER_TEMP}/${EXTENSION_ID}-publication-run.json" + workflow_jobs="${RUNNER_TEMP}/${EXTENSION_ID}-publication-jobs.json" + artifact_zip="${RUNNER_TEMP}/${EXTENSION_ID}-publication.zip" + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${producer_attempt}" \ + > "$workflow_run" + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${producer_attempt}/jobs?per_page=100" | + jq -s '{total_count: ([.[].jobs[]] | length), jobs: [.[].jobs[]]}' > "$workflow_jobs" + curl --fail --location --silent --show-error \ + --retry 2 \ + --retry-all-errors \ + --max-time 120 \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}/zip" \ + -o "$artifact_zip" + + git fetch --no-tags --depth=1 origin "$TARGET_SHA" + source_package_json="${RUNNER_TEMP}/${EXTENSION_ID}-source-package.json" + git show "${TARGET_SHA}:${PACKAGE_DIR}/package.json" > "$source_package_json" + source_package_json_sha256="$(sha256sum "$source_package_json" | awk '{print $1}')" + policy_sha256="$(sha256sum .github/workflows/plugin-npm-release.yml | awk '{print $1}')" + output_dir="${RUNNER_TEMP}/${EXTENSION_ID}-verified-publication" + node scripts/plugin-publication-artifact.mjs verify \ + --artifact-digest "$artifact_digest" \ + --artifact-id "$artifact_id" \ + --artifact-metadata "$artifact_metadata" \ + --artifact-name "$artifact_name" \ + --artifact-size-bytes "$artifact_size" \ + --artifact-zip "$artifact_zip" \ + --consumer-run-attempt "$GITHUB_RUN_ATTEMPT" \ + --github-output "$GITHUB_OUTPUT" \ + --output-dir "$output_dir" \ + --package-dir "$PACKAGE_DIR" \ + --package-name "$PACKAGE_NAME" \ + --package-version "$PACKAGE_VERSION" \ + --producer-job-name "Preflight plugin npm package (${PACKAGE_NAME})" \ + --producer-run-attempt "$producer_attempt" \ + --producer-run-id "$GITHUB_RUN_ID" \ + --publication-reason "Stable npm registry preflight selected ${publish_route}." \ + --publish-tag "$PUBLISH_TAG" \ + --publisher-policy-id plugin-npm-release-workflow \ + --publisher-policy-schema openclaw.plugin-npm-publisher-policy/v1 \ + --publisher-policy-sha256 "$policy_sha256" \ + --repository "$GITHUB_REPOSITORY" \ + --route "$publish_route" \ + --run-state-policy same-run-producer-success \ + --source-package-json-sha256 "$source_package_json_sha256" \ + --target-sha "$TARGET_SHA" \ + --workflow-event workflow_dispatch \ + --workflow-head-branch "$WORKFLOW_HEAD_BRANCH" \ + --workflow-jobs-metadata "$workflow_jobs" \ + --workflow-path .github/workflows/plugin-npm-release.yml \ + --workflow-run-metadata "$workflow_run" \ + --workflow-sha "$WORKFLOW_SHA" + + - name: Authorize immutable publication route + env: + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PUBLISH_ROUTE: ${{ steps.publication_evidence.outputs.publish_route }} + run: | + set -euo pipefail + case "$PUBLISH_ROUTE" in + npm-oidc | npm-token-bootstrap | npm-readback) + ;; + *) + echo "${PACKAGE_NAME}: route ${PUBLISH_ROUTE} requires credential-isolated repair tooling, not this publish path." >&2 + exit 1 + ;; + esac + + - name: Setup trusted publication dependencies + if: steps.publication_evidence.outputs.publish_route == 'npm-token-bootstrap' || steps.publication_evidence.outputs.publish_route == 'npm-readback' + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + install-bun: "false" + + - name: Checkout OIDC publication target + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false ref: ${{ needs.preview_plugins_npm.outputs.ref_revision }} fetch-depth: 1 - - name: Setup Node environment + - name: Setup OIDC publication target + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' uses: ./.github/actions/setup-node-env with: node-version: ${{ env.NODE_VERSION }} install-bun: "false" - - name: Check npm package version + - name: Check OIDC npm package version id: npm_package_version + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' env: PACKAGE_NAME: ${{ matrix.plugin.packageName }} PACKAGE_VERSION: ${{ matrix.plugin.version }} @@ -1050,21 +1191,93 @@ jobs: echo "already_published=false" >> "$GITHUB_OUTPUT" fi - - name: Publish - if: steps.npm_package_version.outputs.already_published != 'true' + - name: Publish with trusted publisher + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' && steps.npm_package_version.outputs.already_published != 'true' env: - NODE_AUTH_TOKEN: ${{ inputs.npm_dist_tag != 'extended-stable' && secrets.NPM_TOKEN || '' }} - NPM_TOKEN: ${{ inputs.npm_dist_tag != 'extended-stable' && secrets.NPM_TOKEN || '' }} OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }} run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}" - - name: Verify published runtime + - name: Verify OIDC published runtime + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' env: PACKAGE_NAME: ${{ matrix.plugin.packageName }} PACKAGE_VERSION: ${{ matrix.plugin.version }} run: node scripts/verify-plugin-npm-published-runtime.mjs "${PACKAGE_NAME}@${PACKAGE_VERSION}" + - name: Publish approved Meta bootstrap tarball + if: steps.publication_evidence.outputs.publish_route == 'npm-token-bootstrap' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + PACKAGE_DIR: ${{ matrix.plugin.packageDir }} + PACKAGE_NAME: ${{ steps.publication_evidence.outputs.package_name }} + PACKAGE_VERSION: ${{ steps.publication_evidence.outputs.package_version }} + PUBLISH_TAG: ${{ steps.publication_evidence.outputs.publish_tag }} + TARBALL_PATH: ${{ steps.publication_evidence.outputs.tarball_path }} + run: | + set -euo pipefail + [[ "$PACKAGE_NAME" == "@openclaw/meta-provider" && "$PACKAGE_DIR" == "extensions/meta" ]] || { + echo "npm token bootstrap is restricted to the approved Meta provider package." >&2 + exit 1 + } + [[ "$PACKAGE_VERSION" == *"-beta."* && "$PUBLISH_TAG" == "beta" ]] || { + echo "Meta npm token bootstrap requires an approved beta package and beta tag." >&2 + exit 1 + } + [[ -n "${NPM_TOKEN// }" ]] || { + echo "Meta npm token bootstrap requires the protected npm release token." >&2 + exit 1 + } + publish_home="$(mktemp -d "${RUNNER_TEMP}/meta-npm-bootstrap.XXXXXX")" + cleanup() { + rm -rf "$publish_home" + } + trap cleanup EXIT + umask 077 + npmrc="$publish_home/npmrc" + { + echo "registry=https://registry.npmjs.org/" + printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" + } > "$npmrc" + chmod 0600 "$npmrc" + unset NODE_AUTH_TOKEN NPM_TOKEN NODE_OPTIONS + HOME="$publish_home" \ + NPM_CONFIG_GLOBALCONFIG=/dev/null \ + NPM_CONFIG_IGNORE_SCRIPTS=true \ + NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ \ + NPM_CONFIG_USERCONFIG="$npmrc" \ + npm publish "$TARBALL_PATH" \ + --access public \ + --ignore-scripts \ + --provenance \ + --tag "$PUBLISH_TAG" + + - name: Verify Meta bootstrap published runtime + if: steps.publication_evidence.outputs.publish_route == 'npm-token-bootstrap' + env: + PACKAGE_NAME: ${{ steps.publication_evidence.outputs.package_name }} + PACKAGE_VERSION: ${{ steps.publication_evidence.outputs.package_version }} + run: node scripts/verify-plugin-npm-published-runtime.mjs "${PACKAGE_NAME}@${PACKAGE_VERSION}" + + - name: Verify immutable npm readback runtime + if: steps.publication_evidence.outputs.publish_route == 'npm-readback' + env: + PACKAGE_NAME: ${{ steps.publication_evidence.outputs.package_name }} + PACKAGE_VERSION: ${{ steps.publication_evidence.outputs.package_version }} + run: node scripts/verify-plugin-npm-published-runtime.mjs "${PACKAGE_NAME}@${PACKAGE_VERSION}" + + - name: Record Meta trusted publisher checkpoint + if: steps.publication_evidence.outputs.publish_route == 'npm-token-bootstrap' + env: + PACKAGE_NAME: ${{ steps.publication_evidence.outputs.package_name }} + run: | + { + echo "## Meta npm bootstrap follow-up" + echo + echo "- Published \`${PACKAGE_NAME}\` from the verified immutable tarball." + echo "- Configure the GitHub trusted publisher for \`plugin-npm-release.yml\` and environment \`npm-release\` before the next OIDC publish." + } >> "$GITHUB_STEP_SUMMARY" + verify_plugins_npm: needs: [preview_plugins_npm, publish_plugins_npm] 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')) }} diff --git a/scripts/plugin-publication-artifact.mjs b/scripts/plugin-publication-artifact.mjs index 9a86408bfd86..e5d362d72cfa 100644 --- a/scripts/plugin-publication-artifact.mjs +++ b/scripts/plugin-publication-artifact.mjs @@ -14,6 +14,7 @@ import { readPublicationArtifactArchive, sha256Digest, validateActionsArtifactBinding, + validateActionsArtifactProducerJob, } from "./lib/actions-artifact-archive.mjs"; import { resolveNpmPublishPlan } from "./lib/npm-publish-plan.mjs"; @@ -25,6 +26,7 @@ export { readBoundedRegularFile, readPublicationArtifactArchive, validateActionsArtifactBinding, + validateActionsArtifactProducerJob, }; const MANIFEST_FILENAME = "plugin-publication-manifest.json"; @@ -1018,6 +1020,8 @@ export function verifyPluginPublicationArtifact(params) { artifactId, artifactName: normalized.artifactName, artifactSizeBytes, + consumerRunAttempt: params.consumerRunAttempt, + producerJobName: params.producerJobName, repository: params.repository, runStatePolicy: params.runStatePolicy ?? "completed-success", runAttempt, @@ -1032,6 +1036,13 @@ export function verifyPluginPublicationArtifact(params) { expected: expectedBinding, workflowRun, }); + if (expectedBinding.runStatePolicy === "same-run-producer-success") { + const workflowJobs = parseBoundedJsonFile( + params.workflowJobsMetadataPath, + "Actions workflow jobs metadata", + ); + validateActionsArtifactProducerJob({ expected: expectedBinding, workflowJobs }); + } const zipBytes = readBoundedRegularFile(params.artifactZipPath, { label: "Actions artifact ZIP", @@ -1230,12 +1241,16 @@ export function main(argv = process.argv.slice(2)) { : Number(values.expectedTarballSizeBytes), expectedTarballSha256: values.expectedTarballSha256, outputDir: values.outputDir, + consumerRunAttempt: + values.consumerRunAttempt === undefined ? undefined : Number(values.consumerRunAttempt), + producerJobName: values.producerJobName, producerRunAttempt: Number(values.producerRunAttempt), producerRunId: Number(values.producerRunId), repository: values.repository, workflowEvent: values.workflowEvent, workflowHeadBranch: values.workflowHeadBranch, workflowPath: values.workflowPath, + workflowJobsMetadataPath: values.workflowJobsMetadata, workflowRunMetadataPath: values.workflowRunMetadata, runStatePolicy: values.runStatePolicy, workflowSha: values.workflowSha, diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 854e0595940b..f10e27fe585e 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -29,6 +29,7 @@ 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 PLUGIN_NPM_RELEASE_WORKFLOW = ".github/workflows/plugin-npm-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"; @@ -3142,7 +3143,7 @@ describe("package artifact reuse", () => { const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8"); const clawHubWorkflow = readFileSync(".github/workflows/plugin-clawhub-release.yml", "utf8"); const clawHubNewWorkflow = readFileSync(".github/workflows/plugin-clawhub-new.yml", "utf8"); - const pluginNpmWorkflow = readFileSync(".github/workflows/plugin-npm-release.yml", "utf8"); + const pluginNpmWorkflow = readFileSync(PLUGIN_NPM_RELEASE_WORKFLOW, "utf8"); const openclawNpmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8"); const fastPretagScript = readFileSync("scripts/release-fast-pretag-check.sh", "utf8"); const pluginPretagPackScript = readFileSync( @@ -3472,10 +3473,18 @@ describe("package artifact reuse", () => { expect(pluginNpmWorkflow).toContain("Validate release publish approval run"); expect(clawHubWorkflow).toContain("Validate release publish approval run"); expect(openclawNpmWorkflow).toContain("Validate release publish approval run"); - expect(pluginNpmWorkflow).toContain("Check npm package version"); - expect(pluginNpmWorkflow).toContain("already_published=true"); - expect(pluginNpmWorkflow).toContain( - "steps.npm_package_version.outputs.already_published != 'true'", + const pluginNpmPublishJob = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "publish_plugins_npm"); + const npmPackageVersionStep = workflowStep( + pluginNpmPublishJob, + "Check OIDC npm package version", + ); + expect(npmPackageVersionStep).toMatchObject({ + id: "npm_package_version", + if: "steps.publication_evidence.outputs.publish_route == 'npm-oidc'", + }); + expect(npmPackageVersionStep.run).toContain("already_published=true"); + expect(workflowStep(pluginNpmPublishJob, "Publish with trusted publisher").if).toBe( + "steps.publication_evidence.outputs.publish_route == 'npm-oidc' && steps.npm_package_version.outputs.already_published != 'true'", ); expect(pluginNpmWorkflow).toContain("Direct Plugin NPM Release dispatch"); expect(clawHubWorkflow).toContain("Direct Plugin ClawHub Release dispatch"); @@ -3706,7 +3715,7 @@ wait_for_run plugin-clawhub-new.yml 123 || status=$? ".github/workflows/macos-release.yml", ".github/workflows/plugin-clawhub-release.yml", PACKAGE_ACCEPTANCE_WORKFLOW, - ".github/workflows/plugin-npm-release.yml", + PLUGIN_NPM_RELEASE_WORKFLOW, ]; for (const workflowPath of releaseWorkflowPaths) { diff --git a/test/scripts/plugin-npm-extended-stable-workflow.test.ts b/test/scripts/plugin-npm-extended-stable-workflow.test.ts index 08b77d1e8d18..b63ad816039e 100644 --- a/test/scripts/plugin-npm-extended-stable-workflow.test.ts +++ b/test/scripts/plugin-npm-extended-stable-workflow.test.ts @@ -85,10 +85,18 @@ describe("plugin npm extended-stable workflow", () => { expect(raw.match(/--npm-dist-tag "\$\{NPM_DIST_TAG\}"/gu)).toHaveLength(2); const expectedOverride = "${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }}"; - for (const name of ["Preview publish command", "Preview npm pack contents", "Publish"]) { + for (const name of [ + "Preview publish command", + "Preview npm pack contents", + "Publish with trusted publisher", + ]) { expect( step( - parsed.jobs?.[name === "Publish" ? "publish_plugins_npm" : "preview_plugin_pack"], + parsed.jobs?.[ + name === "Publish with trusted publisher" + ? "publish_plugins_npm" + : "preview_plugin_pack" + ], name, ).env, ).toMatchObject({ OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: expectedOverride }); @@ -163,6 +171,7 @@ describe("plugin npm extended-stable workflow", () => { expect(prepare.env?.ARTIFACT_NAME).toBe( "plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}", ); + expect(prepare.if).toBeUndefined(); 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"'); @@ -187,12 +196,14 @@ describe("plugin npm extended-stable workflow", () => { expect(upload.with).toMatchObject({ "compression-level": 0, "if-no-files-found": "error", + overwrite: true, "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?.strategy?.matrix?.plugin).toContain("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 }}"); @@ -240,21 +251,14 @@ describe("plugin npm extended-stable workflow", () => { 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"); + const evidence = step(verify, "Create immutable plugin npm publication evidence"); 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"); + expect(evidence.run).toContain("node scripts/plugin-publication-artifact.mjs create"); + expect(evidence.run).toContain("--publisher-policy-id plugin-npm-release-workflow"); + expect(evidence.run).toContain('--route "$PUBLISH_ROUTE"'); + expect(evidence.run).toContain('artifact_name="${ARTIFACT_NAME_PREFIX}-${PUBLISH_ROUTE}-'); 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?.name).toBe("${{ steps.preflight_evidence.outputs.artifact_name }}"); expect(evidenceUpload.with?.path).toBe( "${{ steps.preflight_evidence.outputs.artifact_path }}/*", ); @@ -280,7 +284,7 @@ describe("plugin npm extended-stable workflow", () => { 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.toContain("plugin-npm-publish.sh --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); @@ -308,14 +312,54 @@ describe("plugin npm extended-stable workflow", () => { it("publishes extended-stable with OIDC only and verifies every package tag", () => { const parsed = workflow(); - const publish = step(parsed.jobs?.publish_plugins_npm, "Publish"); - const tokenExpression = - "${{ inputs.npm_dist_tag != 'extended-stable' && secrets.NPM_TOKEN || '' }}"; + const publish = step(parsed.jobs?.publish_plugins_npm, "Publish with trusted publisher"); expect(publish.env).toMatchObject({ - NODE_AUTH_TOKEN: tokenExpression, - NPM_TOKEN: tokenExpression, OPENCLAW_NPM_PUBLISH_AUTH_MODE: "trusted-publisher", }); + expect(publish.env?.NODE_AUTH_TOKEN).toBeUndefined(); + expect(publish.env?.NPM_TOKEN).toBeUndefined(); + const bootstrap = step( + parsed.jobs?.publish_plugins_npm, + "Publish approved Meta bootstrap tarball", + ); + expect(bootstrap.if).toContain("npm-token-bootstrap"); + expect(bootstrap.env?.NPM_TOKEN).toBe("${{ secrets.NPM_TOKEN }}"); + expect(bootstrap.run).toContain( + '[[ "$PACKAGE_NAME" == "@openclaw/meta-provider" && "$PACKAGE_DIR" == "extensions/meta" ]]', + ); + expect(bootstrap.run).toContain("NPM_CONFIG_USERCONFIG"); + expect(bootstrap.run).toContain("unset NODE_AUTH_TOKEN NPM_TOKEN NODE_OPTIONS"); + expect(bootstrap.run).toContain('npm publish "$TARBALL_PATH"'); + expect(bootstrap.run).toContain("--ignore-scripts"); + expect(bootstrap.run).not.toContain("bash scripts/plugin-npm-publish.sh"); + + const consume = step( + parsed.jobs?.publish_plugins_npm, + "Consume immutable npm publication evidence", + ); + expect(consume.run).toContain("node scripts/plugin-publication-artifact.mjs verify"); + expect(consume.run).toContain("--run-state-policy same-run-producer-success"); + expect(consume.run).toContain("producer_attempt"); + expect(consume.run).toContain("last.producer_attempt"); + expect(consume.run).toContain( + '--producer-job-name "Preflight plugin npm package (${PACKAGE_NAME})"', + ); + expect(consume.run).toContain("--workflow-jobs-metadata"); + expect(consume.run).toContain("--source-package-json-sha256"); + expect(consume.run).toContain('[[ "$WORKFLOW_REF" == "refs/heads/main" ]]'); + expect(consume.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main'); + expect( + step(parsed.jobs?.publish_plugins_npm, "Checkout trusted publication tooling").with?.ref, + ).toBe("${{ github.workflow_sha }}"); + expect( + step(parsed.jobs?.publish_plugins_npm, "Setup trusted publication dependencies").if, + ).toContain("npm-token-bootstrap"); + expect( + step(parsed.jobs?.publish_plugins_npm, "Setup trusted publication dependencies").if, + ).toContain("npm-readback"); + expect(step(parsed.jobs?.publish_plugins_npm, "Checkout OIDC publication target").if).toContain( + "npm-oidc", + ); expect(parsed.jobs?.reconcile_plugins_npm).toBeUndefined(); expect(readFileSync(workflowPath, "utf8")).not.toContain( 'npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" extended-stable', diff --git a/test/scripts/plugin-publication-artifact.test.ts b/test/scripts/plugin-publication-artifact.test.ts index 18c899791318..2fc23cf2a133 100644 --- a/test/scripts/plugin-publication-artifact.test.ts +++ b/test/scripts/plugin-publication-artifact.test.ts @@ -29,6 +29,7 @@ const REPOSITORY = "openclaw/openclaw"; const WORKFLOW_PATH = ".github/workflows/plugin-npm-release.yml"; const ARTIFACT_NAME = "plugin-npm-package-meta-2026.7.1-beta.3"; const PACKAGE_NAME = "@openclaw/meta-provider"; +const PRODUCER_JOB_NAME = `Preflight plugin npm package (${PACKAGE_NAME})`; const PACKAGE_VERSION = "2026.7.1-beta.3"; const PACKAGE_DIR = "extensions/meta"; const TARBALL_NAME = "openclaw-meta-provider-2026.7.1-beta.3.tgz"; @@ -365,9 +366,26 @@ function createFixture( const zipPath = path.join(root, "artifact.zip"); const metadataPath = path.join(root, "artifact.json"); const workflowRunPath = path.join(root, "run.json"); + const workflowJobsPath = path.join(root, "jobs.json"); writeFileSync(zipPath, zip); writeArtifactMetadata(metadataPath, zip); writeWorkflowRunMetadata(workflowRunPath); + writeFileSync( + workflowJobsPath, + `${JSON.stringify({ + total_count: 1, + jobs: [ + { + name: PRODUCER_JOB_NAME, + run_id: RUN_ID, + run_attempt: RUN_ATTEMPT, + head_sha: WORKFLOW_SHA, + status: "completed", + conclusion: "success", + }, + ], + })}\n`, + ); return { artifactDir, created, @@ -378,6 +396,7 @@ function createFixture( root, tarball, workflowRunPath, + workflowJobsPath, zip, zipPath, }; @@ -697,6 +716,38 @@ describe("plugin publication artifact", () => { } }); + it("accepts only the exact successful producer job for same-run publication", () => { + const fixture = createFixture(); + const workflowRun = JSON.parse(readFileSync(fixture.workflowRunPath, "utf8")); + workflowRun.status = "in_progress"; + workflowRun.conclusion = null; + writeFileSync(fixture.workflowRunPath, `${JSON.stringify(workflowRun)}\n`); + + expect( + verifyFixture(fixture, { + consumerRunAttempt: RUN_ATTEMPT, + producerJobName: PRODUCER_JOB_NAME, + runStatePolicy: "same-run-producer-success", + workflowJobsMetadataPath: fixture.workflowJobsPath, + }), + ).toMatchObject({ producerRunAttempt: RUN_ATTEMPT, producerRunId: RUN_ID }); + + const jobs = JSON.parse(readFileSync(fixture.workflowJobsPath, "utf8")); + jobs.jobs[0].conclusion = "failure"; + writeFileSync(fixture.workflowJobsPath, `${JSON.stringify(jobs)}\n`); + const failedFixture = createFixture(); + writeFileSync(failedFixture.workflowRunPath, `${JSON.stringify(workflowRun)}\n`); + writeFileSync(failedFixture.workflowJobsPath, `${JSON.stringify(jobs)}\n`); + expect(() => + verifyFixture(failedFixture, { + consumerRunAttempt: RUN_ATTEMPT, + producerJobName: PRODUCER_JOB_NAME, + runStatePolicy: "same-run-producer-success", + workflowJobsMetadataPath: failedFixture.workflowJobsPath, + }), + ).toThrow("producer job did not complete successfully"); + }); + it("retries bounded metadata, attempt, and archive failures against the exact run attempt", async () => { const zip = createZip([{ bytes: Buffer.from("proof"), name: "proof.txt" }]); const artifactMetadata = {