feat: support publishing to multiple registries - #112
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #112 +/- ##
==========================================
+ Coverage 81.71% 81.82% +0.11%
==========================================
Files 30 30
Lines 2958 2993 +35
Branches 883 894 +11
==========================================
+ Hits 2417 2449 +32
Misses 308 308
- Partials 233 236 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Deux remarques hors lignes :
- Le fail-fast n'est pas atomique au sein d'un package (si npm réussit et nexus échoue, le package est déjà partiellement publié). La description parle d'éviter les états incohérents entre registres, mais ce n'est vrai qu'entre packages, pas au sein d'un même package. À reformuler.
- Il manque un test sur le cache OTP par registre (Map<url, otp>), qui est un vrai changement de comportement.
| target => !target.packages?.length || micromatch.isMatch(pkg.name, target.packages), | ||
| ) | ||
|
|
||
| return dedupRegistryTargets([legacyTarget, ...applicableTargets]) |
There was a problem hiding this comment.
Le target legacy est toujours inclus, et config.publish.registry est systématiquement rempli (config.ts:210, fallback getNpmRegistry). Du coup impossible de publier un package uniquement vers un registre interne : un @internal/* ira toujours aussi sur le registre par défaut. C'est du mirroring additif, pas du vrai routing. Soit on permet d'exclure le registre par défaut, soit on le documente clairement.
There was a problem hiding this comment.
Fix
Ajout d'un flag exclusive?: boolean sur RegistryTarget (src/types.ts). Quand une entrée matchée l'a à true, resolveRegistryTargetsForPackage (src/core/npm.ts) exclut le registre par défaut pour ce package — il ne part que vers les registries explicites correspondants (les autres entrées non-exclusives qui matchent restent appliquées en plus).
publish: {
registry: 'https://registry.npmjs.org',
registries: [
{
name: 'jfrog-internal',
registry: 'https://mycompany.jfrog.io/.../npm-internal/',
packages: ['@internal/*'],
exclusive: true,
},
],
}→ @internal/* part uniquement sur JFrog, le reste continue d'aller sur le registre par défaut.
Point d'attention
Le safety-check (resolveAllConfiguredRegistryTargets) n'est pas concerné : il continue d'authentifier le registre par défaut dans tous les cas, car il tourne avant que la liste des packages soit connue.
Validation
- 3 tests ajoutés : exclusion effective, packages non matchés inchangés, coexistence avec un mirroir non-exclusif
- Doc mise à jour
- 1322 tests passent
- 100% rétrocompatible (
exclusiveest opt-in)
| const result: RegistryTarget[] = [] | ||
|
|
||
| for (const target of targets) { | ||
| const key = target.registry || '' |
There was a problem hiding this comment.
Dedup sur l'URL brute : https://registry.npmjs.org et https://registry.npmjs.org/ (slash final) ne dédupliquent pas -> double publish. Une normalisation légère de l'URL serait plus robuste.
There was a problem hiding this comment.
Bonne remarque, corrigé.
Fix
Ajout d'un helper normalizeRegistryKey() (src/core/npm.ts) qui retire le slash final avant comparaison — https://registry.npmjs.org et https://registry.npmjs.org/ sont maintenant bien reconnus comme le même registre :
dedupRegistryTargets(L.185) utilise cette clé normalisée au lieu de l'URL brute.- Cache OTP par registry (
sessionOtpByRegistry) : même correctif appliqué, pour la même raison — deux formes d'URL identiques ne partageaient pas le cache OTP non plus.
L'URL brute (non normalisée) reste utilisée pour la commande --registry réelle ; seule la clé de comparaison/dédup est normalisée.
Tests ajoutés
- Dédup de
resolveRegistryTargetsForPackageetresolveAllConfiguredRegistryTargetsavec/sans slash final - Réutilisation du cache OTP quand seule la présence du slash final diffère entre deux appels
Validation
pnpm lint/pnpm typecheck/pnpm test:unit→ ✅ (1325 tests)
| return | ||
| } | ||
|
|
||
| const registryTargets = resolveAllConfiguredRegistryTargets(config) |
There was a problem hiding this comment.
La safety-check authentifie tous les registres déclarés, y compris ceux scopés à des packages non bumpés cette release. Un registre scopé non matché peut donc bloquer toute la release alors qu'il ne sera pas utilisé. Intentionnel ? À documenter au minimum.
There was a problem hiding this comment.
Non, pas intentionnel comme comportement final — c'était une conséquence du fait que le safety-check tourne avant que la liste des packages à publier soit connue. Corrigé plutôt que documenté.
Fix
- Nouvelle fonction
resolveRegistryTargetsForPackages(packages, config)(src/core/npm.ts) : union dédupliquée des registries réellement nécessaires pour un ensemble de packages. publishSafetyCheck(src/commands/publish.ts) accepte maintenant un paramètre optionnelpackages:- fourni → ne vérifie que les registries utilisés par ces packages (
resolveRegistryTargetsForPackages) - omis → comportement conservateur inchangé (
resolveAllConfiguredRegistryTargets, tout ce qui est configuré)
- fourni → ne vérifie que les registries utilisés par ces packages (
- L'appel dans
publish()est déplacé : il se fait maintenant après la résolution depublishedPackages(et après le early-return "rien à publier"), en lui passant cette liste — au lieu d'avant la découverte des packages.
Résultat
Un registry scopé à @internal/* qui est down ne bloque plus une release qui ne touche aucun package @internal/*. Il reste bien vérifié dès qu'au moins un package de la release le matche.
Compatibilité
100% rétrocompatible : packages est optionnel, les appels existants (tests, éventuels appels programmatiques) sans ce paramètre gardent le comportement superset précédent.
Tests ajoutés
- Registry scopé non matché → ignoré par le safety-check
- Registry scopé matché par au moins un package → bien vérifié
Validation
pnpm lint / pnpm typecheck / pnpm test:unit (1327 tests) / pnpm build → ✅
| * Glob pattern matching package names this registry applies to. | ||
| * Omitted or empty = applies to every publishable package (mirroring). | ||
| */ | ||
| packages?: string[] |
There was a problem hiding this comment.
Collision de nom avec PublishConfig.packages : deux globs sur des noms de package, sémantiques différentes. Un matchPackages (ou packageFilter) serait plus clair.
There was a problem hiding this comment.
Bon point, corrigé.
Fix
Renommé RegistryTarget.packages en RegistryTarget.packageFilter (src/types.ts) pour lever l'ambiguïté avec PublishConfig.packages :
PublishConfig.packages→ quels packages sont publiés du toutRegistryTarget.packageFilter→ routage d'un package déjà publiable vers ce registre précis
Changements
src/types.ts: renommage + doc précisant la distinctionsrc/core/npm.ts(resolveRegistryTargetsForPackage) :target.packages→target.packageFilter- Tests (
npm.spec.ts,publish.spec.ts,redact.spec.ts) et doc (docs/src/config/publish.md) mis à jour en conséquence, avec un encart expliquant le choix de nom
Validation
pnpm lint / pnpm typecheck / pnpm test:unit (1327 tests) / pnpm build → ✅
…eded by the release
Summary
Adds support for publishing packages to multiple registries in a single release (e.g. mirroring to an internal Nexus/JFrog registry in addition to the public npm registry), addressing Relizy's current limitation of a single hardcoded registry.
The feature is fully opt-in and backward compatible: existing configs using
publish.registry/token/tag/access/otpbehave exactly as before. Nothing changes unless the newpublish.registriesarray is set.What changed
Config (
src/types.ts)Config (
src/types.ts)RegistryTargetinterface:PublishConfig.registries?: RegistryTarget[]— additive field, on top of the existing single-registry fields.packagesis a list of glob patterns matched against the package name:packages→ mirrored to every publishable packagepackages→ only applied to matching packagesRegistry resolution (
src/core/npm.ts)resolveRegistryTargetsForPackage(pkg, config)— used at publish time: legacyregistriesentry (global or glob-matched), deduped by registry URL (legacywins).resolveAllConfiguredRegistryTargets(config)— used by the pre-publish safety check, which runs before the package list is known, so it authenticates against every distinct configured registry regardless of package scoping.Publishing (
src/core/npm.ts)publishPackageresolves targets for its package and publishes to each sequentially via a newpublishToRegistryTarget()helper. Version temp-write for dry-run stays package-level (done once); only the publish command itself loops per registry.fails on registry B, A's publish has already happened and is not rolled back; thore attempting anything further (no more registries for that package, no morepackages). This is a deliberate trade-off (npm/pnpm/yarn publish has no cross-registry transaction primitive to make this atomic), discussed and accepted over a "continue on error, report
at the end" alternative, which would have made more packages partially publish
string | undefinedto aMap<registryUrl, otp>, since different registries can require independent OTP challenges — a prompted OTP for one registry is no longer incorrectly reused for a different, never-prompted registry.[nexus],[jfrog]) when more than one target is resolved — single-registry output is byte-identical to before.Safety check (
src/commands/publish.ts)publishSafetyCheckloops overresolveAllConfiguredRegistryTargets(config)and authenticates against each unique registry (fail-fast on the first failure).Programmatic API (
src/commands/publish.ts,release.ts)options.registriesis now forwarded into the config overrides in bothpublisistent withPublishOptions/ReleaseOptionsextendingPublishConfig`.Dependency
micromatch(already a transitive dependency offast-glob) as a **dirmatching of package names.Key technical decisions (agreed upfront)
registriesTesting
17 new unit tests added (1319 total, all green):
publishPackagemulti-registry + fail-fastpublishSafetyCheckmulti-registry + fail-fast + dedupredactSecretsmasking tokens insideregistries[]Documentation
docs/src/config/publish.md— newregistriessection with mirroring/scoped- on safety-check and fail-fast behavior.docs/src/api/publish.md—registriesadded to thePublishOptionsreference.README.md— feature list updated.Every change is additive. Without
publish.registries, config resolution, loggire identical to the previous single-registry implementation (verified by the fullpre-existing test suite passing unmodified).