diff --git a/.github/workflows/release-script-check.yml b/.github/workflows/release-script-check.yml index 4eb13cf2..7ebe2e29 100644 --- a/.github/workflows/release-script-check.yml +++ b/.github/workflows/release-script-check.yml @@ -42,6 +42,29 @@ jobs: ./scripts/cut-release.ps1 -PostReleaseOnly -Branch develop -DryRun if ($LASTEXITCODE -ne 0) { throw "PostReleaseOnly dry-run exited $LASTEXITCODE" } + - name: The asset version follows the tag, and only a final tag + shell: pwsh + run: | + # records the version the committed previews + # were rendered at, and ExampleVersion accepts a released X.Y.Z and nothing else. + # So it must move on a final cut and stay put everywhere else: carried into a + # -SNAPSHOT by the post-release bump it would throw before a single preview was + # compared, and carried into an -rc it would have the previews advertise a release + # that does not exist. Assert the branch that ran, not the file it would write: + # -DryRun mutates nothing, so the notice is the only evidence either way. + $final = ./scripts/cut-release.ps1 -Version 9.9.9 -Branch develop -DryRun -SkipShowcase *>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "final dry-run exited $LASTEXITCODE" } + if ($final -notmatch 'asset version -> 9\.9\.9') { throw 'a final cut must move the asset version' } + if ($final -notmatch 'Re-render the README assets') { throw 'a final cut must re-render the previews' } + + $rc = ./scripts/cut-release.ps1 -Version 2.1.0-rc.1 -Branch develop -DryRun -SkipShowcase *>&1 | Out-String + if ($rc -match 'asset version ->') { throw 'a pre-release cut must NOT move the asset version' } + if ($rc -notmatch 'Skipped the README assets') { throw 'a pre-release cut must leave the previews alone' } + + $post = ./scripts/cut-release.ps1 -PostReleaseOnly -Branch develop -DryRun *>&1 | Out-String + if ($post -match 'asset version ->') { throw 'the post-release bump must NOT move the asset version' } + Write-Host 'asset version: moves on a final cut, stays put on a pre-release and post-release.' + - name: Dry-run a pre-release (RC) cut shell: pwsh run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 715c4026..21b34368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ follow semantic versioning; release dates are ISO 8601. ### Build +- **A release re-renders the previews it publishes.** The committed previews record + the version they were rendered at, and until now nothing moved it: a cut bumped + every pom, regenerated the showcase site at the new version, and left + `assets/readme/**` on the release before — with the drift gate comparing both + sides at the old version and staying green through it. The cut now bumps that + property with the tag and re-renders the previews from the same catalogue the + site is built from, before the verify step that checks them. `-SkipShowcase` + skips the published site under `web/` and no longer skips these, since they ship + in the repository; `-PostReleaseOnly` leaves them alone, because they belong to + the tag rather than to the branch it opens. - **A committed preview cannot fall behind the code that renders it.** README and the showcase site read files under `assets/readme/**` rather than rendering anything, and nothing held those files to the catalogue: a change to an example, diff --git a/core/src/test/java/com/demcha/documentation/ReleaseAssetStepGuardTest.java b/core/src/test/java/com/demcha/documentation/ReleaseAssetStepGuardTest.java new file mode 100644 index 00000000..ea1805e1 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/ReleaseAssetStepGuardTest.java @@ -0,0 +1,100 @@ +package com.demcha.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Keeps the release script's asset step joined to the commit it has to land in. + * + *

A cut re-renders the previews under {@code assets/readme} at the version it is tagging, and + * commits them with everything else it bumped. The two halves sit in different parts of the + * script and neither fails without the other: a refresh that is not staged leaves the release + * carrying previews from the version before, and a staged path nothing refreshes commits + * whatever happened to be in the working tree. Both come back as the same symptom — a release + * publishing figures that do not match the code — which is what the drift gate was written to + * end, and what this keeps it from being reintroduced beneath.

+ * + *

The order matters as much as the presence. The verify step runs the drift gate, so a + * refresh scheduled after it fails the cut on the previews it was about to fix.

