diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 05df4f3..a7ba243 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -3,13 +3,19 @@ name: Publish npm package on: release: types: [published] + workflow_dispatch: + inputs: + release_tag: + description: Existing published GitHub Release tag to retry (for example v0.5.0) + required: true + type: string permissions: contents: read id-token: write concurrency: - group: npm-publish-${{ github.event.release.tag_name }} + group: npm-publish-${{ inputs.release_tag || github.event.release.tag_name }} cancel-in-progress: false jobs: @@ -17,11 +23,32 @@ jobs: name: Publish to npm with OIDC runs-on: ubuntu-latest timeout-minutes: 30 + env: + RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} + WORKFLOW_SHA: ${{ github.workflow_sha }} steps: + - name: Validate release request + env: + GH_TOKEN: ${{ github.token }} + run: | + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$GITHUB_REF" != refs/heads/main ]]; then + echo 'Manual publication must use the main workflow.' >&2 + exit 1 + fi + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo 'A version tag is required.' >&2 + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release.json" + if ! jq -e --arg tag "$RELEASE_TAG" '.draft == false and .published_at != null and .tag_name == $tag' "$RUNNER_TEMP/release.json" > /dev/null; then + echo 'Only an existing published GitHub Release can be published to npm.' >&2 + exit 1 + fi + - name: Checkout the release tag uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name }} + ref: refs/tags/${{ env.RELEASE_TAG }} fetch-depth: 0 persist-credentials: false @@ -42,9 +69,12 @@ jobs: - name: Verify release identity id: release - env: - RELEASE_TAG: ${{ github.event.release.tag_name }} run: | + tag_commit="$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" + if [[ "$(git rev-parse HEAD)" != "$tag_commit" ]]; then + echo 'Checked out source does not match the exact release tag.' >&2 + exit 1 + fi package_version="$(node -p "require('./package.json').version")" if [[ "$RELEASE_TAG" != "v$package_version" ]]; then echo "Release tag $RELEASE_TAG does not match package version $package_version" >&2 @@ -55,11 +85,18 @@ jobs: echo "Release tag $RELEASE_TAG is not reachable from main" >&2 exit 1 fi + if ! git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main; then + echo 'Release tooling must come from a commit reachable from main.' >&2 + exit 1 + fi + echo "Release source: $(git rev-parse HEAD); workflow tooling: $WORKFLOW_SHA" - published_version="$(npm view "react-native-bs-diff-patch@$package_version" version 2>/dev/null || true)" - if [[ "$published_version" == "$package_version" ]]; then + if npm view "react-native-bs-diff-patch@$package_version" version --json --registry=https://registry.npmjs.org/ > "$RUNNER_TEMP/registry-version.json"; then echo "react-native-bs-diff-patch@$package_version is already published" >&2 exit 1 + elif ! jq -e '.error.code == "E404"' "$RUNNER_TEMP/registry-version.json" > /dev/null; then + echo 'Cannot confirm that the release version is absent from npm; refusing to publish.' >&2 + exit 1 fi if [[ "$package_version" == *-* ]]; then @@ -77,6 +114,9 @@ jobs: - name: Run release quality gates run: | + # A retry can fix this unpackaged consumer harness without moving the + # release tag. Restore it before publication so only tag source ships. + git show "$WORKFLOW_SHA:scripts/test-sdk-consumers.mjs" > scripts/test-sdk-consumers.mjs yarn prepare yarn typecheck yarn lint @@ -91,6 +131,8 @@ jobs: yarn test:action yarn test:package yarn test:sdk + git show HEAD:scripts/test-sdk-consumers.mjs > scripts/test-sdk-consumers.mjs + git diff --exit-code HEAD npm pack --dry-run --ignore-scripts env: CHROME_PATH: /usr/bin/google-chrome @@ -118,4 +160,6 @@ jobs: env: PACKAGE_SPEC: react-native-bs-diff-patch@${{ steps.release.outputs.package_version }} CHROME_PATH: /usr/bin/google-chrome - run: yarn test:sdk + run: | + git show "$WORKFLOW_SHA:scripts/test-sdk-consumers.mjs" > scripts/test-sdk-consumers.mjs + yarn test:sdk diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a09855e..158b7a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -153,6 +153,34 @@ repository and the `npm-publish.yml` workflow. No npm-side configuration is required for a release. The release tag must exactly match `v`. +### Recovering a failed npm publication + +For an existing tag and published GitHub Release (the example below uses +`v0.5.0`), recovery reuses that release. Classify a failed publication by +checking the registry first: + +```sh +npm view react-native-bs-diff-patch@0.5.0 version \ + dist.attestations.provenance.predicateType --registry=https://registry.npmjs.org/ +``` + +If `0.5.0` is already present, do not run `npm publish` again; complete only +the provenance and registry consumer checks. If the registry confirms an +explicit E404, fix the publishing tool or fixture, merge that fix into `main`, +and retry the existing release from `main`: + +```sh +gh workflow run npm-publish.yml --ref main -f release_tag=v0.5.0 +gh run list --workflow npm-publish.yml --limit 5 +gh run watch +``` + +See [Development and verification](./docs/development.md#recovering-a-failed-npm-publication) +for the exact tag checkout, commit, fixture, tree-cleanliness, OIDC, provenance, +and registry-smoke invariants. Do not move or delete the existing tag, run +release-it again for the same version, or change the npm Trusted Publisher +configuration. + ### Scripts The `package.json` file contains various scripts for common tasks: diff --git a/docs/development.md b/docs/development.md index 67e5869..aa48ce3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -187,3 +187,50 @@ The npm package's Trusted Publisher is already configured with these values: No npm-side change is required for a normal release, and the workflow does not use a long-lived npm token. + +## Recovering a failed npm publication + +For an existing tag and published GitHub Release (the examples below use +`v0.5.0`), recovery reuses that release. Check the registry before retrying a +failed `npm-publish.yml` run: + +```sh +npm view react-native-bs-diff-patch@0.5.0 version \ + dist.attestations.provenance.predicateType --registry=https://registry.npmjs.org/ +``` + +If `0.5.0` is already published, do not publish it again. Finish only the +provenance and registry smoke checks, such as +`PACKAGE_SPEC=react-native-bs-diff-patch@0.5.0 yarn test:sdk` and the applicable +`yarn test:registry:vite` or `yarn test:registry:expo` check. Proceed with a +retry only when the official registry query confirms an explicit E404; network +errors, 403 responses, timeouts, and any other ambiguous result must stop the +recovery. After fixing the release tool or fixture, merge that fix into `main` +and retry the existing GitHub Release from `main`: + +```sh +gh workflow run npm-publish.yml --ref main -f release_tag=v0.5.0 +gh run list --workflow npm-publish.yml --limit 5 +gh run watch +``` + +After the retry completes, inspect the run, provenance metadata, and registry +smoke output before announcing availability. + +The manual workflow accepts only an existing published GitHub Release. It +checks out the exact `refs/tags/` commit and verifies that `HEAD` +matches that tag, then checks the tag/package version, tag and workflow commit +reachability from `main`, and that the npm version is not already present. It +temporarily takes the `test-sdk-consumers` harness from the workflow commit for +the quality gates, restores the tag script before packing, and asserts that the +tracked tree is clean. The full quality gates, OIDC Trusted Publishing, +provenance verification, and published-package smoke test remain enabled. + +The npm 12 cross-version fixture resolves an exact package version through the +official `https://registry.npmjs.org/` registry and compares its expected +SHA-512 SRI. Keep that exact-registry resolution; do not restore a download URL +fixture or weaken its integrity assertion. + +Do not move or delete the existing tag, run release-it again for the same +version, or change the npm Trusted Publisher settings. A retry repairs the +publication path for the existing release. diff --git a/docs/zh-CN/development.md b/docs/zh-CN/development.md index 0892220..cdf9db3 100644 --- a/docs/zh-CN/development.md +++ b/docs/zh-CN/development.md @@ -171,3 +171,41 @@ npm 包的 Trusted Publisher 已按以下值配置完成: - Environment:留空。 正常发布无需再修改 npm 侧配置,工作流也不使用长期 npm token。 + +## npm 发布失败后的恢复 + +对于已有 tag 和已发布的 GitHub Release(以下示例使用 `v0.5.0`),恢复流程会复用已有 +release。重试失败的 `npm-publish.yml` 前,先检查 registry: + +```sh +npm view react-native-bs-diff-patch@0.5.0 version \ + dist.attestations.provenance.predicateType --registry=https://registry.npmjs.org/ +``` + +如果 `0.5.0` 已经存在,不要再次运行 `npm publish`,只补做 provenance 和 registry +消费者检查,例如 `PACKAGE_SPEC=react-native-bs-diff-patch@0.5.0 yarn test:sdk`,以及适用的 +`yarn test:registry:vite` 或 `yarn test:registry:expo`。只有官方 registry 查询明确返回 E404 +时才可继续重试;网络错误、403、超时或其他不确定结果都必须停止恢复流程。修复发布工具或 +fixture 后,将修复合并到 `main`,再从 `main` 重试已有 GitHub Release: + +```sh +gh workflow run npm-publish.yml --ref main -f release_tag=v0.5.0 +gh run list --workflow npm-publish.yml --limit 5 +gh run watch +``` + +重试完成后,先检查 workflow run、provenance 元数据和 registry smoke 输出,再宣布版本可用。 + +手动 workflow 只接受已经发布的 GitHub Release。它会从精确的 +`refs/tags/` checkout 并确认 `HEAD` 就是该 tag 的 commit,然后检查 release +tag 与 `package.json` 版本一致、tag 和 workflow commit 可从 `main` 到达,以及 npm 中尚不 +存在该版本。质量门禁期间可以临时使用 workflow commit 中的 `test-sdk-consumers` harness, +打包前恢复 tag 中的脚本,并断言 tracked tree 干净。完整门禁、npm OIDC Trusted Publishing、 +provenance 校验和发布包 smoke test 均保留在 workflow 中。 + +npm 12 的跨版本 fixture 必须通过官方 registry(`https://registry.npmjs.org/`)解析精确的 +包版本,并比对预期的 SHA-512 SRI。不要恢复下载 URL fixture,也不要为了绕过 npm 默认的 +URL 策略而削弱完整性断言。 + +不要移动或删除已有 tag,不要对同一版本再次运行 release-it,也不要修改 npm Trusted Publisher +配置。重试只修复已有 GitHub Release 的发布路径,不会创建第二个 release。 diff --git a/scripts/test-sdk-consumers.mjs b/scripts/test-sdk-consumers.mjs index 80d6067..dd874ce 100644 --- a/scripts/test-sdk-consumers.mjs +++ b/scripts/test-sdk-consumers.mjs @@ -38,8 +38,8 @@ const chromeCandidates = [ ].filter(Boolean); const browserCsp = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'"; -const registry040TarballUrl = - 'https://registry.npmjs.org/react-native-bs-diff-patch/-/react-native-bs-diff-patch-0.4.0.tgz'; +const registry040PackageSpec = 'react-native-bs-diff-patch@0.4.0'; +const officialNpmRegistry = 'https://registry.npmjs.org/'; const registry040Integrity = 'sha512-pQXEVIn9yx8zqYtJjnw2xws3g3EB8E2Qz8N5WTwyUtUSpW/fhBFUtE1ACqbVvW6LE+7ndy7NjlrTJIERD0Y0lQ=='; @@ -153,15 +153,25 @@ function readPackedManifest(tarballPath) { } async function prepareRegistry040Tarball() { + const registryPackageDirectory = path.join( + temporaryDirectory, + 'registry-package-resolution' + ); + await mkdir(registryPackageDirectory); const metadata = parseTrailingJson( - run('npm', [ - 'pack', - '--ignore-scripts', - '--json', - '--pack-destination', - temporaryDirectory, - registry040TarballUrl, - ]) + run( + 'npm', + [ + 'pack', + '--ignore-scripts', + '--json', + '--pack-destination', + temporaryDirectory, + `--registry=${officialNpmRegistry}`, + registry040PackageSpec, + ], + { cwd: registryPackageDirectory } + ) ); const tarballPath = path.join( temporaryDirectory,