Skip to content

fix(cicd): resolve the LTS example pin by date instead of writing an invalid version - #37504

Merged
KevinDavilaDotCMS merged 2 commits into
mainfrom
36891-lts-example-pin-resolution-new
Sep 11, 2026
Merged

fix(cicd): resolve the LTS example pin by date instead of writing an invalid version#37504
KevinDavilaDotCMS merged 2 commits into
mainfrom
36891-lts-example-pin-resolution-new

Conversation

@KevinDavilaDotCMS

@KevinDavilaDotCMS KevinDavilaDotCMS commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #37477. The auto-pin added there wrote the normalized RELEASE version into every example manifest, for LTS branches too. For an LTS that produces 26.9.15_lts_v1: not valid semver, never published to npm, and rejected by the guardrail the same PR added -- so the next LTS cut would have shipped six uninstallable example apps and turned its own branch red.

The root cause is that cicd_release-sdk.yml SKIPS the SDK publish for LTS releases (IS_LTS != 'true', an open policy question per its own comment), so no SDK exists at an LTS release's own version. There is nothing to derive from the version string.

The pin is now decided by what the branch is cut FROM, never by parsing the version:

  • already exact -> an existing LTS lineage, already frozen at a compatible SDK. Left untouched; moving a line is a reviewed commit.
  • floating, non-LTS -> the version being released (ADR-0019 date lockstep).
  • floating, LTS -> the newest SDK published ON OR BEFORE the branch's code date, so it can only ever be at or behind the server, never ahead of it -- the direction behind Freshdesk #38677.

Deriving from the release NAME (26.09.15_lts_v1 -> 26.9.15-1) was rejected: 13 dates in 2026 carry 2-4 releases (26.08.19 has four), so assuming counter -01 silently pins a real but WRONG published build, and git describe cannot disambiguate because release tags point at each release branch's own commit and are never ancestors of main.

