fix(cicd): resolve the LTS example pin by date instead of writing an invalid version - #37504
Conversation
…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
left a comment
There was a problem hiding this comment.
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 exactlyT23: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-scriptson a PR-controlled manifest,permissions: contents: read, and the workflow ispull_requestrather thanpull_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 -e — without 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>
|
@dcolina All five applied in b8457ed. Blocker — closedYou were right about the mechanism and about the consequence. Reproduced it in the runner image before and after, with Took your explicit-check version rather than adding Non-blocking — all four applied1. 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- 2. Contract now says three guards and documents the 3. 4. On the equivalent mutantYour Verification after the changes
Ready for another look. |
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:
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 describecannot 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:
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.
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-
latestlookup 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 barenode-- 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
Checklist
Additional Info
** any additional useful context or info **
Screenshots
This PR fixes: #36891