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
295 changes: 108 additions & 187 deletions .github/workflows/publish.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/oss/release-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ The payload is informational — this repository always re-reads the registry ra
| The workflow fails with "DEPLOY_GITHUB_TOKEN is not configured" | The secret is absent from this repository | Add it (see above) |
| A pull request opens but never merges | Required checks failing | Read the checks — this is the automation working; a product release broke something |
| Two open version-update pull requests | The close-the-previous step failed | Close the older one by hand; they race each other's lockfile |
| A published version never appears on the registry | npm accepted the publish but the version is not resolvable | The publish run polls for it and fails if it never appears; if that fails, re-dispatch the workflow — an already-published version is treated as done, so only the missing one publishes |
| A release publish fails on the dev-build check | The committed pins are dev builds — a dev stamp was committed by mistake, or a product has no usable release | Run `node scripts/update-product-versions.mjs --channel release`; if that changes nothing, the product must publish a real release |

## The state this replaced
Expand Down
4 changes: 3 additions & 1 deletion docs/oss/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ The npm registry exposes the CLI packages under these dist-tags:

- **`dev`** — every routine push to `main` publishes `<base>-dev.<run>` here automatically (operator ruling 2026-08-13, superseding the earlier "no dev channel" ruling). The suffix derives from the workflow run number and is stamped ephemerally in CI, never committed, so release versions remain exactly what a commit says. The channel exists so a product's new version reaches a working CLI without a human: an auto-merging pull request moves the version, runs the full quality and conformance checks, and its merge ships the dev build. Today a daily scheduled run is what notices a product release; the immediate path needs a notification step in each product repository, which neither has yet. See [release automation](./release-automation.md). Only a real release — an `rc.N` bump under `next`, or moving `latest` — is a human act.

**Every** run of the publish workflow ships a dev build, including the one that cuts a release — the release publish is an additional half, not an alternative (operator ruling 2026-08-18). When they were alternatives, the release commit was the one merge to `main` that never reached the dev channel, so `dev` named an older version than the release until an unrelated commit landed. The dev build is published as its own version rather than by moving the `dev` tag, because OIDC trusted publishing authorises `npm publish` and nothing else.

PR previews go through [`pkg.pr.new`](https://pkg.pr.new) ([`preview-cli-package.yml`](../../.github/workflows/preview-cli-package.yml)); they carry the committed base version and install via per-commit URLs, not dist-tags.

## Who can publish
Expand All @@ -66,7 +68,7 @@ This is by design. The alternatives cause silent problems:

[`scripts/set-version.ts`](../../scripts/set-version.ts) is what enforces lockstep: a single invocation walks every lockstep workspace `package.json` and writes the requested version (rewriting `workspace:` dependency pins to match). It is a maintainer's tool, invoked through `pnpm bump-version`; the publish workflow does not run it.

The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships that version under its canonical dist-tag — `next` on the RC line (the accompanying GitHub Release is marked pre-release), `latest` for stable. This is what makes "merge the release PR" the publish trigger; there is no separate dispatch step. A push that leaves the version alone publishes a `dev` build instead of a release (operator ruling 2026-08-13; before that ruling it published nothing). Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it), then `prisma`.
The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships that version under its canonical dist-tag — `next` on the RC line (the accompanying GitHub Release is marked pre-release), `latest` for stable. This is what makes "merge the release PR" the publish trigger; there is no separate dispatch step. Every push publishes a `dev` build; one that changes the version publishes a release as well (operator ruling 2026-08-18). Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it), then `prisma`.

**Nothing rewrites a `version` field outside a commit.** `set-version.ts` is run by `pnpm bump-version`, whose output a maintainer reviews and commits; the publish workflow never invokes it. That is what makes "the version is whatever `package.json` says" true rather than aspirational — CI has no way to ship a version no commit describes. It also keeps `pnpm-lock.yaml` honest: the lockfile records the `workspace:` specifiers that `set-version.ts` rewrites, so `bump-version` refreshes it in the same breath and the bump lands as one internally consistent commit.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"lint:fix": "biome check . --write",
"bump-version": "node scripts/bump-version.ts",
"test": "turbo run test",
"test:scripts": "node --test scripts/determine-version-utils.test.ts scripts/set-version-utils.test.ts scripts/bump-cli-engine-version-utils.test.ts scripts/resolve-package-version.test.mjs scripts/update-product-versions.test.mjs",
"test:scripts": "node --test scripts/determine-version-utils.test.ts scripts/set-version-utils.test.ts scripts/bump-cli-engine-version-utils.test.ts scripts/resolve-package-version.test.mjs scripts/update-product-versions.test.mjs scripts/verify-published.test.mjs",
"typecheck": "turbo run typecheck",
"prisma-cli": "tsx packages/cli/src/bin.ts",
"prisma": "tsx packages/cli/src/bin.ts",
Expand Down
23 changes: 23 additions & 0 deletions scripts/determine-version-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
assertCanonicalBase,
assertValidDistTag,
computeNextMinor,
computeNextReleaseVersion,
devVersion,
Expand All @@ -11,6 +12,7 @@ import {
} from "./determine-version-utils.ts";

const NOT_CANONICAL = /not canonical/;
const NOT_A_DIST_TAG = /not a valid dist-tag/;

