publish-release.mjs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. #!/usr/bin/env node
  2. /**
  3. * Publish the packed launcher family from the tarballs `pack-release.mjs`
  4. * produced, in `publish-order.txt` order.
  5. *
  6. * What goes out is decided per package against the registry, never from the
  7. * order file alone: a version the registry lacks is published, a version whose
  8. * published tarball has the same integrity is skipped, and a version whose
  9. * published tarball differs fails the run — that last case means the content
  10. * changed without a version bump. Skipping on identical integrity is what makes
  11. * re-running the publish step over the same artifact safe. Without the
  12. * integrity skip, a partial publication has no way forward: republishing an
  13. * existing version fails permanently.
  14. *
  15. * Usage: `node scripts/publish-release.mjs [packed dir]`.
  16. */
  17. import fs from 'node:fs';
  18. import path from 'node:path';
  19. import crypto from 'node:crypto';
  20. import { spawnSync } from 'node:child_process';
  21. import { setTimeout as sleep } from 'node:timers/promises';
  22. import { root } from './repo.mjs';
  23. /**
  24. * Registry codes that answer a write which did not settle, rather than a
  25. * rejection of what was sent. `E409 Failed to save packument` is the one this
  26. * sequence actually hits: publishing the platform packages and the entry back
  27. * to back can outrun the registry's own processing. A rejected payload (`E403`
  28. * over an existing version, a malformed manifest) never clears on a retry.
  29. */
  30. const TRANSIENT_PUBLISH_CODES = ['E409', 'E429', 'E500', 'E502', 'E503', 'E504', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'];
  31. /** How many times one tarball's publish is attempted before the run fails. */
  32. const PUBLISH_ATTEMPTS = 4;
  33. /**
  34. * Shortest gap between two publishes, and the first retry backoff. The registry
  35. * needs a moment to commit a packument before the next write; back to back
  36. * publishes are what produce `E409`.
  37. */
  38. const PUBLISH_SPACING_MS = 2_000;
  39. const destination = path.resolve(process.argv.slice(2).find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm'));
  40. /**
  41. * @param {string} output Combined npm output.
  42. * @returns {boolean} True when the registry reported a write it did not commit.
  43. */
  44. function isTransientFailure(output) {
  45. return TRANSIENT_PUBLISH_CODES.some((code) => output.includes(`code ${code}`));
  46. }
  47. /**
  48. * @param {string} tarball Absolute tarball path.
  49. * @returns {string} The `sha512-<base64>` integrity npm records for it.
  50. */
  51. function integrityOf(tarball) {
  52. return `sha512-${crypto.createHash('sha512').update(fs.readFileSync(tarball)).digest('base64')}`;
  53. }
  54. /**
  55. * @param {string} tarball Absolute tarball path.
  56. * @returns {{name: string, version: string}} What the packed manifest declares.
  57. */
  58. function packedIdentity(tarball) {
  59. const result = spawnSync('tar', ['-xOzf', tarball, 'package/package.json'], { encoding: 'utf8' });
  60. if (result.status !== 0) throw new Error(`cannot read the manifest inside ${tarball}:\n${result.stderr}`);
  61. const manifest = JSON.parse(result.stdout);
  62. if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string') {
  63. throw new Error(`${tarball} manifest lacks name/version`);
  64. }
  65. return { name: manifest.name, version: manifest.version };
  66. }
  67. /**
  68. * Ask the registry whether a version exists, and with what integrity.
  69. * @param {string} name Package name.
  70. * @param {string} version Package version.
  71. * @returns {{kind: 'absent'} | {kind: 'present', integrity: string}} Registry state.
  72. */
  73. function registryState(name, version) {
  74. const result = spawnSync('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'], { encoding: 'utf8' });
  75. if (result.status !== 0) {
  76. const output = `${result.stdout}${result.stderr}`;
  77. if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' };
  78. throw new Error(`npm view ${name}@${version} failed:\n${output}`);
  79. }
  80. const parsed = JSON.parse(result.stdout);
  81. if (typeof parsed !== 'string' || parsed === '') {
  82. throw new Error(`registry reported no dist.integrity for ${name}@${version}`);
  83. }
  84. return { kind: 'present', integrity: parsed };
  85. }
  86. /**
  87. * Publish one tarball, retrying a registry write that did not settle.
  88. *
  89. * Every retry re-reads the registry first, because `E409` can answer a write
  90. * that landed anyway: republishing a version that now exists fails permanently,
  91. * so the same integrity appearing under the failed attempt counts as success.
  92. * @param {string} tarball Absolute tarball path.
  93. * @param {string} name Package name the tarball declares.
  94. * @param {string} version Package version the tarball declares.
  95. */
  96. async function publishTarball(tarball, name, version) {
  97. // A prerelease version never takes the latest dist-tag.
  98. const tagArgs = version.includes('-') ? ['--tag', 'next'] : [];
  99. for (let tries = 1; tries <= PUBLISH_ATTEMPTS; tries += 1) {
  100. // No --access: publishConfig.access in each manifest decides, and a
  101. // command-line flag would override it.
  102. const result = spawnSync('npm', ['publish', tarball, ...tagArgs], { encoding: 'utf8' });
  103. const output = `${result.stdout}${result.stderr}`;
  104. if (result.status === 0) return;
  105. const settled = registryState(name, version);
  106. if (settled.kind === 'present' && settled.integrity === integrityOf(tarball)) {
  107. console.log(`landlock publish: ${name}@${version} landed despite a reported failure, continuing`);
  108. return;
  109. }
  110. if (tries === PUBLISH_ATTEMPTS || !isTransientFailure(output)) {
  111. throw new Error(`npm publish ${name}@${version} failed:\n${output}`);
  112. }
  113. const backoff = PUBLISH_SPACING_MS * 2 ** (tries - 1);
  114. console.log(
  115. `landlock publish: ${name}@${version} hit a transient registry failure`
  116. + ` (attempt ${tries} of ${PUBLISH_ATTEMPTS}), retrying in ${backoff}ms`,
  117. );
  118. await sleep(backoff);
  119. }
  120. }
  121. const order = fs
  122. .readFileSync(path.join(destination, 'publish-order.txt'), 'utf8')
  123. .split('\n')
  124. .filter((line) => line !== '');
  125. let published = 0;
  126. let skipped = 0;
  127. for (const filename of order) {
  128. const tarball = path.join(destination, filename);
  129. const { name, version } = packedIdentity(tarball);
  130. const state = registryState(name, version);
  131. if (state.kind === 'present') {
  132. const local = integrityOf(tarball);
  133. if (state.integrity !== local) {
  134. throw new Error(
  135. `${name}@${version} is already published with different content`
  136. + `\n registry: ${state.integrity}\n packed: ${local}`
  137. + '\nBump the version, or investigate why the build is not reproducible.',
  138. );
  139. }
  140. console.log(`landlock publish: ${name}@${version} already published, skipping`);
  141. skipped += 1;
  142. continue;
  143. }
  144. // Space out the writes: the gap belongs between publishes, so a run that only
  145. // skips does not wait at all.
  146. if (published > 0) await sleep(PUBLISH_SPACING_MS);
  147. await publishTarball(tarball, name, version);
  148. console.log(`landlock publish: ${name}@${version} published`);
  149. published += 1;
  150. }
  151. console.log(`landlock publish: ${published} published, ${skipped} already present`);