Skip to content

Feat: Publish to multiple registries via matrix jobs - #87

Merged
tykeal merged 1 commit into
lfreleng-actions:mainfrom
modeseven-lfreleng-actions:feat/multi-registry-publish-targets
Sep 9, 2026
Merged

Feat: Publish to multiple registries via matrix jobs#87
tykeal merged 1 commit into
lfreleng-actions:mainfrom
modeseven-lfreleng-actions:feat/multi-registry-publish-targets

Conversation

@ModeSevenIndustrialSolutions

@ModeSevenIndustrialSolutions ModeSevenIndustrialSolutions commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Refs #52. Delivers the merge.yaml half; build-test-release.yaml and the OIDC auth mode follow separately (see Scope).

Problem

Both publish lanes assumed exactly one registry, so there was no way to send a release to Nexus and registry.npmjs.org. Adding a second scalar input would not scale and would duplicate the credential and publish plumbing a third time.

Approach

A JSON array of targets, fanned out as a matrix:

    release_targets: |
      [
        {"name": "nexus",
         "registry_url": "https://nexus3.example.org/repository/npm.release/",
         "auth": "nexus"},
        {"name": "npmjs",
         "registry_url": "https://registry.npmjs.org/",
         "auth": "token",
         "credential_name": "example-npmjs-publish-token"}
      ]

fail-fast: false, because the legs are independent destinations for one artefact and completing a partial publish is easier than redoing a whole one. Each leg names its registry in the job title, so a failed check identifies which registry failed without opening it.

Per-target credentials

This was the design crux — the lanes take no registry secrets, only 1Password access, so two registries with unrelated credentials looked impossible.

credential-load-action already solves it: credential_name selects the 1Password item, and the admin-managed CREDENTIAL_LOAD_GRANTS allow-list still gates which items a repository may load. So naming an item here cannot widen access, and no new secrets are introduced.

The target's auth mode then routes the loaded credential to nexus_password or auth_token, which node-publish-action treats as mutually exclusive.

Why resolution is a reusable workflow

merge.yaml is Gerrit-driven and cannot run in CItesting.yaml self-tests only build-test.yaml, and its header explains why the other two are excluded. Validation rules embedded in merge.yaml would therefore have had no test coverage at all.

As a separate workflow, testing.yaml calls it directly on every pull request. build-test-release.yaml needs identical rules and can reuse it rather than carry a second copy that drifts.

Validation

36 cases run in CI — 32 rejections and 4 acceptances — plus six standalone assertions. The suite extracts the shipped validator and drives it directly, because a called reusable workflow that fails marks the whole run red and so cannot be used to assert a rejection.

Rejected: malformed JSON, non-array, empty array, non-object entry, each missing field, http, uppercase scheme, no host, no trailing slash, ./.. path segments, trailing-dot host, userinfo, bad port (non-numeric and out-of-range), unsupported auth, oidc, non-string auth, newline in name, non-string and unsafe credential_name, duplicate name, duplicate registry, duplicate by host case, duplicate by explicit :443, trailing-dot aliasing, more than 256 targets, and a multiline label.

Accepted, so the rules cannot over-reject: a valid list, a different port, differing path case, and exactly 256 targets.

Beyond outcomes, the suite asserts properties:

  • every rejection arrives as a labelled ::error:: naming the target — a non-zero exit alone would let a Python traceback count as a correct rejection
  • a malicious label cannot forge a second workflow command
  • the deprecation notice is actually emitted, not merely implied by an output flag
  • both the list and scalar matrices match their expected objects exactly, and count agrees with the matrix length

Duplicates matter beyond tidiness: two legs publishing one version to one registry cannot both succeed, and the loser fails with EPUBLISHCONFLICT on a version that did publish — the same confusing post-publish failure shape as #58 and #85.

Backward compatibility

snapshot_registry_url and release_registry_url keep working, resolving to a one-element list with nexus auth and emitting a deprecation notice. They change from required: true to optional, which existing callers do not notice.

Each lane needs one of its two forms; leaving both empty fails, which the input table and the Publish Targets section both state.

Scope

Item Status
merge.yaml snapshot + release ✅ this PR
nexus and token auth ✅ this PR
build-test-release.yaml Follow-up, reusing the same resolver
oidc auth mode Needs node-publish-action#28; see below

#52 lists node-publish-action#28 as a dependency covering both token and OIDC. Token shipped in v0.1.0 and is already pinned here, so the ONAP Nexus + npmjs.org case is deliverable today. OIDC additionally needs id-token: write, which GitHub cannot grant per matrix leg — the issue flags this itself — so it belongs with #54. The resolver rejects auth: oidc with a message naming it.

Known limitation, called out rather than implied away

CI exercises the resolver, not the complete publish paths. merge.yaml needs a merged-commit context and real Nexus credentials, so neither lane can run on a pull request; the same is true of build-test-release.yaml and its tag-push context. That is precisely why the resolver was extracted — it is the part that can be tested — but the credential routing and matrix expansion themselves are first exercised by an instantiating repository's merge cycle.

A tooling conflict worth flagging

