diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c97b1d2b..067d5321 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,13 +2,13 @@ name: release # Release-only publish for FS.GG.Game (ADR-0022 / P5). Tag-triggered; NEVER a required check and # NEVER on push/PR (mirrors the sibling repos' release cadence). Publishes the coherent set -# FS.GG.Game.Core + FS.GG.Game.Render + FS.GG.Game.Harness to the org GitHub Packages feed +# FS.GG.Game.Core + FS.GG.Game.Render + FS.GG.Game.Harness + FS.GG.Game.Physics.Box2D to the org GitHub Packages feed # (nuget.pkg.github.com/FS-GG), then dual-publishes the BYTE-IDENTICAL set to public nuget.org via # Trusted Publishing / OIDC (ADR-0012 §1–§5 / ADR-0013 — no stored key). Gated on a full headless # build+test first: a set is published only after `verify` is green (publish-before-flip, FR-007). # # The set shares ONE version (the repo-wide in Directory.Build.local.props); the packages -# move together, so every release re-publishes all three at the same version. Game.Render depends on +# move together, so every release re-publishes all four at the same version. Game.Render depends on # Game.Core@same-version + FS.GG.UI.Scene (itself dual-published, ADR-0012/0013); Game.Harness # depends on Game.Core@same-version + the BCL only (WI-6, Game#425). FS.GG.Game.Harness joined the # set at 0.6.0 — it is packable and in the slnx as of #423 but landed after the v0.5.1 release, so no @@ -54,6 +54,13 @@ jobs: with: dotnet-version: '10.0.x' + # Cheap exact-roster/version/order checks run before restore or the full release suite. + - name: Preflight 0.17.0 release contract and mutation controls + shell: bash + run: | + tests/release/test-game-0-17-release.sh --source-only + tests/release/test-game-0-17-release-selftest.sh + # COLD, via the shared action (#135 / FS-GG/.github#429) — and this is the job where a warm # restore was worst. There is no `cache: true` here to blame: --locked-mode validates the # committed contentHash against whatever copy of the package is already in the global-packages @@ -71,12 +78,6 @@ jobs: - name: Test (full headless suite) run: dotnet test FS.GG.Game.slnx -c Debug --no-restore - - name: Verify 0.16.0 release contract and inversion - shell: bash - run: | - tests/release/test-game-0-16-release.sh --source-only - tests/release/test-game-0-16-release-selftest.sh - publish-packages: name: Publish FS.GG.Game.* (org feed + nuget.org, release-only) runs-on: ubuntu-latest @@ -147,9 +148,9 @@ jobs: with: target: FS.GG.Game.slnx - # Pack the coherent set. FS.GG.Game.Core, FS.GG.Game.Render and FS.GG.Game.Harness are - # IsPackable=true (the three test projects are IsPackable=false), so packing the whole slnx - # yields exactly those three packages, all at $VER — and each ProjectReference to Game.Core + # Pack the coherent set. FS.GG.Game.Core, FS.GG.Game.Render, FS.GG.Game.Harness and FS.GG.Game.Physics.Box2D are + # IsPackable=true (all test and example projects are IsPackable=false), so packing the whole slnx + # yields exactly those four packages, all at $VER — and each ProjectReference to Game.Core # (Render's and Harness's) is emitted as a package dependency at the same $VER (coherent set). # # `dotnet pack` restores implicitly, and that is left alone deliberately: it re-resolves with @@ -167,7 +168,6 @@ jobs: -p:Version="$VER" \ -p:RepositoryCommit="$GITHUB_SHA" \ -p:EnablePackageValidation=true \ - -p:PackageValidationBaselineVersion=0.15.0 \ -o artifacts/packages (cd artifacts/packages && sha256sum -- *.nupkg | sort -k2 > SHA256SUMS) echo "Packed:"; ls -1 artifacts/packages/*.nupkg @@ -216,11 +216,15 @@ jobs: set -euo pipefail (cd artifacts/packages && sha256sum --check SHA256SUMS) count="$(find artifacts/packages -maxdepth 1 -type f -name '*.nupkg' ! -name '*.snupkg' | wc -l)" - [ "$count" = 3 ] || { echo "::error::release custody must contain exactly three primary packages; found $count"; exit 1; } + [ "$count" = 4 ] || { echo "::error::release custody must contain exactly four primary packages; found $count"; exit 1; } - name: Verify coherent package set, metadata, and clean Journey consumer shell: bash - run: tests/release/test-game-0-16-release.sh --packages artifacts/packages + run: tests/release/test-game-0-17-release.sh --packages artifacts/packages + + - name: Qualify package-only Box2D candidate from retained preparation + shell: bash + run: tests/release/test-box2d-package-consumer.sh --packages artifacts/packages - name: Retain original coherent package bytes before publication if: inputs.source_run_id == '' @@ -311,10 +315,15 @@ jobs: if observed != expected: raise SystemExit(f'{feed} payload differs for {original.name}') row['feeds'][feed]={'archiveSha256':sha256(remote.read_bytes()).hexdigest(),'payloadMembers':len(observed),'payloadMatch':True} rows.append(row) - if len(rows) != 3: raise SystemExit(f'expected three primary package readbacks, got {len(rows)}') + if len(rows) != 4: raise SystemExit(f'expected four primary package readbacks, got {len(rows)}') report.write_text(json.dumps({'schema':'fsgg.game.release-readback/v1','packages':rows},indent=2)+'\n') PY + - name: Qualify fresh public package-only Box2D consumer + if: steps.ver.outputs.push == 'true' + shell: bash + run: tests/release/test-box2d-package-consumer.sh --public + - name: Retain dual-feed readback evidence if: steps.ver.outputs.push == 'true' uses: actions/upload-artifact@v7 diff --git a/Directory.Build.local.props b/Directory.Build.local.props index db9e5d96..b3a28bd6 100644 --- a/Directory.Build.local.props +++ b/Directory.Build.local.props @@ -61,12 +61,15 @@ - - 0.16.0 + which overrides this. This is the selected source release scalar; + native both-feed readback separately establishes its published status. --> + + 0.17.0 + + 0.16.0 FS.GG Contributors FS.GG Contributors git diff --git a/docs/roadmaps/game-box2d-physics.md b/docs/roadmaps/game-box2d-physics.md index 2ae1aeb7..81dc42b7 100644 --- a/docs/roadmaps/game-box2d-physics.md +++ b/docs/roadmaps/game-box2d-physics.md @@ -113,13 +113,13 @@ adoption must preview and preserve user-owned files and record rollback/removal - [ ] `.1` Runtime and headless example: locally qualified; native source delivery pending. - [ ] `.2` Retained snapshots, interpolation and pinned input replay: locally qualified; native source delivery pending. -- [ ] `.3` Public package and fresh installed example: publication/installed support pending. +- [ ] `.3` Public package and fresh installed example: successor source and package-only candidate consumer locally qualified; native source/publication/public installed proof pending. - [ ] `.4` Explicit consumer adoption: not selected. The optional project is `src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj`. Its package identity is `FS.GG.Game.Physics.Box2D` and it inherits Game's coherent -version convention (currently 0.16.0); a future release must select a coherent version -and add the package to release obligations before publication. No package has been published. +version convention (selected successor 0.17.0); the producer release source prepares all four Game packages +at that scalar. Source preparation does not establish publication. The dependency is [published Box2D.NET 3.1.654](https://www.nuget.org/packages/Box2D.NET/3.1.654), whose package metadata records upstream revision @@ -157,3 +157,64 @@ registered type/member baselines (27 types, 52 members); whole-solution qualific and the native source delivery gates remain the integrator's obligation. No public package, installed consumer, portal transfer, browser or wider-platform acceptance is established by these local results. + +## 0.17.0 release source and preflight + +The selected source successor is stable `0.17.0`, with Core, Render, Harness and the new +Physics.Box2D adapter forming one coherent release set. Existing packages use their published +`0.16.0` API baseline. The first adapter package has no predecessor baseline; current-package +validation and the exact package/API/dependency checks still apply. Both feed collision checks +must be refreshed before the release owner selects publication. + +The existing `release.yml` route retains its verify dependency, exact-source version guard, +one coherent preparation, original custody bytes, GitHub Packages before nuget.org/OIDC, +byte comparison/readback, and authenticated retained-byte recovery. Successor fixtures check +four exact package identities, metadata, adapter .fsi/API entries, Core's FSharp.Core-only +runtime boundary and the adapter's absence of rendering dependencies. Candidate and public +smokes copy independent source into a fresh directory outside the checkout and restore only +PackageReferences; neither sibling projects nor repository imports/linked source qualify them. +The smoke executes the same complete 240-tick falling/joint/sensor and exact replay scene. +Candidate artifacts establish preflight only; the public-only fresh restore after genuine +both-feed readback is required before `.3` can close. + +Pipeline-preflight selection is the existing-path static check plus focused mutation controls, +placed before release restore/build/tests. The changes alter scalar/roster and first-release +baseline selection while preserving the existing effect order and recovery route, so a custom +state model is deferred: its maintenance would duplicate unchanged controls. Investment is +bounded to the successor release fixture and isolated consumer; no new release framework or +registry authority is introduced. Source controls accepted the good workflow and rejected +substituted first/second/both push commands, a wrong scalar, foreign API baseline, missing +adapter custody, and missing/foreign/wrong-version artifacts before consumer launch. The +measured local source-only mutation run took about 0.22 seconds with no restore or downloads. +No billed runner savings or native publication acceptance are inferred from that measurement. +Local candidate preflight passed on source `2eaca0271431a1239f9e1ba2640005687b3bd63d`: +one coherent preparation produced exactly the four `0.17.0` packages, with existing-three +API compatibility against `0.16.0` and first-release adapter validation. The source-bound +metadata/API/dependency checks and independent Journey consumer passed. A fresh copied +Box2D package-only consumer cold-restored the candidate packages into an empty cache, +proved an exact four-package dependency closure (adapter, Core, Box2D.NET and FSharp.Core), +and ran the real 240-tick falling/joint/sensor scene with exact replay. It reported +.NET 10.0.12 / Arch Linux / X64, final falling height `0.39992859959602356` m and maximum +joint length error `0.00008517808537522598` m, with sensor enter/exit and floor contact. +The original candidate bytes and consumer inputs were retained; no package was published. +This candidate evidence binds the named source, independently of later documentation edits. +Native source delivery, both-feed publication/readback and public-only installed qualification +remain pending. + +## Portal transfer source join for the selected release + +The isolated successor joins the release source with the qualified P2 adapter/real headless +portal scene (`10f63b469ec8b63635a8f4c5fe6e66f11010b3c9`). Source acceptance of that candidate +remains separate from its native merge readback. The package-only consumer now copies that +scene into its own `PortalScene.fs` and compiles it only against restored package APIs, alongside +the existing falling-body scene. It requires one live traveller after the rotated transfer, +transformed pose/momentum, one step per local world/tick, one traversal, no repeated transfer and +exact fresh-world replay. The package metadata gate also requires the packed `AreaRuntime.fsi`. + +The earlier immutable `2eaca027` preparation and its four artifacts remain historical; they do +not contain or qualify the expanded area-transfer API. The joined successor needs a fresh +version-consistent restore (including the new portal example lock), existing-three API/package +validation, one fresh four-package preparation and independent candidate smoke for both scenes. +Public installed support still requires genuine publication, both-feed readback and the public-only +consumer; product/template opt-in remains separate. No old candidate is substituted for that +fresh obligation. diff --git a/examples/Box2D.Headless/packages.lock.json b/examples/Box2D.Headless/packages.lock.json index ab8d8e25..ac290131 100644 --- a/examples/Box2D.Headless/packages.lock.json +++ b/examples/Box2D.Headless/packages.lock.json @@ -18,7 +18,7 @@ "type": "Project", "dependencies": { "Box2D.NET": "[3.1.654, )", - "FS.GG.Game.Core": "[0.16.0, )", + "FS.GG.Game.Core": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } }, diff --git a/examples/Box2D.Portals/packages.lock.json b/examples/Box2D.Portals/packages.lock.json index ab8d8e25..ac290131 100644 --- a/examples/Box2D.Portals/packages.lock.json +++ b/examples/Box2D.Portals/packages.lock.json @@ -18,7 +18,7 @@ "type": "Project", "dependencies": { "Box2D.NET": "[3.1.654, )", - "FS.GG.Game.Core": "[0.16.0, )", + "FS.GG.Game.Core": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } }, diff --git a/src/Playtest.Cli/packages.lock.json b/src/Playtest.Cli/packages.lock.json index 167a6463..87df68eb 100644 --- a/src/Playtest.Cli/packages.lock.json +++ b/src/Playtest.Cli/packages.lock.json @@ -17,7 +17,7 @@ "fs.gg.game.harness": { "type": "Project", "dependencies": { - "FS.GG.Game.Core": "[0.16.0, )", + "FS.GG.Game.Core": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } } diff --git a/src/ReferenceGame/packages.lock.json b/src/ReferenceGame/packages.lock.json index 167a6463..87df68eb 100644 --- a/src/ReferenceGame/packages.lock.json +++ b/src/ReferenceGame/packages.lock.json @@ -17,7 +17,7 @@ "fs.gg.game.harness": { "type": "Project", "dependencies": { - "FS.GG.Game.Core": "[0.16.0, )", + "FS.GG.Game.Core": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } } diff --git a/tests/Game.Harness.Tests/packages.lock.json b/tests/Game.Harness.Tests/packages.lock.json index 2f8ede19..d8fa63f0 100644 --- a/tests/Game.Harness.Tests/packages.lock.json +++ b/tests/Game.Harness.Tests/packages.lock.json @@ -127,14 +127,14 @@ "fs.gg.game.harness": { "type": "Project", "dependencies": { - "FS.GG.Game.Core": "[0.16.0, )", + "FS.GG.Game.Core": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } }, "fs.gg.game.reference": { "type": "Project", "dependencies": { - "FS.GG.Game.Harness": "[0.16.0, )", + "FS.GG.Game.Harness": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } } diff --git a/tests/Game.Physics.Box2D.Tests/packages.lock.json b/tests/Game.Physics.Box2D.Tests/packages.lock.json index 27f4daa9..625951ca 100644 --- a/tests/Game.Physics.Box2D.Tests/packages.lock.json +++ b/tests/Game.Physics.Box2D.Tests/packages.lock.json @@ -119,7 +119,7 @@ "type": "Project", "dependencies": { "Box2D.NET": "[3.1.654, )", - "FS.GG.Game.Core": "[0.16.0, )", + "FS.GG.Game.Core": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } }, diff --git a/tests/Game.Render.Tests/packages.lock.json b/tests/Game.Render.Tests/packages.lock.json index 362094a7..feee7b6c 100644 --- a/tests/Game.Render.Tests/packages.lock.json +++ b/tests/Game.Render.Tests/packages.lock.json @@ -127,7 +127,7 @@ "fs.gg.game.render": { "type": "Project", "dependencies": { - "FS.GG.Game.Core": "[0.16.0, )", + "FS.GG.Game.Core": "[0.17.0, )", "FS.GG.UI.KeyboardInput": "[0.31.0, )", "FS.GG.UI.Scene": "[0.31.0, )", "FSharp.Core": "[10.1.302, )" diff --git a/tests/Playtest.Cli.Tests/packages.lock.json b/tests/Playtest.Cli.Tests/packages.lock.json index e5622f0e..4ac3fd62 100644 --- a/tests/Playtest.Cli.Tests/packages.lock.json +++ b/tests/Playtest.Cli.Tests/packages.lock.json @@ -118,21 +118,21 @@ "fs.gg.game.harness": { "type": "Project", "dependencies": { - "FS.GG.Game.Core": "[0.16.0, )", + "FS.GG.Game.Core": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } }, "fs.gg.game.reference": { "type": "Project", "dependencies": { - "FS.GG.Game.Harness": "[0.16.0, )", + "FS.GG.Game.Harness": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } }, "fsgg-playtest": { "type": "Project", "dependencies": { - "FS.GG.Game.Harness": "[0.16.0, )", + "FS.GG.Game.Harness": "[0.17.0, )", "FSharp.Core": "[10.1.302, )" } } diff --git a/tests/release/Box2D.PackageConsumer/Consumer.fsproj b/tests/release/Box2D.PackageConsumer/Consumer.fsproj new file mode 100644 index 00000000..e9c144d4 --- /dev/null +++ b/tests/release/Box2D.PackageConsumer/Consumer.fsproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + false + true + false + true + true + true + + + + + + + diff --git a/tests/release/Box2D.PackageConsumer/PortalScene.fs b/tests/release/Box2D.PackageConsumer/PortalScene.fs new file mode 100644 index 00000000..55e89089 --- /dev/null +++ b/tests/release/Box2D.PackageConsumer/PortalScene.fs @@ -0,0 +1,58 @@ +module Box2D.Portals.Scene + +open System +open FS.GG.Game.Core +open FS.GG.Game.Physics.Box2D + +let areaA = AreaTopology.AreaId "a" +let areaB = AreaTopology.AreaId "b" +let private get = function Ok value -> value | Error reason -> failwith reason +let private point x y : Point = { X = x; Y = y } +let mapping = AreaTopology.rigidMap (Math.PI / 2.) (point 10. 5.) |> get +let sourceOpening : AreaTopology.Opening = { Start = point 0. -2.; Finish = point 0. 2. } +let destinationOpening : AreaTopology.Opening = + { Start = AreaTopology.mapPoint mapping sourceOpening.Start; Finish = AreaTopology.mapPoint mapping sourceOpening.Finish } +let forward : AreaTopology.Portal = + { Id = AreaTopology.PortalId "forward"; Source = areaA; Destination = areaB + SourceOpening = sourceOpening; DestinationOpening = destinationOpening + Direction = AreaTopology.PositiveToNegative; Mapping = mapping; Reverse = Some (AreaTopology.PortalId "back") } +let back : AreaTopology.Portal = + { Id = AreaTopology.PortalId "back"; Source = areaB; Destination = areaA + SourceOpening = destinationOpening; DestinationOpening = sourceOpening + Direction = AreaTopology.NegativeToPositive; Mapping = AreaTopology.inverse mapping; Reverse = Some forward.Id } +let graph = AreaTopology.create [areaA; areaB] [forward; back] |> get +let settings = + [ { Area = areaA; Physics = { Physics.defaultSettings with Gravity = point 0. 0.; TickSeconds = 0.1 }; Capacity = 16 } + { Area = areaB; Physics = { Physics.defaultSettings with Gravity = point 0. -3.; TickSeconds = 0.1 }; Capacity = 16 } ] +let moving = + { Physics.circle "traveller" 0.1 (point -0.25 0.) with + LinearVelocity = point 4. 1.; Rotation = 0.3; AngularVelocity = 2. + Density = 2.; Friction = 0.4; Restitution = 0.2 } +let inputs = + [ [ { Area = areaA; Command = CreateBody moving } ]; []; []; []; [] ] +let advance = function Advanced snapshot -> snapshot | Stopped(reason, _) -> failwith ("Stopped: " + reason) +let run () = + use runtime = new AreaRuntime(graph, settings, 0.05) + inputs |> List.map (runtime.Step >> advance) +let qualify () = + let observed = run () + if observed <> run () then failwith "Pinned-profile portal replay differs." + let transferred = observed.Head + let event = match transferred.Traversals with [event] -> event | _ -> failwith "Expected one committed traversal." + if transferred.Ownership <> Map.ofList [moving.Entity, areaB] then failwith "Entity identity/ownership differs." + if transferred.Areas |> Map.toSeq |> Seq.sumBy (fun (_, snapshot) -> snapshot.Bodies.Count) <> 1 then + failwith "Expected exactly one live body after commit." + if transferred.Areas |> Map.exists (fun _ snapshot -> snapshot.Tick <> 1L) then failwith "A local world stepped more than once." + let sourceMotion : AreaTopology.Motion = + { Position = event.SourcePose.Position; Velocity = event.SourcePose.LinearVelocity + Orientation = event.SourcePose.Rotation; AngularVelocity = event.SourcePose.AngularVelocity } + let expected = AreaTopology.mapMotion mapping sourceMotion + let close a b = abs (a - b) <= 2e-5 + let pose = event.DestinationPose + if not (close pose.Position.X expected.Position.X && close pose.Position.Y expected.Position.Y + && close pose.LinearVelocity.X expected.Velocity.X && close pose.LinearVelocity.Y expected.Velocity.Y + && close pose.AngularVelocity expected.AngularVelocity + && close (Math.IEEERemainder(pose.Rotation - expected.Orientation, 2. * Math.PI)) 0.) then + failwithf "Rotated pose/momentum differ. actual=%A expected=%A source=%A" pose expected event.SourcePose + if observed |> List.sumBy (fun snapshot -> snapshot.Traversals.Length) <> 1 then failwith "Unexpected repeated transfer." + observed diff --git a/tests/release/Box2D.PackageConsumer/Program.fs b/tests/release/Box2D.PackageConsumer/Program.fs new file mode 100644 index 00000000..4bbeb43d --- /dev/null +++ b/tests/release/Box2D.PackageConsumer/Program.fs @@ -0,0 +1,34 @@ +module Box2D.Headless.Program + +open System +open System.Runtime.InteropServices +open System.Text.Json + +[] +let main _ = + let snapshots, jointError = Scene.qualify () + let portals = Box2D.Portals.Scene.qualify () + let traversal = portals.Head.Traversals.Head + let events = snapshots |> List.collect _.Events |> List.map string |> List.toArray + let final = snapshots |> List.last + let result = + {| package = "Box2D.NET" + version = "3.1.654" + runtime = RuntimeInformation.FrameworkDescription + runtimeVersion = Environment.Version.ToString() + os = RuntimeInformation.OSDescription + architecture = RuntimeInformation.ProcessArchitecture.ToString() + portalTicks = portals.Length + portalEntity = traversal.Entity + portalTraversals = portals |> List.sumBy (fun snapshot -> snapshot.Traversals.Length) + portalPositionX = traversal.DestinationPose.Position.X + portalPositionY = traversal.DestinationPose.Position.Y + portalVelocityX = traversal.DestinationPose.LinearVelocity.X + portalVelocityY = traversal.DestinationPose.LinearVelocity.Y + ticks = final.Tick + fallingY = final.Bodies["falling"].Position.Y + maximumJointError = jointError + replay = "exact-pinned-profile" + events = events |} + printfn "%s" (JsonSerializer.Serialize result) + 0 diff --git a/tests/release/Box2D.PackageConsumer/README.md b/tests/release/Box2D.PackageConsumer/README.md new file mode 100644 index 00000000..f37a3059 --- /dev/null +++ b/tests/release/Box2D.PackageConsumer/README.md @@ -0,0 +1,19 @@ +# Independent Box2D package consumer + +`../test-box2d-package-consumer.sh --packages /absolute/retained/packages` copies this +source into a new private directory outside the repository and qualifies candidate packages. +`../test-box2d-package-consumer.sh --public` instead cold-restores the exact published0.17.0 +package from public nuget.org; it is the installed-consumer route after both-feed publication +and readback. An optional `--output /absolute/new/directory` retains all restored inputs and +lock evidence at a chosen private location. Every attempt uses a new package cache. + +The copied project contains only exact PackageReferences, locally copied Compile inputs and +no repository ProjectReference/import. The boundary check verifies the exact package closure, +Core's FSharp.Core-only runtime dependencies and the adapter's lack of rendering dependencies. +The real entry point then drives 240 ticks from recorded commands, observes falling motion, +a distance joint, sensor enter/exit and floor contact, and compares exact per-tick copied +poses/events in two fresh worlds. It also executes the copied real five-tick rotated portal +scene against package APIs, requiring one live traveller after transfer, transformed pose/momentum, +one step per local world per tick, one traversal, no repeated transfer and exact replay. SDK10.0.401 and runtime10.0.12 scope this result; output +reports the actual OS/architecture/runtime. Candidate smoke cannot claim public installed +support; neither smoke selects a product/template or establishes cross-platform determinism. diff --git a/tests/release/Box2D.PackageConsumer/Scene.fs b/tests/release/Box2D.PackageConsumer/Scene.fs new file mode 100644 index 00000000..8f0e0b1c --- /dev/null +++ b/tests/release/Box2D.PackageConsumer/Scene.fs @@ -0,0 +1,48 @@ +module Box2D.Headless.Scene + +open FS.GG.Game.Physics.Box2D + +let inputs = + let circle = Physics.circle + let floor = { circle "floor" 1. { X = 0.; Y = -0.5 } with Kind = Static; Shape = Box(12., 0.5) } + let sensor = { circle "sensor" 1. { X = 0.; Y = 4. } with Kind = Static; Shape = Box(2., 0.25); IsSensor = true } + let anchor = { circle "anchor" 0.2 { X = 4.; Y = 8. } with Kind = Static } + let bob = circle "bob" 0.3 { X = 4.; Y = 6. } + [ for tick in 1 .. 240 -> + match tick with + | 1 -> [ CreateBody floor; CreateBody sensor; CreateBody (circle "falling" 0.4 { X = 0.; Y = 8. }) + CreateBody anchor; CreateBody bob + CreateDistanceJoint { Joint = "rope"; EntityA = "anchor"; EntityB = "bob"; Length = 2. } ] + | 30 -> [ ApplyImpulse("bob", { X = 0.25; Y = 0. }) ] + | 90 -> [ ApplyForce("bob", { X = -1.; Y = 0. }) ] + | _ -> [] ] + +let run () = + use runtime = new Runtime(Physics.defaultSettings) + inputs |> List.map runtime.Step + +let qualify () = + use runtime = new Runtime(Physics.defaultSettings) + let first = runtime.Step inputs.Head + let second = runtime.Step inputs.Tail.Head + let retainedY = first.Bodies["falling"].Position.Y, second.Bodies["falling"].Position.Y + let rest = inputs |> List.skip 2 |> List.map runtime.Step + let replay = run () + let observed = first :: second :: rest + if observed <> replay then failwith "Pinned-profile exact replay differs." + if retainedY <> (first.Bodies["falling"].Position.Y, second.Bodies["falling"].Position.Y) then + failwith "Retained snapshots changed." + let middle = Physics.interpolate 0.5 first second + let expectedY = (first.Bodies["falling"].Position.Y + second.Bodies["falling"].Position.Y) / 2. + if abs (middle["falling"].Position.Y - expectedY) > 1e-12 then failwith "Interpolation differs." + let events = observed |> List.collect _.Events + if not (List.contains (SensorEntered("sensor", "falling")) events) then failwith "Missing sensor enter." + if not (List.contains (SensorExited("sensor", "falling")) events) then failwith "Missing sensor exit." + if not (List.contains (ContactStarted("falling", "floor")) events) then failwith "Missing floor contact." + if observed[60].Bodies["falling"].Position.Y >= first.Bodies["falling"].Position.Y - 1. then + failwith "Body did not fall." + let jointError = observed |> List.map (fun snapshot -> + let a, b = snapshot.Bodies["anchor"].Position, snapshot.Bodies["bob"].Position + abs (sqrt ((a.X - b.X) ** 2. + (a.Y - b.Y) ** 2.) - 2.)) |> List.max + if jointError > 0.03 then failwithf "Distance joint error: %g" jointError + observed, jointError diff --git a/tests/release/Box2D.PackageConsumer/global.json b/tests/release/Box2D.PackageConsumer/global.json new file mode 100644 index 00000000..b20e2734 --- /dev/null +++ b/tests/release/Box2D.PackageConsumer/global.json @@ -0,0 +1 @@ +{"sdk":{"version":"10.0.401","rollForward":"disable","allowPrerelease":false}} diff --git a/tests/release/Box2D.PackageConsumer/verify-package-boundary.py b/tests/release/Box2D.PackageConsumer/verify-package-boundary.py new file mode 100644 index 00000000..def94363 --- /dev/null +++ b/tests/release/Box2D.PackageConsumer/verify-package-boundary.py @@ -0,0 +1,24 @@ +from pathlib import Path +import json +import xml.etree.ElementTree as ET + +root = Path(__file__).resolve().parent +project = ET.parse(root / 'Consumer.fsproj').getroot() +for tag in ('ProjectReference', 'Reference', 'Import'): + assert not project.findall('.//' + tag), f'consumer must contain no {tag}' +for source in project.findall('.//Compile'): + path = (root / source.attrib['Include']).resolve() + assert path.parent == root and path.is_file(), 'consumer source must be copied and local' +assets = json.loads((root / 'obj/project.assets.json').read_text()) +expected = {'FS.GG.Game.Physics.Box2D/0.17.0', 'FS.GG.Game.Core/0.17.0', 'Box2D.NET/3.1.654', 'FSharp.Core/10.1.302'} +assert set(assets['libraries']) == expected, set(assets['libraries']) +assert all(row['type'] == 'package' for row in assets['libraries'].values()), 'repository project dependency present' +packages = Path(assets['project']['restore']['packagesPath']) +ns = {'n': 'http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd'} +def dependency_ids(package, version): + path = packages / package.lower() / version / (package.lower() + '.nuspec') + tree = ET.parse(path) + return {node.attrib['id'] for node in tree.iter() if node.tag.rsplit('}', 1)[-1] == 'dependency'} +assert dependency_ids('FS.GG.Game.Core', '0.17.0') == {'FSharp.Core'}, 'Core runtime boundary changed' +assert dependency_ids('FS.GG.Game.Physics.Box2D', '0.17.0') == {'FSharp.Core', 'FS.GG.Game.Core', 'Box2D.NET'}, 'adapter runtime boundary changed' +print('package-only boundary passed: exact four-package closure, Core BCL/FSharp.Core, adapter has no rendering dependency') diff --git a/tests/release/test-box2d-package-consumer.sh b/tests/release/test-box2d-package-consumer.sh new file mode 100755 index 00000000..0fa06f90 --- /dev/null +++ b/tests/release/test-box2d-package-consumer.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +packages="" +mode="" +output="" +while (($#)); do + case "$1" in + --packages) packages="$(cd "$2" && pwd)"; mode=candidate; shift 2 ;; + --public) mode=public; shift ;; + --output) output="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[[ "$mode" == candidate || "$mode" == public ]] || { echo 'select --packages DIR or --public' >&2; exit 2; } +if [[ -n "$output" ]]; then + [[ "$output" == /* && ! -e "$output" ]] || { echo '--output must be a new absolute directory' >&2; exit 2; } + mkdir -m 700 "$output" +else + output="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/box2d-package-consumer.XXXXXX")" +fi +cp "$root/tests/release/Box2D.PackageConsumer/"{Consumer.fsproj,Program.fs,Scene.fs,PortalScene.fs,global.json,verify-package-boundary.py} "$output/" +python3 - "$output/NuGet.Config" "$mode" "$packages" <<'PY' +import sys, xml.etree.ElementTree as ET +path, mode, packages = sys.argv[1:] +root = ET.Element('configuration'); sources = ET.SubElement(root, 'packageSources'); ET.SubElement(sources, 'clear') +mapping = ET.SubElement(root, 'packageSourceMapping') +if mode == 'candidate': + ET.SubElement(sources, 'add', key='candidate', value=packages) + ET.SubElement(ET.SubElement(mapping, 'packageSource', key='candidate'), 'package', pattern='FS.GG.Game.*') +ET.SubElement(sources, 'add', key='nuget.org', value='https://api.nuget.org/v3/index.json') +public = ET.SubElement(mapping, 'packageSource', key='nuget.org') +for pattern in (['Box2D.NET', 'FSharp.Core'] if mode == 'candidate' else ['*']): + ET.SubElement(public, 'package', pattern=pattern) +ET.ElementTree(root).write(path, encoding='unicode', xml_declaration=True) +PY +printf 'Box2D package-only qualification mode=%s directory=%s\n' "$mode" "$output" +cd "$output" +# Every attempt has a new isolated package cache and public HTTPS source mapping. +# Disable the HTTP cache instead of changing the user's global NuGet cache/configuration. +dotnet restore Consumer.fsproj --configfile NuGet.Config --packages "$output/packages" --no-cache --force-evaluate --disable-parallel +python3 verify-package-boundary.py +dotnet build Consumer.fsproj -c Release --no-restore -m:1 --disable-build-servers +# SDK pin and explicit runtime selection scope replay evidence to this profile. +dotnet exec --fx-version 10.0.12 bin/Release/net10.0/Consumer.dll +printf 'Box2D package-only qualification passed mode=%s; lock and restored inputs retained in %s\n' "$mode" "$output" diff --git a/tests/release/test-game-0-17-release-selftest.sh b/tests/release/test-game-0-17-release-selftest.sh new file mode 100755 index 00000000..d8b17a6b --- /dev/null +++ b/tests/release/test-game-0-17-release-selftest.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +make_fixture() { + local fixture="$1" + mkdir -p "$fixture/.github/workflows" + cp "$root/Directory.Build.local.props" "$fixture/Directory.Build.local.props" + cp "$root/.github/workflows/release.yml" "$fixture/.github/workflows/release.yml" +} + +positive="$scratch/positive" +make_fixture "$positive" +"$root/tests/release/test-game-0-17-release.sh" --root "$positive" --source-only +echo "positive control accepted both actual dotnet nuget push operations" + +expect_push_mutation_red() { + local label="$1" indexes="$2" output + local fixture="$scratch/$label" + make_fixture "$fixture" + python3 - "$fixture/.github/workflows/release.yml" "$indexes" <<'PY' +import pathlib, sys + +path = pathlib.Path(sys.argv[1]) +selected = {int(value) for value in sys.argv[2].split(",")} +needle = 'dotnet nuget push "artifacts/packages/*.nupkg"' +text = path.read_text(encoding="utf-8") +parts = text.split(needle) +if len(parts) != 3: + raise SystemExit(f"fixture expected two push operations, observed {len(parts) - 1}") +rebuilt = parts[0] +for index, tail in enumerate(parts[1:]): + rebuilt += ("echo package-push-disabled" if index in selected else needle) + tail +path.write_text(rebuilt, encoding="utf-8") +PY + if output=$("$root/tests/release/test-game-0-17-release.sh" --root "$fixture" --source-only 2>&1); then + echo "$label mutation unexpectedly passed" >&2 + exit 1 + fi + grep -q "exactly two actual dotnet nuget push operations" <<<"$output" || { + echo "$label mutation failed for the wrong reason: $output" >&2 + exit 1 + } + echo "$label witness: substituted push operation rejected" +} + +expect_push_mutation_red first-push 0 +expect_push_mutation_red second-push 1 +expect_push_mutation_red both-pushes 0,1 + +version_fixture="$scratch/version" +make_fixture "$version_fixture" +sed -i 's:0.17.0:0.17.1:' "$version_fixture/Directory.Build.local.props" +if output=$("$root/tests/release/test-game-0-17-release.sh" --root "$version_fixture" --source-only 2>&1); then + echo "release gate inversion unexpectedly passed" >&2 + exit 1 +fi +grep -q "release scalar must be 0.17.0" <<<"$output" || { + echo "release gate inversion failed for the wrong reason: $output" >&2 + exit 1 +} +echo "version witness: 0.17.1 scalar rejected" + +baseline_fixture="$scratch/baseline" +make_fixture "$baseline_fixture" +sed -i 's:>0.16.0:>0.15.0:' "$baseline_fixture/Directory.Build.local.props" +if output=$("$root/tests/release/test-game-0-17-release.sh" --root "$baseline_fixture" --source-only 2>&1); then + echo "foreign API baseline mutation unexpectedly passed" >&2; exit 1 +fi +grep -q 'existing packages must use the published 0.16.0 API baseline' <<<"$output" +echo "baseline witness: foreign predecessor rejected" + +roster_fixture="$scratch/roster" +make_fixture "$roster_fixture" +sed -i 's:\[ "$count" = 4 \]:[ "$count" = 3 ]:' "$roster_fixture/.github/workflows/release.yml" +if output=$("$root/tests/release/test-game-0-17-release.sh" --root "$roster_fixture" --source-only 2>&1); then + echo "missing adapter custody mutation unexpectedly passed" >&2; exit 1 +fi +grep -q 'release custody and dual-feed readback must each require four packages' <<<"$output" +echo "roster witness: missing adapter custody rejected" + +# These controls exercise exact artifact admission before any metadata/CLR consumer work can launch. +missing="$scratch/missing-adapter" +mkdir "$missing" +for id in Core Harness Render; do : > "$missing/FS.GG.Game.$id.0.17.0.nupkg"; done +if output=$("$root/tests/release/test-game-0-17-release.sh" --packages "$missing" 2>&1); then + echo "missing adapter package mutation unexpectedly passed" >&2; exit 1 +fi +grep -q 'expected exactly the coherent 0.17.0 package set' <<<"$output" +echo "artifact witness: missing adapter refused before consumer launch" + +extra="$scratch/extra-package" +mkdir "$extra" +for id in Core Harness Render Physics.Box2D Foreign; do : > "$extra/FS.GG.Game.$id.0.17.0.nupkg"; done +if output=$("$root/tests/release/test-game-0-17-release.sh" --packages "$extra" 2>&1); then + echo "foreign package mutation unexpectedly passed" >&2; exit 1 +fi +grep -q 'expected exactly the coherent 0.17.0 package set' <<<"$output" +echo "artifact witness: foreign package refused before consumer launch" + +wrong="$scratch/wrong-package-version" +mkdir "$wrong" +for id in Core Harness Render; do : > "$wrong/FS.GG.Game.$id.0.17.0.nupkg"; done +: > "$wrong/FS.GG.Game.Physics.Box2D.0.17.1.nupkg" +if output=$("$root/tests/release/test-game-0-17-release.sh" --packages "$wrong" 2>&1); then + echo "mismatched adapter version mutation unexpectedly passed" >&2; exit 1 +fi +grep -q 'expected exactly the coherent 0.17.0 package set' <<<"$output" +echo "artifact witness: mismatched adapter version refused before consumer launch" diff --git a/tests/release/test-game-0-17-release.sh b/tests/release/test-game-0-17-release.sh new file mode 100755 index 00000000..bb9d896c --- /dev/null +++ b/tests/release/test-game-0-17-release.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +packages="" + +while (($#)); do + case "$1" in + --root) root="$(cd "$2" && pwd)"; shift 2 ;; + --packages) packages="$(cd "$2" && pwd)"; shift 2 ;; + --source-only) shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +props="$root/Directory.Build.local.props" +workflow="$root/.github/workflows/release.yml" + +version="$(sed -n 's:.*\([^<]*\).*:\1:p' "$props")" +[[ "$version" == "0.17.0" ]] || { + echo "release scalar must be 0.17.0, observed '$version'" >&2 + exit 1 +} + +python3 - "$workflow" "$props" <<'PY' +import pathlib, sys, xml.etree.ElementTree as ET + +text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +props = ET.parse(sys.argv[2]).getroot() +baselines = props.findall('.//PackageValidationBaselineVersion') +if len(baselines) != 1 or baselines[0].text != '0.16.0': + raise SystemExit('existing packages must use the published 0.16.0 API baseline') +condition = baselines[0].attrib.get('Condition', '') +expected = "'$(MSBuildProjectName)' == 'FS.GG.Game.Core' Or '$(MSBuildProjectName)' == 'FS.GG.Game.Render' Or '$(MSBuildProjectName)' == 'FS.GG.Game.Harness'" +if condition != expected or '-p:PackageValidationBaselineVersion=' in text: + raise SystemExit('API baseline must apply only to the three existing packages; the new adapter has no predecessor') +if '[ "$count" = 4 ]' not in text or 'len(rows) != 4' not in text: + raise SystemExit('release custody and dual-feed readback must each require four packages') +required = { + "verification dependency": "needs: [verify]", + "repository commit binding": '-p:RepositoryCommit="$GITHUB_SHA"', + "API compatibility gate": "-p:EnablePackageValidation=true", + "first release candidate consumer": "tests/release/test-box2d-package-consumer.sh --packages artifacts/packages", + "public installed consumer": "tests/release/test-box2d-package-consumer.sh --public", + "custody checksums": "sha256sum -- *.nupkg", + "retained custody artifact": "game-release-custody-", + "org feed": "https://nuget.pkg.github.com/FS-GG/index.json", + "public feed": "https://api.nuget.org/v3/index.json", +} +for label, token in required.items(): + if token not in text: + raise SystemExit(f"release workflow is missing {label}: {token}") +if text.count("dotnet pack FS.GG.Game.slnx") != 1: + raise SystemExit("release workflow must prepare the coherent set exactly once") +push = 'dotnet nuget push "artifacts/packages/*.nupkg"' +pushes = [index for index in range(len(text)) if text.startswith(push, index)] +if len(pushes) != 2: + raise SystemExit("release workflow must contain exactly two actual dotnet nuget push operations") +retained = text.index("uses: actions/upload-artifact@v7") +if retained >= pushes[0]: + raise SystemExit("original custody bytes must be retained before the first feed push") +org = text.index("https://nuget.pkg.github.com/FS-GG/index.json", pushes[0]) +public = text.index("https://api.nuget.org/v3/index.json", pushes[1]) +if org > pushes[1] or public < pushes[1]: + raise SystemExit("each dotnet nuget push must own its expected feed source") +if pushes[0] >= pushes[1]: + raise SystemExit("release workflow must push the org feed before nuget.org") +if "dotnet pack" in text[pushes[0]:]: + raise SystemExit("release workflow must not re-pack after first publication") +preflight = text.index('tests/release/test-game-0-17-release.sh --source-only') +if preflight >= text.index('target: FS.GG.Game.slnx'): + raise SystemExit('release source preflight must precede restore') +candidate = text.index('tests/release/test-box2d-package-consumer.sh --packages artifacts/packages') +if candidate >= retained: + raise SystemExit('candidate package-only qualification must precede custody retention/publication') +installed = text.index('tests/release/test-box2d-package-consumer.sh --public') +if installed <= text.index('Read back and compare both feed payloads') or installed <= pushes[1]: + raise SystemExit('public installed qualification must follow both feed pushes and readback') +recovery = text.index('Download retained release bytes') +if 'dotnet pack' in text[recovery:pushes[0]]: + raise SystemExit('custody recovery must not re-pack') +PY + +[[ -z "$packages" ]] && exit 0 + +mapfile -t nupkgs < <(find "$packages" -maxdepth 1 -type f -name '*.nupkg' ! -name '*.symbols.nupkg' -printf '%f\n' | sort) +expected=( + FS.GG.Game.Core.0.17.0.nupkg + FS.GG.Game.Harness.0.17.0.nupkg + FS.GG.Game.Physics.Box2D.0.17.0.nupkg + FS.GG.Game.Render.0.17.0.nupkg +) +[[ "${nupkgs[*]}" == "${expected[*]}" ]] || { + echo "expected exactly the coherent 0.17.0 package set" >&2 + printf 'observed: %s\n' "${nupkgs[*]:-}" >&2 + exit 1 +} + +expected_commit="$(git -C "$root" rev-parse HEAD)" +for package in "${nupkgs[@]}"; do + nuspec="$(unzip -Z1 "$packages/$package" | sed -n '/\.nuspec$/p')" + [[ -n "$nuspec" ]] || { echo "$package has no nuspec" >&2; exit 1; } + metadata="$(unzip -p "$packages/$package" "$nuspec")" + grep -q '0.17.0' <<<"$metadata" || { + echo "$package does not declare version 0.17.0" >&2; exit 1; + } + grep -q "commit=\"$expected_commit\"" <<<"$metadata" || { + echo "$package does not bind repository commit $expected_commit" >&2; exit 1; + } +done + +core="$packages/FS.GG.Game.Core.0.17.0.nupkg" +for entry in \ + fable/Replay.fsi fable/Replay.fs \ + fable/Planning.fsi fable/Planning.fs \ + fable/Rules.fsi fable/Rules.fs \ + fable/NetworkSession.fsi fable/NetworkSession.fs \ + fable/FS.GG.Game.Core.fsproj \ + fable-compatibility/compatibility-profile.v1.json \ + fable-compatibility/fixture-schema.v1.json \ + fable-compatibility/fixtures/v1/cases.json \ + fable-compatibility/fixtures/v1/expected.bin; do + unzip -Z1 "$core" | grep -Fxq "$entry" || { + echo "FS.GG.Game.Core 0.17.0 is missing required Fable entry $entry" >&2 + exit 1 + } +done + +# Validate first-release adapter APIs and runtime dependency boundaries from actual candidate bytes. +python3 - "$packages" <<'PY_PACKAGE' +from pathlib import Path +from zipfile import ZipFile +import sys, xml.etree.ElementTree as ET +packages = Path(sys.argv[1]) +def metadata(package): + with ZipFile(packages / (package + '.0.17.0.nupkg')) as archive: + names = archive.namelist() + nuspec = [name for name in names if name.endswith('.nuspec')] + assert len(nuspec) == 1, 'one genuine nuspec required' + document = ET.fromstring(archive.read(nuspec[0])) + dependencies = {row.attrib['id']: row.attrib['version'] for row in document.iter() if row.tag.rsplit('}', 1)[-1] == 'dependency'} + return dependencies, names +core, _ = metadata('FS.GG.Game.Core') +assert set(core) == {'FSharp.Core'}, 'Core must keep its FSharp.Core/BCL runtime boundary' +adapter, names = metadata('FS.GG.Game.Physics.Box2D') +assert set(adapter) == {'FS.GG.Game.Core', 'FSharp.Core', 'Box2D.NET'}, 'adapter must have no rendering dependency' +assert adapter['FS.GG.Game.Core'].strip('[]()').split(',')[0].strip() == '0.17.0', 'adapter/Core coherent dependency mismatch' +assert adapter['Box2D.NET'].strip('[]()').split(',')[0].strip() == '3.1.654', 'adapter engine dependency differs from qualified pin' +for entry in ('lib/net10.0/FS.GG.Game.Physics.Box2D.dll', 'api-surface/Types.fsi', 'api-surface/Runtime.fsi', 'api-surface/AreaRuntime.fsi'): + assert entry in names, 'adapter missing package API entry: ' + entry +print('coherent package metadata passed: adapter API, exact engine pin, Core boundary and no rendering dependency') +PY_PACKAGE + +consumer="$(mktemp -d)" +trap 'rm -rf "$consumer"' EXIT +mkdir -p "$consumer/src" +cat >"$consumer/NuGet.Config" < + +EOF +cat >"$consumer/src/Consumer.fsproj" <<'EOF' + + Exenet10.0 + + + +EOF +cat >"$consumer/src/Program.fs" <<'EOF' +open FS.GG.Game.Harness + +let receiptDigest : JourneyReceipt -> string = JourneyReceipt.definitionDigest +let coverageVerdict : ActionCoverageReport -> bool = ActionCoverageReport.isClean +let coverageCheck = Journey.checkActionCoverage + +printfn "%A %A %A" receiptDigest coverageVerdict coverageCheck +EOF +dotnet restore "$consumer/src/Consumer.fsproj" --configfile "$consumer/NuGet.Config" --packages "$consumer/packages" --disable-parallel +dotnet build "$consumer/src/Consumer.fsproj" --no-restore -m:1 --disable-build-servers