Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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"
10 changes: 6 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# The release: pushing a tag v<version> on main builds, tests with coverage and the coverage gate, and packs every
# package at <version> (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.
Expand Down Expand Up @@ -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 }}
Expand Down
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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
Expand Down Expand Up @@ -124,7 +126,7 @@ Each package has a test project of its own, `tests/WriteableFileProviders.<Packa
- **Physical:** the suite on the local disk, under a temp folder of its own that the class deletes when it ends;
`PhysicalProviderTests` and `PhysicalOptionsTests` cover the temp files, the races, links, the rules that differ by OS
and the stored root forms; `PhysicalLinkAndSweepTests`: `RefuseLinks` and the sweep of leftover temp files. CI runs it
on Linux and Windows.
on Linux (x64 and Arm64), Windows and macOS.
- **Azure Blob**, on Azurite: `AzuriteConformanceTests` (providers built from options) and
`AzuriteStrictConformanceTests` (over a container client whose pipeline, `AzureHandler`, refuses what Azure refuses
and Azurite allows: replacing an append blob, more blocks than the limit, here lowered to 6), and
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ dotnet test --solution WriteableFileProviders.slnx --filter "TestCategory!=Live"
"Generated with" lines.
4. Open the PR with the template filled in, ending `Closes #<issue>`. 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.
12 changes: 6 additions & 6 deletions REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion docs/plans/2026-10-13-2027-1-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<IOException>(() => 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}");
}
}

Expand Down
Loading