Three guards, each failing the release rather than shipping a bad pin:

  1. Shape - must be an exact version.
  2. Existence - every @dotcms/* package the examples reference is confirmed
    published, but ONLY for a pin that should already exist. A
    normal release pins what this pipeline publishes moments
    later, so checking it there would fail every release.
  3. Floor - the pin must be >= MinSdkVersion.VALUE, the oldest SDK the
    branch's own server advertises via X-DotCMS-Min-SDK.

Also: an LTS PATCH now requires an explicit release_commit. Without one it defaults to HEAD of main, silently shipping main's code under an LTS version. Only patches are guarded; the first cut of a line legitimately branches off main and is identified by the line having no tag yet.

The decision lives in .github/scripts/resolve-sdk-pin rather than inline shell. As shell it could only be exercised by cutting a real release, and three defects reached review or main that way: an existence check that aborted every normal release, an npm-latest lookup that handed a back-dated LTS line an SDK newer than its own server, and the LTS-shaped string above. Each is now a named test under describe('regressions'). The module is dependency-free so the release path invokes it with a bare node -- no install, no build, nothing that a registry hiccup can break.

51 unit tests, all six deliberate mutations of the logic killed. Integration verified by extracting the workflow steps verbatim and running them against real manifests in the runner image.

Refs #36891

Proposed Changes

  • change 1
  • change 2

Checklist

  • Tests
  • Translations
  • Security Implications Contemplated (add notes if applicable)

Additional Info

** any additional useful context or info **

Screenshots

Original Updated
** original screenshot ** ** updated screenshot **

This PR fixes: #36891

…invalid version (#36891)

Follow-up to #37477. The auto-pin added there wrote the normalized RELEASE
version into every example manifest, for LTS branches too. For an LTS that
produces `26.9.15_lts_v1`: not valid semver, never published to npm, and
rejected by the guardrail the same PR added -- so the next LTS cut would have
shipped six uninstallable example apps and turned its own branch red.

The root cause is that cicd_release-sdk.yml SKIPS the SDK publish for LTS
releases (IS_LTS != 'true', an open policy question per its own comment), so
no SDK exists at an LTS release's own version. There is nothing to derive from
the version string.

The pin is now decided by what the branch is cut FROM, never by parsing the
version:

- already exact  -> an existing LTS lineage, already frozen at a compatible
                    SDK. Left untouched; moving a line is a reviewed commit.
- floating, non-LTS -> the version being released (ADR-0019 date lockstep).
- floating, LTS  -> the newest SDK published ON OR BEFORE the branch's code
                    date, so it can only ever be at or behind the server,
                    never ahead of it -- the direction behind Freshdesk #38677.

Deriving from the release NAME (26.09.15_lts_v1 -> 26.9.15-1) was rejected:
13 dates in 2026 carry 2-4 releases (26.08.19 has four), so assuming counter
-01 silently pins a real but WRONG published build, and `git describe` cannot
disambiguate because release tags point at each release branch's own commit
and are never ancestors of main.

Three guards, each failing the release rather than shipping a bad pin:
  1. Shape     - must be an exact version.
  2. Existence - every @dotcms/* package the examples reference is confirmed
                 published, but ONLY for a pin that should already exist. A
                 normal release pins what this pipeline publishes moments
                 later, so checking it there would fail every release.
  3. Floor     - the pin must be >= MinSdkVersion.VALUE, the oldest SDK the
                 branch's own server advertises via X-DotCMS-Min-SDK.

Also: an LTS PATCH now requires an explicit release_commit. Without one it
defaults to HEAD of main, silently shipping main's code under an LTS version.
Only patches are guarded; the first cut of a line legitimately branches off
main and is identified by the line having no tag yet.

The decision lives in .github/scripts/resolve-sdk-pin rather than inline shell.
As shell it could only be exercised by cutting a real release, and three
defects reached review or main that way: an existence check that aborted every
normal release, an npm-`latest` lookup that handed a back-dated LTS line an SDK
newer than its own server, and the LTS-shaped string above. Each is now a named
test under describe('regressions'). The module is dependency-free so the
release path invokes it with a bare `node` -- no install, no build, nothing
that a registry hiccup can break.

51 unit tests, all six deliberate mutations of the logic killed. Integration
verified by extracting the workflow steps verbatim and running them against
real manifests in the runner image.

Refs #36891

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

@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.

Reviewed from a CI/CD angle, using specs/37399-sdk-packaging-version-fix/contracts/package-json-shape.md as the checklist. The design is right and the verification story holds up under independent checking — one blocker in the shell guard, everything else is non-blocking.

What I verified, not just read

  • 51/51 unit tests pass.
  • Mutation testing: I re-ran your six mutations independently — all killed. One note: published <= cutoff< survives, but it's an equivalent mutant (nothing publishes at exactly T23:59:59.999Z), so it isn't a test gap.
  • End-to-end against the real npm registry: --release-version 26.08.08_lts_v1 --is-lts true{"action":"pin","version":"26.8.7-1","cutoff":"2026-08-08"} with all 7 packages existence-checked. That is exactly the contract's worked example.
  • actions/setup-node@8207627… does resolve to v7.0.0.
  • No credential surface: no tokens in the module, registry reads unauthenticated, --ignore-scripts on a PR-controlled manifest, permissions: contents: read, and the workflow is pull_request rather than pull_request_target.

Moving the decision out of inline shell and naming the regressions after the defects that caused them is the right call — that part I have no notes on.


Blocker — the LTS-patch guard fails open

.github/workflows/cicd_comp_release-prepare-phase.yml, step Set Version Variables:

prior_tags=$(git ls-remote --tags https://github.com/dotCMS/core.git "v${lts_line}_lts_v*" | wc -l | tr -d '[:space:]')

The step declares no shell:, so it runs under the runner default bash -ewithout pipefail. The pipeline's exit status is tr's, so a failing git ls-remote (DNS blip, rate limit, GitHub degradation) is invisible: stdout is empty, wc -l prints 0, prior_tags becomes "0", the guard concludes "first cut" and the release proceeds.

Reproduced against the guard's exact logic under bash -e:

fatal: unable to access 'https://github.com/dotCMS/core.git/': Could not resolve host
exit status seen by the script: 0
prior_tags='0'
>>> guard does not fire -> release proceeds, LTS patch cut from main

That is the precise outcome the guard exists to prevent, and by your own comment — "Nothing downstream can detect that, so refuse here" — this is the only place it can be caught. A transient network error silently ships main's code under an LTS version.

Separating the fetch from the count is enough:

if ! prior_tag_refs=$(git ls-remote --tags https://github.com/dotCMS/core.git "v${lts_line}_lts_v*"); then
  echo "::error::Could not reach the remote to check for prior ${lts_line} tags. Refusing to cut ${release_version} rather than risk branching a patch from main."
  exit 1
fi
prior_tags=$(printf '%s' "${prior_tag_refs}" | grep -c . || true)

Adding shell: bash to the step (which brings pipefail) would also close it, but I'd prefer the explicit check — it matches the reasoning you already applied to the resolve-sdk-pin invocation a few lines down.


Non-blocking

1. Contradictory leftover comment — same file, the pin block. The first table still states the LTS pin should be npm's current "latest", which "can only ever be at or behind that code, never ahead of it". Twenty lines later the replacement block correctly says latest "would be ahead of the server for a line cut from older code". The stale block documents the exact defect this PR removes, as though it were the design. Given the premise here is that this reasoning has to survive somewhere readable, it's worth deleting.

2. Contract says two guards, the code has three. package-json-shape.md — updated in this PR — documents Shape + Existence, while decide() also enforces the MinSdkVersion floor that the PR description counts as guard 3. One line in the contract closes the gap. Worth flagging that the floor is a no-op today (MinSdkVersion.VALUE = "0.0.0"), so its tests are the only thing exercising it.

3. The resolver's tests don't run when its caller changes. The sdk_package_shapes filter covers .github/scripts/resolve-sdk-pin/**, but editing cicd_comp_release-prepare-phase.yml — the only caller, and the owner of the CLI's argument contract — won't trigger them. Adding that workflow to the filter would keep the two in sync.

4. Nit. The new Test resolve-sdk-pin step runs npm ci uncached; setup-node's cache-dependency-path only points at the validator's lockfile. A second path entry saves the install.


Happy to re-review as soon as the guard is closed — the rest of this is solid, and the regression tests are the kind of thing that pays for itself the next time someone touches this path.

…nt block

Addresses @dcolina's review on #37504.

Blocker: the LTS-patch guard failed open. `Set Version Variables` declares no
`shell:`, so it runs under the runner default `bash -e` WITHOUT pipefail, and a
pipeline's exit status is its last command's. In

  prior_tags=$(git ls-remote ... | wc -l | tr -d '[:space:]')

a failing `ls-remote` (DNS, rate limit, GitHub degradation) is invisible: the
script sees `tr`'s exit 0, stdout is empty, prior_tags becomes "0", the guard
concludes "first cut" and the release proceeds -- cutting an LTS patch from
main, which is the exact outcome the guard exists to prevent and, by its own
comment, the only place it can be caught.

The fetch is now checked on its own before counting. Reproduced both ways in
the runner image: with the network the guard fires on all four cases; with the
network cut it now aborts with an actionable error instead of exiting 0.

Also from the review:
- Removed a 42-line comment block left stacked above the current one. It
  described the npm-`latest` resolution as the design, including the claim that
  it "can only ever be at or behind that code, never ahead of it" -- the exact
  false premise this PR removes -- and contradicted the correct block 20 lines
  below it.
- The contract documented two guards; the code enforces three. Added the
  MinSdkVersion floor, and recorded that it is a no-op today (VALUE = "0.0.0")
  so its unit tests are the only thing exercising it.
- `sdk_package_shapes` now covers cicd_comp_release-prepare-phase.yml, the
  resolver's only caller and the owner of the CLI's argument contract, so
  changing a flag there runs the tests for the module it invokes.
- `cache-dependency-path` now includes the resolver's lockfile.

No change to resolve.js: same logic, same three guards, 51/51 unit tests and
28/28 integration assertions unchanged.

Refs #36891

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

Copy link
Copy Markdown
Contributor Author

@dcolina All five applied in b8457ed. resolve.js is untouched — the changes are the shell guard, comments, the contract, and CI config.

Blocker — closed

You were right about the mechanism and about the consequence. Reproduced it in the runner image before and after, with git present so the failure is genuinely ls-remote and not a missing binary:

before, network cut:  exit status seen by the script = 0
                      prior_tags='0'  ->  guard silent, release proceeds

after,  network cut:  exit=1
                      ::error::Could not reach the remote to check for prior
                      25.07.10 LTS tags. Refusing to cut 25.07.10_lts_v19 rather
                      than risk branching a patch from main.

after,  network up:   all four cases correct — patch without commit aborts,
                      patch with commit passes, first cut of a line passes,
                      normal release passes

Took your explicit-check version rather than adding shell: bash, for the reason you gave: it matches the reasoning already applied to the resolve-sdk-pin invocation below it, and it does not silently change the shell semantics of the other ~120 lines in that step.

Non-blocking — all four applied

1. The stale comment was worse than you flagged. Not a leftover sentence: a full 42-line block left stacked above the current one, describing the npm-latest resolution as the design — including "can only ever be at or behind that code, never ahead of it", the exact false premise this PR exists to remove. My error when I swapped the code block and left its old header in place. Deleted.

2. Contract now says three guards and documents the MinSdkVersion floor, including your point that it is a no-op today at VALUE = "0.0.0", so its unit tests are the only thing exercising it.

3. sdk_package_shapes now covers cicd_comp_release-prepare-phase.yml. Checked the blast radius first: that filter is consumed only by the two job if: conditions and the info_filters list, so the effect is confined to that ~25s job running on more PRs.

4. cache-dependency-path now lists both lockfiles.

On the equivalent mutant

Your published <= cutoff< observation is correct and worth recording: npm publish timestamps carry millisecond precision and nothing lands at exactly T23:59:59.999Z, so the two are indistinguishable on any real registry data. Not a test gap. I had not checked that one.

Verification after the changes

  • resolve-sdk-pin 51/51, validate-sdk-package-shapes 21/21, both from a clean npm ci --ignore-scripts in node:22.22.3
  • 28/28 integration assertions, zero failures — the workflow steps re-extracted verbatim from the current YAML, so the harness cannot drift from what actually ships
  • actionlint clean on all three workflows

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 11, 2026
Merged via the queue into main with commit 13ff170 Sep 11, 2026
57 checks passed
@KevinDavilaDotCMS
KevinDavilaDotCMS deleted the 36891-lts-example-pin-resolution-new branch September 11, 2026 19:55
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

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