diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a352795..54d2885 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,20 +1,64 @@ name: Publish +# A release is two irreversible uploads and one public announcement, and run +# 33854645746 proved they were in the wrong order: the library went to npm, the +# demo's build failed, and the GitHub release for v0.1.10 stayed public for a +# version nobody could install in full. Retrying the whole workflow hit the +# library again, which npm refuses, so recovery burned 0.1.11. +# +# The order here is pack, upload, verify, announce: +# +# * both tarballs are built before either is uploaded, and kept as a run +# artifact, so a failed run can be resumed from the exact bytes it packed; +# * each upload is skipped only against a *verified* published artifact, and +# an unreadable registry is a failure rather than a guess; +# * the GitHub release is published last, once both versions are readable on +# the registry, so a half-done release is never announced. +# +# All three of those live in scripts/publish.mjs, which is tested in +# packages/hqtui/test/publish.test.ts. +# +# Pushing the tag is what starts a release, because a draft release starts +# nothing: GitHub does not trigger workflows for the `created` activity type on +# drafts. So the notes are written as a draft, the tag push does the registry +# work, and the last step turns that draft into the release. Publishing a +# release by hand still works and still publishes both packages — it just +# announces the version before the registry has it. + on: + push: + tags: ["v*"] release: types: [published] workflow_dispatch: inputs: tag: - description: "npm dist-tag" - default: latest + description: "npm dist-tag (default: latest, or next for a prerelease)" + default: "" + +# One release at a time, keyed on the version. `gh release create` without +# --draft fires both triggers; the second run finds everything published and +# does nothing, but it must not run while the first is still deciding. +concurrency: + group: publish-${{ github.event.release.tag_name || github.ref_name }} + cancel-in-progress: false jobs: npm: runs-on: ubuntu-latest permissions: - contents: read + # `contents: write` is only for announcing the release at the end; + # nothing else here writes to the repository. + contents: write id-token: write + env: + RELEASE_REF: ${{ github.event.release.tag_name || github.ref_name }} + # A tag and a release both name a version. A dispatch names a branch, so + # there is nothing for the version check to compare against. + RELEASE_EVENT: ${{ github.event_name == 'workflow_dispatch' && 'dispatch' || 'release' }} + # Empty means "decide from the version": 'latest', or 'next' when it is a + # prerelease. Naming one by hand is for republishing under another tag. + DIST_TAG: ${{ inputs.tag }} steps: - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 @@ -23,69 +67,66 @@ jobs: node-version: 24.x registry-url: https://registry.npmjs.org - run: bun install --frozen-lockfile + # `build` is not incidental here. Publishing a tarball does not run + # `prepublishOnly`, so this is the only thing that produces the demo's + # dist, and `pack` below refuses to package a tarball without it. - run: bun run typecheck && bun test packages/hqtui/test && bun run build - # Nothing tied the release tag to what actually gets published, so a tag - # could ship a version it does not name. This runs on every trigger, not - # only `release`: a workflow_dispatch publishes just as readily, and - # gating the check on the event left that path unchecked. The ref goes - # through the environment rather than the shell, as the dist-tag does. - - name: Check the versions being published - env: - RELEASE_REF: ${{ github.ref_name }} - EVENT: ${{ github.event_name }} - DIST_TAG: ${{ inputs.tag || 'latest' }} - run: | - lib="$(node -p "require('./packages/hqtui/package.json').version")" - demo="$(node -p "require('./apps/demo/package.json').version")" - # Both are published together from one commit, on every trigger. - if [ "$lib" != "$demo" ]; then - echo "the two packages disagree about the version being published:" - echo " packages/hqtui $lib" - echo " apps/demo $demo" - exit 1 - fi + - name: Pack both packages + run: >- + node scripts/publish.mjs pack + --out "$RUNNER_TEMP/release" + --event "$RELEASE_EVENT" + --ref "$RELEASE_REF" + --tag "$DIST_TAG" - # A prerelease must not become what `npm install` resolves to. - case "$lib" in + # Kept before the first upload, so a run that dies mid-publish leaves the + # exact artifacts behind to resume from. + - name: Keep the packed artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + # A branch name can contain a slash, which an artifact name cannot. + name: release-${{ github.ref_type == 'tag' && github.ref_name || github.run_id }} + path: ${{ runner.temp }}/release + if-no-files-found: warn + + - name: Publish what is not already published + run: node scripts/publish.mjs publish --from "$RUNNER_TEMP/release" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Only now is the release true. On the `release: published` path there is + # nothing left to announce, which is exactly why the tag push is the + # better way in. + - name: Announce the release + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + # A prerelease tag stays marked as one, so it does not become the + # release GitHub shows as current. + fields=(-F draft=false) + flags=() + case "$RELEASE_REF" in *-*) - if [ "$DIST_TAG" = "latest" ]; then - echo "$lib is a prerelease and would be published as 'latest'." - echo "re-run with a dist-tag such as 'next'." - exit 1 - fi - echo "prerelease $lib publishing under dist-tag '$DIST_TAG'" + fields+=(-F prerelease=true) + flags+=(--prerelease) ;; esac - # On a release the tag names the version; on a dispatch ref_name is a - # branch, so there is nothing to compare it against. - if [ "$EVENT" = "release" ]; then - tag="${RELEASE_REF#v}" - if [ "$tag" != "$lib" ]; then - echo "release tag '$RELEASE_REF' does not name the version it would publish ($lib)" - exit 1 - fi - echo "tag '$RELEASE_REF' matches both packages at $lib" + # A draft has no git tag, so it cannot be looked up by one. The list + # is the only place it exists. + draft="$(gh api "repos/$GH_REPO/releases" --paginate \ + --jq "[.[] | select(.draft == true and .tag_name == \"$RELEASE_REF\")][0].id // empty")" + + if [ -n "$draft" ]; then + echo "publishing the draft release $RELEASE_REF" + gh api --method PATCH "repos/$GH_REPO/releases/$draft" "${fields[@]}" >/dev/null + elif gh release view "$RELEASE_REF" >/dev/null 2>&1; then + echo "$RELEASE_REF is already a public release" else - echo "$EVENT: publishing $lib under dist-tag '$DIST_TAG'" + echo "no release for $RELEASE_REF, creating one from the commit log" + gh release create "$RELEASE_REF" --verify-tag --generate-notes "${flags[@]}" fi - # `id-token: write` above is what provenance needs, but without the flag - # nothing is attested — the permission was granted and unused. - # - # The dist-tag goes through the environment rather than being - # interpolated into the shell: `${{ inputs.tag }}` is attacker-controlled - # text on a workflow_dispatch, and expressions are substituted before the - # shell ever sees them. - - name: Publish library - run: npm publish --access public --provenance --tag "$DIST_TAG" - working-directory: packages/hqtui - env: - DIST_TAG: ${{ inputs.tag || 'latest' }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish demo - run: npm publish --access public --provenance --tag "$DIST_TAG" - working-directory: apps/demo - env: - DIST_TAG: ${{ inputs.tag || 'latest' }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index bed3b6e..e4e504f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ bun.lockb # Screenshot capture scratch: HTML + raw frames, regenerated by `bun run shots` .shots-tmp/ + +# Tarballs packed by scripts/publish.mjs before they are uploaded +.release-artifacts/ diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..20b091b --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,93 @@ +# Releasing + +Two packages go out together from one commit, `@profullstack/hqtui` and +`@profullstack/hqtui-demo`, at the same version. + +## Cutting one + +1. In one PR, bump the version everywhere it is written by hand, and + `bun.lock` with it. `bun test packages/hqtui/test` fails if a copy is + stale, which is what the version tests are for. +2. Squash it. +3. Write the notes as a draft release, which triggers nothing: + + ```sh + gh release create v0.6.3 --draft --notes "..." + ``` + +4. Push the tag at the merge commit. This is what starts the release: + + ```sh + git tag v0.6.3 "$(git rev-parse origin/main)" + git push origin v0.6.3 + ``` + +The workflow packs, publishes, verifies both versions on npm, and only then +turns the draft into the release. A release that never appears means the +registry work never finished. Step 3 is optional: with no draft, the workflow +creates the release from the commit log at the end instead. + +The tag push is the way in because a draft cannot be one. GitHub does not +trigger workflows for the `created` activity type on draft releases, so a +saved draft sits there doing nothing until a tag arrives. + +A version with a prerelease suffix publishes under the `next` dist-tag rather +than `latest`, and its release is marked as a pre-release, so +`npm install @profullstack/hqtui` keeps resolving to the last real version. + +Creating a public release directly — `gh release create v0.6.3` with no +`--draft` — still works and still publishes both packages. It just announces +the version before the registry has it, which is the ordering that made +issue #93 possible. + +## When a publish fails + +Re-run the failed workflow run. That is the whole recovery. + +The version does not need to be bumped and the release does not need to be +recreated, because nothing in the run is unconditional: + +- both tarballs are packed before either is uploaded, so a build failure + happens while nothing is public; +- each package is compared against what the registry already has. A version + that is present and matches what this run packed is skipped; a version that + is absent is uploaded; +- a version that is present with *different contents*, or a registry that + cannot be read, stops the run before anything is uploaded. Those are the two + cases that need a person. + +This is what [issue #93](https://github.com/profullstack/hqtui/issues/93) +asked for. Run 33854645746 published the library, failed on the demo, and left +a public v0.1.10 release for a version nobody could install in full; retrying +it hit the library again, which npm refuses, so 0.1.10 was abandoned for +0.1.11. The same failure today is a re-run. + +The packed tarballs are kept as a run artifact (`release-`), so the exact +bytes a failed run produced can be inspected or resumed from by hand: + +```sh +node scripts/publish.mjs publish --from ./release +``` + +## Doing it by hand + +```sh +bun install --frozen-lockfile +bun run typecheck && bun test packages/hqtui/test && bun run build +node scripts/publish.mjs pack --out .release-artifacts --event release --ref v0.6.3 +node scripts/publish.mjs publish --from .release-artifacts --dry-run +``` + +The dist-tag comes from the version unless `--tag` names one, so there is +nothing to remember for a prerelease. + +`pack` refuses to package a tarball that is missing its build: it checks every +path `package.json` promises, and follows the relative imports inside the +tarball. `apps/demo`'s entry point is one line — `import "../dist/main.js"` — +so that second check is the one that catches a demo packed without a `dist`. + +Publishing a tarball does not run `prepublishOnly`, by design. The build runs +once, before anything is uploaded, rather than on the way out the door. + +The workflow needs `NPM_TOKEN`, and `contents: write` to turn the draft into a +release. diff --git a/packages/hqtui/test/publish.test.ts b/packages/hqtui/test/publish.test.ts new file mode 100644 index 0000000..5dce183 --- /dev/null +++ b/packages/hqtui/test/publish.test.ts @@ -0,0 +1,535 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { gzipSync } from "node:zlib"; +import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + checkVersions, + contentDigest, + digestOfTarball, + lookupPublished, + lookupWithRetry, + missingEntryPoints, + publishAll, + requiredEntryPoints, + resolveAction, + tarFiles, + unresolvedImports, + waitForAvailability, + MANIFEST, +} from "../../../scripts/publish.mjs"; + +/** + * The release saga lives in scripts/, not in the library, but it has nowhere + * else to be tested: these are the only test directories CI runs, and the + * publish workflow runs this one before it uploads anything. version.test.ts + * already reaches out of the package for the same reason. + * + * Nothing here talks to a registry or to npm. The registry is a function and + * so is npm, which is what makes the incident from issue #93 — library + * published, demo not — reproducible as a test rather than as a post-mortem. + */ + +const ROOT = join(import.meta.dirname, "..", "..", ".."); + +/* -------------------------------------------------------------------------- */ +/* A tar writer, so the reader has something to read */ +/* -------------------------------------------------------------------------- */ + +type Entry = { path: string; body: string; type?: string; mtime?: number }; + +function header(path: string, size: number, type: string, mtime: number): Buffer { + const h = Buffer.alloc(512); + h.write(path.slice(0, 100), 0, 100, "utf8"); + h.write("0000644\0", 100, 8, "ascii"); + h.write("0000000\0", 108, 8, "ascii"); + h.write("0000000\0", 116, 8, "ascii"); + h.write(`${size.toString(8).padStart(11, "0")}\0`, 124, 12, "ascii"); + h.write(`${mtime.toString(8).padStart(11, "0")}\0`, 136, 12, "ascii"); + h.write(" ", 148, 8, "ascii"); + h.write(type, 156, 1, "ascii"); + h.write("ustar\0", 257, 6, "ascii"); + h.write("00", 263, 2, "ascii"); + let sum = 0; + for (const byte of h) sum += byte; + h.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); + return h; +} + +function tar(entries: Entry[]): Buffer { + const chunks: Buffer[] = []; + for (const entry of entries) { + const body = Buffer.from(entry.body, "utf8"); + chunks.push(header(entry.path, body.length, entry.type ?? "0", entry.mtime ?? 0)); + chunks.push(body); + const padding = (512 - (body.length % 512)) % 512; + if (padding > 0) chunks.push(Buffer.alloc(padding)); + } + chunks.push(Buffer.alloc(1024)); + return gzipSync(Buffer.concat(chunks)); +} + +/** A pax record is ` key=value\n`, and the length counts itself. */ +function paxRecord(key: string, value: string): string { + const rest = ` ${key}=${value}\n`.length; + let length = rest + 1; + while (String(length).length + rest !== length) length = String(length).length + rest; + return `${length} ${key}=${value}\n`; +} + +const silently = async (body: () => T | Promise): Promise => { + const log = console.log; + console.log = () => {}; + try { + return await body(); + } finally { + console.log = log; + } +}; + +/* -------------------------------------------------------------------------- */ +/* What is allowed to be published */ +/* -------------------------------------------------------------------------- */ + +test("the two packages must agree about the version", () => { + assert.throws( + () => + checkVersions({ + versions: { "packages/hqtui": "0.6.2", "apps/demo": "0.6.1" }, + event: "release", + ref: "v0.6.2", + distTag: "latest", + }), + /disagree about the version/, + ); +}); + +test("a release tag must name the version it publishes", () => { + const versions = { "packages/hqtui": "0.6.2", "apps/demo": "0.6.2" }; + assert.equal(checkVersions({ versions, event: "release", ref: "v0.6.2" }).version, "0.6.2"); + assert.throws( + () => checkVersions({ versions, event: "release", ref: "v0.6.3" }), + /does not name the version/, + ); + // A dispatch runs from a branch, so there is no tag to disagree with. + assert.equal(checkVersions({ versions, event: "dispatch", ref: "main" }).version, "0.6.2"); +}); + +test("a release goes out as 'latest', a prerelease as 'next'", () => { + const release = { "packages/hqtui": "0.6.2", "apps/demo": "0.6.2" }; + const rc = { "packages/hqtui": "0.7.0-rc.1", "apps/demo": "0.7.0-rc.1" }; + + assert.equal(checkVersions({ versions: release, event: "release", ref: "v0.6.2" }).distTag, "latest"); + assert.equal(checkVersions({ versions: rc, event: "release", ref: "v0.7.0-rc.1" }).distTag, "next"); + + // Asking for it by hand is the only way to point `npm install` at an rc. + assert.throws( + () => checkVersions({ versions: rc, event: "release", ref: "v0.7.0-rc.1", distTag: "latest" }), + /prerelease/, + ); + assert.equal( + checkVersions({ versions: release, event: "release", ref: "v0.6.2", distTag: "next" }).distTag, + "next", + ); +}); + +/* -------------------------------------------------------------------------- */ +/* Reading a tarball */ +/* -------------------------------------------------------------------------- */ + +test("the tar reader returns regular files with their contents", () => { + const files = tarFiles( + tar([ + { path: "package/", body: "", type: "5" }, + { path: "package/package.json", body: '{"name":"x"}' }, + { path: "package/dist/index.js", body: "export const a = 1;\n" }, + ]), + ); + assert.deepEqual( + files.map((f) => f.path), + ["package/package.json", "package/dist/index.js"], + ); + assert.equal(files[1]?.data.toString("utf8"), "export const a = 1;\n"); +}); + +test("a pax header renames the entry that follows it", () => { + const long = `package/dist/${"nested/".repeat(15)}index.js`; + const files = tarFiles( + tar([ + { path: "package/PaxHeaders/0", body: paxRecord("path", long), type: "x" }, + { path: long.slice(0, 100), body: "ok" }, + ]), + ); + assert.deepEqual( + files.map((f) => f.path), + [long], + ); +}); + +test("a repack that differs only in packing has the same content digest", () => { + // The whole point of comparing contents rather than shasums: npm's tarball + // bytes are not the artifact, the files inside are. + const entries: Entry[] = [ + { path: "package/package.json", body: '{"name":"x"}' }, + { path: "package/dist/index.js", body: "export const a = 1;\n" }, + ]; + const first = tar(entries); + const second = tar(entries.map((e) => ({ ...e, mtime: 1_700_000_000 }))); + assert.notEqual(first.toString("base64"), second.toString("base64")); + assert.equal(digestOfTarball(first), digestOfTarball(second)); +}); + +test("a changed file changes the content digest", () => { + const before = tar([{ path: "package/dist/index.js", body: "export const a = 1;\n" }]); + const after = tar([{ path: "package/dist/index.js", body: "export const a = 2;\n" }]); + assert.notEqual(digestOfTarball(before), digestOfTarball(after)); +}); + +test("the content digest does not depend on archive order", () => { + const a = { path: "dist/a.js", data: Buffer.from("a") }; + const b = { path: "dist/b.js", data: Buffer.from("b") }; + assert.equal(contentDigest([a, b]), contentDigest([b, a])); +}); + +/* -------------------------------------------------------------------------- */ +/* A tarball that is missing its build */ +/* -------------------------------------------------------------------------- */ + +test("the library's real entry points are all asked for", () => { + const pkg = JSON.parse(readFileSync(join(ROOT, "packages", "hqtui", "package.json"), "utf8")); + const required = requiredEntryPoints(pkg); + assert.ok(required.includes("dist/index.js"), `missing dist/index.js in ${required.join(", ")}`); + assert.ok(required.includes("dist/index.d.ts")); + assert.ok(required.includes("bin/hqtui.mjs")); + assert.deepEqual(missingEntryPoints(pkg, ["package/README.md"]).includes("dist/index.js"), true); + assert.deepEqual(missingEntryPoints(pkg, required.map((p) => `package/${p}`)), []); +}); + +test("a bin that imports a dist the tarball does not have is caught", () => { + // This is the 0.1.10 shape exactly: apps/demo declares bin/hqtui-demo.mjs, + // which is one `import "../dist/main.js"` line. The declared entry point can + // be present in a tarball that installs nothing runnable. + const bin = { path: "package/bin/hqtui-demo.mjs", data: Buffer.from('import "../dist/main.js";\n') }; + assert.deepEqual(unresolvedImports([bin]), ["bin/hqtui-demo.mjs imports ../dist/main.js"]); + assert.deepEqual( + unresolvedImports([bin, { path: "package/dist/main.js", data: Buffer.from("") }]), + [], + ); +}); + +test("extensionless and index imports still resolve", () => { + const files = [ + { path: "package/dist/index.js", data: Buffer.from('export * from "./widgets";\nimport "./util/index.js";\n') }, + { path: "package/dist/widgets.js", data: Buffer.from("") }, + { path: "package/dist/util/index.js", data: Buffer.from("") }, + ]; + assert.deepEqual(unresolvedImports(files), []); +}); + +/* -------------------------------------------------------------------------- */ +/* Asking the registry */ +/* -------------------------------------------------------------------------- */ + +const response = (status: number, body?: unknown) => ({ + status, + ok: status >= 200 && status < 300, + json: async () => { + if (body === undefined) throw new Error("not json"); + return body; + }, +}); + +test("the registry's answers are classified, not guessed", async () => { + const ask = (impl: unknown) => + lookupPublished({ name: "@profullstack/hqtui", version: "0.1.10", fetchImpl: impl as typeof fetch }); + + assert.deepEqual(await ask(async () => response(404)), { state: "absent" }); + + assert.deepEqual(await ask(async () => response(200, { dist: { shasum: "abc", tarball: "t" } })), { + state: "present", + shasum: "abc", + tarball: "t", + }); + + // A registry that is down must never read as "nothing published here". + assert.equal((await ask(async () => response(503))).state, "indeterminate"); + assert.equal((await ask(async () => response(200, { dist: {} }))).state, "indeterminate"); + assert.equal( + ( + await ask(async () => { + throw new Error("ECONNRESET"); + }) + ).state, + "indeterminate", + ); +}); + +test("the request asks for the version, with the scope escaped", async () => { + let asked = ""; + await lookupPublished({ + name: "@profullstack/hqtui", + version: "0.6.2", + fetchImpl: (async (url: string) => { + asked = url; + return response(404); + }) as unknown as typeof fetch, + }); + assert.equal(asked, "https://registry.npmjs.org/@profullstack%2fhqtui/0.6.2"); +}); + +test("only indeterminate answers are retried", async () => { + let calls = 0; + const flaky = (async () => { + calls += 1; + return calls < 3 ? response(500) : response(200, { dist: { shasum: "abc" } }); + }) as unknown as typeof fetch; + + const found = await lookupWithRetry( + { name: "x", version: "1.0.0", fetchImpl: flaky }, + { attempts: 4, delay: 0, sleep: async () => {} }, + ); + assert.equal(found.state, "present"); + assert.equal(calls, 3); + + calls = 0; + await lookupWithRetry( + { name: "x", version: "1.0.0", fetchImpl: (async () => response(404)) as unknown as typeof fetch }, + { attempts: 4, delay: 0, sleep: async () => {} }, + ); + assert.equal(calls, 0, "a 404 is a fact, not a flake"); +}); + +test("each registry answer maps to one action", () => { + assert.equal(resolveAction({ published: { state: "absent" }, shasum: "a" }).action, "publish"); + assert.equal(resolveAction({ published: { state: "present", shasum: "a" }, shasum: "a" }).action, "skip"); + assert.equal(resolveAction({ published: { state: "present", shasum: "b" }, shasum: "a" }).action, "compare"); + assert.equal( + resolveAction({ published: { state: "indeterminate", reason: "503" }, shasum: "a" }).action, + "fail", + ); +}); + +test("a published version has to become readable, and be the one we sent", async () => { + let calls = 0; + const appearing = (async () => { + calls += 1; + return calls < 3 ? response(404) : response(200, { dist: { shasum: "abc" } }); + }) as unknown as typeof fetch; + + const found = await waitForAvailability({ + name: "x", + version: "1.0.0", + shasum: "abc", + fetchImpl: appearing, + sleep: async () => {}, + }); + assert.equal(found.shasum, "abc"); + + await assert.rejects( + waitForAvailability({ + name: "x", + version: "1.0.0", + shasum: "abc", + fetchImpl: (async () => response(200, { dist: { shasum: "different" } })) as unknown as typeof fetch, + sleep: async () => {}, + }), + /not the abc this run verified/, + ); + + await assert.rejects( + waitForAvailability({ + name: "x", + version: "1.0.0", + fetchImpl: (async () => response(404)) as unknown as typeof fetch, + attempts: 2, + sleep: async () => {}, + }), + /did not become readable/, + ); +}); + +/* -------------------------------------------------------------------------- */ +/* The incident, as a test */ +/* -------------------------------------------------------------------------- */ + +/** A packed release on disk: two tarballs and the manifest `pack` writes. */ +function packedRelease(bodies: Record = {}) { + const dir = mkdtempSync(join(tmpdir(), "hqtui-release-")); + const packages = [ + { dir: "packages/hqtui", label: "library", name: "@profullstack/hqtui" }, + { dir: "apps/demo", label: "demo", name: "@profullstack/hqtui-demo" }, + ].map((pkg, index) => { + const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-0.1.10.tgz`; + const gzipped = tar([{ path: "package/dist/index.js", body: bodies[pkg.name] ?? `pkg ${index}\n` }]); + writeFileSync(join(dir, tarball), gzipped); + return { + ...pkg, + version: "0.1.10", + tarball, + shasum: `sha-${index}`, + contentDigest: digestOfTarball(gzipped), + }; + }); + writeFileSync(join(dir, MANIFEST), JSON.stringify({ version: "0.1.10", distTag: "latest", packages })); + return { dir, packages }; +} + +test("recovery publishes only the package that is missing", async () => { + // Run 33854645746: the library went out, the demo did not. Re-running the + // whole workflow used to die on the library, which is why 0.1.10 was + // abandoned for 0.1.11. + const { dir, packages } = packedRelease(); + const published = new Map([[packages[0]!.name, packages[0]!.shasum]]); + const uploaded: string[] = []; + + const registry = (async (url: string) => { + const name = decodeURIComponent(new URL(url).pathname.split("/").slice(1, -1).join("/")); + const shasum = published.get(name); + return shasum ? response(200, { dist: { shasum } }) : response(404); + }) as unknown as typeof fetch; + + const plan = await silently(() => + publishAll({ + from: dir, + fetchImpl: registry, + sleep: async () => {}, + runNpm: (_cmd: string, args: string[]) => { + const tarball = args[1] ?? ""; + const entry = packages.find((p) => tarball.endsWith(p.tarball))!; + uploaded.push(entry.name); + published.set(entry.name, entry.shasum); + return ""; + }, + }), + ); + + assert.deepEqual( + plan.map((step) => step.decision.action), + ["skip", "publish"], + ); + assert.deepEqual(uploaded, ["@profullstack/hqtui-demo"], "the library must not be uploaded twice"); +}); + +test("a version already published with different contents stops the run before any upload", async () => { + const { dir, packages } = packedRelease(); + const uploaded: string[] = []; + + const registry = (async (url: string) => { + if (url.endsWith(".tgz")) { + // Same version on the registry, different build inside it. + return { + status: 200, + ok: true, + arrayBuffer: async () => tar([{ path: "package/dist/index.js", body: "something else\n" }]), + }; + } + const name = decodeURIComponent(new URL(url).pathname.split("/").slice(1, -1).join("/")); + return name === packages[0]!.name + ? response(200, { dist: { shasum: "not-ours", tarball: "https://registry.npmjs.org/x.tgz" } }) + : response(404); + }) as unknown as typeof fetch; + + await assert.rejects( + silently(() => + publishAll({ + from: dir, + fetchImpl: registry, + sleep: async () => {}, + runNpm: (_cmd: string, args: string[]) => { + uploaded.push(args[1] ?? ""); + return ""; + }, + }), + ), + /different contents under this version/, + ); + assert.deepEqual(uploaded, [], "nothing may be uploaded once a conflict is known"); +}); + +test("a repacked but identical artifact is skipped rather than fought over", async () => { + const { dir, packages } = packedRelease(); + const remote = readFileSync(join(dir, packages[0]!.tarball)); + const uploaded: string[] = []; + // The library is on the registry from a pack that gzipped differently. + const published = new Map([[packages[0]!.name, "packed-by-another-npm"]]); + + const registry = (async (url: string) => { + if (url.endsWith(".tgz")) { + return { status: 200, ok: true, arrayBuffer: async () => remote }; + } + const name = decodeURIComponent(new URL(url).pathname.split("/").slice(1, -1).join("/")); + const shasum = published.get(name); + return shasum + ? response(200, { dist: { shasum, tarball: "https://registry.npmjs.org/x.tgz" } }) + : response(404); + }) as unknown as typeof fetch; + + const plan = await silently(() => + publishAll({ + from: dir, + fetchImpl: registry, + sleep: async () => {}, + runNpm: (_cmd: string, args: string[]) => { + const tarball = args[1] ?? ""; + const entry = packages.find((p) => tarball.endsWith(p.tarball))!; + uploaded.push(entry.name); + published.set(entry.name, entry.shasum); + return ""; + }, + }), + ); + + assert.equal(plan[0]?.decision.action, "skip"); + assert.equal(uploaded.length, 1, "only the demo is uploaded"); +}); + +test("an unreadable registry fails the run instead of guessing", async () => { + const { dir } = packedRelease(); + const uploaded: string[] = []; + + await assert.rejects( + silently(() => + publishAll({ + from: dir, + fetchImpl: (async () => response(500)) as unknown as typeof fetch, + sleep: async () => {}, + runNpm: (_cmd: string, args: string[]) => { + uploaded.push(args[1] ?? ""); + return ""; + }, + }), + ), + /did not give a usable answer/, + ); + assert.deepEqual(uploaded, []); +}); + +/* -------------------------------------------------------------------------- */ +/* The workflow actually uses it */ +/* -------------------------------------------------------------------------- */ + +test("the publish workflow goes through the saga, not straight at npm", () => { + const workflow = readFileSync(join(ROOT, ".github", "workflows", "publish.yml"), "utf8"); + + assert.match(workflow, /scripts\/publish\.mjs pack/); + assert.match(workflow, /scripts\/publish\.mjs publish/); + + // A draft release triggers nothing — GitHub does not run workflows for the + // `created` activity type on drafts — so the tag push is what starts a + // release, and it is the only path that can announce one afterwards. + assert.match(workflow, /tags: \["v\*"\]/); + + // The announcement has to come after the uploads it is announcing. + const uploads = workflow.indexOf("publish.mjs publish"); + const announce = workflow.indexOf("Announce the release"); + assert.ok(uploads > 0 && announce > uploads, "the release is announced before it is published"); + + // An unconditional `npm publish` of a directory is the thing that could not + // be retried. Packing and publishing a recorded tarball is the replacement. + const bare = workflow + .split("\n") + .filter((line) => /npm publish/.test(line) && !/publish\.mjs/.test(line)); + assert.deepEqual(bare, [], "publish.yml calls npm publish directly again"); +}); diff --git a/packages/hqtui/tsconfig.json b/packages/hqtui/tsconfig.json index 5132867..fd4b0ab 100644 --- a/packages/hqtui/tsconfig.json +++ b/packages/hqtui/tsconfig.json @@ -15,6 +15,7 @@ "verbatimModuleSyntax": true, "isolatedModules": true, "allowImportingTsExtensions": true, + "allowJs": true, "noEmit": true }, "include": ["src/**/*.ts", "test/**/*.ts"] diff --git a/scripts/publish.mjs b/scripts/publish.mjs new file mode 100644 index 0000000..d75e908 --- /dev/null +++ b/scripts/publish.mjs @@ -0,0 +1,663 @@ +#!/usr/bin/env node +/** + * The two-package release, as a saga that can be resumed. + * + * Run 33854645746 published `@profullstack/hqtui@0.1.10`, then failed on the + * demo. The registry kept the library, the GitHub release stayed public, and a + * whole-workflow retry could not get past the library again: npm answers a + * second upload of a published version with "You cannot publish over the + * previously published versions". Recovery meant burning 0.1.11 on a release + * nobody had shipped. + * + * Two irreversible uploads cannot be made atomic, so this makes them + * resumable instead: + * + * 1. `pack` builds both tarballs before either is uploaded, and records the + * exact bytes. A build failure now happens while nothing is public. + * 2. `publish` asks the registry about each version first and classifies the + * answer. A verified match is skipped, an absent version is uploaded, and + * anything else — a different artifact under the same version, or a + * registry that will not say — fails without touching the registry. + * 3. `publish` then waits for both versions to be readable before it exits + * zero, which is what lets the workflow leave a draft release drafted + * until the registry work is actually done. + * + * Skipping is decided on content, not on the name alone. `dist.shasum` is + * compared first; if it differs the published tarball is downloaded and + * compared file by file, because two packs of one commit can differ in gzip + * bytes without differing in what they install. Only a real content difference + * is a conflict. + * + * Everything above the CLI at the bottom is pure enough to test, and + * packages/hqtui/test/publish.test.ts does. + */ + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { gunzipSync } from "node:zlib"; +import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/** Published together, from one commit, library first: the demo depends on it. */ +export const PACKAGES = [ + { dir: "packages/hqtui", label: "library" }, + { dir: "apps/demo", label: "demo" }, +]; + +export const DEFAULT_REGISTRY = "https://registry.npmjs.org"; +export const MANIFEST = "release-manifest.json"; + +/** + * The types are JSDoc because this file is plain JavaScript: the workflow runs + * it with bare `node`, before anything in the repository has been built. + * + * @typedef {{ path: string, data: Buffer }} TarFile + * @typedef {{ state: "present", shasum: string, tarball?: string }} OnRegistry + * @typedef {{ state: "absent" } + * | OnRegistry + * | { state: "indeterminate", reason: string }} Published + * @typedef {{ action: "publish" | "skip" | "compare" | "fail", reason: string }} Decision + * @typedef {{ name: string, version: string, registry?: string, fetchImpl?: typeof fetch }} Query + * @typedef {(command: string, args: string[], cwd: string) => string} RunNpm + * @typedef {(ms: number) => Promise} Sleep + */ + +const ROOT = resolve(import.meta.dirname, ".."); + +const log = (...parts) => console.log(...parts); +const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); + +/* -------------------------------------------------------------------------- */ +/* What is allowed to be published at all */ +/* -------------------------------------------------------------------------- */ + +/** + * Nothing tied the release tag to what actually gets published, so a tag could + * ship a version it does not name. This runs on every trigger: a dispatch + * publishes just as readily as a release, and gating the check on the event + * left that path unchecked. + * + * Returns the agreed version and the dist-tag it will publish under; throws + * with the reason if there is not one. + * + * @param {{ versions: Record, event: string, ref: string, distTag?: string }} options + * @returns {{ version: string, distTag: string }} + */ +export function checkVersions({ versions, event, ref, distTag = "" }) { + const names = Object.keys(versions); + const agreed = versions[names[0]]; + for (const name of names) { + if (versions[name] !== agreed) { + const lines = names.map((n) => ` ${n.padEnd(15)} ${versions[n]}`); + throw new Error( + ["the packages disagree about the version being published:", ...lines].join("\n"), + ); + } + } + + // A prerelease must not become what `npm install` resolves to. Left to + // itself it goes out under 'next'; asking for 'latest' by hand is the only + // way to get this wrong, and it is refused. + const prerelease = agreed.includes("-"); + const tag = distTag || (prerelease ? "next" : "latest"); + if (prerelease && tag === "latest") { + throw new Error( + `${agreed} is a prerelease and would be published as 'latest'.\n` + + "re-run without a dist-tag to publish it as 'next'.", + ); + } + + // A tag names the version it publishes. On a dispatch `ref` is a branch, so + // there is nothing to compare it against. + if (event === "release") { + const tagged = ref.replace(/^v/, ""); + if (tagged !== agreed) { + throw new Error( + `release tag '${ref}' does not name the version it would publish (${agreed})`, + ); + } + } + + return { version: agreed, distTag: tag }; +} + +/* -------------------------------------------------------------------------- */ +/* Reading a packed tarball */ +/* -------------------------------------------------------------------------- */ + +const readField = (buf, off, len) => { + const nul = buf.indexOf(0, off); + const end = nul === -1 || nul > off + len ? off + len : nul; + return buf.toString("utf8", off, end); +}; + +const readOctal = (buf, off, len) => { + const text = buf + .toString("ascii", off, off + len) + .replace(/\0.*$/, "") + .trim(); + return text === "" ? 0 : Number.parseInt(text, 8); +}; + +/** pax records are ` =\n`, repeated. */ +function parsePax(buf) { + const out = {}; + let off = 0; + while (off < buf.length) { + const space = buf.indexOf(0x20, off); + if (space === -1) break; + const length = Number.parseInt(buf.toString("ascii", off, space), 10); + if (!Number.isInteger(length) || length <= 0 || off + length > buf.length) break; + const record = buf.toString("utf8", space + 1, off + length).replace(/\n$/, ""); + const eq = record.indexOf("="); + if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1); + off += length; + } + return out; +} + +/** + * The regular files in a gzipped tar, in archive order. + * + * `tar -tzf` would do this in one line, but the tests for it run on Windows + * too, and a few hundred lines of tarball is not worth a dependency in a + * repository that has none. + * + * @param {Uint8Array} gzipped + * @returns {TarFile[]} + */ +export function tarFiles(gzipped) { + const buf = gunzipSync(gzipped); + const files = []; + let off = 0; + let pax = null; + let longName = null; + + while (off + 512 <= buf.length) { + const header = buf.subarray(off, off + 512); + if (header.every((byte) => byte === 0)) break; + + const size = readOctal(header, 124, 12); + const type = String.fromCharCode(header[156] || 0); + const start = off + 512; + const data = buf.subarray(start, start + size); + off = start + Math.ceil(size / 512) * 512; + + if (type === "x" || type === "X") { + pax = parsePax(data); + continue; + } + if (type === "L") { + longName = data.toString("utf8").replace(/\0+$/, ""); + continue; + } + // A global header, or a long *link* name, applies to nothing we collect. + if (type === "g" || type === "K") continue; + + let path = readField(header, 0, 100); + const prefix = readField(header, 345, 155); + if (prefix) path = `${prefix}/${path}`; + if (longName) path = longName; + if (pax?.path) path = pax.path; + longName = null; + pax = null; + + if (type === "0" || type === "\0") files.push({ path, data }); + } + + return files; +} + +/** + * A digest of what a tarball installs, ignoring how it was packed. + * + * Two packs of the same commit can differ byte for byte — a different npm, a + * different gzip level — while installing exactly the same files. Comparing + * names and contents is the difference between "the registry already has this + * release" and "the registry has something else under this version". + * + * @param {TarFile[]} files + * @returns {string} + */ +export function contentDigest(files) { + const lines = files + .map(({ path, data }) => `${createHash("sha256").update(data).digest("hex")} ${path}`) + .sort(); + return createHash("sha256").update(lines.join("\n")).digest("hex"); +} + +/** The npm tarball prefix, so digests compare `dist/index.js`, not `package/dist/index.js`. */ +const stripPrefix = (path) => path.replace(/^package\//, ""); + +export const digestOfTarball = (gzipped) => + contentDigest(tarFiles(gzipped).map(({ path, data }) => ({ path: stripPrefix(path), data }))); + +/* -------------------------------------------------------------------------- */ +/* A tarball that is missing its build is not a release */ +/* -------------------------------------------------------------------------- */ + +/** + * Every path package.json promises a consumer: `main`, `types`, each `bin`, + * and every file an `exports` subpath resolves to. + * + * Publishing a tarball skips `prepublishOnly`, which is the point — the build + * happens once, before anything is uploaded — but it also removes the accident + * that used to build the demo on its way out the door. This is the check that + * replaces it, and it is stricter: it asks for the files, not for the script. + */ +export function requiredEntryPoints(pkg) { + const paths = new Set(); + const add = (value) => { + if (typeof value === "string" && value.startsWith(".")) { + paths.add(value.replace(/^\.\//, "")); + } + }; + + add(pkg.main); + add(pkg.types); + if (typeof pkg.bin === "string") add(pkg.bin); + else for (const target of Object.values(pkg.bin ?? {})) add(target); + + const walk = (node) => { + if (typeof node === "string") add(node); + else if (node && typeof node === "object") for (const child of Object.values(node)) walk(child); + }; + walk(pkg.exports); + + return [...paths]; +} + +export function missingEntryPoints(pkg, filePaths) { + const present = new Set(filePaths.map(stripPrefix)); + return requiredEntryPoints(pkg).filter((path) => !present.has(path)); +} + +const RELATIVE_IMPORT = + /(?:\bfrom\s*|\bimport\s*|\bexport\s+\*\s+from\s*)["'](\.[^"']*)["']|\bimport\(\s*["'](\.[^"']*)["']\s*\)/g; + +/** `a/b/../c.js` without touching the filesystem: tarball paths are already posix. */ +function resolveInTarball(fromDir, specifier) { + const parts = [...fromDir.split("/").filter(Boolean), ...specifier.split("/")]; + const out = []; + for (const part of parts) { + if (part === "." || part === "") continue; + if (part === "..") out.pop(); + else out.push(part); + } + return out.join("/"); +} + +/** + * Relative imports inside the tarball that the tarball does not contain. + * + * `bin/hqtui-demo.mjs` is one line — `import "../dist/main.js"` — so the demo's + * declared entry point can be present in a tarball that installs nothing + * runnable. That is exactly how 0.1.10 got as far as it did. Following the + * imports is what turns "the file package.json names exists" into "the package + * runs". + * + * @param {TarFile[]} files + * @returns {string[]} + */ +export function unresolvedImports(files) { + const present = new Set(files.map(({ path }) => stripPrefix(path))); + const missing = []; + + for (const { path, data } of files) { + const name = stripPrefix(path); + if (!/\.(?:js|mjs|cjs)$/.test(name)) continue; + const dir = name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : ""; + const source = data.toString("utf8"); + + for (const match of source.matchAll(RELATIVE_IMPORT)) { + const specifier = match[1] ?? match[2]; + const target = resolveInTarball(dir, specifier); + const candidates = [target, `${target}.js`, `${target}/index.js`]; + if (!candidates.some((candidate) => present.has(candidate))) { + missing.push(`${name} imports ${specifier}`); + } + } + } + + return missing; +} + +/* -------------------------------------------------------------------------- */ +/* Asking the registry what it already has */ +/* -------------------------------------------------------------------------- */ + +export const versionUrl = (registry, name, version) => + `${registry.replace(/\/$/, "")}/${name.replace("/", "%2f")}/${encodeURIComponent(version)}`; + +/** + * One of three answers, never a guess: + * + * absent — the registry says this version does not exist + * present — the registry described it, and said what it hashes to + * indeterminate — anything else, including a 5xx or a socket that closed + * + * "Indeterminate" is a distinct state on purpose. Treating an unreachable + * registry as absent is how you publish twice; treating it as present is how + * you skip a package that never shipped. + * + * @param {Query} options + * @returns {Promise} + */ +export async function lookupPublished({ + name, + version, + registry = DEFAULT_REGISTRY, + fetchImpl = fetch, +}) { + let res; + try { + res = await fetchImpl(versionUrl(registry, name, version), { + headers: { accept: "application/json" }, + }); + } catch (err) { + return { state: "indeterminate", reason: `request failed: ${err.message}` }; + } + + if (res.status === 404) return { state: "absent" }; + if (!res.ok) return { state: "indeterminate", reason: `registry answered ${res.status}` }; + + let body; + try { + body = await res.json(); + } catch (err) { + return { state: "indeterminate", reason: `unreadable response: ${err.message}` }; + } + + const shasum = body?.dist?.shasum; + if (typeof shasum !== "string" || shasum === "") { + return { state: "indeterminate", reason: "the response carries no dist.shasum" }; + } + return { state: "present", shasum, tarball: body?.dist?.tarball }; +} + +const wait = (ms) => new Promise((done) => setTimeout(done, ms)); + +/** + * Retry only the indeterminate answers: absent and present are facts. + * + * @param {Query} opts + * @param {{ attempts?: number, delay?: number, sleep?: Sleep }} [retry] + * @returns {Promise} + */ +export async function lookupWithRetry(opts, { attempts = 4, delay = 2000, sleep = wait } = {}) { + /** @type {Published} */ + let last = { state: "indeterminate", reason: "the registry was never asked" }; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + last = await lookupPublished(opts); + if (last.state !== "indeterminate") return last; + if (attempt < attempts) await sleep(delay * attempt); + } + return last; +} + +/** + * What to do about one package, given what the registry has and what we packed. + * + * `compare` means the version exists under different bytes, which is not yet a + * verdict — the caller fetches the published tarball and compares contents. + * + * @param {{ published: Published, shasum: string }} options + * @returns {Decision} + */ +export function resolveAction({ published, shasum }) { + if (published.state === "absent") return { action: "publish", reason: "not on the registry" }; + if (published.state === "present") { + return published.shasum === shasum + ? { action: "skip", reason: "already published, identical tarball" } + : { action: "compare", reason: "already published under a different shasum" }; + } + return { + action: "fail", + reason: `the registry did not give a usable answer (${published.reason})`, + }; +} + +/** + * After an upload, the version has to become readable before the release is + * finished. + * + * @param {Query & { shasum?: string, attempts?: number, delay?: number, sleep?: Sleep }} options + * @returns {Promise} + */ +export async function waitForAvailability({ + name, + version, + shasum = "", + registry = DEFAULT_REGISTRY, + fetchImpl = fetch, + attempts = 10, + delay = 5000, + sleep = wait, +}) { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const found = await lookupPublished({ name, version, registry, fetchImpl }); + if (found.state === "present") { + if (shasum && found.shasum !== shasum) { + throw new Error( + `${name}@${version} is on the registry as ${found.shasum}, not the ${shasum} this run verified`, + ); + } + return found; + } + if (attempt < attempts) await sleep(delay); + } + throw new Error(`${name}@${version} did not become readable on ${registry}`); +} + +/* -------------------------------------------------------------------------- */ +/* The two commands */ +/* -------------------------------------------------------------------------- */ + +function spawn(command, args, cwd, stdio) { + const result = spawnSync(command, args, { cwd, encoding: "utf8", stdio }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited ${result.status}`); + return result.stdout ?? ""; +} + +/** `npm pack --json`, whose stdout is the answer rather than a log. */ +const capture = (command, args, cwd) => spawn(command, args, cwd, ["ignore", "pipe", "inherit"]); + +/** `npm publish`, whose output belongs in the run log where it can be read. */ +const run = (command, args, cwd) => spawn(command, args, cwd, "inherit"); + +/** + * @param {{ out: string, root?: string, event?: string, ref?: string, distTag?: string }} options + */ +export function packAll({ root = ROOT, out, event = "", ref = "", distTag = "" }) { + const dirs = PACKAGES.map(({ dir, label }) => ({ + dir, + label, + pkg: readJson(join(root, dir, "package.json")), + })); + + const versions = Object.fromEntries(dirs.map(({ dir, pkg }) => [dir, pkg.version])); + const { version, distTag: tag } = checkVersions({ versions, event, ref, distTag }); + log(`packing ${version} for dist-tag '${tag}'`); + + rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + + const packages = dirs.map(({ dir, label, pkg }) => { + const stdout = capture("npm", ["pack", "--json", "--pack-destination", resolve(out)], join(root, dir)); + const [packed] = JSON.parse(stdout); + const gzipped = readFileSync(join(out, packed.filename)); + const files = tarFiles(gzipped); + + const missing = missingEntryPoints( + pkg, + files.map((file) => file.path), + ); + if (missing.length > 0) { + throw new Error( + `${packed.name} packed without ${missing.join(", ")} — the build did not produce what package.json promises`, + ); + } + + const dangling = unresolvedImports(files); + if (dangling.length > 0) { + throw new Error( + [`${packed.name} packed with imports it does not contain:`, ...dangling.map((d) => ` ${d}`)].join("\n"), + ); + } + + log(` ${label}: ${packed.filename} (${files.length} files, shasum ${packed.shasum})`); + return { + dir, + label, + name: packed.name, + version: packed.version, + tarball: packed.filename, + shasum: packed.shasum, + integrity: packed.integrity, + contentDigest: digestOfTarball(gzipped), + }; + }); + + const manifest = { version, distTag: tag, packages }; + writeFileSync(join(out, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} + +/** + * @param {{ from: string, distTag?: string, dryRun?: boolean, registry?: string, + * fetchImpl?: typeof fetch, runNpm?: RunNpm, sleep?: Sleep }} options + * @returns {Promise<{ entry: any, decision: Decision, published: Published }[]>} + */ +export async function publishAll({ + from, + distTag = "", + dryRun = false, + registry = DEFAULT_REGISTRY, + fetchImpl = fetch, + runNpm = run, + sleep = wait, +}) { + const manifest = readJson(join(from, MANIFEST)); + const tag = distTag || manifest.distTag; + + // Every decision is made before any upload, so a conflict on the demo keeps + // the library from going out: the ordering the first incident did not have. + const plan = []; + for (const entry of manifest.packages) { + const published = await lookupWithRetry( + { name: entry.name, version: entry.version, registry, fetchImpl }, + { sleep }, + ); + let decision = resolveAction({ published, shasum: entry.shasum }); + + if (decision.action === "compare") { + log(`${entry.name}@${entry.version}: ${decision.reason}, comparing contents`); + const res = await fetchImpl( + published.tarball ?? versionUrl(registry, entry.name, entry.version), + ); + if (!res.ok) { + decision = { action: "fail", reason: `could not read the published tarball (${res.status})` }; + } else { + const remote = digestOfTarball(Buffer.from(await res.arrayBuffer())); + decision = + remote === entry.contentDigest + ? { action: "skip", reason: "already published, same contents repacked" } + : { action: "fail", reason: "the registry has different contents under this version" }; + } + } + + log(`${entry.name}@${entry.version}: ${decision.action} (${decision.reason})`); + plan.push({ entry, decision, published }); + } + + const conflicts = plan.filter(({ decision }) => decision.action === "fail"); + if (conflicts.length > 0) { + throw new Error( + conflicts + .map(({ entry, decision }) => `${entry.name}@${entry.version}: ${decision.reason}`) + .join("\n"), + ); + } + + for (const { entry, decision } of plan) { + if (decision.action !== "publish") continue; + const args = [ + "publish", + resolve(from, entry.tarball), + "--access", + "public", + "--provenance", + "--tag", + tag, + ]; + if (dryRun) args.push("--dry-run"); + log(`publishing ${entry.name}@${entry.version} as '${tag}'`); + runNpm("npm", args, resolve(from)); + } + + if (dryRun) return plan; + + for (const { entry, decision, published } of plan) { + const shasum = decision.action === "publish" ? entry.shasum : published.shasum; + await waitForAvailability({ + name: entry.name, + version: entry.version, + shasum, + registry, + fetchImpl, + sleep, + }); + log(`verified ${entry.name}@${entry.version} is readable on the registry`); + } + + return plan; +} + +/* -------------------------------------------------------------------------- */ +/* CLI */ +/* -------------------------------------------------------------------------- */ + +export function parseArgs(argv) { + const flags = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + if (argv[i + 1] && !argv[i + 1].startsWith("--")) flags[key] = argv[++i]; + else flags[key] = "true"; + } + return flags; +} + +const USAGE = + "usage: publish.mjs pack|publish [--out dir] [--from dir] [--tag latest] [--event release] [--ref v0.0.0] [--dry-run]"; + +async function main(argv) { + const [command, ...rest] = argv; + const flags = parseArgs(rest); + const out = resolve(flags.out ?? join(ROOT, ".release-artifacts")); + + if (command === "pack") { + packAll({ out, event: flags.event ?? "", ref: flags.ref ?? "", distTag: flags.tag ?? "" }); + return; + } + + if (command === "publish") { + await publishAll({ + from: resolve(flags.from ?? out), + distTag: flags.tag, + dryRun: flags["dry-run"] === "true", + }); + return; + } + + throw new Error(USAGE); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + main(process.argv.slice(2)).catch((err) => { + console.error(err.message); + process.exit(1); + }); +}