feat: add npm nightly release channel (#2126)
This commit is contained in:
@@ -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
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:/);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user