Adding local uses: references raised zizmor's self-repository count from 1 to 5. zizmor recommends GitHub's $/... syntax, and it is right to — per its docs the $/ form "is not subject to runtime filesystem state, meaning that it can't load an action that was cloned at runtime in a previous step."

I applied it, and actionlint rejected all five as malformed reusable-workflow calls. Support is requested upstream in rhysd/actionlint#711 and #732, both open — so no version bump resolves it.

Silencing actionlint would be the worse trade: it validates whether a workflow call is well formed at all, where self-repository is advisory. So the ./ form stays, with a line-scoped ignore naming the five calls, and the exact follow-up recorded.

Line scoping rather than file scoping matters: a file entry would also cover step-level uses: ./..., and a step-local action resolved after an attacker-controlled checkout is the case this rule most needs to catch.

Net result: zizmor reports 0 findings, down from 1 on main.

Verification

  • prek across all workflows and examples: clean
  • zizmor --persona auditor: 0 findings (was 1 on main)
  • 30/30 CI checks green
  • Both new CI steps extracted and executed locally exactly as CI runs them, after an earlier round shipped a harness bug (allow vs accept) that resolver-only testing could not have caught
  • Security fixes verified by reproducing the attack first and confirming it fails after, including a mutation showing the label-forgery assertion has teeth
  • Examples verified by parsing: the shipped values and the documented two-entry alternative both produce valid JSON

This comment was marked as resolved.

@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions force-pushed the feat/multi-registry-publish-targets branch from 92449e6 to 3a6b7dc Compare September 2, 2026 15:01
Copilot AI review requested due to automatic review settings September 2, 2026 15:01

This comment was marked as resolved.

Copilot AI review requested due to automatic review settings September 2, 2026 15:14
@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions force-pushed the feat/multi-registry-publish-targets branch from 3a6b7dc to 6d95765 Compare September 2, 2026 15:14

This comment was marked as resolved.

Copilot AI review requested due to automatic review settings September 2, 2026 15:34
@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions force-pushed the feat/multi-registry-publish-targets branch from 6d95765 to 95cfc98 Compare September 2, 2026 15:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The high-impact credential and publication fan-out lacks end-to-end CI coverage, and the conditional input requirement remains unclear.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread README.md Outdated

This comment was marked as resolved.

@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions force-pushed the feat/multi-registry-publish-targets branch 2 times, most recently from 0a38289 to 29ddaa8 Compare September 2, 2026 16:16
@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions requested a balanced review from Copilot and removed request for Copilot September 2, 2026 16:46

This comment was marked as resolved.

Copilot AI review requested due to automatic review settings September 2, 2026 17:01
@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions force-pushed the feat/multi-registry-publish-targets branch from 29ddaa8 to d5c4eb5 Compare September 2, 2026 17:01

This comment was marked as resolved.

Both publish lanes in merge.yaml assumed exactly one registry, so
there was no way to send a release to Nexus and registry.npmjs.org.
Adding a second scalar input would not scale and would duplicate the
credential and publish plumbing again.

Take a JSON array of targets instead, fanned out as a matrix with
fail-fast disabled: the legs are independent destinations for one
artefact, and completing a partial publish is easier than redoing a
whole one. Each leg names the registry in its job title, so a failed
check identifies which registry failed.

Per-target credentials work through credential-load-action's
credential_name input, which selects the 1Password item. That is what
makes several registries workable at all, since their credentials are
unrelated. Authorisation is unchanged: the administrator-managed
grants list still gates which items a repository may load, so naming
an item cannot widen access. The target's auth mode routes the loaded
credential to nexus_password or auth_token, which node-publish-action
treats as mutually exclusive.

Resolution lives in a reusable workflow rather than a job inside
merge.yaml. merge.yaml is Gerrit-driven and cannot run in CI, so
validation rules embedded there would have no coverage; as a separate
workflow, testing.yaml calls it directly. build-test-release.yaml
needs the same rules and can reuse it rather than carry a copy.

Validation rejects malformed JSON, non-arrays, empty arrays, missing
or malformed fields, non-https URLs, URLs without a trailing slash,
unknown auth modes, and duplicate names or registry URLs. Duplicates
matter because two legs publishing one version to one registry cannot
both succeed, and the loser fails with EPUBLISHCONFLICT on a version
that did publish. A bad list fails once, before any leg starts, and
names the offending entry.

The scalar registry inputs keep working, resolving to a one-element
list with nexus auth and a deprecation notice. They change from
required to optional, which existing callers do not notice.

OIDC is deliberately absent as an auth mode. It needs
'id-token: write', which cannot be granted per matrix leg, so it
belongs with lfreleng-actions#54 rather than here.

Refs lfreleng-actions#52

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>
Copilot AI review requested due to automatic review settings September 2, 2026 17:15
@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions force-pushed the feat/multi-registry-publish-targets branch from d5c4eb5 to 52307e4 Compare September 2, 2026 17:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Release publishing and credential routing are operationally sensitive, while CI exercises the resolver but not the complete publishing paths.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@tykeal
tykeal merged commit 66e205f into lfreleng-actions:main Sep 9, 2026
31 checks passed
@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions deleted the feat/multi-registry-publish-targets branch September 9, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants