feat: add npm nightly release channel (#2126)

This commit is contained in:
José Almeida
2026-08-04 00:45:50 +01:00
committed by GitHub
parent f985ef1ff9
commit 1d5c3e3f5f
87 changed files with 7253 additions and 446 deletions
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
usage() {
cat >&2 <<EOF
Usage:
$0 prepare --scheduled-sha <sha> --output <path>
$0 publish --stable-at-start <version> --nightly-at-start <version> \\
--tags-at-start <snapshot> --version <version> --assets-dir <path>
EOF
exit 2
}
npm_version() {
bash "$ROOT_DIR/scripts/release/npm-view-version.sh" "$1"
}
latest_stable_tag() {
local tag
while IFS= read -r tag; do
if [[ "$tag" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
printf '%s\n' "$tag"
return 0
fi
done < <(git tag --list 'v*' --sort=-version:refname)
return 0
}
normalize_stable_tag() {
local tag="$1"
if [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+)$ ]]; then
printf '%s.0\n' "${BASH_REMATCH[1]}"
fi
}
MATCHED_STABLE_TAG=""
stable_tag_matches_npm() {
local npm_latest="$1"
local tagged_stable
MATCHED_STABLE_TAG="$(latest_stable_tag)"
if [[ -z "$MATCHED_STABLE_TAG" ]]; then
echo "No stable Git tag is available; skipping this best-effort Nightly run."
return 1
fi
tagged_stable="$(normalize_stable_tag "$MATCHED_STABLE_TAG")"
if [[ "$tagged_stable" != "$npm_latest" ]]; then
echo "Highest stable Git tag $MATCHED_STABLE_TAG does not match npm @latest $npm_latest; skipping Nightly."
return 1
fi
return 0
}
write_output() {
printf '%s=%s\n' "$1" "$2" >> "$OUTPUT_PATH"
}
resolve_nightly_commit() {
local version="$1"
local label="$2"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-nightly\.sha([0-9a-f]{12})$ ]]; then
echo "$label has unsupported version '$version'; refusing to infer publication order." >&2
return 1
fi
local sha="${BASH_REMATCH[1]}"
local commit
if ! commit="$(git rev-parse --verify "${sha}^{commit}" 2>/dev/null)"; then
echo "Cannot resolve $label commit prefix $sha in main history." >&2
return 1
fi
printf '%s\n' "$commit"
}
prepare() {
local scheduled_sha=""
OUTPUT_PATH=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
--scheduled-sha)
[[ "$#" -ge 2 ]] || usage
scheduled_sha="$2"
shift 2
;;
--output)
[[ "$#" -ge 2 ]] || usage
OUTPUT_PATH="$2"
shift 2
;;
*) usage ;;
esac
done
[[ -n "$scheduled_sha" && -n "$OUTPUT_PATH" ]] || usage
write_output should_publish false
local main_sha
main_sha="$(git rev-parse HEAD)"
if [[ "$main_sha" != "$scheduled_sha" ]]; then
echo "Checked out $main_sha instead of scheduled SHA $scheduled_sha." >&2
exit 1
fi
local latest_stable
if ! latest_stable="$(npm_version kandev@latest)"; then
echo "Could not resolve npm @latest; skipping this best-effort Nightly run."
return
fi
if [[ -z "$latest_stable" ]]; then
echo "npm @latest is missing; skipping this best-effort Nightly run."
return
fi
if ! stable_tag_matches_npm "$latest_stable"; then
return
fi
write_output stable_version "$latest_stable"
local stable_commit
stable_commit="$(git rev-parse "${MATCHED_STABLE_TAG}^{commit}")"
if ! git merge-base --is-ancestor "$stable_commit" "$main_sha"; then
if git merge-base --is-ancestor "$main_sha" "$stable_commit"; then
echo "Scheduled commit $main_sha was superseded by stable v$latest_stable; skipping Nightly."
return
fi
echo "Latest stable v$latest_stable is not an ancestor of scheduled main $main_sha." >&2
exit 1
fi
if [[ "$main_sha" == "$stable_commit" ]]; then
echo "No commits on main since stable v$latest_stable; skipping nightly."
return
fi
local nightly_version
nightly_version="$(node "$ROOT_DIR/scripts/release/nightly-version.mjs" "$latest_stable" "$main_sha")"
write_output version "$nightly_version"
write_output tag "v$nightly_version"
write_output ref "$main_sha"
local published_nightly
if ! published_nightly="$(npm_version kandev@nightly)"; then
echo "Could not resolve npm @nightly; skipping this best-effort Nightly run."
return
fi
write_output nightly_version_at_start "$published_nightly"
# shellcheck source=npm-packages.sh
source "$ROOT_DIR/scripts/release/npm-packages.sh"
local published_commit=""
if [[ -n "$published_nightly" ]]; then
if ! published_commit="$(resolve_nightly_commit "$published_nightly" "kandev@nightly")"; then
exit 1
fi
if [[ "$published_commit" != "$main_sha" ]] &&
git merge-base --is-ancestor "$main_sha" "$published_commit"; then
echo "A Nightly from a newer main commit is already published; skipping superseded commit $main_sha."
return
fi
if ! git merge-base --is-ancestor "$published_commit" "$main_sha"; then
echo "Published Nightly commit $published_commit is not an ancestor of scheduled main $main_sha." >&2
exit 1
fi
fi
local all_packages_published=true
local nightly_tags_at_start=""
local package published_version tagged_version tagged_commit
for package in "${NIGHTLY_PACKAGES[@]}"; do
if ! published_version="$(npm_version "${package}@$nightly_version")"; then
echo "Could not verify ${package}@$nightly_version; skipping this best-effort Nightly run."
return
fi
if ! tagged_version="$(npm_version "${package}@nightly")"; then
echo "Could not verify ${package}@nightly; skipping this best-effort Nightly run."
return
fi
nightly_tags_at_start+="${package}=${tagged_version};"
if [[ -n "$published_version" ]]; then
if [[ "$tagged_version" != "$nightly_version" ]]; then
echo "${package}@$nightly_version exists but @nightly resolves to '${tagged_version:-nothing}'." >&2
exit 1
fi
continue
fi
all_packages_published=false
if [[ -n "$tagged_version" && "$tagged_version" != "$published_nightly" ]]; then
if ! tagged_commit="$(resolve_nightly_commit "$tagged_version" "${package}@nightly")"; then
exit 1
fi
if ! git merge-base --is-ancestor "$tagged_commit" "$main_sha"; then
echo "${package}@nightly commit $tagged_commit is not an ancestor of scheduled main $main_sha." >&2
exit 1
fi
echo "${package}@nightly is an older partial publication; the current publish will repair it."
fi
done
write_output nightly_tags_at_start "$nightly_tags_at_start"
if [[ -n "$published_nightly" && "$all_packages_published" == "true" ]]; then
if [[ "$published_commit" != "$main_sha" ]]; then
echo "Nightly version identity does not resolve to scheduled commit $main_sha." >&2
exit 1
fi
echo "All Nightly packages are already published under @nightly; skipping."
return
fi
write_output should_publish true
}
publish() {
local stable_at_start=""
local nightly_at_start=""
local tags_at_start=""
local version=""
local assets_dir=""
local nightly_set=false
local tags_set=false
while [[ "$#" -gt 0 ]]; do
case "$1" in
--stable-at-start)
[[ "$#" -ge 2 ]] || usage
stable_at_start="$2"
shift 2
;;
--nightly-at-start)
[[ "$#" -ge 2 ]] || usage
nightly_at_start="$2"
nightly_set=true
shift 2
;;
--tags-at-start)
[[ "$#" -ge 2 ]] || usage
tags_at_start="$2"
tags_set=true
shift 2
;;
--version)
[[ "$#" -ge 2 ]] || usage
version="$2"
shift 2
;;
--assets-dir)
[[ "$#" -ge 2 ]] || usage
assets_dir="$2"
shift 2
;;
*) usage ;;
esac
done
if [[ -z "$stable_at_start" || -z "$version" || -z "$assets_dir" ||
"$nightly_set" != "true" || "$tags_set" != "true" ]]; then
usage
fi
local current_latest
if ! current_latest="$(npm_version kandev@latest)"; then
echo "Could not revalidate npm @latest; skipping Nightly publication."
return
fi
if [[ -z "$current_latest" ]]; then
echo "npm @latest is missing; skipping Nightly publication."
return
fi
if ! stable_tag_matches_npm "$current_latest"; then
return
fi
if [[ "$current_latest" != "$stable_at_start" ]]; then
echo "Stable npm baseline moved from $stable_at_start to $current_latest while this run was building; skipping stale Nightly publication."
return
fi
local current_nightly
if ! current_nightly="$(npm_version kandev@nightly)"; then
echo "Could not revalidate npm @nightly; skipping Nightly publication."
return
fi
if [[ "$current_nightly" != "$nightly_at_start" ]]; then
echo "npm @nightly moved from ${nightly_at_start:-nothing} to ${current_nightly:-nothing} while this run was building; skipping superseded publication."
return
fi
# shellcheck source=npm-packages.sh
source "$ROOT_DIR/scripts/release/npm-packages.sh"
local current_nightly_tags=""
local package tagged_version
for package in "${NIGHTLY_PACKAGES[@]}"; do
if ! tagged_version="$(npm_version "${package}@nightly")"; then
echo "Could not revalidate ${package}@nightly; skipping Nightly publication."
return
fi
current_nightly_tags+="${package}=${tagged_version};"
done
if [[ "$current_nightly_tags" != "$tags_at_start" ]]; then
echo "One or more npm @nightly tags moved while this run was building; skipping superseded publication."
return
fi
bash "$ROOT_DIR/scripts/release/publish-npm.sh" \
--version "$version" \
--dist-tag nightly \
--assets-dir "$assets_dir"
}
cd "$ROOT_DIR"
command="${1:-}"
[[ -n "$command" ]] || usage
shift
case "$command" in
prepare) prepare "$@" ;;
publish) publish "$@" ;;
*) usage ;;
esac
+546
View File
@@ -0,0 +1,546 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import {
chmod,
copyFile,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
const sourceRoot = path.resolve(import.meta.dirname, "../..");
const packages = [
"kandev",
"@kdlbs/runtime-linux-x64",
"@kdlbs/runtime-linux-arm64",
"@kdlbs/runtime-darwin-x64",
"@kdlbs/runtime-darwin-arm64",
"@kdlbs/runtime-win32-x64",
];
async function writeExecutable(file, content) {
await writeFile(file, content);
await chmod(file, 0o755);
}
function git(root, ...args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
}
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), "kandev-nightly-release-"));
const releaseDir = path.join(root, "scripts/release");
const binDir = path.join(root, "bin");
const assetsDir = path.join(root, "assets");
const stateFile = path.join(root, "npm-state.tsv");
const outputFile = path.join(root, "github-output");
const publishLog = path.join(root, "publish.log");
await Promise.all([
mkdir(releaseDir, { recursive: true }),
mkdir(binDir, { recursive: true }),
mkdir(assetsDir, { recursive: true }),
writeFile(stateFile, ""),
writeFile(outputFile, ""),
writeFile(publishLog, ""),
]);
await Promise.all(
[
"nightly-release.sh",
"nightly-version.mjs",
"npm-packages.sh",
"npm-view-version.sh",
].map((name) =>
copyFile(
path.join(sourceRoot, "scripts/release", name),
path.join(releaseDir, name),
),
),
);
await writeExecutable(
path.join(releaseDir, "publish-npm.sh"),
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$@" >> "$MOCK_PUBLISH_LOG"
`,
);
await writeExecutable(
path.join(binDir, "npm"),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" != "view" || "$3" != "version" ]]; then
printf 'unexpected npm command: %s\n' "$*" >&2
exit 2
fi
spec="$2"
while IFS=$'\t' read -r key value; do
if [[ "$key" != "$spec" ]]; then
continue
fi
if [[ "$value" == "!ERROR!" ]]; then
printf '%s\n' 'npm error code EAI_AGAIN' >&2
exit 1
fi
printf '%s\n' "$value"
exit 0
done < "$MOCK_NPM_STATE"
printf '%s\n' 'npm error code E404' >&2
exit 1
`,
);
await writeExecutable(
path.join(binDir, "sleep"),
"#!/usr/bin/env bash\nexit 0\n",
);
git(root, "init", "-q");
git(root, "config", "user.name", "Nightly Test");
git(root, "config", "user.email", "nightly@example.test");
await writeFile(path.join(root, "history.txt"), "stable\n");
git(root, "add", "history.txt");
git(root, "commit", "-q", "-m", "stable");
const stableSha = git(root, "rev-parse", "HEAD");
git(root, "tag", "v1.2.3");
await writeFile(path.join(root, "history.txt"), "stable\nmain\n");
git(root, "add", "history.txt");
git(root, "commit", "-q", "-m", "main");
const mainSha = git(root, "rev-parse", "HEAD");
await writeFile(path.join(root, "history.txt"), "stable\nmain\nnewer\n");
git(root, "add", "history.txt");
git(root, "commit", "-q", "-m", "newer");
const newerSha = git(root, "rev-parse", "HEAD");
git(root, "checkout", "-q", "--detach", stableSha);
await writeFile(path.join(root, "history.txt"), "stable\ndivergent\n");
git(root, "add", "history.txt");
git(root, "commit", "-q", "-m", "divergent");
const divergentSha = git(root, "rev-parse", "HEAD");
git(root, "checkout", "-q", "--detach", mainSha);
return {
root,
releaseDir,
binDir,
assetsDir,
stateFile,
outputFile,
publishLog,
stableSha,
mainSha,
newerSha,
divergentSha,
version: `1.2.4-nightly.sha${mainSha.slice(0, 12)}`,
};
}
async function setRegistry(fixture, entries) {
const lines = [...entries].map(([spec, value]) => `${spec}\t${value}`);
await writeFile(
fixture.stateFile,
lines.length === 0 ? "" : `${lines.join("\n")}\n`,
);
}
function runScript(fixture, ...args) {
return spawnSync(
"bash",
[path.join(fixture.releaseDir, "nightly-release.sh"), ...args],
{
cwd: fixture.root,
encoding: "utf8",
env: {
...process.env,
MOCK_NPM_STATE: fixture.stateFile,
MOCK_PUBLISH_LOG: fixture.publishLog,
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
},
);
}
async function runPrepare(fixture, scheduledSha = fixture.mainSha) {
await writeFile(fixture.outputFile, "");
const result = runScript(
fixture,
"prepare",
"--scheduled-sha",
scheduledSha,
"--output",
fixture.outputFile,
);
const output = Object.fromEntries(
(await readFile(fixture.outputFile, "utf8"))
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const separator = line.indexOf("=");
return [line.slice(0, separator), line.slice(separator + 1)];
}),
);
return { ...result, output };
}
function registryFor(version, nightly = "") {
const entries = new Map([["kandev@latest", "1.2.3"]]);
if (nightly) entries.set("kandev@nightly", nightly);
for (const packageName of packages) {
entries.set(`${packageName}@${version}`, version);
entries.set(`${packageName}@nightly`, version);
}
return entries;
}
function tagsSnapshot(version) {
return packages.map((packageName) => `${packageName}=${version};`).join("");
}
test("prepare publishes a changed main commit and snapshots empty tags", async () => {
const fixture = await createFixture();
try {
await setRegistry(fixture, new Map([["kandev@latest", "1.2.3"]]));
const result = await runPrepare(fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output.should_publish, "true");
assert.equal(result.output.stable_version, "1.2.3");
assert.equal(result.output.version, fixture.version);
assert.equal(result.output.tag, `v${fixture.version}`);
assert.equal(result.output.ref, fixture.mainSha);
assert.equal(result.output.nightly_version_at_start, "");
assert.equal(result.output.nightly_tags_at_start, tagsSnapshot(""));
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare normalizes a legacy two-part stable tag", async () => {
const fixture = await createFixture();
try {
git(fixture.root, "tag", "-d", "v1.2.3");
git(fixture.root, "tag", "v1.2", fixture.stableSha);
await setRegistry(fixture, new Map([["kandev@latest", "1.2.0"]]));
const result = await runPrepare(fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output.should_publish, "true");
assert.equal(result.output.stable_version, "1.2.0");
assert.equal(
result.output.version,
`1.2.1-nightly.sha${fixture.mainSha.slice(0, 12)}`,
);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare skips when main has no commit after stable", async () => {
const fixture = await createFixture();
try {
git(fixture.root, "checkout", "-q", "--detach", fixture.stableSha);
await setRegistry(fixture, new Map([["kandev@latest", "1.2.3"]]));
const result = await runPrepare(fixture, fixture.stableSha);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output.should_publish, "false");
assert.match(result.stdout, /No commits on main since stable/);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare skips while a stable Git tag is ahead of npm latest", async () => {
const fixture = await createFixture();
try {
git(fixture.root, "tag", "v1.3.0", fixture.mainSha);
await setRegistry(fixture, new Map([["kandev@latest", "1.2.3"]]));
const result = await runPrepare(fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output.should_publish, "false");
assert.match(result.stdout, /stable Git tag v1\.3\.0.*npm @latest 1\.2\.3/);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare skips a scheduled commit superseded by the current stable", async () => {
const fixture = await createFixture();
try {
git(fixture.root, "tag", "v1.3.0", fixture.newerSha);
await setRegistry(fixture, new Map([["kandev@latest", "1.3.0"]]));
const result = await runPrepare(fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output.should_publish, "false");
assert.match(result.stdout, /superseded by stable v1\.3\.0/);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare skips a complete publication and repairs a partial one", async () => {
const fixture = await createFixture();
try {
const complete = registryFor(fixture.version, fixture.version);
await setRegistry(fixture, complete);
const completeResult = await runPrepare(fixture);
assert.equal(completeResult.status, 0, completeResult.stderr);
assert.equal(completeResult.output.should_publish, "false");
complete.delete(`${packages.at(-1)}@${fixture.version}`);
await setRegistry(fixture, complete);
const partialResult = await runPrepare(fixture);
assert.equal(partialResult.status, 0, partialResult.stderr);
assert.equal(partialResult.output.should_publish, "true");
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare skips a scheduled commit superseded by a newer main Nightly", async () => {
const fixture = await createFixture();
try {
const newerVersion = `1.2.4-nightly.sha${fixture.newerSha.slice(0, 12)}`;
await setRegistry(
fixture,
new Map([
["kandev@latest", "1.2.3"],
["kandev@nightly", newerVersion],
]),
);
const result = await runPrepare(fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output.should_publish, "false");
assert.match(result.stdout, /newer main commit is already published/);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare rejects a Nightly version that cannot identify a commit", async () => {
const fixture = await createFixture();
try {
await setRegistry(
fixture,
new Map([
["kandev@latest", "1.2.3"],
["kandev@nightly", "1.2.4-nightly.bad"],
]),
);
const result = await runPrepare(fixture);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /unsupported version/);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare rejects registry commits outside scheduled main history", async () => {
const cases = [
{
name: "stable baseline",
configure(fixture, registry) {
git(fixture.root, "tag", "-f", "v1.2.3", fixture.divergentSha);
return registry;
},
error: /Latest stable .* is not an ancestor/,
},
{
name: "top-level Nightly",
configure(fixture, registry) {
registry.set(
"kandev@nightly",
`1.2.4-nightly.sha${fixture.divergentSha.slice(0, 12)}`,
);
return registry;
},
error: /Published Nightly commit .* is not an ancestor/,
},
{
name: "partial runtime tag",
configure(fixture, registry) {
const packageName = packages.at(-1);
registry.set("kandev@nightly", fixture.version);
registry.delete(`${packageName}@${fixture.version}`);
registry.set(
`${packageName}@nightly`,
`1.2.4-nightly.sha${fixture.divergentSha.slice(0, 12)}`,
);
return registry;
},
error: /@nightly commit .* is not an ancestor/,
},
];
for (const scenario of cases) {
const fixture = await createFixture();
try {
const registry = scenario.configure(
fixture,
registryFor(fixture.version, fixture.version),
);
await setRegistry(fixture, registry);
const result = await runPrepare(fixture);
assert.notEqual(result.status, 0, scenario.name);
assert.match(result.stderr, scenario.error, scenario.name);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
}
});
test("prepare rejects an exact package whose Nightly tag points elsewhere", async () => {
const fixture = await createFixture();
try {
const registry = registryFor(fixture.version, fixture.version);
registry.set(
`${packages.at(-1)}@nightly`,
`1.2.4-nightly.sha${fixture.stableSha.slice(0, 12)}`,
);
await setRegistry(fixture, registry);
const result = await runPrepare(fixture);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /exists but @nightly resolves/);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare safely skips registry failures", async () => {
const fixture = await createFixture();
try {
await setRegistry(fixture, new Map([["kandev@latest", "!ERROR!"]]));
const result = await runPrepare(fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output.should_publish, "false");
assert.match(result.stdout, /Could not resolve npm @latest/);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("prepare safely skips a package registry failure", async () => {
const fixture = await createFixture();
try {
const registry = new Map([
["kandev@latest", "1.2.3"],
[`${packages[1]}@${fixture.version}`, "!ERROR!"],
]);
await setRegistry(fixture, registry);
const result = await runPrepare(fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output.should_publish, "false");
assert.match(result.stdout, /Could not verify/);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("publish revalidates the registry snapshot before delegating", async () => {
const fixture = await createFixture();
try {
await setRegistry(fixture, registryFor(fixture.version, fixture.version));
const result = runScript(
fixture,
"publish",
"--stable-at-start",
"1.2.3",
"--nightly-at-start",
fixture.version,
"--tags-at-start",
tagsSnapshot(fixture.version),
"--version",
fixture.version,
"--assets-dir",
fixture.assetsDir,
);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(
(await readFile(fixture.publishLog, "utf8")).trim().split("\n"),
[
"--version",
fixture.version,
"--dist-tag",
"nightly",
"--assets-dir",
fixture.assetsDir,
],
);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("publish skips when any locked registry value moves", async () => {
const cases = [
["stable baseline", "kandev@latest", "1.2.4"],
["top-level Nightly", "kandev@nightly", "1.2.4-nightly.sha000000000000"],
["runtime tag", `${packages[1]}@nightly`, "1.2.4-nightly.sha000000000000"],
];
for (const [name, spec, movedValue] of cases) {
const fixture = await createFixture();
try {
const registry = registryFor(fixture.version, fixture.version);
registry.set(spec, movedValue);
await setRegistry(fixture, registry);
const result = runScript(
fixture,
"publish",
"--stable-at-start",
"1.2.3",
"--nightly-at-start",
fixture.version,
"--tags-at-start",
tagsSnapshot(fixture.version),
"--version",
fixture.version,
"--assets-dir",
fixture.assetsDir,
);
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
assert.equal(await readFile(fixture.publishLog, "utf8"), "", name);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
}
});
test("publish skips while a stable Git tag is ahead of npm latest", async () => {
const fixture = await createFixture();
try {
git(fixture.root, "tag", "v1.3.0", fixture.mainSha);
await setRegistry(fixture, registryFor(fixture.version, fixture.version));
const result = runScript(
fixture,
"publish",
"--stable-at-start",
"1.2.3",
"--nightly-at-start",
fixture.version,
"--tags-at-start",
tagsSnapshot(fixture.version),
"--version",
fixture.version,
"--assets-dir",
fixture.assetsDir,
);
assert.equal(result.status, 0, result.stderr);
assert.equal(await readFile(fixture.publishLog, "utf8"), "");
assert.match(result.stdout, /stable Git tag v1\.3\.0.*npm @latest 1\.2\.3/);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
import { pathToFileURL } from "node:url";
const STABLE_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
const FULL_GIT_SHA = /^[0-9a-f]{40}$/;
export function nightlyVersion(stableVersion, commitSha) {
const match = STABLE_SEMVER.exec(stableVersion);
if (match === null) {
throw new Error(`Expected a stable SemVer X.Y.Z, received: ${stableVersion}`);
}
if (!FULL_GIT_SHA.test(commitSha)) {
throw new Error("Expected a 40-character lowercase hexadecimal Git commit SHA");
}
const [, major, minor, patch] = match;
return `${major}.${minor}.${BigInt(patch) + 1n}-nightly.sha${commitSha.slice(0, 12)}`;
}
function main(args) {
if (args.length !== 2) {
throw new Error("Usage: nightly-version.mjs <stable-version> <40-character-commit-sha>");
}
process.stdout.write(`${nightlyVersion(args[0], args[1])}\n`);
}
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main(process.argv.slice(2));
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { nightlyVersion } from "./nightly-version.mjs";
const fullSha = "abc123def4567890abc123def4567890abc123de";
test("builds the next patch nightly version from a stable version and commit SHA", () => {
assert.equal(
nightlyVersion("0.82.0", fullSha),
"0.82.1-nightly.shaabc123def456",
);
});
test("keeps leading zeroes in the hexadecimal SHA prefix", () => {
assert.equal(
nightlyVersion("12.34.56", "0000000000017890abc123def4567890abc123de"),
"12.34.57-nightly.sha000000000001",
);
});
test("rejects non-stable or non-canonical base versions", () => {
for (const version of [
"v0.82.0",
"0.82",
"0.82.0-beta.1",
"01.2.3",
"1.02.3",
"1.2.03",
]) {
assert.throws(() => nightlyVersion(version, fullSha), /stable SemVer/);
}
});
test("rejects anything except a full lowercase Git commit SHA", () => {
for (const sha of [
"abc123def456",
fullSha.toUpperCase(),
`${fullSha}0`,
"not-a-sha",
]) {
assert.throws(
() => nightlyVersion("0.82.0", sha),
/40-character lowercase hexadecimal/,
);
}
});
test("prints the nightly version when invoked as a CLI", () => {
const output = execFileSync(process.execPath, [
fileURLToPath(new URL("./nightly-version.mjs", import.meta.url)),
"0.82.0",
fullSha,
]);
assert.equal(output.toString(), "0.82.1-nightly.shaabc123def456\n");
});
test("exits non-zero with usage when invoked without arguments", () => {
const result = spawnSync(
process.execPath,
[fileURLToPath(new URL("./nightly-version.mjs", import.meta.url))],
{ encoding: "utf8" },
);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Usage:/);
});
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Shared platform and package inventory for npm packaging, tests, preflight,
# and publication. Launcher mappings and package metadata remain independently
# checked against this inventory by release-config.test.ts.
RUNTIME_PLATFORMS=(
"linux-x64"
"linux-arm64"
"macos-x64"
"macos-arm64"
"windows-x64"
)
declare -A RUNTIME_PACKAGE_BY_PLATFORM=(
["linux-x64"]="@kdlbs/runtime-linux-x64"
["linux-arm64"]="@kdlbs/runtime-linux-arm64"
["macos-x64"]="@kdlbs/runtime-darwin-x64"
["macos-arm64"]="@kdlbs/runtime-darwin-arm64"
["windows-x64"]="@kdlbs/runtime-win32-x64"
)
RUNTIME_PACKAGES=()
for runtime_platform in "${RUNTIME_PLATFORMS[@]}"; do
RUNTIME_PACKAGES+=("${RUNTIME_PACKAGE_BY_PLATFORM[$runtime_platform]}")
done
unset runtime_platform
NIGHTLY_PACKAGES=("kandev" "${RUNTIME_PACKAGES[@]}")
readonly -a RUNTIME_PLATFORMS RUNTIME_PACKAGES NIGHTLY_PACKAGES
readonly -A RUNTIME_PACKAGE_BY_PLATFORM
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Resolve one npm package/version spec. A missing package, version, or dist-tag
# is a successful empty result; registry and network failures are errors.
set -euo pipefail
if [[ "$#" -ne 1 || -z "$1" ]]; then
echo "Usage: $0 <package-spec>" >&2
exit 2
fi
SPEC="$1"
ERROR_FILE="$(mktemp)"
trap 'rm -f "$ERROR_FILE"' EXIT
MAX_ATTEMPTS=3
RETRY_DELAY_SECONDS=2
for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
if VERSION="$(npm view "$SPEC" version --loglevel=error 2>"$ERROR_FILE")"; then
printf '%s\n' "$VERSION"
exit 0
fi
if grep -qiE '^npm (error|ERR!) (code E404|404([[:space:]]|$)|No match found for version|[^[:space:]]+ is not in this registry)' "$ERROR_FILE"; then
exit 0
fi
if ((attempt < MAX_ATTEMPTS)); then
echo "npm view attempt $attempt/$MAX_ATTEMPTS failed for $SPEC; retrying" >&2
sleep "$RETRY_DELAY_SECONDS"
fi
done
echo "npm view failed for $SPEC" >&2
exit 1
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const repoRoot = path.resolve(import.meta.dirname, "../..");
const script = path.join(repoRoot, "scripts/release/npm-view-version.sh");
async function runView(mode) {
const fixtureDir = await mkdtemp(path.join(tmpdir(), "kandev-npm-view-"));
const npm = path.join(fixtureDir, "npm");
const sleep = path.join(fixtureDir, "sleep");
const countFile = path.join(fixtureDir, "attempts");
const sleepFile = path.join(fixtureDir, "sleeps");
await writeFile(
npm,
`#!/usr/bin/env bash
count=0
if [[ -f "$MOCK_NPM_COUNT_FILE" ]]; then read -r count < "$MOCK_NPM_COUNT_FILE"; fi
count=$((count + 1))
printf '%s\n' "$count" > "$MOCK_NPM_COUNT_FILE"
case "$MOCK_NPM_MODE" in
found) printf '%s\\n' '1.2.4-nightly.shaabc123def456' ;;
missing) printf '%s\\n' 'npm error code E404' 'npm error No match found for version nightly' >&2; exit 1 ;;
transient)
if (( count < 3 )); then
printf '%s\\n' 'npm error code EAI_AGAIN' >&2
exit 1
fi
printf '%s\\n' '1.2.4-nightly.shaabc123def456'
;;
failure) printf '%s\\n' 'npm error code EAI_AGAIN' 'registry-secret-detail' >&2; exit 1 ;;
misleading) printf '%s\\n' 'npm error code EAI_AGAIN' 'npm error request to https://registry.test/e404/404-Not-Found/is-not-in-this-registry failed' >&2; exit 1 ;;
esac
`,
);
await writeFile(sleepFile, "");
await writeFile(
sleep,
'#!/usr/bin/env bash\nprintf \'%s\\n\' "$1" >> "$MOCK_SLEEP_FILE"\n',
);
await chmod(npm, 0o755);
await chmod(sleep, 0o755);
try {
const result = spawnSync("bash", [script, "kandev@nightly"], {
encoding: "utf8",
env: {
...process.env,
MOCK_NPM_COUNT_FILE: countFile,
MOCK_NPM_MODE: mode,
MOCK_SLEEP_FILE: sleepFile,
PATH: `${fixtureDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
});
result.attempts = Number(await readFile(countFile, "utf8"));
result.sleeps = (await readFile(sleepFile, "utf8"))
.trim()
.split("\n")
.filter(Boolean);
return result;
} finally {
await rm(fixtureDir, { recursive: true, force: true });
}
}
test("prints a resolved npm version", async () => {
const result = await runView("found");
assert.equal(result.status, 0);
assert.equal(result.stdout.trim(), "1.2.4-nightly.shaabc123def456");
assert.deepEqual(result.sleeps, []);
});
test("treats a missing version or dist-tag as an empty result", async () => {
const result = await runView("missing");
assert.equal(result.status, 0);
assert.equal(result.stdout, "");
assert.equal(result.stderr, "");
assert.equal(result.attempts, 1);
assert.deepEqual(result.sleeps, []);
});
test("retries transient registry failures before succeeding", async () => {
const result = await runView("transient");
assert.equal(result.status, 0);
assert.equal(result.stdout.trim(), "1.2.4-nightly.shaabc123def456");
assert.equal(result.attempts, 3);
assert.deepEqual(result.sleeps, ["2", "2"]);
});
test("fails closed on registry and network errors", async () => {
const result = await runView("failure");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /npm view failed for kandev@nightly/);
assert.doesNotMatch(result.stderr, /registry-secret-detail/);
assert.equal(result.attempts, 3);
assert.deepEqual(result.sleeps, ["2", "2"]);
});
test("does not mistake missing-looking request URLs for npm E404 diagnostics", async () => {
const result = await runView("misleading");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /npm view failed for kandev@nightly/);
assert.equal(result.attempts, 3);
assert.deepEqual(result.sleeps, ["2", "2"]);
});
+18 -35
View File
@@ -13,55 +13,29 @@
# release-assets-dir Directory containing kandev-*.tar.gz files
# output-dir Directory where per-platform npm packages are written
#
# Output (one directory per platform ready for npm publish):
# <output-dir>/@kdlbs/runtime-linux-x64/
# <output-dir>/@kdlbs/runtime-linux-arm64/
# <output-dir>/@kdlbs/runtime-darwin-x64/
# <output-dir>/@kdlbs/runtime-darwin-arm64/
# <output-dir>/@kdlbs/runtime-win32-x64/
# Output: one scoped directory per package in npm-packages.sh.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=scripts/release/npm-packages.sh
source "$ROOT_DIR/scripts/release/npm-packages.sh"
VERSION="${1:?Usage: $0 <version> <release-assets-dir> <output-dir>}"
ASSETS_DIR="${2:?Usage: $0 <version> <release-assets-dir> <output-dir>}"
OUT_DIR="${3:?Usage: $0 <version> <release-assets-dir> <output-dir>}"
# Maps platform dir name → npm package name + npm os/cpu fields
declare -A PLATFORM_TO_PACKAGE=(
["linux-x64"]="@kdlbs/runtime-linux-x64"
["linux-arm64"]="@kdlbs/runtime-linux-arm64"
["macos-x64"]="@kdlbs/runtime-darwin-x64"
["macos-arm64"]="@kdlbs/runtime-darwin-arm64"
["windows-x64"]="@kdlbs/runtime-win32-x64"
)
declare -A PLATFORM_TO_OS=(
["linux-x64"]='["linux"]'
["linux-arm64"]='["linux"]'
["macos-x64"]='["darwin"]'
["macos-arm64"]='["darwin"]'
["windows-x64"]='["win32"]'
)
declare -A PLATFORM_TO_CPU=(
["linux-x64"]='["x64"]'
["linux-arm64"]='["arm64"]'
["macos-x64"]='["x64"]'
["macos-arm64"]='["arm64"]'
["windows-x64"]='["x64"]'
)
echo "Packaging npm runtime packages for version $VERSION..."
echo " assets dir: $ASSETS_DIR"
echo " output dir: $OUT_DIR"
for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do
for platform in "${RUNTIME_PLATFORMS[@]}"; do
archive="$ASSETS_DIR/kandev-${platform}.tar.gz"
if [[ ! -f "$archive" ]]; then
echo "Error: missing archive $archive" >&2
exit 1
fi
package_name="${PLATFORM_TO_PACKAGE[$platform]}"
package_name="${RUNTIME_PACKAGE_BY_PLATFORM[$platform]}"
# @kdlbs/runtime-linux-x64 → scope=@kdlbs, name=runtime-linux-x64
scope_dir="${package_name%%/*}" # @kdlbs
pkg_dir="${package_name##*/}" # runtime-linux-x64
@@ -85,8 +59,17 @@ for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do
cp -R "$bundle_root/bin" "$pkg_out/bin"
rm -rf "$local_tmp"
os_field="${PLATFORM_TO_OS[$platform]}"
cpu_field="${PLATFORM_TO_CPU[$platform]}"
case "$platform" in
linux-*) os_field='["linux"]' ;;
macos-*) os_field='["darwin"]' ;;
windows-*) os_field='["win32"]' ;;
*) echo "Error: unsupported runtime platform $platform" >&2; exit 1 ;;
esac
case "$platform" in
*-x64) cpu_field='["x64"]' ;;
*-arm64) cpu_field='["arm64"]' ;;
*) echo "Error: unsupported runtime architecture $platform" >&2; exit 1 ;;
esac
# Note: `repository` is required when publishing with `npm publish --provenance`.
# npm's sigstore attestation embeds repo info from the OIDC token and refuses
+137 -55
View File
@@ -7,23 +7,19 @@
# short-lived publish token. No NPM_TOKEN secret is needed.
#
# Prerequisites:
# - GitHub release assets for <tag> must already exist (verified before publishing).
# - Five runtime archives from either a GitHub release or a local directory.
# - Running inside GitHub Actions with `id-token: write` permission set on
# the publish-npm job. (npm publish from a local shell will fall back to
# classic auth — but tokens are not the recommended path going forward.)
#
# Usage:
# publish-npm.sh <version> <tag>
#
# Arguments:
# version SemVer string (e.g. 0.17.0)
# tag Git tag (e.g. v0.17.0) — used to verify GitHub release assets exist
# publish-npm.sh --version <semver> --dist-tag <latest|nightly> \
# (--release-tag <git-tag> | --assets-dir <path>)
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
VERSION="${1:?Usage: $0 <version> <tag>}"
TAG="${2:?Usage: $0 <version> <tag>}"
# shellcheck source=scripts/release/npm-packages.sh
source "$ROOT_DIR/scripts/release/npm-packages.sh"
bold() { printf '\033[1m%s\033[0m' "$*"; }
green() { printf '\033[32m%s\033[0m' "$*"; }
@@ -33,15 +29,8 @@ yellow(){ printf '\033[33m%s\033[0m' "$*"; }
log() { echo " >> $*"; }
log_ok() { echo " $(green "ok") $*"; }
package_already_published() {
local pkg="$1"
npm view "${pkg}@${VERSION}" version --silent >/dev/null 2>&1
}
record_already_published() {
local pkg="$1"
echo " $(yellow "skip") $pkg@$VERSION already published (treated as idempotent success)" >&2
ALREADY_PUBLISHED+=("$pkg")
usage() {
echo "Usage: $0 --version <semver> --dist-tag <latest|nightly> (--release-tag <git-tag> | --assets-dir <path>)" >&2
}
die() {
@@ -49,33 +38,133 @@ die() {
exit 1
}
# -- Verify GitHub release assets exist before publishing ---------------------
npm_view_version() {
bash "$ROOT_DIR/scripts/release/npm-view-version.sh" "$1"
}
REQUIRED_PLATFORMS=(linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64)
VERSION=""
DIST_TAG=""
RELEASE_TAG=""
SOURCE_ASSETS_DIR=""
log "Verifying GitHub release assets exist for $TAG..."
for platform in "${REQUIRED_PLATFORMS[@]}"; do
asset="kandev-${platform}.tar.gz"
if ! gh release view "$TAG" --json assets --jq ".assets[].name" 2>/dev/null | grep -q "^${asset}$"; then
die "GitHub release asset missing: $asset in release $TAG. Run release workflow first."
fi
while [[ "$#" -gt 0 ]]; do
case "$1" in
--version)
[[ "$#" -ge 2 ]] || die "--version requires a value"
VERSION="$2"
shift 2
;;
--dist-tag)
[[ "$#" -ge 2 ]] || die "--dist-tag requires a value"
DIST_TAG="$2"
shift 2
;;
--release-tag)
[[ "$#" -ge 2 ]] || die "--release-tag requires a value"
RELEASE_TAG="$2"
shift 2
;;
--assets-dir)
[[ "$#" -ge 2 ]] || die "--assets-dir requires a value"
SOURCE_ASSETS_DIR="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
usage
die "unknown argument: $1"
;;
esac
done
log_ok "All 5 platform assets present in GitHub release $TAG"
# -- Download release assets for packaging ------------------------------------
[[ -n "$VERSION" ]] || die "--version is required"
[[ -n "$DIST_TAG" ]] || die "--dist-tag is required"
case "$DIST_TAG" in
latest)
[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \
die "--version must be stable X.Y.Z for --dist-tag latest"
;;
nightly)
[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-nightly\.sha[0-9a-f]{12}$ ]] || \
die "--version must be X.Y.Z-nightly.sha<12-hex> for --dist-tag nightly"
;;
*)
die "--dist-tag must be latest or nightly"
;;
esac
if [[ -n "$RELEASE_TAG" && -n "$SOURCE_ASSETS_DIR" ]]; then
die "provide exactly one asset source: --release-tag or --assets-dir"
elif [[ -z "$RELEASE_TAG" && -z "$SOURCE_ASSETS_DIR" ]]; then
die "provide exactly one asset source: --release-tag or --assets-dir"
elif [[ "$DIST_TAG" == "latest" && -z "$RELEASE_TAG" ]]; then
die "--dist-tag latest requires --release-tag"
elif [[ "$DIST_TAG" == "nightly" && -z "$SOURCE_ASSETS_DIR" ]]; then
die "--dist-tag nightly requires --assets-dir"
fi
package_already_published() {
local pkg="$1"
local published_version
if ! published_version="$(npm_view_version "${pkg}@${VERSION}")"; then
die "could not verify whether $pkg@$VERSION is already published"
fi
[[ -n "$published_version" ]]
}
record_already_published() {
local pkg="$1"
if [[ "$DIST_TAG" == "nightly" ]]; then
local tagged_version
if ! tagged_version="$(npm_view_version "${pkg}@${DIST_TAG}")"; then
die "could not verify $pkg@$DIST_TAG for idempotent publication"
fi
if [[ "$tagged_version" != "$VERSION" ]]; then
die "$pkg@$VERSION exists, but $pkg@$DIST_TAG resolves to '${tagged_version:-nothing}'; refusing idempotent success"
fi
fi
echo " $(yellow "skip") $pkg@$VERSION already published (treated as idempotent success)" >&2
ALREADY_PUBLISHED+=("$pkg")
}
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
ASSETS_DIR="$WORK_DIR/assets"
mkdir -p "$ASSETS_DIR"
CLI_PACKAGE_JSON="$ROOT_DIR/apps/cli/package.json"
CLI_PACKAGE_BACKUP="$WORK_DIR/cli-package.json"
cp "$CLI_PACKAGE_JSON" "$CLI_PACKAGE_BACKUP"
cleanup() {
if [[ -f "$CLI_PACKAGE_BACKUP" ]]; then
cp "$CLI_PACKAGE_BACKUP" "$CLI_PACKAGE_JSON"
fi
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
log "Downloading release assets for $TAG..."
for platform in "${REQUIRED_PLATFORMS[@]}"; do
# -- Resolve and verify release assets ----------------------------------------
if [[ -n "$RELEASE_TAG" ]]; then
ASSETS_DIR="$WORK_DIR/assets"
mkdir -p "$ASSETS_DIR"
log "Downloading release assets for $RELEASE_TAG..."
for platform in "${RUNTIME_PLATFORMS[@]}"; do
asset="kandev-${platform}.tar.gz"
log " downloading $asset..."
gh release download "$RELEASE_TAG" --pattern "$asset" --dir "$ASSETS_DIR" || \
die "GitHub release asset missing: $asset in release $RELEASE_TAG"
done
else
ASSETS_DIR="$SOURCE_ASSETS_DIR"
[[ -d "$ASSETS_DIR" ]] || die "asset directory does not exist: $ASSETS_DIR"
log "Using release assets from $ASSETS_DIR..."
fi
for platform in "${RUNTIME_PLATFORMS[@]}"; do
asset="kandev-${platform}.tar.gz"
log " downloading $asset..."
gh release download "$TAG" --pattern "$asset" --dir "$ASSETS_DIR"
[[ -f "$ASSETS_DIR/$asset" ]] || die "release asset missing: $ASSETS_DIR/$asset"
done
log_ok "Assets downloaded to $ASSETS_DIR"
log_ok "All 5 platform assets present"
# -- Generate npm runtime packages --------------------------------------------
@@ -84,14 +173,6 @@ bash "$ROOT_DIR/scripts/release/package-npm-runtime.sh" "$VERSION" "$ASSETS_DIR"
# -- Publish @kdlbs/runtime-* packages first ---------------------------------
RUNTIME_PACKAGES=(
"@kdlbs/runtime-linux-x64"
"@kdlbs/runtime-linux-arm64"
"@kdlbs/runtime-darwin-x64"
"@kdlbs/runtime-darwin-arm64"
"@kdlbs/runtime-win32-x64"
)
echo
echo "$(bold "Publishing @kdlbs/runtime-* packages...")"
FAILED_PACKAGES=()
@@ -117,7 +198,7 @@ for pkg in "${RUNTIME_PACKAGES[@]}"; do
# Capture full npm output so we can show the real error on failure rather
# than just a generic warning. Distinguish "already published" (idempotent
# case — fine) from real failures (must abort).
if output="$(cd "$pkg_dir" && npm publish --access public --provenance 2>&1)"; then
if output="$(cd "$pkg_dir" && npm publish --access public --provenance --tag "$DIST_TAG" 2>&1)"; then
log_ok "$pkg@$VERSION published"
elif echo "$output" | grep -qE "EPUBLISHCONFLICT|cannot publish over the previously published versions|You cannot publish over"; then
record_already_published "$pkg"
@@ -151,19 +232,20 @@ fi
# package, we want optionalDependencies to point at @kdlbs/runtime-*@VERSION
# so users get matching runtime bundles. The runtime packages were just
# published above, so this version exists on npm now.
log "Pinning optionalDependencies to $VERSION before publishing main package..."
node -e "
const fs = require('fs');
const path = '$ROOT_DIR/apps/cli/package.json';
const pkg = JSON.parse(fs.readFileSync(path, 'utf8'));
log "Setting the launcher version and optionalDependencies to $VERSION..."
node - "$CLI_PACKAGE_JSON" "$VERSION" <<'NODE'
const fs = require("fs");
const [path, version] = process.argv.slice(2);
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = version;
if (pkg.optionalDependencies) {
for (const k of Object.keys(pkg.optionalDependencies)) {
pkg.optionalDependencies[k] = '$VERSION';
pkg.optionalDependencies[k] = version;
}
}
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
"
log_ok "optionalDependencies pinned to $VERSION"
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
NODE
log_ok "Launcher metadata pinned to $VERSION"
# -- Publish main kandev package ----------------------------------------------
@@ -174,7 +256,7 @@ echo "$(bold "Publishing kandev@$VERSION...")"
# `prepublishOnly` (in package.json) runs `pnpm build` automatically.
if package_already_published "kandev"; then
record_already_published "kandev"
elif main_output="$(cd "$ROOT_DIR/apps/cli" && npm publish --access public --provenance 2>&1)"; then
elif main_output="$(cd "$ROOT_DIR/apps/cli" && npm publish --access public --provenance --tag "$DIST_TAG" 2>&1)"; then
log_ok "kandev@$VERSION published"
elif echo "$main_output" | grep -qE "EPUBLISHCONFLICT|cannot publish over the previously published versions|You cannot publish over"; then
record_already_published "kandev"
+356
View File
@@ -0,0 +1,356 @@
import assert from "node:assert/strict";
import {
chmod,
copyFile,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const sourceRoot = path.resolve(import.meta.dirname, "../..");
async function loadRuntimeInventory() {
const inventoryPath = path.join(
sourceRoot,
"scripts/release/npm-packages.sh",
);
const source = await readFile(inventoryPath, "utf8");
const platformBlock = source.match(/RUNTIME_PLATFORMS=\(([\s\S]*?)\n\)/);
assert.ok(platformBlock, "runtime platform inventory is missing");
const platforms = [...platformBlock[1].matchAll(/"([^"]+)"/g)].map(
(match) => match[1],
);
const packageByPlatform = new Map(
[...source.matchAll(/^\s*\["([^"]+)"\]="([^"]+)"$/gm)].map((match) => [
match[1],
match[2],
]),
);
return platforms.map((platform) => {
const packageName = packageByPlatform.get(platform);
assert.ok(packageName, `runtime package is missing for ${platform}`);
return { platform, packageName };
});
}
const runtimeInventory = await loadRuntimeInventory();
const runtimePackages = runtimeInventory.map(({ packageName }) => packageName);
const assets = runtimeInventory.map(
({ platform }) => `kandev-${platform}.tar.gz`,
);
const releases = [
{ name: "Stable", version: "1.2.3", distTag: "latest" },
{
name: "Nightly",
version: "1.2.4-nightly.shaabcdef123456",
distTag: "nightly",
},
];
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), "kandev-publish-npm-"));
const releaseDir = path.join(root, "scripts/release");
const cliDir = path.join(root, "apps/cli");
const assetsDir = path.join(root, "assets");
const binDir = path.join(root, "bin");
const publishLog = path.join(root, "publish.log");
const publishedMetadata = path.join(root, "published-package.json");
await Promise.all([
mkdir(releaseDir, { recursive: true }),
mkdir(cliDir, { recursive: true }),
mkdir(assetsDir, { recursive: true }),
mkdir(binDir, { recursive: true }),
]);
await Promise.all(
["publish-npm.sh", "npm-packages.sh", "npm-view-version.sh"].map((name) =>
copyFile(
path.join(sourceRoot, "scripts/release", name),
path.join(releaseDir, name),
),
),
);
await Promise.all(
assets.map((name) => writeFile(path.join(assetsDir, name), "fixture")),
);
await Promise.all([
writeFile(publishLog, ""),
writeFile(publishedMetadata, ""),
]);
const originalPackageJSON = `${JSON.stringify(
{
name: "kandev",
version: "0.0.0-bootstrap",
optionalDependencies: Object.fromEntries(
runtimePackages.map((name) => [name, "0.0.0-bootstrap"]),
),
},
null,
2,
)}\n`;
await writeFile(path.join(cliDir, "package.json"), originalPackageJSON);
const runtimePackageLines = runtimePackages
.map((name) => `${name} ${name.slice("@kdlbs/".length)}`)
.join("\n");
await writeExecutable(
path.join(releaseDir, "package-npm-runtime.sh"),
`#!/usr/bin/env bash
set -euo pipefail
version="$1"
out="$3"
while read -r package name; do
dir="$out/@kdlbs/$name"
mkdir -p "$dir"
printf '{"name":"%s","version":"%s"}\n' "$package" "$version" > "$dir/package.json"
done <<'PACKAGES'
${runtimePackageLines}
PACKAGES
`,
);
await writeExecutable(
path.join(binDir, "npm"),
`#!/usr/bin/env bash
set -euo pipefail
case "$1" in
view)
spec="$2"
if [[ "$MOCK_NPM_MODE" == "registry-failure" ]]; then
printf '%s\n' 'npm error code EAI_AGAIN' >&2
exit 1
fi
if [[ "$MOCK_NPM_MODE" == "fresh" || "$MOCK_NPM_MODE" == "runtime-publish-failure" ]]; then
printf '%s\n' 'npm error code E404' >&2
exit 1
fi
if [[ "$MOCK_NPM_MODE" == main-conflict* && "$spec" == "kandev@$MOCK_NPM_VERSION" ]]; then
printf '%s\n' 'npm error code E404' >&2
exit 1
fi
if [[ "$MOCK_NPM_MODE" == "main-conflict" && "$spec" == "kandev@nightly" ]]; then
printf '%s\n' '1.2.3-nightly.sha000000000000'
exit 0
fi
printf '%s\n' "$MOCK_NPM_VERSION"
;;
publish)
package="$(node -p "require('./package.json').name")"
printf '%s\n' "$package" >> "$MOCK_NPM_PUBLISH_LOG"
if [[ "$package" == "kandev" ]]; then
cp package.json "$MOCK_NPM_PUBLISHED_METADATA"
fi
if [[ "$MOCK_NPM_MODE" == "runtime-publish-failure" && "$package" == "$MOCK_RUNTIME_FAILURE_PACKAGE" ]]; then
printf '%s\n' 'npm error code E500' >&2
exit 1
fi
if [[ "$MOCK_NPM_MODE" == main-conflict* && "$package" == "kandev" ]]; then
printf '%s\n' 'npm error code EPUBLISHCONFLICT' >&2
exit 1
fi
;;
*)
printf 'unexpected npm command: %s\n' "$*" >&2
exit 2
;;
esac
`,
);
await writeExecutable(
path.join(binDir, "sleep"),
"#!/usr/bin/env bash\nexit 0\n",
);
await writeExecutable(
path.join(binDir, "gh"),
`#!/usr/bin/env bash
set -euo pipefail
pattern=""
directory=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
--pattern) pattern="$2"; shift 2 ;;
--dir) directory="$2"; shift 2 ;;
*) shift ;;
esac
done
mkdir -p "$directory"
: > "$directory/$pattern"
`,
);
return {
root,
releaseDir,
cliDir,
assetsDir,
binDir,
publishLog,
publishedMetadata,
originalPackageJSON,
};
}
async function writeExecutable(file, content) {
await writeFile(file, content);
await chmod(file, 0o755);
}
async function runPublish(fixture, { version, distTag, mode }) {
const args = [
path.join(fixture.releaseDir, "publish-npm.sh"),
"--version",
version,
"--dist-tag",
distTag,
];
if (distTag === "latest") {
args.push("--release-tag", `v${version}`);
} else {
args.push("--assets-dir", fixture.assetsDir);
}
const result = spawnSync("bash", args, {
encoding: "utf8",
cwd: fixture.root,
env: {
...process.env,
MOCK_NPM_MODE: mode,
MOCK_NPM_PUBLISH_LOG: fixture.publishLog,
MOCK_NPM_PUBLISHED_METADATA: fixture.publishedMetadata,
MOCK_RUNTIME_FAILURE_PACKAGE: runtimePackages[1],
MOCK_NPM_VERSION: version,
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
});
return {
...result,
packageJSON: await readFile(
path.join(fixture.cliDir, "package.json"),
"utf8",
),
publishedMetadata: await readFile(fixture.publishedMetadata, "utf8"),
published: (await readFile(fixture.publishLog, "utf8"))
.trim()
.split("\n")
.filter(Boolean),
};
}
for (const release of releases) {
test(`${release.name} publication is idempotent when every exact package already exists`, async () => {
const fixture = await createFixture();
try {
const result = await runPublish(fixture, {
...release,
mode: "all-published",
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.published, []);
assert.equal(result.packageJSON, fixture.originalPackageJSON);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
}
for (const release of releases) {
test(`a fresh ${release.name} publishes every package with pinned launcher metadata`, async () => {
const fixture = await createFixture();
try {
const result = await runPublish(fixture, { ...release, mode: "fresh" });
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.published, [...runtimePackages, "kandev"]);
const metadata = JSON.parse(result.publishedMetadata);
assert.equal(metadata.version, release.version);
assert.deepEqual(
metadata.optionalDependencies,
Object.fromEntries(
runtimePackages.map((name) => [name, release.version]),
),
);
assert.equal(result.packageJSON, fixture.originalPackageJSON);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
}
test("a conflicting Nightly launcher publish fails closed and restores package metadata", async () => {
const fixture = await createFixture();
try {
const result = await runPublish(fixture, {
version: "1.2.4-nightly.shaabcdef123456",
distTag: "nightly",
mode: "main-conflict",
});
assert.notEqual(result.status, 0);
assert.match(
result.stderr,
/kandev@nightly resolves to '1\.2\.3-nightly\.sha000000000000'/,
);
assert.deepEqual(result.published, ["kandev"]);
assert.equal(result.packageJSON, fixture.originalPackageJSON);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("a matching Nightly conflict converges as an idempotent success", async () => {
const fixture = await createFixture();
try {
const result = await runPublish(fixture, {
version: "1.2.4-nightly.shaabcdef123456",
distTag: "nightly",
mode: "main-conflict-matching",
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.published, ["kandev"]);
assert.equal(result.packageJSON, fixture.originalPackageJSON);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("a transient registry failure aborts before publishing and preserves metadata", async () => {
const fixture = await createFixture();
try {
const result = await runPublish(fixture, {
version: "1.2.4-nightly.shaabcdef123456",
distTag: "nightly",
mode: "registry-failure",
});
assert.notEqual(result.status, 0);
assert.match(
result.stderr,
/could not verify whether @kdlbs\/runtime-linux-x64/,
);
assert.deepEqual(result.published, []);
assert.equal(result.packageJSON, fixture.originalPackageJSON);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("a runtime publish failure withholds the launcher", async () => {
const fixture = await createFixture();
try {
const result = await runPublish(fixture, {
version: "1.2.4-nightly.shaabcdef123456",
distTag: "nightly",
mode: "runtime-publish-failure",
});
assert.notEqual(result.status, 0);
assert.deepEqual(result.published, runtimePackages);
assert.ok(
result.stderr.includes(`Failed to publish ${runtimePackages[1]}`),
);
assert.match(result.stderr, /Refusing to publish main kandev/);
assert.equal(result.packageJSON, fixture.originalPackageJSON);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});