From ee73e474fd3c048e04f93d1fc4c69e5968afda24 Mon Sep 17 00:00:00 2001 From: Iktahana <171251543+Iktahana@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:31:21 +0900 Subject: [PATCH 1/4] fix: wait for npm registry visibility during immutable recovery --- .github/workflows/release.yml | 24 ++++++++++- docs/comment-release.md | 12 +++++- nodejs/scripts/publish-versioned-packages.mjs | 6 +-- nodejs/scripts/release-artifacts.mjs | 27 ++++++++++++ nodejs/scripts/release-artifacts.test.mjs | 41 ++++++++++++++++++- 5 files changed, 103 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cfe931..4c4a28f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ jobs: env: RELEASE_SHA: ${{ github.event.workflow_run.head_sha || inputs.release_sha || github.sha }} RELEASE_ARTIFACTS_DIR: output/npm-release + RELEASE_SOURCE_DIR: ${{ github.workspace }} steps: - uses: actions/checkout@v4 with: @@ -53,6 +54,14 @@ jobs: ) test "$count" -gt 0 + # Recovery can use repaired orchestration without rebuilding the original + # candidate. The manifest still pins the candidate SHA and every byte. + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: .release-tools + persist-credentials: false + - uses: jetli/wasm-pack-action@v0.4.0 - uses: pnpm/action-setup@v4 with: @@ -123,13 +132,24 @@ jobs: RELEASE_TARGET_VERSION: ${{ inputs.target_version }} run: node nodejs/scripts/prepare-release-artifacts.mjs --reuse + - name: Use the selected recovery tooling with the original candidate + if: inputs.resume_run_id != '' + env: + RECOVERY_TOOLING_SHA: ${{ github.sha }} + run: | + # Only publication tooling may change during recovery. HEAD, package + # manifests, and the saved tarballs still belong to RELEASE_SHA. + for script in release-artifacts.mjs publish-versioned-packages.mjs; do + git show "$RECOVERY_TOOLING_SHA:nodejs/scripts/$script" > "nodejs/scripts/$script" + done + - name: Publish or verify the original artifacts using OIDC id: publish - run: node nodejs/scripts/publish-versioned-packages.mjs + run: node .release-tools/nodejs/scripts/publish-versioned-packages.mjs - name: Create GitHub Releases with the original tarballs if: steps.publish.outputs.ready == 'true' - run: node nodejs/scripts/create-github-releases.mjs + run: node .release-tools/nodejs/scripts/create-github-releases.mjs env: GH_TOKEN: ${{ github.token }} PUBLISHED_PACKAGES: ${{ steps.candidate.outputs.publishedPackages }} diff --git a/docs/comment-release.md b/docs/comment-release.md index 79f7cb8..c00b465 100644 --- a/docs/comment-release.md +++ b/docs/comment-release.md @@ -34,7 +34,10 @@ For a failed publication, rerun the existing workflow attempt. It restores the original artifact automatically. To recover from another run, dispatch Release with `release_sha` set to the original gated main commit and `resume_run_id` set to the original run ID. Leave `target_version` empty or use the saved version. -Missing or expired recovery artifacts fail closed. Never rebuild or overwrite an +Registry verification waits up to five minutes for accepted packages to become visible. +Recovery uses the current workflow commit for orchestration while the saved manifest +and checkout still pin the original candidate and archive bytes. Publishing-tool +repairs do not force new package versions. Missing or expired recovery artifacts fail closed. Never rebuild or overwrite an already published version; fix incorrect artifacts with a new patch release. Rust, Python, Android and Swift retain their existing CI publication workflows. @@ -51,3 +54,10 @@ round trips and publication omission. Preserve registry versions, source commits CI URLs, artifact integrity values and verification results in the release record. Partial language publication is an incomplete release; recover each remaining artifact without replacing successful publications. + +Recovery can select a workflow ref containing corrected publication tooling. +Only `release-artifacts.mjs` and `publish-versioned-packages.mjs` are read from +that ref; checkout HEAD, the gated source SHA, package versions, checksums, and +uploaded tarballs remain those of the original candidate. Registry visibility +is polled after npm acknowledges publication; transient absence never causes +an immediate repeat upload, and conflicting bytes still fail closed. diff --git a/nodejs/scripts/publish-versioned-packages.mjs b/nodejs/scripts/publish-versioned-packages.mjs index dcc9b3d..e368ecf 100644 --- a/nodejs/scripts/publish-versioned-packages.mjs +++ b/nodejs/scripts/publish-versioned-packages.mjs @@ -1,9 +1,9 @@ import { execFileSync } from 'node:child_process'; import { appendFileSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { registryArtifactMatches, verifyReleaseArtifacts } from './release-artifacts.mjs'; +import { registryArtifactMatches, verifyReleaseArtifacts, waitForRegistryArtifact, waitForRegistryArtifact } from './release-artifacts.mjs'; -const root = resolve(import.meta.dirname, '../..'); +const root = resolve(process.env.RELEASE_SOURCE_DIR ?? resolve(import.meta.dirname, '../..')); const directory = resolve(root,process.env.RELEASE_ARTIFACTS_DIR ?? 'output/npm-release'); const sourceSha = execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(); const manifest = verifyReleaseArtifacts(JSON.parse(readFileSync(join(directory,'manifest.json'),'utf8')),directory,sourceSha); @@ -27,7 +27,7 @@ const pending = manifest.artifacts.filter((artifact) => !registryArtifactMatches if (!dryRun) { for (const artifact of pending) { execFileSync('npm',['publish',join(directory,artifact.filename),'--access','public'],{cwd:root,stdio:'inherit'}); - if (!registryArtifactMatches(artifact,registryIntegrity(artifact))) throw new Error(`Published artifact is not yet visible: ${artifact.name}`); + await waitForRegistryArtifact(artifact, registryIntegrity); } } if (process.env.GITHUB_OUTPUT) { diff --git a/nodejs/scripts/release-artifacts.mjs b/nodejs/scripts/release-artifacts.mjs index d164bc9..d6f1d9b 100644 --- a/nodejs/scripts/release-artifacts.mjs +++ b/nodejs/scripts/release-artifacts.mjs @@ -51,3 +51,30 @@ export function applyReleaseVersions(manifest, packages) { writeFileSync(manifestPath, `${JSON.stringify({...value, version}, null, 2)}\n`); } } + +// npm can acknowledge an upload before registry metadata becomes visible. +// Retry reads of the original artifact; never publish it a second time here. +export async function waitForRegistryArtifact(artifact, readIntegrity, { + attempts = 60, + delayMs = 10000, + sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +} = {}) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (registryArtifactMatches(artifact, await readIntegrity(artifact))) return; + if (attempt + 1 < attempts) await sleep(delayMs); + } + throw new Error(`Published artifact is not yet visible: ${artifact.name}@${artifact.version}`); +} + +/** Registry acceptance can precede public availability by several minutes. */ +export async function waitForRegistryArtifact(artifact, query, { + attempts = 60, + delayMs = 5000, + sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +} = {}) { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + if (registryArtifactMatches(artifact, await query(artifact))) return; + if (attempt < attempts) await sleep(delayMs); + } + throw new Error(`Published artifact did not become visible: ${artifact.name}@${artifact.version}`); +} diff --git a/nodejs/scripts/release-artifacts.test.mjs b/nodejs/scripts/release-artifacts.test.mjs index 93c0752..a4b2439 100644 --- a/nodejs/scripts/release-artifacts.test.mjs +++ b/nodejs/scripts/release-artifacts.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { applyReleaseVersions, integrity, registryArtifactMatches, validateReleaseManifest, verifyReleaseArtifacts } from './release-artifacts.mjs'; +import { applyReleaseVersions, integrity, registryArtifactMatches, validateReleaseManifest, verifyReleaseArtifacts, waitForRegistryArtifact } from './release-artifacts.mjs'; const sha = 'a'.repeat(40); const artifact = {name:'test-package',version:'2.1.0',filename:'test-package-2.1.0.tgz',integrity:integrity('original artifact')}; @@ -38,3 +38,42 @@ test('replay validates the complete package set before changing any manifest',() assert.equal(JSON.parse(readFileSync(manifestPath)).version,'2.1.0'); } finally { rmSync(directory,{recursive:true,force:true}); } }); + +test('publication tolerates delayed registry visibility without repeating an upload', async () => { + let reads = 0; + const delays = []; + await waitForRegistryArtifact(artifact, () => ++reads < 3 ? undefined : artifact.integrity, + { attempts: 3, delayMs: 10, sleep: async (ms) => delays.push(ms) }); + assert.equal(reads, 3); + assert.deepEqual(delays, [10, 10]); +}); +test('registry polling fails closed on conflicts, lookup errors, and exhaustion', async () => { + let sleeps = 0; + const options = { attempts: 2, sleep: async () => { sleeps += 1; } }; + await assert.rejects(waitForRegistryArtifact(artifact, () => integrity('foreign'), options), /conflict/); + await assert.rejects(waitForRegistryArtifact(artifact, () => { throw new Error('registry offline'); }, options), /registry offline/); + assert.equal(sleeps, 0); + await assert.rejects(waitForRegistryArtifact(artifact, () => undefined, options), /not yet visible/); + assert.equal(sleeps, 1); +}); + +test('waits for delayed registry visibility without publishing again', async () => { + let queries = 0; + let sleeps = 0; + await waitForRegistryArtifact(artifact, () => ++queries < 3 ? undefined : artifact.integrity, { + attempts: 3, delayMs: 5, sleep: async delay => { assert.equal(delay, 5); sleeps += 1; }, + }); + assert.equal(queries, 3); + assert.equal(sleeps, 2); +}); + +test('registry waiting is bounded and rejects conflicting bytes immediately', async () => { + let queries = 0; + await assert.rejects(waitForRegistryArtifact(artifact, () => { queries += 1; return undefined; }, { + attempts: 2, sleep: async () => {}, + }), /did not become visible/); + assert.equal(queries, 2); + await assert.rejects(waitForRegistryArtifact(artifact, () => integrity('foreign bytes'), { + sleep: async () => assert.fail('conflicts must not wait'), + }), /conflict/); +}); From 6a167e539197ed1463ba14c1e8a5e797513e7ec4 Mon Sep 17 00:00:00 2001 From: Iktahana <171251543+Iktahana@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:32:10 +0900 Subject: [PATCH 2/4] fix: recover npm publications after registry propagation delays --- docs/comment-release.md | 2 +- nodejs/scripts/compute-release-versions.mjs | 8 ++----- nodejs/scripts/create-github-releases.mjs | 4 ++-- nodejs/scripts/release-artifacts.mjs | 13 ------------ nodejs/scripts/release-artifacts.test.mjs | 21 ------------------- nodejs/scripts/release-version-policy.mjs | 2 ++ .../scripts/release-version-policy.test.mjs | 7 ++++++- 7 files changed, 13 insertions(+), 44 deletions(-) diff --git a/docs/comment-release.md b/docs/comment-release.md index c00b465..392cdae 100644 --- a/docs/comment-release.md +++ b/docs/comment-release.md @@ -34,7 +34,7 @@ For a failed publication, rerun the existing workflow attempt. It restores the original artifact automatically. To recover from another run, dispatch Release with `release_sha` set to the original gated main commit and `resume_run_id` set to the original run ID. Leave `target_version` empty or use the saved version. -Registry verification waits up to five minutes for accepted packages to become visible. +Registry verification waits up to ten minutes for accepted packages to become visible. Recovery uses the current workflow commit for orchestration while the saved manifest and checkout still pin the original candidate and archive bytes. Publishing-tool repairs do not force new package versions. Missing or expired recovery artifacts fail closed. Never rebuild or overwrite an diff --git a/nodejs/scripts/compute-release-versions.mjs b/nodejs/scripts/compute-release-versions.mjs index e7c8ca8..dd62b24 100644 --- a/nodejs/scripts/compute-release-versions.mjs +++ b/nodejs/scripts/compute-release-versions.mjs @@ -2,7 +2,7 @@ import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { globSync } from "node:fs"; import { join, relative } from "node:path"; -import { nextReleaseVersion, releaseClosure } from "./release-version-policy.mjs"; +import { nextReleaseVersion, releaseClosure, changesPackedArtifacts } from "./release-version-policy.mjs"; const root = new URL("..", import.meta.url).pathname; const dryRun = process.argv.includes("--dry-run"); @@ -39,11 +39,7 @@ const changedFiles = execFileSync( .filter(Boolean); const changedPackages = new Set(); const dependencyRoots = new Set(); -const packagingChanged = changedFiles.some( - (file) => - file === "nodejs/scripts/pack-publishable-package.mjs" || - file === "nodejs/scripts/publish-versioned-packages.mjs" -); +const packagingChanged = changesPackedArtifacts(changedFiles); for (const file of changedFiles) { const nodejsRelative = relative(root, join(root, "..", file)); diff --git a/nodejs/scripts/create-github-releases.mjs b/nodejs/scripts/create-github-releases.mjs index bab8675..f638fab 100644 --- a/nodejs/scripts/create-github-releases.mjs +++ b/nodejs/scripts/create-github-releases.mjs @@ -9,8 +9,8 @@ import { tmpdir } from "node:os"; import { integrity, verifyReleaseArtifacts } from "./release-artifacts.mjs"; import { fileURLToPath } from "node:url"; -const packagesRoot = path.resolve(fileURLToPath(import.meta.url), "../.."); -const repositoryRoot = path.resolve(packagesRoot, ".."); +const repositoryRoot = path.resolve(process.env.RELEASE_SOURCE_DIR ?? path.resolve(fileURLToPath(import.meta.url), "../../..")); +const packagesRoot = path.join(repositoryRoot, "nodejs"); const packagesDir = path.join(packagesRoot, "packages"); if (process.env.GITHUB_ACTIONS !== "true") throw new Error("Production release creation must run in GitHub Actions"); const artifactsDirectory = path.resolve(repositoryRoot, process.env.RELEASE_ARTIFACTS_DIR ?? "output/npm-release"); diff --git a/nodejs/scripts/release-artifacts.mjs b/nodejs/scripts/release-artifacts.mjs index d6f1d9b..f5b5787 100644 --- a/nodejs/scripts/release-artifacts.mjs +++ b/nodejs/scripts/release-artifacts.mjs @@ -65,16 +65,3 @@ export async function waitForRegistryArtifact(artifact, readIntegrity, { } throw new Error(`Published artifact is not yet visible: ${artifact.name}@${artifact.version}`); } - -/** Registry acceptance can precede public availability by several minutes. */ -export async function waitForRegistryArtifact(artifact, query, { - attempts = 60, - delayMs = 5000, - sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), -} = {}) { - for (let attempt = 1; attempt <= attempts; attempt += 1) { - if (registryArtifactMatches(artifact, await query(artifact))) return; - if (attempt < attempts) await sleep(delayMs); - } - throw new Error(`Published artifact did not become visible: ${artifact.name}@${artifact.version}`); -} diff --git a/nodejs/scripts/release-artifacts.test.mjs b/nodejs/scripts/release-artifacts.test.mjs index a4b2439..bc8c928 100644 --- a/nodejs/scripts/release-artifacts.test.mjs +++ b/nodejs/scripts/release-artifacts.test.mjs @@ -56,24 +56,3 @@ test('registry polling fails closed on conflicts, lookup errors, and exhaustion' await assert.rejects(waitForRegistryArtifact(artifact, () => undefined, options), /not yet visible/); assert.equal(sleeps, 1); }); - -test('waits for delayed registry visibility without publishing again', async () => { - let queries = 0; - let sleeps = 0; - await waitForRegistryArtifact(artifact, () => ++queries < 3 ? undefined : artifact.integrity, { - attempts: 3, delayMs: 5, sleep: async delay => { assert.equal(delay, 5); sleeps += 1; }, - }); - assert.equal(queries, 3); - assert.equal(sleeps, 2); -}); - -test('registry waiting is bounded and rejects conflicting bytes immediately', async () => { - let queries = 0; - await assert.rejects(waitForRegistryArtifact(artifact, () => { queries += 1; return undefined; }, { - attempts: 2, sleep: async () => {}, - }), /did not become visible/); - assert.equal(queries, 2); - await assert.rejects(waitForRegistryArtifact(artifact, () => integrity('foreign bytes'), { - sleep: async () => assert.fail('conflicts must not wait'), - }), /conflict/); -}); diff --git a/nodejs/scripts/release-version-policy.mjs b/nodejs/scripts/release-version-policy.mjs index bc8897b..aa2d5ad 100644 --- a/nodejs/scripts/release-version-policy.mjs +++ b/nodejs/scripts/release-version-policy.mjs @@ -44,3 +44,5 @@ export function releaseClosure(manifests, changedNames) { } return selected; } + +export const changesPackedArtifacts = (files) => files.includes("nodejs/scripts/pack-publishable-package.mjs"); diff --git a/nodejs/scripts/release-version-policy.test.mjs b/nodejs/scripts/release-version-policy.test.mjs index 0ea91be..f493adf 100644 --- a/nodejs/scripts/release-version-policy.test.mjs +++ b/nodejs/scripts/release-version-policy.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { nextReleaseVersion, releaseClosure } from './release-version-policy.mjs'; +import { nextReleaseVersion, releaseClosure, changesPackedArtifacts } from './release-version-policy.mjs'; test('starts a new minor at zero and keeps normal patch behavior', () => { assert.equal(nextReleaseVersion({baseline:'2.0.6', series:'2.1'}), '2.1.0'); @@ -21,3 +21,8 @@ test('dependency closure includes optional and peer consumers transitively', () const manifests = [{name:'core'}, {name:'mdi',dependencies:{core:'workspace:*'}}, {name:'remark',peerDependencies:{mdi:'workspace:*'}}, {name:'cli',optionalDependencies:{remark:'workspace:*'}}, {name:'other'}]; assert.deepEqual([...releaseClosure(manifests, ['core'])], ['core','mdi','remark','cli']); }); + +test('publishing-tool repairs retain existing artifact versions', () => { + assert.equal(changesPackedArtifacts(['nodejs/scripts/publish-versioned-packages.mjs', '.github/workflows/release.yml']), false); + assert.equal(changesPackedArtifacts(['nodejs/scripts/pack-publishable-package.mjs']), true); +}); From 490e8789d588bc56deee8d45d3eff3501c5cef93 Mon Sep 17 00:00:00 2001 From: Iktahana <171251543+Iktahana@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:40:56 +0900 Subject: [PATCH 3/4] fix: isolate recovery tooling and validate release entry points --- .github/workflows/release.yml | 11 ----------- nodejs/scripts/publish-versioned-packages.mjs | 2 +- nodejs/scripts/release-artifacts.test.mjs | 9 +++++++++ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c4a28f..359e18e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,17 +132,6 @@ jobs: RELEASE_TARGET_VERSION: ${{ inputs.target_version }} run: node nodejs/scripts/prepare-release-artifacts.mjs --reuse - - name: Use the selected recovery tooling with the original candidate - if: inputs.resume_run_id != '' - env: - RECOVERY_TOOLING_SHA: ${{ github.sha }} - run: | - # Only publication tooling may change during recovery. HEAD, package - # manifests, and the saved tarballs still belong to RELEASE_SHA. - for script in release-artifacts.mjs publish-versioned-packages.mjs; do - git show "$RECOVERY_TOOLING_SHA:nodejs/scripts/$script" > "nodejs/scripts/$script" - done - - name: Publish or verify the original artifacts using OIDC id: publish run: node .release-tools/nodejs/scripts/publish-versioned-packages.mjs diff --git a/nodejs/scripts/publish-versioned-packages.mjs b/nodejs/scripts/publish-versioned-packages.mjs index e368ecf..a6ab740 100644 --- a/nodejs/scripts/publish-versioned-packages.mjs +++ b/nodejs/scripts/publish-versioned-packages.mjs @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { appendFileSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { registryArtifactMatches, verifyReleaseArtifacts, waitForRegistryArtifact, waitForRegistryArtifact } from './release-artifacts.mjs'; +import { registryArtifactMatches, verifyReleaseArtifacts, waitForRegistryArtifact } from './release-artifacts.mjs'; const root = resolve(process.env.RELEASE_SOURCE_DIR ?? resolve(import.meta.dirname, '../..')); const directory = resolve(root,process.env.RELEASE_ARTIFACTS_DIR ?? 'output/npm-release'); diff --git a/nodejs/scripts/release-artifacts.test.mjs b/nodejs/scripts/release-artifacts.test.mjs index bc8c928..eb98926 100644 --- a/nodejs/scripts/release-artifacts.test.mjs +++ b/nodejs/scripts/release-artifacts.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -56,3 +58,10 @@ test('registry polling fails closed on conflicts, lookup errors, and exhaustion' await assert.rejects(waitForRegistryArtifact(artifact, () => undefined, options), /not yet visible/); assert.equal(sleeps, 1); }); + +// Exercise executable modules as well as their shared helper imports. +test('release entry points remain valid JavaScript after recovery changes', () => { + for (const script of ['publish-versioned-packages.mjs', 'create-github-releases.mjs', 'compute-release-versions.mjs', 'prepare-release-artifacts.mjs']) { + execFileSync(process.execPath, ['--check', fileURLToPath(new URL(script, import.meta.url))]); + } +}); From c04dc8aff4a7fdef31da82bd604e7acffd206814 Mon Sep 17 00:00:00 2001 From: Iktahana <171251543+Iktahana@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:48:21 +0900 Subject: [PATCH 4/4] docs: identify all repaired release entry points --- docs/comment-release.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/comment-release.md b/docs/comment-release.md index 392cdae..b2a13b4 100644 --- a/docs/comment-release.md +++ b/docs/comment-release.md @@ -56,8 +56,8 @@ Partial language publication is an incomplete release; recover each remaining artifact without replacing successful publications. Recovery can select a workflow ref containing corrected publication tooling. -Only `release-artifacts.mjs` and `publish-versioned-packages.mjs` are read from -that ref; checkout HEAD, the gated source SHA, package versions, checksums, and +`publish-versioned-packages.mjs`, `create-github-releases.mjs`, and their shared +`release-artifacts.mjs` helper are read from that ref; checkout HEAD, the gated source SHA, package versions, checksums, and uploaded tarballs remain those of the original candidate. Registry visibility is polled after npm acknowledges publication; transient absence never causes an immediate repeat upload, and conflicting bytes still fail closed.