diff --git a/.claude/skills/release-pipeline/SKILL.md b/.claude/skills/release-pipeline/SKILL.md index 314db893..a0564e7f 100644 --- a/.claude/skills/release-pipeline/SKILL.md +++ b/.claude/skills/release-pipeline/SKILL.md @@ -15,7 +15,7 @@ before answering "is X supported" — they answer different questions. |---|---| | Projects | `boot`, `framework`, `security` | | Version format | GA `major.minor.patch` only — M/RC/SNAPSHOT are rejected by `isGaVersion` | -| Buildable ranges | `boot` → `3.3.0`–`<4.0.0` (synthesized) and `>= 4.0.8` (archive); `framework` → `>= 6.1.0` (overlay); `security` → `>= 6.2.0` (overlay, two eras) | +| Buildable ranges | `boot` → `3.3.0`–`<4.0.0` and `4.0.0`–`<4.0.8` (both synthesized, different paths) and `>= 4.0.8` (archive); `framework` → `>= 6.1.0` (overlay); `security` → `>= 6.2.0` (overlay, two eras) | | Published | check `catalog.json`; an empty `projects` object means nothing has shipped yet | A project is a sequence of **layout eras** (`LayoutEra`, ADR-0004), not a single floor. An era @@ -25,6 +25,7 @@ without the other yields a tree that classifies but converts wrongly: | Era | Component path | `assembly.descriptor` | Generated half comes from | Needs Maven Central | |---|---|---|---|---| | `boot` `3.3.0` – `<4.0.0` | `spring-boot-project/spring-boot-docs/src/docs/antora` | `synthesized` | rebuilt from the tag (`SynthesisSources`) plus the eight published `spring-boot-*` jars carrying configuration-property metadata | yes — metadata jars | +| `boot` `4.0.0` – `<4.0.8` | `documentation/spring-boot-docs/src/docs/antora` | `synthesized` | the same reconstruction, against the paths 4.x moved (`documentation/…/src/main`, `platform/spring-boot-dependencies/build.gradle`) and the 103 `spring-boot-*` jars Boot 4's module split carries the metadata in | yes — metadata jars | | `boot` `>= 4.0.8` | `documentation/spring-boot-docs/src/docs/antora` | `archive` | the published `root-aggregate-content` zip, merged over the checkout | yes — content zips | | `framework` `>= 6.1.0` | `framework-docs` | `overlay` | the committed `antora.yml`, topped up with the version and the attributes the build contributes | no | | `security` `6.2.0` – `<6.5.1` | `docs` | `overlay` | the committed `antora.yml`, topped up with attributes derived from `gradle/libs.versions.toml` and `gradle.properties` | no | @@ -34,12 +35,14 @@ without the other yields a tree that classifies but converts wrongly: that project's `generateAntoraResources` actually produces. Spring Framework's is one attribute (`spring-version`), so nothing is downloaded at all. -Eras are deliberately **not contiguous**, and `eraFor` returns `undefined` between them: +Eras need not be contiguous, and `eraFor` returns `undefined` below the oldest floor: - **3.2 and older** predate the Antora component (`antora.yml` first appears at v3.3.0). -- **4.0.0–4.0.7** moved to the 4.x path but publish no content archive, so they belong to - neither era and are refused rather than fetched from a path their tag does not have. -- **3.3–3.x archives exist but are unreachable** — Spring's +- **4.0.0–4.0.7** moved to the 4.x path while still publishing no content archive. The two + halves moved apart, so they get their own synthesized era rather than widening a + neighbour: taking the 3.x era's paths would fetch a directory their tag does not have, and + taking the archive era's assembly would merge a zip that 404s. +- **3.3–4.0.7 archives exist but are unreachable** — Spring's `sync-to-maven-central/artifacts.spec` excludes `spring-boot-docs` from the sync, and `repo.spring.io` returns 401 anonymously. Hence synthesis rather than download. - **4.1.0** is a *publication* fact, not a layout one: it is inside the archive era but its @@ -50,9 +53,11 @@ The archive era's floor is not a compatibility guess either: upstream published 2.2.x–2.4.2, then not again until 4.0.8. The synthesized era's 3.3.0 floor has nothing to do with it — 3.3.x–3.x rebuilds from the tag's BOM, attributes file and metadata jars instead. -3.x releases omit the generated appendix (auto-configuration listings and configuration-property -tables, ~101 pages) — it is a Gradle build output with no published equivalent. The prose corpus -is complete. +Synthesized releases omit the generated appendix (auto-configuration listings and +configuration-property tables) — it is a Gradle build output with no published equivalent. +~101 pages for 3.3–3.x; 92 for 4.0.0–4.0.7, which convert 147 pages against 4.0.8's 239. The +prose corpus is complete either way, and leaves the same two unresolved attribute references +the 4.0.8 archive build does. ## What is buildable right now diff --git a/.please/docs/decisions/0006-synthesize-4-0-x-component.md b/.please/docs/decisions/0006-synthesize-4-0-x-component.md new file mode 100644 index 00000000..423c4e27 --- /dev/null +++ b/.please/docs/decisions/0006-synthesize-4-0-x-component.md @@ -0,0 +1,162 @@ +# ADR-0006: Reconstruct the Generated Component for Spring Boot 4.0.0-4.0.7 + +## Status + +Accepted — 2026-09-17 + +## Context + +ADR-0004 models each project as a sequence of **layout eras** and reconstructs the generated +half of the Antora component for eras that publish no content archive. It left 4.0.0-4.0.7 +belonging to no era at all, so `eraFor` returned `undefined`, `detect-upstream-versions.ts` +never offered them and `fetch-upstream.ts` refused them. + +That gap was a statement about the era table, not about the versions. Measured 2026-09-17, +they are archive-less on the 4.x path — the same two-sided situation that produced ADR-0004: + +1. **No content archive.** `spring-boot-docs--root-aggregate-content.zip` answers 404 for + 4.0.0 and 4.0.7, and 200 from 4.0.8. Spring's Maven Central sync exclusion outlives the + 4.0.0 restructure by eight patches. +2. **The component had already moved.** 4.0.0 relocated it from + `spring-boot-project/spring-boot-docs/src/docs/antora` to + `documentation/spring-boot-docs/src/docs/antora`, where the 3.3-3.x era's paths do not + resolve. + +So the component path and the assembly changed at different versions, and neither existing +era covers the eight releases between them. + +Three of ADR-0004's four synthesis inputs also moved or changed shape, which is why this was +a conversion change rather than a range widening: + +| `SynthesisSources` field | 3.3-3.x | 4.0.0 | +|---|---|---| +| `examplesPath` | `spring-boot-project/spring-boot-docs/src/main` | `documentation/spring-boot-docs/src/main` | +| `bomBuildScriptPath` | `spring-boot-project/spring-boot-dependencies/build.gradle` | `platform/spring-boot-dependencies/build.gradle` | +| `staticAttributesPath` | unchanged | unchanged — 123 entries against 3.5.16's 111, same `key=value` shape | +| `gradlePropertiesPath` | unchanged | unchanged | +| `metadataArtifacts` | 8 `spring-boot-*` jars | 103 — Boot 4 split three module trees into ~140 projects | + +`AntoraAsciidocAttributes.java` changed with them: 4.x reads Jackson 3 from the +`tools.jackson` coordinates, keeps the 2.x line as a second `Jackson 2 Bom` library behind a +new `version-jackson2-databind`, and drops `pulsar-client-reactive-api`. The port in +`antora-attributes.ts` hard-coded the 3.x coordinates, so running it unchanged against a 4.x +BOM silently withholds four attributes the corpus links through. + +## Decision + +Declare a **third era** for `4.0.0`-`<4.0.8`: the 4.x component path with ADR-0004's +synthesized assembly, against the inputs 4.x moved. + +Widening an existing era cannot express this. An era pins the component path *and* the +assembly together, and here the two moved apart — widening the 3.x era down would fetch +`spring-boot-project/…` from a tag that does not have it, and widening the archive era up +would merge a zip that 404s. + +Alongside it, split the port's `addDependencyVersion` table into two era-scoped constants, +`BOOT_3_MANAGED_VERSIONS` and `BOOT_4_MANAGED_VERSIONS`, carried through +`SynthesisSources.managedVersionAttributes` into both `synthesizeAttributes` and +`versionSourceBoms`. Threading one table through both is what keeps the BOM fetch and the +attribute synthesis from disagreeing about which coordinates exist. The table is era-scoped +rather than a union because the difference lives in upstream's Java source, not in the +checkout: `com.fasterxml.jackson.core:jackson-databind` is managed in **both** lines, so a +union would emit `version-jackson2-databind` for 3.x, where upstream names no such attribute. + +The 103-jar metadata set was measured two independent ways that agree exactly, rather than +derived from the 3.x list: + +- the artifacts the 4.0.8 content archive ships a `spring-configuration-metadata.json` + partial for, and +- the artifacts publishing a 4.0.0 *and* a 4.0.7 jar carrying + `META-INF/spring-configuration-metadata.json`. + +Of the 34 other modules v4.0.0 declares, 4 publish no jar and 30 publish one with no metadata +file — `spring-boot-test` among them, as in 3.x. + +## Verification + +4.0.8 is ground truth for this era the way 4.1.1 was for ADR-0004's, and a tighter one: it is +one patch above the range, so it shares the module split, the moved paths and the Jackson +coordinates. Running the reconstruction over 4.0.8's checked-out inputs and diffing against +the descriptor its content archive ships: + +- 876 of 876 attributes **identical**, 0 differing, 0 missing +- 24 extra, all the `version--docs` / `-javadoc` pairs #199 added + deliberately for 3.3.4-3.3.5 + +End to end under `--strict`, against the 4.0.8 archive build as the control: + +| Counter | 4.0.0 | 4.0.7 | 4.0.8 (archive) | +|---|---|---|---| +| exit code | 0 | 0 | 0 | +| pages | 147 | 147 | 239 | +| synthesized attributes | 899 | 900 | n/a | +| unresolved attribute references | **2** | **2** | **2** | +| unresolved `configprop:` | 0 | 0 | 0 | +| empty `include-code::` groups | 1 | 1 | 1 | + +The two residual references are upstream's own: the `[source,shell]` blocks in +`tutorial/first-application` and `how-to/native-image/developing-your-first-application` omit +`subs="attributes"`, and docs.spring.io renders them literally too. They are the same two +files in all three builds, so the reconstruction leaves nothing the published archive resolves. + +3.x was checked for regression at 3.3.0 and 3.5.16: no `version-jackson2-databind`, Jackson +values unchanged on the 2.x coordinates. + +## Consequences + +### Positive + +- **Eight releases become buildable** with no change to the converter, the packager or the + release workflow. `detect-upstream-versions.ts --project boot` reports 4.0.0-4.0.7. +- **The era model absorbed a layout change it had not seen.** The path move and the + publication change happening at different versions is exactly the case + `componentPath` + `assembly` were pinned together for, and it cost one era entry. +- **The attribute port is now honestly era-scoped.** The Jackson move would have withheld four + attributes silently; the two tables make an upstream rename a visible diff in one place + rather than a missing `{version-…}` in the output. + +### Negative + +- **The generated appendix is lost here too.** 92 pages of auto-configuration listings and + configuration-property tables, so 4.0.0-4.0.7 ship 147 pages against 4.0.8's 239 — the same + Gradle-output gap ADR-0004 accepted for 3.x. +- **A third era to keep true.** Every future upstream restructure now has three synthesis + path sets and two managed-version tables to be checked against, not one. + +### Neutral + +- **Eras are no longer non-contiguous for Boot.** `eraFor` still returns `undefined` below + 3.3.0, and the ceiling logic is unchanged — 4.0.0 and 4.0.8 are simply now the two versions + that prove `until` is exclusive. +- **4.1.0 stays unbuildable**, and deliberately so: it sits inside the archive era with its + zip unpublished. That is a publication fact the availability gate catches, not an era gap. + +## Alternatives Considered + +- **Widen the 3.3-3.x era's ceiling to 4.0.8.** Rejected: an era pins one component path, and + 4.0.0 moved it. The sparse checkout would ask for `spring-boot-project/spring-boot-docs` at + a tag where it does not exist, failing before conversion. + +- **Widen the archive era's floor to 4.0.0.** Rejected on measurement: the + `root-aggregate-content` zip 404s for 4.0.0 and 4.0.7, so the merge has nothing to merge. + +- **Ship 4.0.0-4.0.7 from the git checkout alone.** Rejected for the reason ADR-0004 gives: + measured on 3.5.16 it produces 350 unresolved attribute references, 837 unresolved + `configprop:` macros and 326 empty `include-code::` groups, and unresolved markup reaches + the reader as literal text. + +- **One union table for both lines' `addDependencyVersion` calls.** Rejected: 3.x and 4.x both + manage `com.fasterxml.jackson.core:jackson-databind`, so the union emits a + `version-jackson2-databind` for 3.x that upstream never writes. Gating each row on its + library being declared in the BOM was tried first and dropped — it produces the right answer + for the wrong reason, since upstream resolves the coordinate without consulting the library. + +## Related + +- ADR-0004 — the era model and the reconstruction this extends; unchanged and still governing + 3.3-3.x. +- ADR-0002 — the two-half content source both rest on. +- `scripts/lib/upstream-sources.ts` — the era table and `BOOT_4_METADATA_ARTIFACTS`. +- `scripts/lib/antora-attributes.ts` — the two era-scoped managed-version tables. +- `.please/docs/knowledge/upstream-antora.md` — the measured upstream traps behind it. +- Issue #137. diff --git a/.please/docs/decisions/index.md b/.please/docs/decisions/index.md index 57b02820..8445d2bf 100644 --- a/.please/docs/decisions/index.md +++ b/.please/docs/decisions/index.md @@ -9,3 +9,4 @@ | [0003](./0003-idempotent-release-recovery.md) | Recover a Published-but-Unregistered Release by Re-running the Tag | 2026-09-12 | Accepted | | [0004](./0004-synthesize-3x-component.md) | Reconstruct the Generated Component for Spring Boot 3.x | 2026-09-12 | Accepted | | [0005](./0005-supersession-on-the-publish-path.md) | Classify a Superseded Tag as `complete` Whether or Not It Was Published | 2026-09-16 | Accepted | +| [0006](./0006-synthesize-4-0-x-component.md) | Reconstruct the Generated Component for Spring Boot 4.0.0-4.0.7 | 2026-09-17 | Accepted | diff --git a/.please/docs/knowledge/upstream-antora.md b/.please/docs/knowledge/upstream-antora.md index e61b4487..6d00e373 100644 --- a/.please/docs/knowledge/upstream-antora.md +++ b/.please/docs/knowledge/upstream-antora.md @@ -50,6 +50,11 @@ not add up to 246. > `https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-docs/maven-metadata.xml` > before assuming a version is buildable. +> **Update 2026-09-17**: the availability above still holds, but it no longer decides +> buildability. 3.3-3.x (ADR-0004) and 4.0.0-4.0.7 (ADR-0006) reconstruct the generated half +> from the release tag instead of downloading it. Only 4.1.0 is still unbuildable, because it +> sits inside the archive era and that era does want the zip. + Spring Boot's `antora.yml` carries almost no attributes — they are generated by Gradle (`buildSrc/.../AntoraAsciidocAttributes.java`) from the dependency BOM, and the Java/Kotlin sample sources are copied into `ROOT:example$` by the build. That would normally force us diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 209b5eae..84f75d4c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -220,7 +220,7 @@ Both upstream halves are required. The checked-out `antora.yml` is a build-time Where the generated half comes from depends on the version's **layout era** (ADR-0004). An archive era takes it from the content zip Spring publishes to Maven Central. A synthesized era has no such zip, so `fetch-upstream.ts` rebuilds it: the sample tree and the attribute inputs come from the tag itself (the static attributes file, the dependency BOM build script, `gradle.properties`), while Maven Central supplies the jars shipping `spring-configuration-metadata.json` and the BOMs that build script imports, which is where the managed dependency versions resolve from. -**This makes buildability a property of upstream's layout and publishing, not of the converter**, and the two interact. `spring-boot-docs` is published to Maven Central for 2.2.x-2.4.2 and then again from 4.0.8, so the 3.3-3.5 line has no content archive and is reconstructed instead. 4.0.0 moved the component to `documentation/`, and 4.0.0-4.0.7 are archive-less on that new path, which neither route covers — so they are refused. 4.1.0 is tagged with its archive not yet published; that is a publication fact rather than a layout one, so it is left to detection rather than frozen into an era boundary. `detect-upstream-versions.ts` checks each candidate's required artifacts — the content zip for an archive era, the metadata jars for a synthesized one — before reporting it, so the nightly workflow does not file issues for versions nobody can build. `fetch-upstream.ts` asks the same question of the same list before it clones, so a build started by hand fails on the missing artifact rather than after wiping its output directory. +**This makes buildability a property of upstream's layout and publishing, not of the converter**, and the two interact. `spring-boot-docs` is published to Maven Central for 2.2.x-2.4.2 and then again from 4.0.8, so the 3.3-3.5 line has no content archive and is reconstructed instead. 4.0.0 moved the component to `documentation/`, and 4.0.0-4.0.7 are archive-less on that new path — the component path and the assembly moved apart, so they get a third era that reconstructs the generated half from the 4.x paths rather than widening either neighbour. 4.1.0 is tagged with its archive not yet published; that is a publication fact rather than a layout one, so it is left to detection rather than frozen into an era boundary. `detect-upstream-versions.ts` checks each candidate's required artifacts — the content zip for an archive era, the metadata jars for a synthesized one — before reporting it, so the nightly workflow does not file issues for versions nobody can build. `fetch-upstream.ts` asks the same question of the same list before it clones, so a build started by hand fails on the missing artifact rather than after wiping its output directory. **Determinism guarantee**: Same upstream commit + same `scripts/` SHA = byte-identical Markdown output and identical archive checksum. This is the load-bearing property of the entire system. diff --git a/README.md b/README.md index 458b210e..dbee5237 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ Consumers should prefer `catalog.json` over scraping the Releases page — it's ## Generation pipeline 1. **Detect new upstream release** — `nightly-detect.yml` runs `detect-upstream-versions.ts`, which diffs upstream's tags against `catalog.json` and files one issue per missing GA version. It builds nothing: a new upstream line can change the documentation layout, so a human decides. -2. **Fetch** — `fetch-upstream.ts` assembles one Antora content source from two halves. The authored half is always a sparse checkout of the docs subtree at the release tag. The generated half — the resolved `antora.yml` attributes, the sample sources `include-code::` reads, and the configuration-property metadata `configprop:` validates against — depends on the version's layout era ([ADR-0004](./.please/docs/decisions/0004-synthesize-3x-component.md)): Spring Boot 4.0.8+ merge the content archive published to Maven Central; Boot 3.3-3.x reconstruct it from the tag plus the published `spring-boot-*` jars, because those archives are excluded from Spring's Maven Central sync; and Spring Framework 6.1+ and Spring Security 6.2+ need neither, because the tag already carries a complete descriptor and its own examples — the committed `antora.yml` is overlaid rather than rebuilt, with the version for Framework and, for Security, with the documentation URLs and four dependency versions its build resolves, all read out of the committed version catalog and `gradle.properties`. Either way: no submodules, no Gradle, no JVM. +2. **Fetch** — `fetch-upstream.ts` assembles one Antora content source from two halves. The authored half is always a sparse checkout of the docs subtree at the release tag. The generated half — the resolved `antora.yml` attributes, the sample sources `include-code::` reads, and the configuration-property metadata `configprop:` validates against — depends on the version's layout era ([ADR-0004](./.please/docs/decisions/0004-synthesize-3x-component.md)): Spring Boot 4.0.8+ merge the content archive published to Maven Central; Boot 3.3-4.0.7 reconstruct it from the tag plus the published `spring-boot-*` jars, because those archives are excluded from Spring's Maven Central sync until 4.0.8 ([ADR-0006](./.please/docs/decisions/0006-synthesize-4-0-x-component.md) covers 4.0.0-4.0.7, which reconstruct the same way from paths 4.0.0 moved); and Spring Framework 6.1+ and Spring Security 6.2+ need neither, because the tag already carries a complete descriptor and its own examples — the committed `antora.yml` is overlaid rather than rebuilt, with the version for Framework and, for Security, with the documentation URLs and four dependency versions its build resolves, all read out of the committed version catalog and `gradle.properties`. Either way: no submodules, no Gradle, no JVM. 3. **Convert** — `convert.ts` drives Antora's own pipeline modules with Spring's Asciidoctor extensions registered, so `xref:`, `include::`, `include-code::`, `javadoc:` and `configprop:` are resolved by the same code that produces docs.spring.io. Our converter then emits Markdown from the resolved AST: GFM alerts for admonitions, headed code fences for tab groups, relative `.md` links for internal xrefs, absolute `docs.spring.io` URLs for references into components we do not build. An unhandled construct fails the build rather than being dropped. 4. **Package** — `package-release.ts` writes `NOTICE`, checksums every file, and builds a reproducible `tar.gz`: entries sorted, timestamps and ownership pinned, gzip's mtime field suppressed. The same converted tree always yields byte-identical bytes. 5. **Release** — a `-` tag push runs `release.yml`, which rebuilds from the tag, verifies the manifest against it, and publishes the archive, its checksum and the manifest. @@ -194,8 +194,8 @@ The pipeline is matrix-parallelized: typical full ecosystem rebuild (~10 project |---|---| | **Tag immutability** | Once a `-` tag is published, it is not deleted. Re-generation creates a new tag suffix (`framework-6.2.0+rebuild.1`) and updates `catalog.json` to point at the latest | | **Pre-release versions** | Not built. Only GA versions of upstream projects | -| **Buildable versions** | Spring Boot `3.3.0`-`3.x` and `4.0.8`+. 3.2 and older predate the Antora component entirely. 4.0.0-4.0.7 moved to the 4.x layout but published no content archive, so they fall between the two eras and are refused; 4.1.0 is tagged with no archive either | -| **3.x appendix** | The generated appendix — auto-configuration class listings and configuration-property tables, ~101 pages — is a Gradle build output with no published equivalent, so 3.x archives omit it. The prose corpus (reference, how-to, tutorial, specification) is complete | +| **Buildable versions** | Spring Boot `3.3.0`+. 3.2 and older predate the Antora component entirely. 4.0.0-4.0.7 moved to the 4.x layout but published no content archive, so they are reconstructed from the tag like 3.3-3.x rather than downloaded; 4.1.0 is tagged with no archive published yet | +| **Reconstructed appendix** | The generated appendix — auto-configuration class listings and configuration-property tables — is a Gradle build output with no published equivalent, so every reconstructed version omits it: ~101 pages for 3.3-3.x, 92 for 4.0.0-4.0.7. The prose corpus (reference, how-to, tutorial, specification) is complete | | **Coverage window** | The newest N missing GA versions per project, N being the `limit` input of `matrix-build.yml` (default 3) | | **Backfill** | Older versions can be requested via issue and built on-demand | diff --git a/scripts/fetch-upstream.ts b/scripts/fetch-upstream.ts index 4dd6d01d..00819164 100644 --- a/scripts/fetch-upstream.ts +++ b/scripts/fetch-upstream.ts @@ -26,6 +26,7 @@ * 2 — bad arguments */ +import type { ManagedVersionAttribute } from './lib/antora-attributes.ts' import type { Fetcher } from './lib/artifact-availability.ts' import type { Attributes } from './lib/component-descriptor.ts' import type { DerivedAttributes, SynthesisSources, UpstreamCoordinates } from './lib/upstream-sources.ts' @@ -215,7 +216,12 @@ async function writeSynthesizedDescriptor( staticAttributes: await readSource(synthesis.staticAttributesPath), bomBuildScript, gradleProperties, - managedVersions: await fetchManagedVersions(bomBuildScript, gradleProperties), + managedVersions: await fetchManagedVersions( + bomBuildScript, + gradleProperties, + synthesis.managedVersionAttributes, + ), + managedVersionAttributes: synthesis.managedVersionAttributes, }) if (unresolved.length > 0) @@ -306,10 +312,11 @@ async function deriveAttributes( async function fetchManagedVersions( bomBuildScript: string, gradleProperties: string, + managedVersionAttributes: readonly ManagedVersionAttribute[], ): Promise>> { const versions: Record = {} - for (const bom of versionSourceBoms(bomBuildScript, gradleProperties)) { + for (const bom of versionSourceBoms(bomBuildScript, gradleProperties, managedVersionAttributes)) { const path = `${bom.groupId.replaceAll('.', '/')}/${bom.artifactId}/${bom.version}` const url = `https://repo1.maven.org/maven2/${path}/${bom.artifactId}-${bom.version}.pom` try { diff --git a/scripts/lib/antora-attributes.ts b/scripts/lib/antora-attributes.ts index f9899935..e51a9289 100644 --- a/scripts/lib/antora-attributes.ts +++ b/scripts/lib/antora-attributes.ts @@ -37,6 +37,15 @@ export interface AttributeSources { * Assemble it with {@link versionSourceBoms} and {@link parseManagedVersions}. */ readonly managedVersions: Readonly> + /** + * The `addDependencyVersion` calls this version's build makes. + * + * Era-scoped rather than global: the calls live in upstream's Java source, not + * in the checkout, and 4.x renamed the Jackson coordinates behind three of + * them. {@link BOOT_3_MANAGED_VERSIONS} and {@link BOOT_4_MANAGED_VERSIONS} are + * the two the eras name. + */ + readonly managedVersionAttributes: readonly ManagedVersionAttribute[] } /** The reconstructed attribute map, plus what could not be reconstructed. */ @@ -108,41 +117,74 @@ const TESTCONTAINERS_MODULES: readonly string[] = [ 'redpanda', ] +/** One `addDependencyVersion` call: an attribute and the coordinate behind it. */ +export interface ManagedVersionAttribute { + /** Attribute name without the `version-` prefix. */ + readonly attribute: string + /** BOM library whose import pins the coordinate, as the build script names it. */ + readonly library: string + readonly groupId: string + readonly artifactId: string +} + +/** The testcontainers rows, identical in both eras. */ +const TESTCONTAINERS_VERSION_ATTRIBUTES: readonly ManagedVersionAttribute[] + = TESTCONTAINERS_MODULES.map(artifactId => ({ + attribute: `testcontainers-${artifactId}`, + library: 'Testcontainers', + groupId: 'org.testcontainers', + artifactId, + })) + /** - * Other managed dependencies upstream names one by one, and the BOM library - * whose import pins each. + * Other managed dependencies upstream names one by one, for the 3.3-3.x line. * * Mirrors the explicit `addDependencyVersion` calls in * `AntoraAsciidocAttributes.addVersionAttributes`. The corpus references these * directly — `version-jackson-databind` alone appears 30 times in 3.5.16 — so * leaving them out is visible in the output. */ -const MANAGED_VERSION_ATTRIBUTES: readonly { - readonly attribute: string - readonly library: string - readonly groupId: string - readonly artifactId: string -}[] = [ +export const BOOT_3_MANAGED_VERSIONS: readonly ManagedVersionAttribute[] = [ { attribute: 'jackson-annotations', library: 'Jackson Bom', groupId: 'com.fasterxml.jackson.core', artifactId: 'jackson-annotations' }, { attribute: 'jackson-core', library: 'Jackson Bom', groupId: 'com.fasterxml.jackson.core', artifactId: 'jackson-core' }, { attribute: 'jackson-databind', library: 'Jackson Bom', groupId: 'com.fasterxml.jackson.core', artifactId: 'jackson-databind' }, { attribute: 'jackson-dataformat-xml', library: 'Jackson Bom', groupId: 'com.fasterxml.jackson.dataformat', artifactId: 'jackson-dataformat-xml' }, { attribute: 'pulsar-client-api', library: 'Pulsar', groupId: 'org.apache.pulsar', artifactId: 'pulsar-client-api' }, { attribute: 'pulsar-client-reactive-api', library: 'Pulsar Reactive', groupId: 'org.apache.pulsar', artifactId: 'pulsar-client-reactive-api' }, - ...TESTCONTAINERS_MODULES.map(artifactId => ({ - attribute: `testcontainers-${artifactId}`, - library: 'Testcontainers', - groupId: 'org.testcontainers', - artifactId, - })), + ...TESTCONTAINERS_VERSION_ATTRIBUTES, ] -/** BOM libraries whose imported BOMs have to be resolved for the attributes above. */ -const VERSION_SOURCE_LIBRARIES: readonly string[] = [ - 'Spring Data Bom', - ...new Set(MANAGED_VERSION_ATTRIBUTES.map(managed => managed.library)), +/** + * The same calls as `AntoraAsciidocAttributes` makes them in the 4.0.x line. + * + * Kept as a separate table rather than merged into {@link BOOT_3_MANAGED_VERSIONS}, + * because the difference is upstream's Java source and not the checkout: 4.x + * moved Jackson 3 to the `tools.jackson` coordinates, kept the 2.x line as a + * second `Jackson 2 Bom` library behind `version-jackson2-databind`, and dropped + * `pulsar-client-reactive-api`. A union would emit a `version-jackson2-databind` + * for 3.x too, where `com.fasterxml.jackson.core:jackson-databind` is managed and + * upstream names no such attribute. + * + * `jackson-annotations` stays on the 2.x coordinates in both: 4.x's `Jackson Bom` + * permits that one artifact through explicitly. + */ +export const BOOT_4_MANAGED_VERSIONS: readonly ManagedVersionAttribute[] = [ + { attribute: 'jackson-annotations', library: 'Jackson Bom', groupId: 'com.fasterxml.jackson.core', artifactId: 'jackson-annotations' }, + { attribute: 'jackson-core', library: 'Jackson Bom', groupId: 'tools.jackson.core', artifactId: 'jackson-core' }, + { attribute: 'jackson-databind', library: 'Jackson Bom', groupId: 'tools.jackson.core', artifactId: 'jackson-databind' }, + { attribute: 'jackson-dataformat-xml', library: 'Jackson Bom', groupId: 'tools.jackson.dataformat', artifactId: 'jackson-dataformat-xml' }, + { attribute: 'jackson2-databind', library: 'Jackson 2 Bom', groupId: 'com.fasterxml.jackson.core', artifactId: 'jackson-databind' }, + { attribute: 'pulsar-client-api', library: 'Pulsar', groupId: 'org.apache.pulsar', artifactId: 'pulsar-client-api' }, + ...TESTCONTAINERS_VERSION_ATTRIBUTES, ] +/** BOM libraries whose imported BOMs have to be resolved for one era's attributes. */ +function versionSourceLibraries( + managedVersionAttributes: readonly ManagedVersionAttribute[], +): ReadonlySet { + return new Set(['Spring Data Bom', ...managedVersionAttributes.map(managed => managed.library)]) +} + /** A library version declared as a single Groovy property reference. */ const INTERPOLATED_VERSION = /^\$\{([^}]+)\}$/ /** Java package to `javadoc-location-…` attribute name. */ @@ -194,7 +236,7 @@ export function synthesizeAttributes(sources: AttributeSources): SynthesizedAttr attributes.set(`version-${name}-javadoc`, `${majorMinor}.x`) } - for (const managed of MANAGED_VERSION_ATTRIBUTES) { + for (const managed of sources.managedVersionAttributes) { setIfPresent( attributes, `version-${managed.attribute}`, @@ -423,19 +465,23 @@ export interface VersionSourceBom extends ImportedBom { /** * The BOMs whose contents the attribute set depends on. * - * Only the few libraries {@link MANAGED_VERSION_ATTRIBUTES} and the Spring Data - * modules draw from — resolving every imported BOM would mean ~45 downloads for - * versions nothing references. + * Only the few libraries `managedVersionAttributes` and the Spring Data modules + * draw from — resolving every imported BOM would mean ~45 downloads for versions + * nothing references. Takes the same era-scoped table + * {@link synthesizeAttributes} reads, so the fetch cannot resolve a different set + * of BOMs than the attributes are built from. */ export function versionSourceBoms( bomBuildScript: string, gradleProperties: string, + managedVersionAttributes: readonly ManagedVersionAttribute[], ): readonly VersionSourceBom[] { const properties = parseProperties(gradleProperties) + const wanted = versionSourceLibraries(managedVersionAttributes) const boms: VersionSourceBom[] = [] for (const library of parseBomLibraries(bomBuildScript)) { - if (!VERSION_SOURCE_LIBRARIES.includes(library.name)) + if (!wanted.has(library.name)) continue const version = libraryVersion(library, properties) if (version === undefined) diff --git a/scripts/lib/upstream-sources.ts b/scripts/lib/upstream-sources.ts index fbe0029a..cefa6091 100644 --- a/scripts/lib/upstream-sources.ts +++ b/scripts/lib/upstream-sources.ts @@ -7,13 +7,18 @@ * out from the release tag, plus the generated half of the component. Where that * generated half comes from depends on the version's layout era (ADR-0004): * published content zips for Boot 4.0.8+, reconstruction from the tag for Boot - * 3.3-3.x, and — where a project commits a descriptor its build only tops up — + * 3.3-4.0.7, and — where a project commits a descriptor its build only tops up — * an overlay of the checked-in one. */ +import type { ManagedVersionAttribute } from './antora-attributes.ts' import type { Attributes } from './component-descriptor.ts' import type { DeclaredSymlink } from './reject-symlinks.ts' -import { parseProperties } from './antora-attributes.ts' +import { + BOOT_3_MANAGED_VERSIONS, + BOOT_4_MANAGED_VERSIONS, + parseProperties, +} from './antora-attributes.ts' import { securityAttributes } from './security-attributes.ts' import { parseVersionCatalog } from './version-catalog.ts' @@ -137,6 +142,15 @@ export interface SynthesisSources { readonly bomBuildScriptPath: string /** Properties file holding toolchain versions the BOM does not manage. */ readonly gradlePropertiesPath: string + /** + * The dependency-version attributes this era's upstream build declares. + * + * Not derivable from the paths above: the `addDependencyVersion` calls live in + * `AntoraAsciidocAttributes.java`, and 4.x renamed the Jackson coordinates + * three of them read. Pinned with the paths so an era cannot take one line's + * build inputs and the other line's reading of them. + */ + readonly managedVersionAttributes: readonly ManagedVersionAttribute[] /** * Maven artifact ids whose published jar ships * `META-INF/spring-configuration-metadata.json`. @@ -241,13 +255,15 @@ type EraAssembly /** * One documentation layout era of an upstream project. * - * Spring Boot has moved its Antora component twice and changed how — and - * whether — the generated half of it reaches the public: 3.3.0 introduced the - * component but its content archives are excluded from the Maven Central sync - * (`.github/actions/sync-to-maven-central/artifacts.spec`), while 4.x dropped - * that exclusion and publishes them. An era pins both facts together, because - * getting one without the other produces a tree that classifies but converts - * with unresolved attributes. + * Spring Boot has moved its Antora component and changed how — and whether — + * the generated half of it reaches the public, and the two did not move + * together: 3.3.0 introduced the component but its content archives are + * excluded from the Maven Central sync + * (`.github/actions/sync-to-maven-central/artifacts.spec`); 4.0.0 relocated it + * under `documentation/` while the archives stayed unpublished; 4.0.8 is where + * they first appear. An era pins both facts together, because getting one + * without the other produces a tree that classifies but converts with + * unresolved attributes. */ interface LayoutEra { /** Inclusive floor: the oldest version built with this layout. */ @@ -255,10 +271,11 @@ interface LayoutEra { /** * Exclusive ceiling, when the era does not run to the newest release. * - * Eras are not contiguous: 4.0.0-4.0.7 changed to the 4.x component path but - * published no content archive, so they belong to neither era and are not - * buildable at all. Without a ceiling they would fall back to the preceding - * era and be fetched from a path that does not exist at their tag. + * Adjacent eras can share neither their component path nor their assembly: + * 4.0.0 moved the component to `documentation/` while still publishing no + * content archive, and 4.0.8 began publishing one without moving anything. + * Each boundary needs a ceiling — without one an era would swallow the next + * and fetch from a path, or an archive, that does not exist at its tag. */ readonly until?: string /** Repo-relative path of the Antora component root (holds `antora.yml`). */ @@ -309,6 +326,124 @@ const BOOT_3_METADATA_ARTIFACTS = [ 'spring-boot-testcontainers', ] as const +/** + * Modules whose jar carries configuration-property metadata in the 4.0.x line. + * + * Boot 4 split the three 3.x module trees into ~140 projects, so + * {@link BOOT_3_METADATA_ARTIFACTS} does not carry over. Measured two ways that + * agree exactly on these 103 (2026-09-17): they are the artifacts the 4.0.8 + * content archive ships a `spring-configuration-metadata.json` partial for, and + * every one of them publishes a 4.0.0 and a 4.0.7 jar carrying + * `META-INF/spring-configuration-metadata.json`. Of the 34 other modules v4.0.0 + * declares, 4 publish no jar and 30 publish one with no metadata file — + * `spring-boot-test` among them, as in 3.x. + */ +const BOOT_4_METADATA_ARTIFACTS = [ + 'spring-boot', + 'spring-boot-activemq', + 'spring-boot-actuator', + 'spring-boot-actuator-autoconfigure', + 'spring-boot-amqp', + 'spring-boot-artemis', + 'spring-boot-autoconfigure', + 'spring-boot-batch', + 'spring-boot-batch-jdbc', + 'spring-boot-cache', + 'spring-boot-cache-test', + 'spring-boot-cassandra', + 'spring-boot-couchbase', + 'spring-boot-data-cassandra', + 'spring-boot-data-commons', + 'spring-boot-data-couchbase', + 'spring-boot-data-elasticsearch', + 'spring-boot-data-jdbc', + 'spring-boot-data-jpa', + 'spring-boot-data-ldap', + 'spring-boot-data-mongodb', + 'spring-boot-data-neo4j', + 'spring-boot-data-r2dbc', + 'spring-boot-data-redis', + 'spring-boot-data-rest', + 'spring-boot-devtools', + 'spring-boot-docker-compose', + 'spring-boot-elasticsearch', + 'spring-boot-flyway', + 'spring-boot-freemarker', + 'spring-boot-graphql', + 'spring-boot-groovy-templates', + 'spring-boot-gson', + 'spring-boot-h2console', + 'spring-boot-hateoas', + 'spring-boot-hazelcast', + 'spring-boot-health', + 'spring-boot-hibernate', + 'spring-boot-http-client', + 'spring-boot-http-codec', + 'spring-boot-http-converter', + 'spring-boot-integration', + 'spring-boot-jackson', + 'spring-boot-jackson2', + 'spring-boot-jdbc', + 'spring-boot-jdbc-test', + 'spring-boot-jersey', + 'spring-boot-jetty', + 'spring-boot-jms', + 'spring-boot-jooq', + 'spring-boot-jpa', + 'spring-boot-kafka', + 'spring-boot-kotlinx-serialization-json', + 'spring-boot-ldap', + 'spring-boot-liquibase', + 'spring-boot-mail', + 'spring-boot-micrometer-metrics', + 'spring-boot-micrometer-metrics-test', + 'spring-boot-micrometer-observation', + 'spring-boot-micrometer-tracing', + 'spring-boot-micrometer-tracing-brave', + 'spring-boot-micrometer-tracing-opentelemetry', + 'spring-boot-micrometer-tracing-test', + 'spring-boot-mongodb', + 'spring-boot-mustache', + 'spring-boot-neo4j', + 'spring-boot-netty', + 'spring-boot-opentelemetry', + 'spring-boot-persistence', + 'spring-boot-pulsar', + 'spring-boot-quartz', + 'spring-boot-r2dbc', + 'spring-boot-reactor', + 'spring-boot-reactor-netty', + 'spring-boot-restclient-test', + 'spring-boot-restdocs', + 'spring-boot-rsocket', + 'spring-boot-security', + 'spring-boot-security-oauth2-authorization-server', + 'spring-boot-security-oauth2-client', + 'spring-boot-security-oauth2-resource-server', + 'spring-boot-security-saml2', + 'spring-boot-sendgrid', + 'spring-boot-servlet', + 'spring-boot-session', + 'spring-boot-session-data-redis', + 'spring-boot-session-jdbc', + 'spring-boot-sql', + 'spring-boot-test-autoconfigure', + 'spring-boot-testcontainers', + 'spring-boot-thymeleaf', + 'spring-boot-tomcat', + 'spring-boot-transaction', + 'spring-boot-validation', + 'spring-boot-web-server', + 'spring-boot-webflux', + 'spring-boot-webflux-test', + 'spring-boot-webmvc', + 'spring-boot-webmvc-test', + 'spring-boot-webservices', + 'spring-boot-webservices-test', + 'spring-boot-websocket', + 'spring-boot-zipkin', +] as const + /** The committed version catalog Spring Security's build resolves against. */ const SECURITY_CATALOG_PATH = 'gradle/libs.versions.toml' @@ -366,8 +501,9 @@ const PROJECTS: Readonly> = { // ship the pre-Antora `src/docs/asciidoc` layout this pipeline cannot // classify. Verified by probing `antora.yml` at each minor's `.0` tag. since: '3.3.0', - // 4.0.0 moved the component to `documentation/`, so this era stops short - // of it even though 4.0.0-4.0.7 are equally archive-less. + // 4.0.0 moved the component to `documentation/` and restructured the + // build inputs the reconstruction reads, so the synthesis continues in + // the next era rather than here. until: '4.0.0', componentPath: 'spring-boot-project/spring-boot-docs/src/docs/antora', assembly: { @@ -378,15 +514,47 @@ const PROJECTS: Readonly> = { 'buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties', bomBuildScriptPath: 'spring-boot-project/spring-boot-dependencies/build.gradle', gradlePropertiesPath: 'gradle.properties', + managedVersionAttributes: BOOT_3_MANAGED_VERSIONS, metadataArtifacts: BOOT_3_METADATA_ARTIFACTS, }, }, }, + { + // 4.0.0-4.0.7 sit between the two published states: the 4.x component + // path, but no content archive on Maven Central (`root-aggregate-content` + // 404s for 4.0.0 and 4.0.7 and answers 200 from 4.0.8, probed + // 2026-09-17). Same shape as 3.3-3.x, so the same reconstruction runs — + // only the paths it reads moved, and `documentation/` is why this is a + // third era rather than a wider first one. + since: '4.0.0', + until: '4.0.8', + componentPath: 'documentation/spring-boot-docs/src/docs/antora', + assembly: { + descriptor: 'synthesized', + synthesis: { + // Moved with the component, out of `spring-boot-project/`. + examplesPath: 'documentation/spring-boot-docs/src/main', + // Unchanged across the restructure; its contents differ (123 entries + // against 3.5.16's 111) but the `key=value` shape `parseProperties` + // reads does not, and 4.0.0's file is byte-identical to 4.0.8's. + staticAttributesPath: + 'buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties', + // The dependency BOM moved to `platform/`. Same `library(...)`/`links` + // DSL: reconstructing 4.0.8 from it reproduces all 876 attributes of + // the descriptor its own content archive ships, none differing. + bomBuildScriptPath: 'platform/spring-boot-dependencies/build.gradle', + gradlePropertiesPath: 'gradle.properties', + managedVersionAttributes: BOOT_4_MANAGED_VERSIONS, + metadataArtifacts: BOOT_4_METADATA_ARTIFACTS, + }, + }, + }, { // `spring-boot-docs` is published to Maven Central only for 2.2.x-2.4.2 // and then again from 4.0.8, and this era needs that artifact's - // `root-aggregate-content` archive. 4.0.0-4.0.7 and 4.1.0 have no archive - // at all, so they cannot be built however the converter behaves. + // `root-aggregate-content` archive. 4.1.0 has no archive at all, so it + // cannot be built however the converter behaves — a publication gap the + // availability gate catches, not an era gap. since: '4.0.8', componentPath: 'documentation/spring-boot-docs/src/docs/antora', assembly: { diff --git a/tests/unit/antora-attributes.test.ts b/tests/unit/antora-attributes.test.ts index ad5e7d16..6756e6ef 100644 --- a/tests/unit/antora-attributes.test.ts +++ b/tests/unit/antora-attributes.test.ts @@ -1,6 +1,8 @@ import type { AttributeSources } from '../../scripts/lib/antora-attributes.ts' import { describe, expect, test } from 'bun:test' import { + BOOT_3_MANAGED_VERSIONS, + BOOT_4_MANAGED_VERSIONS, parseManagedVersions, parseProperties, synthesizeAttributes, @@ -101,6 +103,7 @@ function sources(overrides: Partial = {}): AttributeSources { bomBuildScript: BOM_BUILD_SCRIPT, gradleProperties: GRADLE_PROPERTIES, managedVersions: { 'org.springframework.data:spring-data-jpa': '3.5.4' }, + managedVersionAttributes: BOOT_3_MANAGED_VERSIONS, ...overrides, } } @@ -419,6 +422,52 @@ bom { expect(orphaned.unresolved).toContain('javadoc-location-org-hibernate') expect(orphaned.attributes['javadoc-location-org-hibernate']).toBeUndefined() }) + + test('reads each jackson attribute from the coordinate its era table names', () => { + // 4.0.0 moved Jackson 3 to `tools.jackson.*` and kept the 2.x line behind a + // separate `version-jackson2-databind`. Managing both coordinate sets at + // once is what makes a wrong groupId visible: the attribute resolves to the + // other line's version instead of to nothing, so it survives the + // `setIfPresent` that would otherwise hide it. + const managedVersions = { + 'com.fasterxml.jackson.core:jackson-annotations': '2.21.5', + 'com.fasterxml.jackson.core:jackson-core': '2.21.5', + 'com.fasterxml.jackson.core:jackson-databind': '2.21.5', + 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml': '2.21.5', + // Managed at a coordinate neither table names, so only a regressed + // `jackson-annotations` row can reach it. + 'tools.jackson.core:jackson-annotations': '3.1.5', + 'tools.jackson.core:jackson-core': '3.1.5', + 'tools.jackson.core:jackson-databind': '3.1.5', + 'tools.jackson.dataformat:jackson-dataformat-xml': '3.1.5', + } + + const boot4 = synthesizeAttributes(sources({ + managedVersions, + managedVersionAttributes: BOOT_4_MANAGED_VERSIONS, + })).attributes + + expect(boot4['version-jackson-core']).toBe('3.1.5') + expect(boot4['version-jackson-databind']).toBe('3.1.5') + expect(boot4['version-jackson-dataformat-xml']).toBe('3.1.5') + expect(boot4['version-jackson2-databind']).toBe('2.21.5') + // `jackson-annotations` is the one artifact 4.x's `Jackson Bom` permits + // through on the 2.x coordinate, so both tables read the same row. + expect(boot4['version-jackson-annotations']).toBe('2.21.5') + + const boot3 = synthesizeAttributes(sources({ + managedVersions, + managedVersionAttributes: BOOT_3_MANAGED_VERSIONS, + })).attributes + + expect(boot3['version-jackson-core']).toBe('2.21.5') + expect(boot3['version-jackson-databind']).toBe('2.21.5') + expect(boot3['version-jackson-dataformat-xml']).toBe('2.21.5') + expect(boot3['version-jackson-annotations']).toBe('2.21.5') + // Upstream names no such attribute before 4.0.0, and the corpus never links + // through it; emitting it there would be a fabricated attribute. + expect(boot3['version-jackson2-databind']).toBeUndefined() + }) }) describe('versionSourceBoms', () => { @@ -443,10 +492,40 @@ bom { } `, 'version=3.5.16\n', + BOOT_3_MANAGED_VERSIONS, ) expect(boms).toEqual([ { groupId: 'org.testcontainers', artifactId: 'testcontainers-bom', version: '1.20.4' }, ]) }) + + test('follows the era table, so 4.x also resolves the second jackson bom', () => { + // 4.x pins `version-jackson2-databind` through a `Jackson 2 Bom` library the + // 3.x table never names. Resolving the BOM set from the same table the + // attributes are built from is what keeps the fetch and the synthesis from + // disagreeing about which coordinates exist. + const buildScript = ` +bom { + library("Jackson 2 Bom", "2.21.5") { + group("com.fasterxml.jackson") { + bom("jackson-bom") + } + } + library("Jackson Bom", "3.1.5") { + group("tools.jackson") { + bom("jackson-bom") + } + } +} +` + + expect(versionSourceBoms(buildScript, '', BOOT_4_MANAGED_VERSIONS)).toEqual([ + { groupId: 'com.fasterxml.jackson', artifactId: 'jackson-bom', version: '2.21.5' }, + { groupId: 'tools.jackson', artifactId: 'jackson-bom', version: '3.1.5' }, + ]) + expect(versionSourceBoms(buildScript, '', BOOT_3_MANAGED_VERSIONS)).toEqual([ + { groupId: 'tools.jackson', artifactId: 'jackson-bom', version: '3.1.5' }, + ]) + }) }) diff --git a/tests/unit/fetch-upstream.test.ts b/tests/unit/fetch-upstream.test.ts index 416e551c..f23d3f16 100644 --- a/tests/unit/fetch-upstream.test.ts +++ b/tests/unit/fetch-upstream.test.ts @@ -79,6 +79,7 @@ describe('copyExamples', () => { staticAttributesPath: 'static.properties', bomBuildScriptPath: 'build.gradle', gradlePropertiesPath: 'gradle.properties', + managedVersionAttributes: [], metadataArtifacts: [], } diff --git a/tests/unit/upstream-sources.test.ts b/tests/unit/upstream-sources.test.ts index 9ea77525..21189c6b 100644 --- a/tests/unit/upstream-sources.test.ts +++ b/tests/unit/upstream-sources.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import { BOOT_4_MANAGED_VERSIONS } from '../../scripts/lib/antora-attributes.ts' import { cloneUrlFor, compareGaVersions, @@ -127,8 +128,16 @@ describe('resolveUpstream version floor spellings', () => { expect(() => resolveUpstream('boot', version)).not.toThrow() }) - test('still rejects a leading-zero spelling of a version inside the 4.0 gap', () => { - expect(() => resolveUpstream('boot', '04.0.7')).toThrow(/not buildable/) + test('canonicalizes on the way to an era, not just past the floor', () => { + // A padded spelling has to pick the same era as the plain one: 4.0.7 is the + // last version of the synthesized 4.x era, and reading "04.0.7" literally + // would order it below every floor. + expect(resolveUpstream('boot', '04.0.7').componentPath) + .toBe(resolveUpstream('boot', '4.0.7').componentPath) + }) + + test('still rejects a leading-zero spelling of a version below every floor', () => { + expect(() => resolveUpstream('boot', '03.2.12')).toThrow(/not buildable/) }) }) @@ -148,10 +157,10 @@ describe('supportedVersionsFromTags', () => { }) test('keeps 3.3+ tags and drops ones no era covers', () => { - // 4.0.7 is above the oldest floor yet belongs to no era, so era membership - // — not a bare floor comparison — has to decide. + // 3.2.12 is below the oldest floor, so era membership — not a bare + // comparison against the newest floor — has to decide. expect(supportedVersionsFromTags('boot', ['v4.0.7', 'v3.5.8', 'v4.0.8', 'v3.2.12'])) - .toEqual(['3.5.8', '4.0.8']) + .toEqual(['3.5.8', '4.0.7', '4.0.8']) }) test('drops pre-releases and unrelated tag names', () => { @@ -176,25 +185,24 @@ describe('resolveUpstream layout eras', () => { expect(() => resolveUpstream('boot', '3.2.12')).toThrow(/not buildable/) }) - test('refuses a version that falls in the gap between two eras', () => { - // 4.0.0-4.0.7 moved to the 4.x path but published no content archive. - expect(() => resolveUpstream('boot', '4.0.7')).toThrow(/not buildable/) - }) - - test('treats an era ceiling as exclusive, so the ceiling itself is refused', () => { - // 4.0.0 is the only version that distinguishes `< until` from `<= until`: - // every other gap version is above the ceiling and refused either way. A - // ceiling read as inclusive would resolve 4.0.0 to the 3.x era and fetch a - // component path that does not exist at its tag. - expect(() => resolveUpstream('boot', '4.0.0')).toThrow(/not buildable/) + test('treats an era ceiling as exclusive, so the ceiling starts the next era', () => { + // The two ceilings are the only versions that distinguish `< until` from + // `<= until`. Read as inclusive, 4.0.0 would take the 3.x era and be fetched + // from a component path that does not exist at its tag, and 4.0.8 would be + // synthesized instead of taking the archive it does publish. + expect(resolveUpstream('boot', '4.0.0').componentPath) + .toBe('documentation/spring-boot-docs/src/docs/antora') + expect(resolveUpstream('boot', '4.0.8').assembly.descriptor).toBe('archive') }) test('names the buildable ranges when it refuses', () => { - expect(() => resolveUpstream('boot', '4.0.7')).toThrow(/3\.3\.0-<4\.0\.0, >= 4\.0\.8/) + expect(() => resolveUpstream('boot', '3.2.12')) + .toThrow(/3\.3\.0-<4\.0\.0, 4\.0\.0-<4\.0\.8, >= 4\.0\.8/) }) test('accepts each era floor itself', () => { expect(resolveUpstream('boot', '3.3.0').tag).toBe('v3.3.0') + expect(resolveUpstream('boot', '4.0.0').tag).toBe('v4.0.0') expect(resolveUpstream('boot', '4.0.8').tag).toBe('v4.0.8') }) @@ -207,6 +215,38 @@ describe('resolveUpstream layout eras', () => { expect(upstream.checkoutPaths).toContain('gradle.properties') }) + test('resolves a 4.0.0-4.0.7 version to the synthesized era on the 4.x paths', () => { + // The two halves moved apart here: 4.0.0 relocated the component under + // `documentation/` while the content archive stayed unpublished until 4.0.8, + // so this era has to take the 4.x paths and the 3.x assembly. + for (const version of ['4.0.0', '4.0.7']) { + const upstream = resolveUpstream('boot', version) + + expect(upstream.componentPath).toBe('documentation/spring-boot-docs/src/docs/antora') + expect(upstream.assembly.descriptor).toBe('synthesized') + expect(upstream.archives).toEqual([]) + expect(upstream.checkoutPaths).toEqual([ + 'documentation/spring-boot-docs/src/docs/antora', + 'documentation/spring-boot-docs/src/main', + 'buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties', + 'platform/spring-boot-dependencies/build.gradle', + 'gradle.properties', + ]) + } + }) + + test('reads the 4.x dependency bom, not the one 3.x kept under spring-boot-project', () => { + const { assembly } = resolveUpstream('boot', '4.0.7') + if (assembly.descriptor !== 'synthesized') + throw new Error(`expected a synthesized era, got "${assembly.descriptor}"`) + + expect(assembly.synthesis.bomBuildScriptPath) + .toBe('platform/spring-boot-dependencies/build.gradle') + // 4.x renamed the Jackson coordinates, so taking the 3.x table against the + // 4.x bom would silently drop `version-jackson-databind` and friends. + expect(assembly.synthesis.managedVersionAttributes).toBe(BOOT_4_MANAGED_VERSIONS) + }) + test('resolves a 4.x version to the archive era', () => { const upstream = resolveUpstream('boot', '4.1.1') @@ -230,6 +270,24 @@ describe('metadataJars', () => { } }) + test('the 4.0.x era carries the 4.x metadata set, which 3.x does not cover', () => { + const { metadataJars } = resolveUpstream('boot', '4.0.7') + const artifacts = metadataJars.map(jar => jar.artifact) + + // Boot 4 split the 3.x modules into ~140 projects. Measured 2026-09-17: + // 103 of them publish a 4.0.x jar carrying the metadata file, and those are + // exactly the partials the 4.0.8 content archive ships. + expect(artifacts).toHaveLength(103) + expect(artifacts).toContain('spring-boot-kafka') + // Split out of `spring-boot-autoconfigure` in 4.x, so a 3.x list would miss it. + expect(artifacts).toContain('spring-boot-webmvc') + // Publishes a jar and no metadata file, in both lines. + expect(artifacts).not.toContain('spring-boot-test') + expect(metadataJars[0]?.url).toBe( + 'https://repo1.maven.org/maven2/org/springframework/boot/spring-boot/4.0.7/spring-boot-4.0.7.jar', + ) + }) + test('an archive era has none, because the zip already carries that metadata', () => { expect(resolveUpstream('boot', '4.1.1').metadataJars).toEqual([]) }) diff --git a/tests/unit/version-detect.test.ts b/tests/unit/version-detect.test.ts index cf4cb59b..727714da 100644 --- a/tests/unit/version-detect.test.ts +++ b/tests/unit/version-detect.test.ts @@ -47,10 +47,10 @@ describe('missingVersions', () => { }) test('drops pre-releases and versions no layout era covers', () => { - // 3.5.0 is built from the synthesized era; 4.0.7 falls in the gap between - // the two eras and 3.2.12 predates both. + // 3.5.0 and 4.0.7 are built from the two synthesized eras; 3.2.12 predates + // every era, and only era membership can tell it from the rest. const noisy = ['v3.2.12', 'v3.5.0', 'v4.0.7', 'v4.1.0', 'v4.2.0-M1', 'v4.2.0-RC1', 'not-a-tag'] - expect(missingVersions(catalog({}), 'boot', noisy)).toEqual(['3.5.0', '4.1.0']) + expect(missingVersions(catalog({}), 'boot', noisy)).toEqual(['3.5.0', '4.0.7', '4.1.0']) }) test('sorts numerically, oldest first, so callers can take the newest few', () => {