+ */ +class ReleaseAssetStepGuardTest { + + private static final Path SCRIPT = RepoRoot.get().resolve("scripts/cut-release.ps1"); + + @Test + void theReleaseScriptRefreshesTheCommittedPreviewsAndCommitsThem() throws IOException { + String script = Files.readString(SCRIPT); + + assertThat(script) + .describedAs("cut-release.ps1 no longer refreshes the committed previews: a cut " + + "would tag a release whose figures are the previous one's") + .contains("Refresh-CommittedPreviews"); + assertThat(script) + .describedAs("cut-release.ps1 no longer stages assets/readme/examples, so a refresh " + + "would happen and never reach the release commit") + .contains("'assets/readme/examples'"); + assertThat(script) + .describedAs("cut-release.ps1 no longer moves the version the previews record; the " + + "drift gate would then compare a release's previews at the version before " + + "it, and pass") + .contains("function Update-AssetVersion"); + } + + /** + * The version the previews record moves on a final cut and on nothing else. + * + *

{@code ExampleVersion} accepts a released {@code X.Y.Z} and rejects everything else, so + * the property cannot ride along with the generic pom bump: the post-release step carries the + * train to {@code X.Y.(Z+1)-SNAPSHOT}, which would throw before a single preview was compared, + * and a pre-release cut carries {@code X.Y.Z-rc.N}, whose qualifier-stripped form names a + * release that does not exist yet — the previews would advertise it.

+ * + *

The three modes are exercised for real in {@code release-script-check.yml}. What this + * pins is the wiring that makes those outcomes structural rather than incidental: the generic + * bump does not touch the property, and the step that does sits inside the final-release + * branch.

+ */ + @Test + void onlyAFinalCutMovesTheVersionThePreviewsRecord() throws IOException { + String script = Files.readString(SCRIPT); + + int genericBump = script.indexOf("function Update-PomVersion"); + int assetBump = script.indexOf("function Update-AssetVersion"); + assertThat(genericBump).describedAs("Update-PomVersion is gone").isNotNegative(); + assertThat(assetBump).describedAs("Update-AssetVersion is gone").isNotNegative(); + assertThat(script.substring(genericBump, assetBump)) + .describedAs("the generic pom bump touches the asset version again — it runs for " + + "the post-release SNAPSHOT and for a pre-release, and both values are " + + "ones the examples module refuses") + .doesNotContain("assetVersion"); + + int finalBranch = script.indexOf("if ($isFinalRelease) {", script.indexOf("Step 4 ")); + int call = script.indexOf("Update-AssetVersion (Join-Path"); + assertThat(finalBranch).describedAs("the final-release branch around Step 4 is gone") + .isNotNegative(); + assertThat(call) + .describedAs("the asset version is moved outside the final-release branch, so a " + + "pre-release cut would move it too") + .isGreaterThan(finalBranch); + } + + @Test + void thePreviewsAreRefreshedBeforeTheStepThatChecksThem() throws IOException { + String script = Files.readString(SCRIPT); + + int refresh = script.indexOf(" Refresh-CommittedPreviews"); + int verify = script.indexOf("Run mvnw clean verify"); + assertThat(refresh).describedAs("the refresh call is gone").isNotNegative(); + assertThat(verify).describedAs("the verify step is gone").isNotNegative(); + + assertThat(refresh) + .describedAs("the previews are refreshed after the verify step that compares them, " + + "so a cut fails on exactly the files it was about to bring up to date") + .isLessThan(verify); + } +} diff --git a/examples/pom.xml b/examples/pom.xml index 468f043d..90715395 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -29,12 +29,10 @@ only compare like with like because the surefire configuration below pins the display version to this value. - Nothing moves it yet. Until cut-release.ps1 re-renders the previews and - bumps this in the same commit as the tag, a release leaves the previews - at the version below while the showcase site goes out at the new one — - and the drift gate, comparing both sides at this value, stays green - through it. That step is the reason this property exists; it is not - written yet. + cut-release.ps1 moves it on a final cut, in the same commit as the tag and + next to the step that re-renders the previews at that version. A + pre-release cut leaves both alone: the version they would carry is this + one's qualifier stripped, which names a release that does not exist yet. --> 2.1.0 diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1 index b1d6da09..ecde5d2a 100644 --- a/scripts/cut-release.ps1 +++ b/scripts/cut-release.ps1 @@ -223,6 +223,46 @@ function Update-PomVersion($pomPath, $newVersion) { } } +function Update-AssetVersion($pomPath, $newVersion) { + # Moves — the version the committed previews + # under assets/readme were rendered at. CommittedAssetDriftTest renders at it to + # compare like with like, and ExampleVersion accepts a released X.Y.Z and nothing + # else, so this is deliberately NOT part of Update-PomVersion: + # + # * -PostReleaseOnly bumps the train to X.Y.(Z+1)-SNAPSHOT. Carrying the property + # along would hand surefire a -SNAPSHOT display version, and the examples module + # would throw before comparing a single preview. + # * a pre-release cut sets X.Y.Z-rc.N, which the same check rejects — and whose + # qualifier-stripped form names a final version that does not exist yet, so the + # previews would advertise an unpublished release. + # + # Only a final cut moves it, in the same commit as the tag, next to the step that + # re-renders the previews at that version. + # + # Independent of Update-PomVersion's early return as well: a cut interrupted after + # the version bump leaves the poms on the new version and this property behind, and + # re-running has to be able to finish the job rather than report nothing to do. + $content = [System.IO.File]::ReadAllText($pomPath) + $assetRegex = [regex]'[\w\.\-]+' + $match = $assetRegex.Match($content) + if (-not $match.Success) { + Note "no in $pomPath — nothing to move" + return + } + if ($match.Value -eq "$newVersion") { + Note "asset version already $newVersion" + return + } + if ($DryRun) { + Write-Host " [DRY RUN] asset version -> $newVersion" -ForegroundColor Yellow + return + } + $content = $assetRegex.Replace($content, + "$newVersion", 1) + [System.IO.File]::WriteAllText($pomPath, $content) + Note "asset version -> $newVersion" +} + function Get-NextSnapshotVersion($version) { # A final release X.Y.Z opens the next patch development line X.Y.(Z+1)-SNAPSHOT. # Pre-release versions (rc / beta / alpha) stay on their own cycle, so return @@ -554,7 +594,10 @@ function Update-ShowcaseGhBase($newRef) { return $true } -function Run-ShowcaseSync { +function Build-ExampleCatalogue { + # Renders the whole example catalogue into examples/target/generated-pdfs at the + # version the poms now carry. Both the published site and the committed previews + # are copied out of that tree, so it is built once and read twice. # Quote the -D argument: PowerShell's call operator drops the leading # '-D' on the way to mvnw.cmd, so Maven sees ".mainClass=..." as a # lifecycle phase. Wrapping the whole token in quotes preserves it @@ -576,7 +619,6 @@ function Run-ShowcaseSync { Write-Host " [DRY RUN] $mvnw -B -ntp -DskipTests install -f $modulePom" -ForegroundColor Yellow } Write-Host " [DRY RUN] $mvnw -B -ntp -f examples/pom.xml -DskipTests clean compile exec:java $generateProp" -ForegroundColor Yellow - Write-Host " [DRY RUN] $mvnw -B -ntp -f examples/pom.xml -DskipTests compile exec:java $execProp" -ForegroundColor Yellow return } Push-Location $repoRoot @@ -627,6 +669,21 @@ function Run-ShowcaseSync { if ($LASTEXITCODE -ne 0) { throw "GenerateAllExamples failed (exit $LASTEXITCODE)" } + } finally { + Pop-Location + } +} + +function Sync-ShowcaseSite { + # Copies the generated catalogue into web/showcase and writes web/examples.json. + # Reads the tree Build-ExampleCatalogue leaves behind — call it first. + $execProp = '"-Dexec.mainClass=com.demcha.examples.support.ShowcaseSync"' + if ($DryRun) { + Write-Host " [DRY RUN] $mvnw -B -ntp -f examples/pom.xml -DskipTests compile exec:java $execProp" -ForegroundColor Yellow + return + } + Push-Location $repoRoot + try { # `compile` before exec:java is REQUIRED: Step 3 rewrote ShowcaseMetadata.GH_BASE # to /blob/, and exec:java runs the COMPILED class. Without recompiling it here, # ShowcaseSync would emit examples.json with the previous release's "View Code" links @@ -645,6 +702,75 @@ function Run-ShowcaseSync { } } +function Run-ShowcaseSync { + # The pair, for the post-release pass: it regenerates the site with branch links + # and deliberately leaves the committed previews alone — those belong to the tag. + Build-ExampleCatalogue + Sync-ShowcaseSite +} + +function Refresh-CommittedPreviews { + # Copies the freshly generated catalogue over the previews the repository + # commits under assets/readme/examples. README and the showcase site read + # those files rather than rendering anything, and until CommittedAssetDriftTest + # arrived nothing held them to the code: they drifted release by release, and + # a deck went two of them without the bold weights its styles asked for. + # + # Which previews are published is the folder itself — every file already there + # gets its counterpart, and nothing new is added. That is the same set the + # drift gate compares, so the refresh and the check cannot disagree about what + # is published. + # + # Runs on every cut, -SkipShowcase or not: that flag is about the published + # site under web/, while these files ship in the repository. It must also run + # BEFORE Step 5, since `mvnw verify` is where the drift gate would otherwise + # fail the release on previews this step exists to refresh. + $previews = Join-Path $repoRoot 'assets/readme/examples' + $generated = Join-Path $repoRoot 'examples/target/generated-pdfs' + if (-not (Test-Path $previews)) { + Note "no committed previews at $previews — nothing to refresh" + return + } + if (-not $DryRun -and -not (Test-Path $generated)) { + throw "Refresh-CommittedPreviews: $generated is missing — the catalogue must be generated first." + } + + $committed = Get-ChildItem -Path $previews -File + if ($DryRun) { + Write-Host " [DRY RUN] refresh $($committed.Count) committed previews from $generated" -ForegroundColor Yellow + return + } + + # The committed folder is flat, so a name is the whole address. Two rendered + # documents sharing one would leave whichever the walk reached first deciding what + # a preview gets refreshed from — the same silence CommittedAssetDriftTest refuses, + # and the reason to refuse it here too: with -SkipVerify that gate never runs, and + # the wrong document would be committed under the right name. + $rendered = @{} + foreach ($file in Get-ChildItem -Path $generated -File -Recurse) { + if ($rendered.ContainsKey($file.Name)) { + throw ("Refresh-CommittedPreviews: two rendered documents share the name " + + "$($file.Name) ($($rendered[$file.Name]) and $($file.FullName)).") + } + $rendered[$file.Name] = $file.FullName + } + + $missing = @() + foreach ($file in $committed) { + if ($rendered.ContainsKey($file.Name)) { + Copy-Item -Path $rendered[$file.Name] -Destination $file.FullName -Force + } else { + $missing += $file.Name + } + } + if ($missing.Count -gt 0) { + # A committed preview no example renders cannot be refreshed, and the drift + # gate fails on it a step later. Say so here, where the name is still known. + throw "Refresh-CommittedPreviews: nothing renders $($missing -join ', ')." + } + Note "previews: $($committed.Count) refreshed from the catalogue" +} + function Render-ReadmeBanner { # Re-renders assets/readme/repository_showcase_render.png — the 2.0 module-first # hero (EngineDeckV2Example.renderBannerImage) — so the hero's version pill @@ -652,7 +778,7 @@ function Render-ReadmeBanner { # banner.properties). The `compile` is REQUIRED: banner.properties is filtered # at examples-compile time, so the examples module must be recompiled AFTER the # Step-1 version bump — otherwise the banner would carry the previous release - # version. Runs after Run-ShowcaseSync, which already installed the bumped root + # version. Runs after Build-ExampleCatalogue, which already installed the bumped root # artifact into the local m2 cache so the examples module resolves it. Write-Host " > Re-render the version-stamped README hero banner" -ForegroundColor Cyan $banner = Join-Path $repoRoot 'assets/readme/repository_showcase_render.png' @@ -968,12 +1094,37 @@ try { if (-not $SkipShowcase) { Step 3 "Switch ShowcaseMetadata GH_BASE to /blob/$tag" Update-ShowcaseGhBase $tag | Out-Null + } else { + Step 3 "Skipped showcase GH_BASE flip (-SkipShowcase)" + } - Step 4 "Regenerate web/examples.json with $tag links" - Run-ShowcaseSync + # The catalogue is built either way: the site is copied out of it, and so are the + # previews. -SkipShowcase is about the published tree under web/, not about this. + Step 4 "Build the example catalogue at $Version" + Build-ExampleCatalogue + + # The README assets follow the tag, so only a final cut moves them. On a + # pre-release the version they would carry is the qualifier-stripped one — a + # release that does not exist yet — so they stay on the last published version + # along with the property that records it. + # + # -SkipShowcase does not skip this: these files ship in the repository, and a + # preview left at the previous release is what CommittedAssetDriftTest fails the + # next build on. It has to happen before Step 5, which is where that gate runs. + if ($isFinalRelease) { + Step "4b" "Re-render the README assets at $Version" + Update-AssetVersion (Join-Path $repoRoot 'examples/pom.xml') $Version + Refresh-CommittedPreviews Render-ReadmeBanner } else { - Step 3 "Skipped showcase GH_BASE flip + regen + banner (-SkipShowcase)" + Step "4b" "Skipped the README assets (pre-release cut: they stay on the last published version)" + } + + if (-not $SkipShowcase) { + Step "4c" "Regenerate web/examples.json with $tag links" + Sync-ShowcaseSite + } else { + Step "4c" "Skipped web/showcase sync (-SkipShowcase)" } if (-not $SkipVerify) { @@ -1076,12 +1227,20 @@ try { $commitFiles += $moduleReadme } } + # The README assets ride along whenever they were re-rendered — every final cut, + # -SkipShowcase or not, since that flag is about the published site and these ship + # in the repository. A pre-release leaves them alone, so it stages nothing here. + if ($isFinalRelease) { + $commitFiles += @( + 'assets/readme/examples', + 'assets/readme/repository_showcase_render.png' + ) + } if (-not $SkipShowcase) { $commitFiles += @( 'examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java', 'web/examples.json', - 'web/showcase', - 'assets/readme/repository_showcase_render.png' + 'web/showcase' ) } if ($DryRun) {