diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05e9563..55de6f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ semantic versioning; the version is a conceptual tag (no git tag is created).
## Unreleased
+- **Feature: `mkS390xNpmPublishMulti` (cluster #192).** A multi-version npm
+ builder mirroring the PyPI multi one: publishes a fixed list of
+ `{ version; file; distTag? }` per tag, each staged into its own dir and
+ `npm publish`ed with its dist-tag (idempotent — "already exists" == success).
+ `file` may be a `.node` addon OR a plain binary, and `packageJson` (with a
+ `$VERSION` the stage heredoc expands) declares the shape (`main` vs `bin`), so
+ it covers both nextjs-swc (16.1.6 `@latest` + 15.2.0 `@next15`) and sentry-cli
+ (a binary published as an npm package at two versions). Shared
+ `parity_npm_publish_dir` helper added to `ci/parity-lib.sh`.
- **Feature: `mkPyPiWheelPublishMulti` (cluster #197).** A multi-version PyPI
builder that publishes a fixed list of `{ version; wheel; }` per tag instead of
just the default — the pre-parity behaviour several `*-s390x` repos rely on.
diff --git a/ci/parity-lib.sh b/ci/parity-lib.sh
index 39223fc..6743448 100644
--- a/ci/parity-lib.sh
+++ b/ci/parity-lib.sh
@@ -173,6 +173,30 @@ parity_wheel_version() {
basename "$1" | cut -d- -f2
}
+# ---------------------------------------------------------------------------
+# `npm publish` a ready-to-publish package directory to the Gitea npm registry.
+# Idempotent: an "already exists"/409 is treated as success. Never echoes the
+# token (the output is sed-redacted). Args:
[]. Used by
+# both the single- and multi-version npm publish apps.
+# ---------------------------------------------------------------------------
+parity_npm_publish_dir() {
+ local dir="$1" tok="$2" dt="${3:-latest}" reg out rc
+ reg="https://$PARITY_REGISTRY_HOST/api/packages/$PARITY_REGISTRY_OWNER/npm/"
+ out="$(cd "$dir" && npm publish --registry="$reg" \
+ "--//$PARITY_REGISTRY_HOST/api/packages/$PARITY_REGISTRY_OWNER/npm/:_authToken=$tok" \
+ --tag "$dt" 2>&1)"
+ rc=$?
+ printf '%s\n' "$out" | sed "s/$tok/***REDACTED***/g"
+ if [ "$rc" -ne 0 ]; then
+ if printf '%s' "$out" | grep -qiE 'already exists|409|conflict'; then
+ echo "npm: $(basename "$dir") already published — idempotent success."
+ return 0
+ fi
+ return "$rc"
+ fi
+ echo "Published $(basename "$dir") (dist-tag $dt)"
+}
+
# ---------------------------------------------------------------------------
# Stage directory helpers. The convention: each staged artifact is written to
# ${PARITY_STAGE_DIR}// alongside a one-line meta file recording the
diff --git a/flake.nix b/flake.nix
index 474414e..591c446 100644
--- a/flake.nix
+++ b/flake.nix
@@ -34,6 +34,7 @@
mkPyPiWheelPublish = wrap "mkPyPiWheelPublish";
mkPyPiWheelPublishMulti = wrap "mkPyPiWheelPublishMulti";
mkS390xNpmPublish = wrap "mkS390xNpmPublish";
+ mkS390xNpmPublishMulti = wrap "mkS390xNpmPublishMulti";
mkGenericBinaryPublish = wrap "mkGenericBinaryPublish";
mkNix2ContainerPublish = wrap "mkNix2ContainerPublish";
mkGoBinaryPublish = wrap "mkGoBinaryPublish";
diff --git a/lib/builders.nix b/lib/builders.nix
index 13d7aa9..6269e1f 100644
--- a/lib/builders.nix
+++ b/lib/builders.nix
@@ -355,6 +355,121 @@ let
};
};
+ # =========================================================================
+ # s390x npm native addon / binary, MULTI-version (cluster #192). Publishes a
+ # fixed list of { version; file; distTag? } per tag — the behaviour repos like
+ # nextjs-swc (16.1.6 @latest + 15.2.0 @next15) and sentry-cli (3.2.2 + 2.38.2)
+ # rely on. `file` may be a .node addon OR a plain binary; `fileName` is its name
+ # inside the package and `packageJson` (with a $VERSION the stage heredoc
+ # expands) declares the shape (a `main` for an addon, a `bin` for an exe). Each
+ # version is staged into its own dir and published with its dist-tag; idempotent
+ # (npm "already exists" == success). No dev-tag guard: explicit fixed version
+ # list, gated by the dry-run default.
+ # Args: { pname, versions = [ { version; file; distTag ? "latest"; } ],
+ # fileName, packageJson, arch ? "s390x", registry* }
+ # =========================================================================
+ mkS390xNpmPublishMulti =
+ {
+ pname,
+ versions,
+ fileName,
+ packageJson,
+ arch ? "s390x",
+ registryHost ? "git.oleks.space",
+ registryOwner ? "oleks",
+ passEntry ? "infra/gitea/personal_access_token_packages_rw",
+ }:
+ let
+ head = preamble { inherit registryHost registryOwner passEntry; };
+ nVersions = builtins.length versions;
+ # One stage block per version: its own dir, the file, a version-stamped
+ # package.json (the <"$vd/.disttag"
+ VERSION=${lib.escapeShellArg e.version}
+ cat >"$vd/package.json" <"$d/.parity-meta"
+ echo " staged ${toString nVersions} version dir(s) under $d"
+ '';
+ stage = pkgs.writeShellApplication {
+ name = "stage-${arch}";
+ runtimeInputs = baseInputs ++ [ pkgs.nodejs ];
+ text = stageText;
+ };
+ dryList = ''
+ echo "[dry-run] would 'npm publish' these ${pname} versions -> https://$PARITY_REGISTRY_HOST/api/packages/$PARITY_REGISTRY_OWNER/npm/:"
+ for vd in "$d"/*/; do
+ [ -f "$vd/package.json" ] || continue
+ echo " - $(basename "$vd") (dist-tag $(cat "$vd/.disttag"))"
+ done
+ echo "[dry-run] re-run with --publish / PUBLISH=1 to push."
+ '';
+ uploadLoop = ''
+ tok="$(parity_resolve_token)"
+ parity_registry_preflight
+ rc=0
+ for vd in "$d"/*/; do
+ [ -f "$vd/package.json" ] || continue
+ parity_npm_publish_dir "$vd" "$tok" "$(cat "$vd/.disttag")" || rc=1
+ done
+ exit "$rc"
+ '';
+ publish = pkgs.writeShellApplication {
+ name = "publish-${arch}";
+ runtimeInputs = baseInputs ++ [ pkgs.nodejs ];
+ text = ''
+ ${head}
+ parity_parse_args "Build + publish all ${toString nVersions} ${pname} ${arch} npm versions to the Gitea npm registry" "$@"
+ ${lib.getExe stage}
+ d="$(parity_stage_path ${lib.escapeShellArg arch})"
+ if [ "$PARITY_PUBLISH" != "1" ]; then
+ ${dryList}
+ exit 0
+ fi
+ ${uploadLoop}
+ '';
+ };
+ pushStaged = pkgs.writeShellApplication {
+ name = "push-staged";
+ runtimeInputs = baseInputs ++ [ pkgs.nodejs ];
+ text = ''
+ ${head}
+ parity_parse_args "Replay the staged ${pname} ${arch} npm versions from .parity-stage" "$@"
+ d="$(parity_stage_path ${lib.escapeShellArg arch})"
+ if ! find "$d" -mindepth 2 -name package.json -print -quit | grep -q .; then
+ echo "BLOCKER(no-stage): no staged versions under $d — run 'nix run .#stage-${arch}' first." >&2
+ exit 1
+ fi
+ if [ "$PARITY_PUBLISH" != "1" ]; then
+ ${dryList}
+ exit 0
+ fi
+ ${uploadLoop}
+ '';
+ };
+ in
+ {
+ "stage-${arch}" =
+ mkApp stage "Stage all ${nVersions} ${pname} ${arch} npm versions into .parity-stage (no registry contact).";
+ "publish-${arch}" =
+ mkApp publish "Stage + publish all ${pname} ${arch} npm versions (dry-run by default; --publish to push).";
+ "publish" = mkApp publish "Publish all ${pname} ${arch} npm versions (${arch} only for this repo).";
+ "push-staged" =
+ mkApp pushStaged "Replay the staged ${pname} ${arch} npm versions from .parity-stage (dry-run by default; --publish to push).";
+ };
+
# =========================================================================
# Generic binary (single-arch). Stage a built binary, PUT it to the Gitea
# generic registry (idempotent: 201 created / 409 exists).
@@ -857,6 +972,7 @@ in
mkPyPiWheelPublish
mkPyPiWheelPublishMulti
mkS390xNpmPublish
+ mkS390xNpmPublishMulti
mkGenericBinaryPublish
mkNix2ContainerPublish
mkGoBinaryPublish