Skip to content

feat: support publishing to multiple registries - #112

Open
lestote wants to merge 6 commits into
LouisMazel:feat/multi-registry-publishfrom
lestote:feat/multi-registry-publish
Open

feat: support publishing to multiple registries#112
lestote wants to merge 6 commits into
LouisMazel:feat/multi-registry-publishfrom
lestote:feat/multi-registry-publish

Conversation

@lestote

@lestote lestote commented Jul 28, 2026

Copy link
Copy Markdown

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 / otp behave exactly as before. Nothing changes unless the new publish.registries array is set.


What changed

Config (src/types.ts)

Config (src/types.ts)

  • New RegistryTarget interface:
    interface RegistryTarget {
      name?: string
      registry: string
      token?: string
      tag?: string
      access?: 'public' | 'restricted'
      otp?: string
      packages?: string[]
    }
  • PublishConfig.registries?: RegistryTarget[] — additive field, on top of the existing single-registry fields.
  • packages is a list of glob patterns matched against the package name:
    • without packages → mirrored to every publishable package
    • with packages → only applied to matching packages

Registry resolution (src/core/npm.ts)

  • resolveRegistryTargetsForPackage(pkg, config) — used at publish time: legacy registries entry (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)

  • publishPackage resolves targets for its package and publishes to each sequentially via a new publishToRegistryTarget() helper. Version temp-write for dry-run stays package-level (done once); only the publish command itself loops per registry.
  • Fail-fast, at the granularity of "the next registry/package attempt is not started" — not atomic within a single package. If a package publishes successfully to registry A and then
    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
  • OTP session cache changed from a single module-level string | undefined to a Map<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.
  • Log lines only show a registry label ([nexus], [jfrog]) when more than one target is resolved — single-registry output is byte-identical to before.

Safety check (src/commands/publish.ts)

  • publishSafetyCheck loops over resolveAllConfiguredRegistryTargets(config) and authenticates against each unique registry (fail-fast on the first failure).

Programmatic API (src/commands/publish.ts, release.ts)

  • options.registries is now forwarded into the config overrides in both publisistent with PublishOptions/ReleaseOptionsextendingPublishConfig`.

Dependency

  • Added micromatch (already a transitive dependency of fast-glob) as a **dirmatching of package names.

Key technical decisions (agreed upfront)

Decision Rationale
Mirroring by default + optional per-package routing via glob patterns Gives consumers flexibility for various deployment strategies
Fail-fast across registries and packages Bounds the blast radius of a failure — nothing further is attempted. Does not make a single package's multi-registry publish atomic: a
package can end up published on some of its registries and not others if a later
Dedup by registry URL, legacy wins Prevents double-publishing to the same URL if a user redundantly re-declares the default registry inside registries

Testing

17 new unit tests added (1319 total, all green):

  • Target resolution: legacy-only, global mirroring, glob-scoped, dedup
  • publishPackage multi-registry + fail-fast
  • Per-registry OTP caching: reuse on the same registry across packages, no leak into a different never-prompted registry
  • publishSafetyCheck multi-registry + fail-fast + dedup
  • redactSecrets masking tokens inside registries[]
pnpm typecheck   #
pnpm lint        #
pnpm test:unit   # ✅ 1319 passed
pnpm build       #

Documentation

  • docs/src/config/publish.md — new registries section with mirroring/scoped- on safety-check and fail-fast behavior.
  • docs/src/api/publish.mdregistries added to the PublishOptions reference.
  • README.md — feature list updated.

⚠️ No breaking changes

Every change is additive. Without publish.registries, config resolution, loggire identical to the previous single-registry implementation (verified by the full
pre-existing test suite passing unmodified).

@LouisMazel LouisMazel self-assigned this Jul 29, 2026
@LouisMazel
LouisMazel self-requested a review July 29, 2026 07:26
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.52239% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.82%. Comparing base (76983ad) to head (20f31ea).

Files with missing lines Patch % Lines
src/core/npm.ts 96.00% 0 Missing and 2 partials ⚠️
src/commands/publish.ts 94.11% 0 Missing and 1 partial ⚠️
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     
Flag Coverage Δ
unit 81.82% <95.52%> (+0.11%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@LouisMazel
LouisMazel changed the base branch from main to develop July 29, 2026 07:27
@LouisMazel
LouisMazel changed the base branch from develop to feat/multi-registry-publishing July 29, 2026 07:29
@LouisMazel LouisMazel changed the title feat(relizy): support publishing to multiple registries feat: support publishing to multiple registries Jul 29, 2026
@LouisMazel
LouisMazel changed the base branch from feat/multi-registry-publishing to main July 29, 2026 07:35
@LouisMazel
LouisMazel changed the base branch from main to develop July 29, 2026 07:35
@LouisMazel
LouisMazel changed the base branch from develop to feat/multi-registry-publish July 29, 2026 07:36

@LouisMazel LouisMazel left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/core/npm.ts Outdated
target => !target.packages?.length || micromatch.isMatch(pkg.name, target.packages),
)

return dedupRegistryTargets([legacyTarget, ...applicableTargets])

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (exclusive est opt-in)

Comment thread src/core/npm.ts Outdated
const result: RegistryTarget[] = []

for (const target of targets) {
const key = target.registry || ''

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 resolveRegistryTargetsForPackage et resolveAllConfiguredRegistryTargets avec/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)

Comment thread src/commands/publish.ts Outdated
return
}

const registryTargets = resolveAllConfiguredRegistryTargets(config)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 optionnel packages :
    • fourni → ne vérifie que les registries utilisés par ces packages (resolveRegistryTargetsForPackages)
    • omis → comportement conservateur inchangé (resolveAllConfiguredRegistryTargets, tout ce qui est configuré)
  • L'appel dans publish() est déplacé : il se fait maintenant après la résolution de publishedPackages (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 → ✅

Comment thread src/types.ts Outdated
* Glob pattern matching package names this registry applies to.
* Omitted or empty = applies to every publishable package (mirroring).
*/
packages?: string[]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collision de nom avec PublishConfig.packages : deux globs sur des noms de package, sémantiques différentes. Un matchPackages (ou packageFilter) serait plus clair.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 tout
  • RegistryTarget.packageFilter → routage d'un package déjà publiable vers ce registre précis

Changements

  • src/types.ts : renommage + doc précisant la distinction
  • src/core/npm.ts (resolveRegistryTargetsForPackage) : target.packagestarget.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 → ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants