diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fc3c70..9a9e45e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - run: bun install --frozen-lockfile + # npm publish drops a `bin` whose file is missing before prepublishOnly runs, so dist/ + # must exist up front or the tarball ships without the `changeset` command. + - run: bun run build # With changesets pending, opens or updates the "Version Packages" PR; once it is # merged, publishes. npm authenticates through trusted publishing (OIDC). - uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 diff --git a/scripts/publish.ts b/scripts/publish.ts index 1d74530..1ac5132 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -1,7 +1,7 @@ /** * The `publish-script` of `.github/workflows/release.yml`: publishes the current version when npm - * does not have it yet, tags it, and reports the tag through `CHANGESETS_OUTPUT` so - * changesets/action pushes it and cuts the GitHub release. + * does not have it yet, tags it when the tag is missing (also for a version published by hand), and + * reports the tag through `CHANGESETS_OUTPUT` so changesets/action pushes it and cuts the GitHub release. */ import { NodeRuntime, NodeServices } from "@effect/platform-node" import { Config, Console, Effect, FileSystem, Option, Schema } from "effect" @@ -18,19 +18,32 @@ const program = Effect.gen(function* () { const { name, version } = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))( yield* fs.readFileString("package.json"), ) + const tag = `${name}@${version}` const published = yield* run("npm", ["view", `${name}@${version}`, "version"], ".") - if (published.stdout.trim() === version) { - return yield* Console.log(`${name}@${version} is already on npm.`) + const publishNow = published.stdout.trim() !== version + if (publishNow) { + const publish = yield* run("npm", ["publish", "--provenance", "--access", "public"], ".") + yield* Console.log(publish.stdout) + if (publish.exitCode !== 0) { + return yield* new ReleaseError({ message: `npm publish failed:\n${publish.stderr}` }) + } + } else { + yield* Console.log(`${tag} is already on npm.`) } - const publish = yield* run("npm", ["publish", "--provenance", "--access", "public"], ".") - yield* Console.log(publish.stdout) - if (publish.exitCode !== 0) { - return yield* new ReleaseError({ message: `npm publish failed:\n${publish.stderr}` }) + // Exit code 2 means the remote has no such tag. + const remoteTag = yield* run("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`], ".") + if (remoteTag.exitCode !== 0 && remoteTag.exitCode !== 2) { + return yield* new ReleaseError({ message: `git ls-remote failed:\n${remoteTag.stderr}` }) + } + const tagMissing = remoteTag.exitCode === 2 + if (!publishNow && !tagMissing) return yield* Console.log(`${tag} is already tagged.`) + // A retry in the same checkout reuses the tag an earlier run created but never pushed. + const localTag = yield* run("git", ["rev-parse", "--quiet", "--verify", `refs/tags/${tag}`], ".") + if (tagMissing && localTag.exitCode !== 0) { + const tagged = yield* run("git", ["tag", tag], ".") + if (tagged.exitCode !== 0) return yield* new ReleaseError({ message: `git tag failed:\n${tagged.stderr}` }) + yield* Console.log(`New tag: ${tag}`) } - const tag = `${name}@${version}` - const tagged = yield* run("git", ["tag", "-a", tag, "-m", tag], ".") - if (tagged.exitCode !== 0) return yield* new ReleaseError({ message: `git tag failed:\n${tagged.stderr}` }) - yield* Console.log(`New tag: ${tag}`) const output = yield* Config.option(Config.String("CHANGESETS_OUTPUT")) if (Option.isSome(output)) { yield* fs.writeFileString(output.value, `${JSON.stringify({ type: "git-tag", tag, packageName: name })}\n`, {