Skip to content

SDK packaging: fix malformed version strings and floating/orphaned dist-tags (#36891) - #37477

Merged
KevinDavilaDotCMS merged 21 commits into
mainfrom
36891-sdk-packaging-version-fix-impl-v2
Sep 10, 2026
Merged

SDK packaging: fix malformed version strings and floating/orphaned dist-tags (#36891)#37477
KevinDavilaDotCMS merged 21 commits into
mainfrom
36891-sdk-packaging-version-fix-impl-v2

Conversation

@KevinDavilaDotCMS

@KevinDavilaDotCMS KevinDavilaDotCMS commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the spec in #37452 (approved, spec-only PR 1) for #36891. Fixes four related defects in the SDK npm release pipeline and package manifests, plus a root-cause automation so future release branches (LTS or not) can't regress into the same bug:

  • Defect A — the release pipeline wrote zero-padded version strings (26.08.03-01) that mismatch npm's own normalized registry metadata (26.8.3-1), per ADR-0019. Normalized in deploy-javascript-sdk/action.yml, extended to also cover devDependencies.
  • Defect B1/B1breact/angular/vue/analytics declared sibling @dotcms/* packages as dependencies instead of peerDependencies (the actual mechanism behind yarn/pnpm installing duplicate, mismatched copies), all pinned to "latest". Moved to peerDependencies with a "0.0.0" sentinel (rewritten to the real version at publish time); READMEs corrected to document the manual-install requirement for yarn classic (1.x) / npm < 7.
  • Defect B2 — two of main's six example apps pointed at an internal "next" pre-release tag instead of "latest". Fixed; all six now correctly float on "latest" (Evergreen-aligned), with a README note for non-Evergreen customers.
  • Defect B3 (root cause) — both active LTS branches' examples/nextjs floated on "latest", resolving far ahead of the LTS server (the live customer ticket, Freshdesk #38677). Backported as two separate PRs (fix(sdk): pin examples/nextjs to 1.2.0 instead of floating latest #37475, fix(sdk): pin examples/nextjs to 1.2.0 instead of floating latest #37476, pinned to 1.2.0, verified via static GraphQL schema analysis). Root cause fixed here: cicd_comp_release-prepare-phase.yml now auto-pins every example app's @dotcms/* deps to the exact release version at branch-cut time, for every future release branch — main is never touched.
  • CI guardrail: new .github/scripts/validate-sdk-package-shapes package (tested, 10/10) wired into cicd_1-pr.yml and cicd_5-lts.yml to fail the build on any regression of the shapes above.

Full defect analysis, root-cause hypothesis, and acceptance criteria in the approved spec: specs/37399-sdk-packaging-version-fix/spec.md (#37452).

Test plan

  • sdk-client Jest suite (sdk-compatibility.spec.ts) — 12/12 passing, includes a regression-lock test for leading-zero version equality (AC-008)
  • validate-sdk-package-shapes Jest suite — 10/10 passing
  • Validator CLI run against all 7 SDK lib manifests + 6 main example manifests — 13/13 Green
  • Manual local reproduction of the release-pipeline normalization against a scratch copy of real manifeststhis claim was wrong. The step aborted on the first package ($dep_name unbound under set -u), so this check never exercised the code it claimed to verify. Caught in review; see the re-verification below.
  • Manual scratch-install across npm/yarn(classic 1.22.22)/pnpm confirming no duplicate @dotcms/client copy under node_modules/@dotcms/react/node_modules/
  • Manual clean-install verification on all six main examples confirming npm ls @dotcms/client resolves a single deduped copy from latest
  • Local reproduction of the release-branch-cut auto-pin step against a scratch copy of all six example manifests — confirmed correct rewrite, non-@dotcms/* deps untouched
  • /speckit-converge run against final code — ✅ Converged, zero gaps
  • LTS backport PRs fix(sdk): pin examples/nextjs to 1.2.0 instead of floating latest #37475 and fix(sdk): pin examples/nextjs to 1.2.0 instead of floating latest #37476 — separate PRs, not part of this one, tracked independently

Re-verification after the review fixes

  • update-versions step body extracted from action.yml verbatim and run against scratch copies of the real manifests — 11/11 packages, exit 0 on 26.08.03-01, 26.9.3-1-next.2632 and 2026.06.24-01 (previously died on the first package)
  • Prerelease suffix preserved end to end: 26.9.3-1-next.2632 survives into the resulting manifests
  • Unscoped-sibling regression test: injected "dotcms": "latest" into react → rewritten to 26.8.3-1
  • Normalization verified byte-identical under mawk (the ubuntu-24.04 default), gawk and gawk --posix across 9 release-shaped inputs, inside ubuntu:24.04
  • create-branch step run verbatim against a real git repo (bare origin, post-receive push counter): release tag's target_commitish carries the pin, exactly 1 push to release-*, no-op re-run does not abort, examples diff is 26 lines instead of 121. The same harness run against the pre-fix code fails all four — counterfactual confirmed
  • validate-sdk-package-shapes job run verbatim in a clean node:22.22.3 container (the .nvmrc version), no cache, npm ci --ignore-scripts → build → 21/21 tests → 11 libs + 6 examples, exit 0
  • Guardrail negative/positive probes: 10/10 correct (B1b ×2, floating peerDep, latest on LTS, ^ range on LTS, next on main all red; 0.0.0 sentinel, latest on master, exact pin, exact prerelease pin all green)
  • actionlint clean on all three substantively changed workflows (SC2044 resolved); cicd_comp_initialize-phase.yml had 44 pre-existing warnings and still has 44
  • Filter wiring (sdk_package_shapesdorny/paths-filterinitialize outputs → job if:) — not reproducible locally; verified by this push's CI run

🤖 Generated with Claude Code

This PR fixes: #36891

This PR fixes: #36891

KevinDavilaDotCMS and others added 19 commits September 7, 2026 23:34
…ion issues

This commit introduces a new specification document addressing defects in the SDK packaging mechanism, specifically focusing on malformed version strings and incorrect dist-tags. The spec outlines the problem, reproduction steps, expected vs. actual behavior, and the impact on customers, aiming to guide the resolution process for these critical issues.
…e doc fix

rjvelazco's review on PR #37452 pointed out that react/README.md,
angular/README.md, and vue/README.md currently claim installing the main
package "will automatically install the required dependencies" -- a claim
this fix's peerDependencies change makes unreliable, and one that was
already inaccurate for @dotcms/types (a devDependency, never auto-installed
even before this fix, but never called out in the docs either).

Names the exact files/sections to correct, expands the required manual
install list to all three affected packages (@dotcms/client, @dotcms/uve,
@dotcms/types), and broadens "yarn classic" to also cover npm below v7,
which has the same no-auto-install-peers behavior. Adds AC-009 to make
this independently verifiable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Setup + Foundational phase (tasks T001-T007) for issue #36891 / spec
specs/37399-sdk-packaging-version-fix.

- Delete core-web/bump-sdk-versions.js (confirmed unreferenced by any
  workflow -- superseded by deploy-javascript-sdk/action.yml's rewrite
  logic, per the spec's Defect B1 root-cause).
- Add .github/scripts/validate-sdk-package-shapes, a standalone TS/Jest
  package (matching the gather-release-data/release-qa-status convention)
  exporting validateSdkLibPackageJson and validateExamplePackageJson, plus
  a CLI entry point. Written test-first (10 tests, developer-approved,
  confirmed Red against the missing module before implementation). Every
  SDK/B1/B2/B3 task from here on uses this as its automated test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Discovered during implementation: main also carries examples/angular-ssr
(already correctly "latest") and examples/nextjs-experiments (still
"next", same defect as nextjs/vuejs) -- neither was in the original file
listing. Confirmed via git ls-tree that neither exists on either LTS
branch (both carry only the original four), so Defect B3's scope is
unaffected.

Every "four main examples" reference updated to six; the shared
validation script needs no code change since it takes a path/branch
generically rather than hardcoding the example list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tasks T013-T019 for spec specs/37399-sdk-packaging-version-fix.

The release pipeline wrote the zero-padded dotCMS release tag (e.g.
26.08.03-01) verbatim into every published package.json field, mismatching
npm's own normalized registry metadata (26.8.3-1) -- ADR-0019 already
mandates stripping the zeros; the pipeline never implemented that rule.

- deploy-javascript-sdk/action.yml: normalize $RELEASE_VERSION once, right
  after checkout, before it's used anywhere. Surface the normalized value
  as a step output and reference it explicitly from the later "Publish"
  step's own env block and the action's own output -- a step's literal
  `env: FOO: ${{ inputs.x }}` always wins over an earlier step's $GITHUB_ENV
  write, so normalizing only the first step's local variable would not
  have propagated.
- Extend the same rewrite loop to devDependencies (previously only
  dependencies/peerDependencies) -- confirmed live on npm that this field
  was the one actually shipping unmasked to customers today
  (devDependencies.@dotcms/types: "latest" in every currently-published
  package).
- sdk-compatibility.spec.ts: add a regression-lock test asserting
  compareVersions('26.08.03-01', '26.8.3-1') === 0 (AC-008) -- this already
  passes today, locking in that a future refactor can't break it.

Verified locally against a scratch copy of the real core-web/libs/sdk/*
package.json files (both before and after the fix) rather than via
`workflow_dispatch --dry-run`: that flag turned out to skip the entire
action under test, and running without it risks a real, irreversible npm
publish -- neither is an acceptable way to test this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… B1/B1b)

Tasks T020-T027 for spec specs/37399-sdk-packaging-version-fix.

react, angular, vue, and analytics declared @dotcms/client/@dotcms/uve as
regular `dependencies` -- the actual mechanism letting yarn/pnpm install a
second, independently-resolved copy alongside a consumer's own pinned
version, regardless of the value's correctness. Moved to `peerDependencies`
(the shape `experiments` already used) so they defer to whatever the
consumer already installed, matching how react-dom avoids shipping its
own react.

Value fixed to the "0.0.0" sentinel everywhere a sibling @dotcms/*
package appears in peerDependencies (including experiments' pre-existing
four entries) -- "latest" is not a valid semver range, so a local
peer-dependency-satisfaction check can't evaluate it sanely regardless of
what's actually installed, which was the real cause of local pre-publish
testing breakage. dependencies/devDependencies keep "latest" unchanged --
masked by the publish-time rewrite (Defect A fix), no customer-facing
effect.

Corrected react/angular/vue's READMEs: each claimed installing the main
package "will automatically install the required dependencies" -- no
longer reliably true. Now lists @dotcms/client, @dotcms/uve, and
@dotcms/types as required manual installs, naming yarn classic (1.x) and
npm below v7 as needing this (AC-009, addresses PR #37452 review feedback).

Verified with real locally-built tarballs (sdk-react + sdk-client) across
npm, pnpm, and yarn classic 1.22.22: no nested duplicate @dotcms/client
under react's own node_modules in any of the three -- the original
duplicate-copy bug is fixed. Yarn classic correctly warns instead of
auto-installing a peer that's genuinely missing from the manifest,
confirming the documented behavior change is real, not hypothetical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t B2)

Tasks T028-T033 for spec specs/37399-sdk-packaging-version-fix.

examples/nextjs and examples/vuejs pinned "next" -- an internal dev/QA
pre-release tag, never meant for customers. A third example not in the
original scope, examples/nextjs-experiments, had the same defect
(discovered mid-implementation, alongside examples/angular-ssr which was
already correctly "latest"). All three fixed to "latest", matching
examples/angular/astro/angular-ssr.

main's six examples deliberately track "latest" rather than an exact pin
(ADR-0019's Evergreen-convergence reasoning, see spec) -- added a note to
each of the six READMEs telling a non-Evergreen customer (older
self-hosted release, or LTS) to replace "latest" with their own server's
exact version before installing.

Verified against the real, currently-published npm registry (not a local
tarball, since these examples install real @dotcms/* packages): deleted
node_modules/package-lock.json and ran a clean install for each of the
six, then `npm ls @dotcms/client`. All six resolve to exactly one deduped
copy at the current `latest` (26.9.3-1). Confirmed the bug was live before
this fix: nextjs's pre-fix node_modules had resolved the stale
26.9.3-1-next.2632 pre-release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task T034 for spec specs/37399-sdk-packaging-version-fix.

Added a new `examples` path filter (.github/filters.yaml) alongside the
existing `sdk_libs` one, registered as an info_filter in
cicd_comp_initialize-phase.yml.

New "Validate SDK package.json shapes" job, gated on either filter, added
to both cicd_1-pr.yml and cicd_5-lts.yml -- discovered mid-implementation
that cicd_1-pr.yml only triggers for PRs against main/master, so LTS
release-* branches would otherwise never get this check at all (their
only CI is cicd_5-lts.yml, on push, post-merge). Both jobs build and run
.github/scripts/validate-sdk-package-shapes against every SDK lib and
example manifest, passing the PR's base ref (cicd_1-pr.yml) or the pushed
branch name (cicd_5-lts.yml) so the examples check can allow "latest" on
main and require an exact pin everywhere else. Wired into each workflow's
`finalize` job so a violation blocks the aggregate PR/build status, not
just a silent warning.

Verified locally (the exact commands each job runs): current repo state
passes across all 16 manifests; a deliberately-reintroduced peerDependency
violation in react/package.json correctly fails, cleanly restored after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Developer scaffolded examples/nextjs from release-25.07.10_lts_v12
against a real 25.07.10 LTS server and found the then-current "latest"
pin (resolving to 26.9.3-1) rendered correctly, including inside UVE --
no FieldUndefined errors. 1.2.0 was never independently verified and is
now known to be far behind the SDK's current feature set.

Backport PR: #37475

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…010)

Developer correctly objected that manually re-pinning the two existing
LTS branches doesn't stop the *next* release branch (LTS or not) from
being cut with the same floating-example defect -- main's examples
deliberately float on "latest" by design, so any freshly-cut branch
inherits that. Root-cause fix: cicd_comp_release-prepare-phase.yml (the
workflow that cuts every release branch) now pins every example's
@dotcms/* dependency to that release's own exact, normalized version at
cut time -- correct by construction per ADR-0019's date-lockstep
guarantee, no per-branch manual verification needed going forward.

Adds AC-010, updates Defect B3's root-cause analysis, narrows the
"no ongoing re-sync" non-goal to only cover already-existing branches
(the new step handles every future one automatically), and updates
Blast Radius / Verification method for the second high-blast-radius
workflow this fix now touches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tasks T038-T041 for spec specs/37399-sdk-packaging-version-fix.

Developer objection during implementation: manually re-pinning the two
existing LTS branches (Phase 3) doesn't stop the *next* release branch
(LTS or not) from being cut with the same floating-example defect --
main's examples deliberately float on "latest" by design, so any freshly
cut branch inherits that as-is. Nothing in cicd_comp_release-prepare-phase.yml
(the workflow that cuts every release branch and bumps several other
versioned artifacts on it) ever touched examples/*/package.json.

Added a new step there, right after the existing branch-creation step:
normalizes the release version (same leading-zero-stripping as Defect A's
fix) and pins every examples/*/package.json's @dotcms/* dependency to
that exact value, committing and pushing to the new branch -- applies to
every release branch unconditionally (LTS and non-LTS), never touches
main. Correct by construction per ADR-0019's date-lockstep guarantee: the
SDK version matching a given release is known with certainty the moment
the branch exists, so no per-branch manual verification is needed going
forward (unlike the one-time retrofit Phase 3 does for the two branches
that predate this step).

Verified locally against a scratch copy of the real six examples/*
manifests (not a real release-branch cut, for the same reason Defect A's
dry-run was replaced with a local reproduction): confirms Red (nothing
currently touches examples/) and Green (all six correctly pinned,
non-@dotcms/* dependencies untouched, and the shared shape validator
passes against the result).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e test

The prior "verified against a real server" commit was itself based on an
invalid test (a Docker container running trunk, not 25.07.10 LTS -- trunk
already has every field the query needs, so nothing could have failed).

Static, no-server-needed verification instead: release-25.07.10_lts_v12's
GraphQL schema lacks lockedBy, lockedByName, numberContents,
styleEditorSchemas, and layout metadata; @dotcms/client's query has
requested all five since 2025-11-26 through 2026-05-07 (PRs #33905,
#34966, #34173, #35528); @dotcms/client@1.2.0 published 2025-10-24, over
a month before any of them, with no other stable release in that gap.
1.2.0 requests none of the five fields -- schema-compatible by
construction, and this confirms the original issue's own guess rather
than replacing it, as the previous commit's message claimed.

PR #37475 corrected to match (both the pin and its
description's test-plan claims). AC-005's verification method updated to
prefer this static approach over a live-server test, given how easily the
latter produces a false pass against the wrong build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Verified independently, not assumed identical to _v12 -- same static
method (checked all 80 files under dotCMS/src/main/java/com/dotcms/graphql/
on release-25.07.10_lts_v16) confirms the same schema gap, so 1.2.0
applies there too. Both LTS backports (#37475, #37476) are now open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
data-model.md was a pure N/A placeholder with no content; dropped.
contracts/package-json-shape.md documents the shape the CI guardrail
enforces, updated to reflect the LTS pins as verified (1.2.0, PRs
#37475/#37476) rather than pending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added Area : CI/CD PR changes GitHub Actions/workflows Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries labels Sep 9, 2026

@dcolina dcolina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CI/CD review — request changes

Spec traceability here is excellent and most packaging ACs are met and verified. But the change to deploy-javascript-sdk/action.yml is deterministically broken: as-is, this breaks 100% of future SDK npm releases. Reproduced locally.

Note the test plan has 10 checked boxes and none of them exercised the highest-blast-radius file — the spec's own Regression Risk section flagged exactly this.


Must fix

1. $dep_name is now unbound under set -u → every SDK release aborts

action.yml:100 still references $dep_name, but the PR deleted its assignment. With set -euo pipefail (line 68) that is a hard failure.

Simulated the exact step against this branch's real manifests:

Normalized release version: 26.8.3-1
Found SDK packages: uve types experiments vue angular cli ai create-app client react analytics
  OK uve -> 26.8.3-1
bash: line 22: dep_name: unbound variable        <-- dies on the FIRST package

deploy-javascript-sdk is the only publish path for the SDK (cicd_release-sdk.yml + cicd_3-trunk.yml). This fails on every release, always.

It went unnoticed because cicd_release-sdk.yml:162-164 skips the whole action on dry-run:

if: >-
  (github.event_name == 'release' || github.event.inputs.dry-run != 'true')

So the dry-run offered as verification executes none of the changed code.

2. Normalization destroys the -next.<run> suffix → breaks the next dist-tag publish

cicd_3-trunk.yml:177 builds NEXT_VERSION="${LATEST_VERSION}-next.${{ github.run_number }}". The new awk splits on [.-] and rebuilds from only 4 fields, dropping the rest:

Input Output
26.08.03-01 26.8.3-1 OK
26.9.3-1-next.2632 26.9.3-1 — suffix gone
25.07.10-1-next.99 25.7.10-1 — suffix gone

Every SDK-touching merge to main would then try to publish the stable release version number. The npm view idempotency guard finds it already published, prints already published — skipping, sets published=true, and Slack announces success — so next silently stops publishing forever. In the window before that version exists on the registry it's worse: trunk code gets published under the stable release number, and the real release later hits a 403.

The spec says "No change to the next dist-tag publishing mechanism" — this changes its behavior through the shared action.

3. Reintroduces a bug already fixed and documented in this same file

-              dep_name=$(jq -r '.name' "$dep/package.json" 2>/dev/null || echo "@dotcms/$dep")
-              for field in dependencies peerDependencies; do
-                if jq -e ".${field}[\"$dep_name\"]" "$pkg" >/dev/null 2>&1; then
+              for field in dependencies peerDependencies devDependencies; do
+                if jq -e ".${field}[\"@dotcms/$dep\"]" "$pkg" >/dev/null 2>&1; then

Two steps below, in this same file:

"The package's OWN name, never @dotcms/<directory>. The scope used to be hardcoded here, which silently assumed every SDK is scoped. It held until dotcms — the unscoped CLI — where the guard below could never match."

Verified on this branch: core-web/libs/sdk/cli publishes as the unscoped dotcms (the other 10 match @dotcms/<dir>). No lib depends on it today, so it doesn't explode yet — but when one does, the rewrite skips it silently and ships a literal "latest", which is Defect B1. The spec asked to extend the loop to devDependencies, not to revert this.

4. Spec says "seven" SDK libs — there are eleven

ai, cli, create-app, types are missing from contracts/package-json-shape.md and from AC-003's count. The CI validator does walk all 11 (verified, 11/11 green), so nothing breaks — but the signed contract is incomplete and sdk_packages iterates all 11.


Concerns

5. pin-examples leaves the release tag pointing at a pre-pin commit. create-branch computes release_commit=$(git log -1 --pretty=%H) before the new step, and Create GitHub Release uses it as target_commitish. The pin only exists on the branch tip.

6. pin-examples is not idempotent. CHANGED=true is set when a @dotcms/* entry exists, not when its value changed. If the pin is a no-op, git add examples stages nothing, git commit exits 1, set -e kills the step, and Create GitHub Release (if: success()) never runs — branch pushed, no release. The variable measures FOUND, not CHANGED.

7. The second git push cancels the LTS run the first one started. cicd_5-lts.yml triggers on push: release-* with cancel-in-progress: true on ${{ github.workflow }}-${{ github.ref }}.

5, 6 and 7 all disappear by folding the pin into the create-branch commit (diff C).

8. Latent false positive: master blocks PRs. cicd_1-pr.yml accepts branches: [main, master]; the validator compares branch !== 'main':

$ node dist/index.js examples/nextjs/package.json --branch master
  - dependencies["@dotcms/client"] is "latest" on branch "master" — only "main" may float
  exit=1

The step comment says "so 'main' is always the correct branch context here" right after naming main/master — it contradicts itself.

9. False negative: version ranges pass on release branches. AC-005/AC-010 require an exact pin; the validator only checks latest/next/*:

$ echo '{"dependencies":{"@dotcms/client":"^26.9.3-1","@dotcms/uve":"~1.2.0","@dotcms/react":">=1.0.0"}}'
$ node dist/index.js ... --branch release-25.07.10_lts_v12
OK — 1 file(s) validated, no violations.        <-- should fail

Not hypothetical: action.yml:112-119 writes ^$RELEASE_VERSION into example manifests. A ^ on an LTS branch reintroduces the drift that caused Freshdesk #38677.

10. The validator's tests never run in CI. The step runs npm ci && npm run build, never npm test. Also, the job's if: only checks sdk_libs/examples — so changing the validator itself does not trigger the job. You can break the guardrail and CI stays green.

11. Missing hardening on both new jobs:

  • No permissions: — not at job or workflow level (neither cicd_1-pr.yml nor cicd_5-lts.yml has a block), so it inherits the repo default. This matters most in cicd_1-pr.yml: npm ci runs against a PR-controlled package.json and executes lifecycle scripts. That workflow's own header warns "PR checks are run on code that is not yet merged". Needs permissions: contents: read + npm ci --ignore-scripts.
  • No timeout-minutes — 360min default for a ~1min job.
  • No npm cache — one line in setup-node.

actionlint also reports SC2044 (for over find) on all three new scripts.


What's solid

  • Gating actually works. cicd_comp_finalize-phase.yml aggregates needs on result == "failure"|"cancelled" and exits 1; skipped is not treated as failure, so the change-detection if: doesn't break unrelated PRs. Correct by design.
  • Branch coverage is right. cicd_5-lts.yml fires on release-* (not just LTS), so AC-007 genuinely covers non-main. The per-event TARGET_BRANCH (base.ref vs ref_name) is well reasoned and documented.
  • All actions SHA-pinned with version comments.
  • filters.examples correctly wired end-to-end (filters.yamlinfo_filters → output JSON → job if:). Traced it fully.
  • Single normalization point, and the npm view idempotency check consumes the normalized value. The design is right; the implementation is what fails.
  • Validator is well written — typed, single-responsibility, comments tied to ACs. Ran it against all 17 real manifests (green) and it correctly catches the B1b regression.
  • bump-sdk-versions.js cleanly retired — no references outside the spec.

AC compliance

AC Status Evidence
AC-001 normalized version FAIL code is unreachable (#1); breaks prereleases (#2)
AC-002 normalized pins across 3 fields FAIL same, plus scope regression (#3)
AC-003 "0.0.0" in peerDeps PASS verified across the 7 relevant libs
AC-003b client/uve moved to peerDeps PASS react, angular, vue, analytics — verified
AC-004 six main examples on latest PASS 6/6 latest, zero next; 6 READMEs carry the note
AC-005 LTS pins N/A separate PRs #37475/#37476, declared
AC-006 pinned install across npm/yarn/pnpm N/A manual, not CI-verifiable
AC-007 CI guardrail PARTIAL works, but #8, #9, #10
AC-008 compareVersions regression lock PASS expect(compareVersions('26.08.03-01','26.8.3-1')).toBe(0)
AC-009 corrected READMEs PASS all three, naming yarn 1.x / npm <7 explicitly
AC-010 auto-pin at branch cut PARTIAL implemented, but #5, #6, #7

Suggested diffs

A) action.yml — restore .name resolution and fix the echo (closes #1, #3):

             for dep in "${sdk_packages[@]}"; do
-              for field in dependencies peerDependencies devDependencies; do
-                if jq -e ".${field}[\"@dotcms/$dep\"]" "$pkg" >/dev/null 2>&1; then
-                  jq --arg field "$field" --arg dep "@dotcms/$dep" --arg v "$RELEASE_VERSION" \
+              # Same reason as the publish step below: resolve the sibling's real name
+              # rather than assuming the @dotcms/ scope. libs/sdk/cli publishes as the
+              # UNSCOPED `dotcms`, so a hardcoded scope silently skips it.
+              dep_name=$(jq -r '.name' "$dep/package.json" 2>/dev/null || echo "@dotcms/$dep")
+              for field in dependencies peerDependencies devDependencies; do
+                if jq -e ".${field}[\"$dep_name\"]" "$pkg" >/dev/null 2>&1; then
+                  jq --arg field "$field" --arg dep "$dep_name" --arg v "$RELEASE_VERSION" \
                     '.[$field][$dep] = $v' "$pkg" > tmp.$$.json && mv tmp.$$.json "$pkg"
                   echo "    ↳ $field $dep_name -> $RELEASE_VERSION"
                 fi

B) Prerelease-preserving normalization (closes #2) — needed in both places (action.yml and cicd_comp_release-prepare-phase.yml):

-        RELEASE_VERSION=$(echo "$RELEASE_VERSION" | awk -F'[.-]' '{
-          for (i = 1; i <= NF; i++) { sub(/^0+/, "", $i); if ($i == "") $i = "0" }
-          out = $1 "." $2 "." $3
-          if (NF >= 4) out = out "-" $4
-          print out
-        }')
+        # Strip leading zeros from every purely-numeric segment while PRESERVING the
+        # original separators and all trailing segments. Rebuilding from only $1..$4
+        # silently truncates cicd_3-trunk.yml's `-next.<run_number>` suffix, which makes
+        # every `next` publish collide with the stable release version.
+        RELEASE_VERSION=$(echo "$RELEASE_VERSION" | awk '{
+          s = $0; out = ""
+          while (match(s, /[.-]/)) {
+            seg = substr(s, 1, RSTART - 1); sep = substr(s, RSTART, 1)
+            if (seg ~ /^[0-9]+$/) { sub(/^0+/, "", seg); if (seg == "") seg = "0" }
+            out = out seg sep
+            s = substr(s, RSTART + 1)
+          }
+          if (s ~ /^[0-9]+$/) { sub(/^0+/, "", s); if (s == "") s = "0" }
+          print out s
+        }')

Verified equivalent on every release-shaped input, and correct on prereleases:

26.08.03-01            -> 26.8.3-1              (same as PR)
26.10.20-01            -> 26.10.20-1            (same)
2026.06.24-01          -> 2026.6.24-1           (same; ADR-0019's literal example)
26.00.03-01            -> 26.0.3-1              (same)
26.9.3-1-next.2632     -> 26.9.3-1-next.2632    (PR gave 26.9.3-1)
26.08.03-01-next.2632  -> 26.8.3-1-next.2632    (PR gave 26.8.3-1)

POSIX awk, no gawk dependency.

C) Fold the pin into the branch-cut commit (closes #5, #6, #7) — move the loop into create-branch right before git commit -a, and drop the separate pin-examples step:

           git status
+          # Pin every example app's @dotcms/* deps to this release's normalized SDK
+          # version. Folded into THIS commit on purpose: a separate commit+push would
+          # leave the release tag (target_commitish, computed below) pointing at a
+          # pre-pin commit, and would re-trigger cicd_5-lts.yml, cancelling the run the
+          # first push just started.
+          for pkg_json in $(find examples -maxdepth 2 -name package.json 2>/dev/null); do
+            for dep in $(jq -r '.dependencies // {} | keys[] | select(startswith("@dotcms/"))' "$pkg_json"); do
+              jq --arg dep "$dep" --arg v "$NORMALIZED_VERSION" \
+                '.dependencies[$dep] = $v' "$pkg_json" > tmp.$$.json && mv tmp.$$.json "$pkg_json"
+            done
+          done
           git commit -a -m "🏁 Publishing release version [${release_version}]"
           git push origin ${release_branch}

git commit -a already picks the examples up, so the "nothing to commit" failure disappears. If you'd rather keep the step separate, then at minimum:

-          if [ "$CHANGED" = "true" ]; then
-            git add examples
+          git add examples
+          if ! git diff --cached --quiet -- examples; then
             git commit -m "🏁 Pin example app SDK dependencies to ${NORMALIZED_VERSION}"

…and move release_commit=$(git log -1 --pretty=%H) to after the pin.

D) Harden both new jobs (closes #10, #11) — in cicd_1-pr.yml and cicd_5-lts.yml:

   validate-sdk-package-shapes:
     name: Validate SDK package.json shapes
     needs: [ initialize ]
     if: >-
       always() && !cancelled() &&
       (fromJSON(needs.initialize.outputs.filters).sdk_libs == 'true' ||
-       fromJSON(needs.initialize.outputs.filters).examples == 'true')
+       fromJSON(needs.initialize.outputs.filters).examples == 'true' ||
+       fromJSON(needs.initialize.outputs.filters).cicd == 'true')
     runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }}
+    timeout-minutes: 10
+    permissions:
+      contents: read
     steps:
       - name: 'Checkout'
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

       - name: 'Setup Node'
         uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
         with:
           node-version-file: 'core-web/.nvmrc'
+          cache: npm
+          cache-dependency-path: .github/scripts/validate-sdk-package-shapes/package-lock.json

-      - name: 'Build validator'
+      - name: 'Build and test validator'
         working-directory: .github/scripts/validate-sdk-package-shapes
         run: |
-          npm ci
+          # --ignore-scripts: on cicd_1-pr.yml this manifest is PR-controlled, and
+          # lifecycle scripts would execute arbitrary code from an untrusted branch.
+          npm ci --ignore-scripts
           npm run build
+          # The guardrail's own tests — without this, breaking the validator is a green build.
+          npm test
         shell: bash

E) Validator — accept master, reject ranges (closes #8, #9):

 const FLOATING_SPECIFIERS = new Set(['latest', 'next', '*']);
+/** cicd_1-pr.yml triggers on PRs to `main` OR `master` — both are the floating-allowed trunk. */
+const FLOATING_ALLOWED_BRANCHES = new Set(['main', 'master']);
+/** AC-005/AC-010 require an EXACT pin on release branches — `^`/`~`/ranges drift forward too. */
+const RANGE_SPECIFIER = /^[\^~><=]|\s\|\|\s|\s-\s|x$/;
@@
         if (version === 'next' || version === '*') {
             violations.push(...);
-        } else if (version === 'latest' && branch !== 'main') {
+        } else if (
+            !FLOATING_ALLOWED_BRANCHES.has(branch) &&
+            (version === 'latest' || RANGE_SPECIFIER.test(version))
+        ) {
             violations.push(
-                `examples: dependencies["${dep}"] is "latest" on branch "${branch}" — only "main" may float; ...`
+                `examples: dependencies["${dep}"] is "${version}" on branch "${branch}" — only main/master may float; this branch must pin an exact version`
             );
         }

action.yml:112-119 should also write $RELEASE_VERSION without the ^, so the pipeline stops producing a shape its own guardrail would reject.


How to verify

actionlint .github/workflows/cicd_1-pr.yml .github/workflows/cicd_5-lts.yml \
           .github/workflows/cicd_comp_release-prepare-phase.yml \
           .github/actions/core-cicd/deployment/deploy-javascript-sdk/action.yml
cd .github/scripts/validate-sdk-package-shapes && npm ci --ignore-scripts && npm run build && npm test

Beyond this PR: the normalization point needs to be testable without cutting a release. Extract it into .github/scripts/ with a unit test (26.08.03-01-next.2632 → expected output) and have action.yml call it. As long as the only way to exercise it is a real release, every change there is a blind bet — and this PR is the proof.

Resolves the CHANGES_REQUESTED review on PR #37477. The three blocking
findings made the SDK release pipeline deterministically broken.

deploy-javascript-sdk/action.yml
- Restore the sibling-name resolution deleted in the peerDependencies
  commit. `$dep_name` was still referenced by the echo at line 104 with
  no assignment left, and under `set -euo pipefail` that aborted the
  step on the first package carrying a sibling dep -- every SDK release,
  always. Resolution is now hoisted into an `sdk_package_names` array
  built once, instead of a jq call per package per field.
- Stop assuming the @dotcms/ scope. libs/sdk/cli publishes as the
  UNSCOPED `dotcms`, and hardcoding the scope reverts a fix the same
  file documents 100 lines below; a sibling depending on it would have
  shipped a literal "latest" (Defect B1).
- Normalize versions by walking segments instead of rebuilding from
  $1..$4. The old form truncated the `-next.<run>` suffix that
  cicd_3-trunk.yml appends (26.9.3-1-next.2632 -> 26.9.3-1), which would
  have made every `next` publish collide with the stable release
  version. Verified byte-identical on release-shaped inputs under mawk
  (the ubuntu-24.04 default), gawk and gawk --posix.
- Write example pins exact, without the caret. The pipeline must not
  emit a shape its own guardrail rejects.

cicd_comp_release-prepare-phase.yml
- Fold the example pin into the branch-cut commit and drop the separate
  pin-examples step. As a separate commit+push it left the release tag's
  target_commitish pointing at a pre-pin tree, re-triggered
  cicd_5-lts.yml (cancelling the run the first push started), and could
  abort on "nothing to commit" when the pin was a no-op.
- Preserve each manifest's indentation. jq reprints the whole document,
  so without this a four-value pin landed as a 121-line whole-file
  whitespace diff; examples/ mixes 2- and 4-space manifests.

validate-sdk-package-shapes
- Treat master as trunk: cicd_1-pr.yml accepts PRs to main or master,
  and the validator only allowed main.
- Reject ranges on release branches. AC-005/AC-010 require an exact pin,
  and ^/~/>=/1.2.x drift forward just as "latest" does. Implemented as
  an allow-list of exact versions rather than a list of operators, so
  unanticipated range syntax cannot slip through.
- 11 new tests (21 total).

cicd_1-pr.yml / cicd_5-lts.yml
- Run the guardrail's own tests. Without `npm test` a broken validator
  was a green build.
- New sdk_package_shapes filter so editing the validator triggers the
  job that runs it. Kept separate from sdk_libs, which also gates the
  trunk npm publish.
- permissions: contents: read, timeout-minutes, npm cache, and
  `npm ci --ignore-scripts` -- on cicd_1-pr.yml that manifest is
  PR-controlled and lifecycle scripts would run unmerged code.
- Iterate find output with while-read (actionlint SC2044).

contracts/package-json-shape.md
- Record that libs/sdk holds eleven directories, that the other four
  declare no @dotcms/* dependency, and that cli publishes unscoped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@KevinDavilaDotCMS

Copy link
Copy Markdown
Contributor Author

@dcolina Thanks — this was a genuinely valuable review. All 11 findings are addressed in dc1ac04. Summary of what I took, what I implemented differently, and where I think two of your points need adjusting.

Blocking — you were right, and the impact was exactly as you described

#1 $dep_name unbound. Confirmed and fixed. Rather than just restoring the assignment inside the loop, I hoisted resolution into an sdk_package_names array built once after sdk_packages, so the two loops (libs and examples) share it instead of running a jq per package per field.

#2 prerelease truncation. Confirmed and fixed with your segment-walking approach. I traced the chain you described: cicd_3-trunk.yml:177 builds NEXT_VERSION and passes it as the version: input to this same action at line 193. Applied in both places, as you noted.

#3 scope regression. Confirmed — libs/sdk/cli publishes as dotcms, and the comment 100 lines below in the same file explains exactly why hardcoding the scope is wrong. Fixed by the same hoist as #1. I also added a direct regression test: injecting "dotcms": "latest" as a sibling dep now gets rewritten instead of silently skipped.

#5 / #6 / #7 — diff C taken as-is. Folded into the branch-cut commit, standalone pin-examples step removed.

Where I implemented it differently

#9 ranges — allow-list, not operator blacklist. Your regex would work, but enumerating ^~><=, ||, -, x$ leaves whatever syntax nobody thought of passing silently. I inverted it: on any non-trunk branch a @dotcms/* pin must match /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/, and anything else fails. Same outcome for your cases, closed by construction for the rest.

#10 — your diff D doesn't fix the second half. It proposes fromJSON(...).cicd == 'true', but there is no cicd key in .github/filters.yaml; the closest is area_cicd, and that covers .github/workflows/** and .github/actions/**, not .github/scripts/**. So editing the validator still wouldn't have triggered the job. I added a dedicated sdk_package_shapes filter pointing at the validator's own source, registered in info_filters. Kept separate from sdk_libs on purpose: that one also gates the trunk npm publish job, and touching the validator must not trigger an SDK release.

Everything else in D taken: npm test, npm ci --ignore-scripts, permissions: contents: read, timeout-minutes, npm cache, plus the SC2044 while read conversion.

Two points that need adjusting

#11 — "neither cicd_1-pr.yml nor cicd_5-lts.yml has a permissions block" is not accurate. cicd_1-pr.yml has three, at lines 56, 77 and 101. What was missing is a block on this job. The finding stands and I fixed it; the supporting argument doesn't.

#9 — "not hypothetical" doesn't hold. You justify the urgency with action.yml:112-119 writing ^$RELEASE_VERSION into example manifests. That action never runs git add/commit/push — those are ephemeral runner-workspace edits for npm publish, and examples aren't published to npm, so the caret never reaches the repo. The pipeline was not emitting a shape its own guardrail would reject. The validator gap is real and worth closing (a hand-written ^ on an LTS example would slip through), but the risk is smaller than stated. I dropped the caret anyway — there's no reason for it.

#4 — documentation only, not a Must fix. Eleven directories, contract lists seven. I checked the other four: ai, cli, create-app and types declare zero @dotcms/* entries in dependencies, peerDependencies or devDependencies. There was no functional gap and the fix missed nothing. Added a note to contracts/package-json-shape.md recording the count, that the four carry no sibling deps, and that cli publishes unscoped.

#6 — real in the code, but unreachable in practice. Building the counterfactual surfaced this: CHANGED does measure found rather than changed, exactly as you say, but the old step could never hit the no-op path. jq without --indent reformats, so the three 4-space manifests always produced a diff and there was always something to commit. It would only have fired if main's examples were already pinned and already in jq's exact output format. Fixed regardless — the fold makes it moot.

A defect neither of us flagged

The pin loop called jq without --indent, and jq reprints the whole document. The release commit would have rewritten all six manifests: 121 insertions / 121 deletions for a four-value change. examples/ mixes 2-space (angular, angular-ssr, astro) and 4-space (nextjs, nextjs-experiments, vuejs), so no fixed --indent works. Now detected per file — 26/26, both styles preserved, non-@dotcms deps untouched. This existed in the original step too.

On your closing point — agreed, and it's the most important one

the normalization point needs to be testable without cutting a release

You're right, and #1 and #2 are the proof. I did not do the extraction in this PR — it's a refactor of the release path beyond answering the review — but I'll open a follow-up for it.

What I did instead was stop testing by hand. Every check now extracts the step body verbatim from the YAML and runs it, so the test can't drift from the code:

  • update-versions against scratch copies of the real manifests: 11/11 packages, exit 0 on stable, -next.<run> and the ADR-0019 input
  • The normalization verified byte-identical under mawk (the ubuntu-24.04 default), gawk and gawk --posix inside ubuntu:24.04. Worth calling out: everything I'd checked before that ran on BSD awk, so this was an open assumption
  • create-branch against a real git repo with a bare origin and a post-receive push counter: tag target_commitish carries the pin, exactly 1 push, no-op re-run doesn't abort, 26-line diff. The same harness run against the pre-fix code fails all four — without that counterfactual the harness would prove nothing
  • The CI job verbatim in a clean node:22.22.3 container, no cache: 21/21 tests, 11 libs + 6 examples, exit 0
  • 10 guardrail probes, negative and positive, all correct

Test plan

You were right that none of the ten boxes exercised the highest-blast-radius file. The normalization box was worse than useless — it claimed a verification that could not have run, since the step aborted on the first package. I've struck it in the description with a note, and listed the re-verification above it.

The one thing not provable locally is the filter wiring (sdk_package_shapesdorny/paths-filterinitialize outputs → job if:). This push's CI run covers it.

Ready for another look.

@dcolina dcolina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Go for it

@KevinDavilaDotCMS
KevinDavilaDotCMS added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 6ca627f Sep 10, 2026
59 checks passed
@KevinDavilaDotCMS
KevinDavilaDotCMS deleted the 36891-sdk-packaging-version-fix-impl-v2 branch September 10, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : CI/CD PR changes GitHub Actions/workflows Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

SDK packaging: malformed published version strings and floating/orphaned dist-tags in SDK sources and example apps

2 participants