diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cfe931..359e18e 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: @@ -125,11 +134,11 @@ jobs: - 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..b2a13b4 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 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 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. +`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. 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/publish-versioned-packages.mjs b/nodejs/scripts/publish-versioned-packages.mjs index dcc9b3d..a6ab740 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 } 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..f5b5787 100644 --- a/nodejs/scripts/release-artifacts.mjs +++ b/nodejs/scripts/release-artifacts.mjs @@ -51,3 +51,17 @@ 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}`); +} diff --git a/nodejs/scripts/release-artifacts.test.mjs b/nodejs/scripts/release-artifacts.test.mjs index 93c0752..eb98926 100644 --- a/nodejs/scripts/release-artifacts.test.mjs +++ b/nodejs/scripts/release-artifacts.test.mjs @@ -1,9 +1,11 @@ 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'; -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 +40,28 @@ 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); +}); + +// 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))]); + } +}); 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); +});