Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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 }}
12 changes: 11 additions & 1 deletion docs/comment-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
8 changes: 2 additions & 6 deletions nodejs/scripts/compute-release-versions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions nodejs/scripts/create-github-releases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 3 additions & 3 deletions nodejs/scripts/publish-versioned-packages.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions nodejs/scripts/release-artifacts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
29 changes: 28 additions & 1 deletion nodejs/scripts/release-artifacts.test.mjs
Original file line number Diff line number Diff line change
@@ -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')};
Expand Down Expand Up @@ -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))]);
}
});
2 changes: 2 additions & 0 deletions nodejs/scripts/release-version-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ export function releaseClosure(manifests, changedNames) {
}
return selected;
}

export const changesPackedArtifacts = (files) => files.includes("nodejs/scripts/pack-publishable-package.mjs");
7 changes: 6 additions & 1 deletion nodejs/scripts/release-version-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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);
});
Loading