diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f545e67..5da11b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,8 @@ # Build, test with coverage, and pack, on every pull request and every push to main. The emulator suites start their # containers through Testcontainers, which needs Docker: present on the Linux runner, where WFP_REQUIRE_DOCKER makes a # missing emulator fail the run instead of leaving its tests inconclusive. Live suites (TestCategory=Live) never run here. -# The Native AOT job publishes tests/WriteableFileProviders.Aot with native AOT and runs it (its README says why). +# The Native AOT job publishes tests/WriteableFileProviders.Aot with native AOT and runs it (its README says why). The +# Physical jobs run the local disk provider's tests on macOS and on Linux on Arm64, which the others don't reach. name: CI on: @@ -166,3 +167,32 @@ jobs: # No Linux containers on Windows runners: the emulator suites are inconclusive here and run on Linux. - name: Test run: dotnet test --solution WriteableFileProviders.slnx --no-build -c Release --filter "TestCategory!=Live" + + physical: + # Physical calls the C library itself, and its rules differ by system (case, names, links, permissions): its tests + # run where the jobs above don't, on macOS and on Linux on Arm64. + name: Physical (${{ matrix.name }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: macOS + runner: macos-latest + - name: Linux, Arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + - name: Restore (locked) + run: dotnet restore tests/WriteableFileProviders.Physical.Tests --locked-mode + - name: Build + run: dotnet build tests/WriteableFileProviders.Physical.Tests --no-restore -c Release + - name: Test + run: dotnet test --project tests/WriteableFileProviders.Physical.Tests --no-build -c Release --filter "TestCategory!=Live" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7aa517..d4d3718 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,9 @@ # The release: pushing a tag v on main builds, tests with coverage and the coverage gate, and packs every # package at (MinVer reads the tag), checks them, pushes them to nuget.org and creates the GitHub release with # the packages attached and the version's section of CHANGELOG.md as its notes. Nothing is pushed unless ci.yml -# concluded success on the tagged commit, which covers what this workflow does not run (the Windows build and tests). A -# tag with a prerelease suffix (v2027.1.0-rc.1) makes a prerelease on both. docs/releasing.md is the runbook. +# concluded success on the tagged commit, which covers what this workflow does not run (the Windows build and tests, the +# Native AOT app, and Physical's tests on macOS and on Linux on Arm64). A tag with a prerelease suffix (v2027.1.0-rc.1) +# makes a prerelease on both. docs/releasing.md is the runbook. # # Setup: docs/releasing.md. No long-lived API key exists anywhere: NuGet/login trades the job's OIDC token for a key that # lives one hour. @@ -108,8 +109,9 @@ jobs: actions: read steps: # ci.yml runs on every push to main, so the tagged commit has a run of its own: it must have concluded success, - # Windows job included, before anything is pushed. A run still going (a tag pushed right after the merge) is - # waited for; a run that failed, or none at all, stops the release. Re-run this workflow once CI is green. + # its Windows, Native AOT and Physical jobs included, before anything is pushed. A run still going (a tag pushed + # right after the merge) is waited for; a run that failed, or none at all, stops the release. Re-run this workflow + # once CI is green. - name: Require a successful ci.yml run env: GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index 424e9b1..d652974 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,12 +80,14 @@ Run everything from the repo root, with a .NET 10 SDK (`global.json`). Docker is available and a missing emulator fails the run (`WFP_REQUIRE_DOCKER=1`), and the build and tests on Windows. Its Native AOT job publishes `tests/WriteableFileProviders.Aot` with native AOT, which fails on any trim or AOT warning in the packages or the SDKs they call (but one, its README says which), and runs it on the local disk, - the gateway, Azurite, MinIO and the Graph fake. + the gateway, Azurite, MinIO and the Graph fake. Its Physical jobs build and run Physical's tests on macOS and on + Linux on Arm64. - **Release:** a maintainer tags `v` on `main`. `release.yml` builds, runs the tests with coverage and the coverage gate, packs, checks that every package has that version and depends on our other packages within its major - (`eng/package-check.cs`), and requires that `ci.yml` concluded success on the tagged commit (its Windows job included); - then, once a maintainer approves the `nuget` environment, it pushes the packages to nuget.org through trusted - publishing and creates the GitHub release, with the version's section of `CHANGELOG.md` as its notes. + (`eng/package-check.cs`), and requires that `ci.yml` concluded success on the tagged commit (its Windows, Native AOT + and Physical jobs included); then, once a maintainer approves the `nuget` environment, it pushes the packages to + nuget.org through trusted publishing and creates the GitHub release, with the version's section of `CHANGELOG.md` as + its notes. [docs/releasing.md](docs/releasing.md) has the steps. ### Tests @@ -124,7 +126,7 @@ Each package has a test project of its own, `tests/WriteableFileProviders.`. Its title becomes the commit on `main`. -CI builds and tests every PR on Linux and Windows, with the emulator suites and the coverage gate, and CodeQL scans it. +CI builds and tests every PR on Linux and Windows, with the emulator suites and the coverage gate, runs Physical's tests +on macOS and on Linux on Arm64 too, and CodeQL scans it. The maintainers review a PR from outside by reading it: they never check it out, build or run it on their machines, where it would run next to their credentials. Your PR is ready for review when CI is green. A maintainer squash-merges it. diff --git a/REVIEW.md b/REVIEW.md index faefe35..f0f6388 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -8,12 +8,12 @@ acceptable. 1. **Build and tests.** Is CI green on the PR's head commit (`AGENTS.md`, Build and test)? `ci.yml` builds the solution, runs every test outside the `Live` category on Linux, where Docker runs the emulator suites and a missing - emulator fails the run, and on Windows, then runs the coverage gate and packs, and its Native AOT job publishes - `tests/WriteableFileProviders.Aot` with native AOT and runs it; `codeql.yml` scans the code and the workflows. CI - never runs the live suites against real accounts: for a change to a provider, does the PR say whether its live - suite ran, and if it did not, why? For a PR from outside the maintainers, the answer comes from CI, never - from running its code on a maintainer's machine, so a live suite it needs runs only once the change is on `main`, - through `live.yml`, and the PR says so. + emulator fails the run, and on Windows, then runs the coverage gate and packs, its Native AOT job publishes + `tests/WriteableFileProviders.Aot` with native AOT and runs it, and its Physical jobs run Physical's tests on macOS + and on Linux on Arm64; `codeql.yml` scans the code and the workflows. CI never runs the live suites against real + accounts: for a change to a provider, does the PR say whether its live suite ran, and if it did not, why? For a PR + from outside the maintainers, the answer comes from CI, never from running its code on a maintainer's machine, so a + live suite it needs runs only once the change is on `main`, through `live.yml`, and the PR says so. 2. **One-way doors.** Does the change leave the one-way doors in `AGENTS.md` alone, or does its PR name the one it changes and link the issue where the maintainers agreed? 3. **Every provider.** Is a change to Common's contract or to shared behaviour made in all seven providers, tested on diff --git a/docs/plans/2026-10-13-2027-1-1.md b/docs/plans/2026-10-13-2027-1-1.md index 949c6a1..0cf9ceb 100644 --- a/docs/plans/2026-10-13-2027-1-1.md +++ b/docs/plans/2026-10-13-2027-1-1.md @@ -459,4 +459,11 @@ expectation (the winner is done before the loser retries); the locked restore of is read past again; G3, the live fixture says the race test can fail by design without hard links. Round 3: G1 to G3 verified; H1, the interface's exception doc now says what errors.md says; H2, a test pins the Windows read-past with an empty winner and the refusal elsewhere; H3 and H4, a reflow and one reason for the corner. Round 4: H1 to H4 - verified, clean; three wording nits applied. + verified, clean; three wording nits applied. Merged as #74. +- **2026-10-13, step 2 (#73):** the two Physical jobs, `Physical (macOS)` and `Physical (Linux, Arm64)`. Their first runs: + Arm64 passed as it was; macOS failed one test, a link chain made under `/var` (a link to `/private/var`), so it passed + one link more than counted. The chains are now made in the folder's physical path, and the test also reads through + them, so Darwin's limit (32) and Linux's (40) are pinned on the kernel. The docs name the jobs, and the Native AOT job + that the release docs had left out. Code review: round 1, no blocker or major, five fixes (two on the maintainer's + yes); round 2 clean. On the maintainer's choice, both jobs become required checks on `main` after the merge (P8's + ruleset question). diff --git a/tests/WriteableFileProviders.Physical.Tests/PhysicalProviderTests.cs b/tests/WriteableFileProviders.Physical.Tests/PhysicalProviderTests.cs index 080932e..35bbad8 100644 --- a/tests/WriteableFileProviders.Physical.Tests/PhysicalProviderTests.cs +++ b/tests/WriteableFileProviders.Physical.Tests/PhysicalProviderTests.cs @@ -589,9 +589,13 @@ public void PhysicalPathOf_StopsWhereTheKernelDoes() Assert.IsNull(WriteablePhysicalFileProvider.PhysicalPathOf(file + Path.DirectorySeparatorChar)); Assert.IsNull(WriteablePhysicalFileProvider.PhysicalPathOf(Path.Combine(file, "."))); Assert.AreEqual(WriteablePhysicalFileProvider.PhysicalPathOf(folder), WriteablePhysicalFileProvider.PhysicalPathOf(folder + Path.DirectorySeparatorChar)); - Assert.AreEqual(Path.Combine(Path.GetDirectoryName(physicalFile)!, "missing.txt"), WriteablePhysicalFileProvider.PhysicalPathOf(Path.Combine(folder, "missing.txt"))); + // The folder as the kernel finds it: on macOS the temp folder is under /var, a link to /private/var. + var physicalFolder = Path.GetDirectoryName(physicalFile)!; + Assert.AreEqual(Path.Combine(physicalFolder, "missing.txt"), + WriteablePhysicalFileProvider.PhysicalPathOf(Path.Combine(folder, "missing.txt"))); - // Chains of links: one through exactly the limit resolves, one link more does not (32 on macOS, 40 elsewhere). + // Chains of links: one through exactly the limit resolves, one link more does not (32 on macOS, 40 elsewhere), + // for the provider and for the kernel. They are made in that folder, so that no link of its own path counts. Assert.AreEqual(OperatingSystem.IsMacOS() ? 32 : 40, WriteablePhysicalFileProvider.MaxLinks); try { @@ -602,14 +606,22 @@ public void PhysicalPathOf_StopsWhereTheKernelDoes() Assert.Inconclusive($"Creating a symbolic link on Windows needs Developer Mode or elevation: {e.Message}"); } Chain("b", WriteablePhysicalFileProvider.MaxLinks + 1); - Assert.AreEqual(physicalFile, WriteablePhysicalFileProvider.PhysicalPathOf(Path.Combine(folder, "a0"))); - Assert.IsNull(WriteablePhysicalFileProvider.PhysicalPathOf(Path.Combine(folder, "b0"))); + Assert.AreEqual(physicalFile, WriteablePhysicalFileProvider.PhysicalPathOf(Path.Combine(physicalFolder, "a0"))); + Assert.IsNull(WriteablePhysicalFileProvider.PhysicalPathOf(Path.Combine(physicalFolder, "b0"))); + if (!OperatingSystem.IsWindows()) + { + var limit = Path.Combine(physicalFolder, "a0"); + var past = Path.Combine(physicalFolder, "b0"); + Assert.AreEqual("x", File.ReadAllText(limit), "The kernel follows the limit's links,"); + Assert.ThrowsExactly(() => File.ReadAllText(past), "and refuses one more"); + } // prefix0 -> prefix1 -> ... -> prefix(count - 1) -> file.txt: a path through count links. void Chain(string prefix, int count) { for (var i = 0; i < count; i++) - File.CreateSymbolicLink(Path.Combine(folder, $"{prefix}{i}"), i == count - 1 ? "file.txt" : $"{prefix}{i + 1}"); + File.CreateSymbolicLink(Path.Combine(physicalFolder, $"{prefix}{i}"), + i == count - 1 ? "file.txt" : $"{prefix}{i + 1}"); } }