describe("parseVersion", () => {
it("parses a clean release", () => {
Expand Down Expand Up @@ -187,3 +189,24 @@ describe("isReleasePublish", () => {
assert.equal(isReleasePublish("8.0.0-rc.2", "latest"), false);
});
});

describe("assertValidDistTag", () => {
it("accepts the tags the contract uses", () => {
for (const tag of ["latest", "next", "beta", "dev"]) {
assert.doesNotThrow(() => assertValidDistTag(tag));
}
});

it("refuses anything that is not a plain lowercase word", () => {
for (const tag of ["", "Latest", "--tag", "8.0.0", "a tag"]) {
assert.throws(() => assertValidDistTag(tag), NOT_A_DIST_TAG);
}
});

it("refuses a value with a newline, which could smuggle extra workflow outputs", () => {
assert.throws(
() => assertValidDistTag("next\nEOF\ngithubRelease<<EOF\ntrue"),
NOT_A_DIST_TAG,
);
});
});
16 changes: 16 additions & 0 deletions scripts/determine-version-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ export function assertCanonicalBase(base: string): void {
}
}

const DIST_TAG_PATTERN = /^[a-z][a-z0-9-]*$/;

/**
* Asserts that a dispatch-supplied dist-tag is a plain lowercase word.
* The value ends up in `pnpm publish --tag` and in a GITHUB_OUTPUT
* heredoc, so anything with whitespace, a newline, or a leading `-`
* must be refused, not passed through.
*/
export function assertValidDistTag(tag: string): void {
if (!DIST_TAG_PATTERN.test(tag)) {
throw new Error(
`"${tag}" is not a valid dist-tag: expected a plain lowercase word like "latest", "next", "beta" or "dev".`,
);
}
}

/**
* The dist-tag a release-bump push publishes under. RC-line versions go
* to `next`: `latest` keeps serving the pre-8 CLI until the operator
Expand Down
143 changes: 49 additions & 94 deletions scripts/determine-version.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,22 @@
#!/usr/bin/env node

/**
* Composes the version + dist-tag the publish workflow will use.
* The versions the publish workflow will use, from the root
* `package.json` at this ref: always a dev version, and a release
* version when this push changed it. Not either/or — see
* docs/oss/versioning.md for why.
*
* The base version comes from the root `package.json` (the workspace-wide
* lockstep source of truth — see docs/oss/versioning.md). This script is
* responsible only for the suffix and dist-tag appropriate to the GitHub
* event:
*
* - `push` → if the root `version` changed in this push,
* `<base>`, dist-tag from `releaseDistTag`:
* `next` on the RC line, `latest` for stable.
* This is how a merged `chore(release): ...`
* PR ships a release automatically — `latest`
* keeps serving the pre-8 CLI until the
* operator deliberately moves it.
* Otherwise `<base>-dev.<run>` under the `dev`
* dist-tag: every routine main push — above
* all one that follows a product's new version
* — ships an installable dev build
* automatically (operator ruling 2026-08-13).
* - `workflow_dispatch` → `<base>` (no suffix), dist-tag from
* `INPUT_DIST_TAG`; empty means the version's
* canonical tag (`releaseDistTag`). Useful as a
* manual escape hatch (re-publish after a
* transient failure, cut a beta) — and passing
* `latest` explicitly for an RC version is the
* deliberate cutover act.
*
* Outputs `publish`, `version`, `tag` and `release` to `$GITHUB_OUTPUT`
* for downstream workflow steps to consume.
*
* This script never rewrites a manifest. Release versions are the ones
* committed at this ref, always; a dev version derives its suffix from
* the run number, and the workflow stamps it ephemerally in CI without
* committing it (docs/oss/versioning.md).
* Outputs `devVersion`, `release`, `releaseVersion`, `releaseTag` and
* `githubRelease`. Never rewrites a manifest.
*/

import { execFileSync } from "node:child_process";
import { appendFileSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { VersionResult } from "./determine-version-utils.ts";
import {
assertCanonicalBase,
assertValidDistTag,
devVersion,
isReleasePublish,
releaseDistTag,
Expand Down Expand Up @@ -72,13 +45,7 @@ type PreviousVersionLookup =
| { available: true; version: string | undefined }
| { available: false };

/**
* Reads the root `package.json` `version` at `PUSH_BEFORE_SHA` (the ref
* that `main` pointed at *before* the push). Distinguishes "we
* successfully read the previous file" (so the comparison is meaningful)
* from "we couldn't" (shallow clone, missing SHA, etc.) so the caller
* can fall back to the safe `dev` path on any I/O hiccup.
*/
/** The root `version` at `PUSH_BEFORE_SHA`, if it can be read at all. */
function readPreviousRootVersion(): PreviousVersionLookup {
const beforeSha = process.env.PUSH_BEFORE_SHA;
if (!beforeSha || ALL_ZERO_SHA_PATTERN.test(beforeSha)) {
Expand All @@ -101,82 +68,66 @@ function readPreviousRootVersion(): PreviousVersionLookup {
}

function writeGitHubOutput(
base: string,
result: VersionResult | undefined,
publish: boolean,
devVersion: string,
release: { version: string; tag: string } | undefined,
): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (!outputFile) return;
appendFileSync(outputFile, `publish<<EOF\n${String(publish)}\nEOF\n`);
if (result === undefined) return;
appendFileSync(outputFile, `version<<EOF\n${result.version}\nEOF\n`);
appendFileSync(outputFile, `tag<<EOF\n${result.tag}\nEOF\n`);
// A run is a release — and gets the GitHub Release + tag — when it
// publishes under the canonical tag for its BASE version. Push bumps
// always do; a dispatch counts only when the chosen tag matches
// (re-publishing a release); dev publishes never do, and their
// suffixed version must not reach releaseDistTag's canonical check.
const release = isReleasePublish(base, result.tag);
appendFileSync(outputFile, `release<<EOF\n${String(release)}\nEOF\n`);
appendFileSync(outputFile, `devVersion<<EOF\n${devVersion}\nEOF\n`);
appendFileSync(
outputFile,
`release<<EOF\n${String(release !== undefined)}\nEOF\n`,
);
if (release === undefined) return;
appendFileSync(outputFile, `releaseVersion<<EOF\n${release.version}\nEOF\n`);
appendFileSync(outputFile, `releaseTag<<EOF\n${release.tag}\nEOF\n`);
// A beta cut publishes to npm but gets no GitHub Release.
appendFileSync(
outputFile,
`githubRelease<<EOF\n${String(isReleasePublish(release.version, release.tag))}\nEOF\n`,
);
}

const eventName = process.env.GITHUB_EVENT_NAME;
const inputDistTag = process.env.INPUT_DIST_TAG;
const runNumber = process.env.GITHUB_RUN_NUMBER ?? "";

const baseVersion = readRootVersion();
assertCanonicalBase(baseVersion);

console.log(`Event: ${eventName}`);
console.log(`Base version (root): ${baseVersion}`);

let result: VersionResult | undefined;
const dev = devVersion(baseVersion, runNumber);

let release: { version: string; tag: string } | undefined;

switch (eventName) {
case "workflow_dispatch":
// `??` is wrong here: an empty INPUT_DIST_TAG must fall through to
// the canonical tag, not become `pnpm publish --tag ""` downstream.
// Empty (the input's default) means "this version's canonical tag",
// so a routine re-publish dispatch can never move `latest` onto the
// RC line by accident; moving it takes an explicit `latest` input.
result = {
// `||`, not `??`: an empty INPUT_DIST_TAG must fall through to the
// canonical tag, not become `pnpm publish --tag ""`.
if (inputDistTag) assertValidDistTag(inputDistTag);
release = {
version: baseVersion,
tag: inputDistTag || releaseDistTag(baseVersion),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
break;

case "push": {
// A push publishes exactly when it changes the committed version.
// Every other push has nothing to ship: the version at this ref is
// already on the registry, and inventing a different one would mean
// publishing something no commit describes.
//
// `available: false` (shallow clone, missing SHA) deliberately means
// "do not publish": a transient git error must never promote to
// `latest`, and skipping is recoverable by dispatching the workflow.
// An unreadable previous version means no release: a transient git
// error must never promote to `latest`.
const previous = readPreviousRootVersion();
const isReleaseBump =
previous.available && previous.version !== baseVersion;
if (isReleaseBump) {
if (!previous.available) {
console.log(
`Previous root version: ${previous.version ?? "(unset)"} → release bump detected.`,
"Could not read the previous root version — publishing the dev build only.",
);
result = { version: baseVersion, tag: releaseDistTag(baseVersion) };
} else if (previous.available) {
// Routine push: publish `<base>-dev.<run>` under `dev` (operator
// ruling 2026-08-13 — a product's new version reaches the CLI and
// deploys without a human; only a real release needs one). The
// suffix is derived here and stamped ephemerally in CI; it is
// never committed, so releases remain committed-at-HEAD.
const runNumber = process.env.GITHUB_RUN_NUMBER ?? "";
} else if (previous.version !== baseVersion) {
console.log(
`Root version unchanged by this push → dev publish (run ${runNumber}).`,
`Previous root version: ${previous.version ?? "(unset)"} → release bump detected.`,
);
result = { version: devVersion(baseVersion, runNumber), tag: "dev" };
release = { version: baseVersion, tag: releaseDistTag(baseVersion) };
} else {
// A transient git error must never publish anything; skipping is
// recoverable by dispatching the workflow.
console.log("Could not read the previous root version — not publishing.");
result = undefined;
console.log("Root version unchanged by this push → dev build only.");
}
break;
}
Expand All @@ -185,10 +136,14 @@ switch (eventName) {
throw new Error(`don't know how to handle event ${eventName}`);
}

if (result === undefined) {
writeGitHubOutput(baseVersion, undefined, false);
} else {
console.log(`Resolved version: ${result.version}`);
console.log(`Resolved dist-tag: ${result.tag}`);
writeGitHubOutput(baseVersion, result, true);
if (release !== undefined && !isReleasePublish(baseVersion, release.tag)) {
console.log(
`Dist-tag ${release.tag} is not ${baseVersion}'s canonical tag — publishing it, but not as a release.`,
);
}

console.log(`Dev version: ${dev}`);
console.log(
`Release: ${release === undefined ? "no" : `${release.version} under ${release.tag}`}`,
);
writeGitHubOutput(dev, release);
25 changes: 25 additions & 0 deletions scripts/publish-packages.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash

# Usage: publish-packages.sh <dist-tag> <package>...
#
# `pnpm publish` for each package, treating an already-published version
# as done: a re-run of a partially failed workflow run must reach the
# later steps. Every other failure fails the run.

set -euo pipefail

tag="$1"
shift

for pkg in "$@"; do
if out=$(pnpm --filter "$pkg" publish --tag "$tag" --access public --no-git-checks 2>&1); then
printf '%s\n' "$out"
else
printf '%s\n' "$out"
if grep -qiE 'E409|EPUBLISHCONFLICT|cannot publish over|previously published' <<<"$out"; then
echo "$pkg: this version is already on the registry — continuing."
else
exit 1
fi
fi
done
Loading
Loading