diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3b08bb53b..713f0c064 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,5 +8,5 @@ # changelogs, etc.) have no code owner and are gated only by the required CI # status check. -/src/** @ChrisonSimtian -/tests/** @ChrisonSimtian +/src/** @Fallout-build/maintainers +/tests/** @Fallout-build/maintainers diff --git a/.github/workflows/experimental.yml b/.github/workflows/experimental.yml index 76faad6d7..8307489d1 100644 --- a/.github/workflows/experimental.yml +++ b/.github/workflows/experimental.yml @@ -28,6 +28,11 @@ on: - '.assets/**' - '**/*.md' +# Cancel a superseded alpha build when a newer commit lands (#322). Never on release.yml. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -44,7 +49,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 # Nerdbank.GitVersioning needs full history - submodules: recursive - name: 'Cache: .fallout/temp, ~/.nuget/packages' uses: actions/cache@v4 with: @@ -52,27 +56,21 @@ jobs: .fallout/temp ~/.nuget/packages key: ${{ runner.os }}-experimental-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props', 'version.json') }} + restore-keys: | + ${{ runner.os }}-experimental- - name: 'Setup: .NET SDK' uses: actions/setup-dotnet@v4 with: global-json-file: global.json - name: 'Restore: dotnet tools' run: dotnet tool restore - - name: 'Run: Pack' - run: dotnet fallout Pack - - name: 'Push: all *.nupkg to GitHub Packages (alpha)' - run: | - set -euo pipefail - shopt -s nullglob - packages=(output/packages/*.nupkg) - if [ ${#packages[@]} -eq 0 ]; then - echo "::error::No packages found — nothing to publish to the experimental channel." - exit 1 - fi - for pkg in "${packages[@]}"; do - echo "Pushing $pkg to GitHub Packages (alpha)..." - dotnet nuget push "$pkg" \ - --source "https://nuget.pkg.github.com/ChrisonSimtian/index.json" \ - --api-key "${{ secrets.GITHUB_TOKEN }}" \ - --skip-duplicate - done + # Test + Pack + push alpha → GitHub Packages, all through our own framework (#333) — + # dogfooding `dotnet fallout Publish` instead of a hand-rolled `dotnet nuget push`. + # `Publish` depends on Test + Pack, so NUKE runs Restore → Compile → Test → Pack → + # Publish as discrete internal stages and fails at the breaking stage; if Test fails + # the job stops before any push. Which packages go where (Fallout.* + Nuke.* → GitHub + # Packages) is decided by Build.cs IPublish.PublishTargets, not here. + - name: 'Publish: alpha → GitHub Packages' + run: dotnet fallout Publish --publish-to github-packages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/macos-latest.yml b/.github/workflows/macos-latest.yml index b17aacacd..426d42ffa 100644 --- a/.github/workflows/macos-latest.yml +++ b/.github/workflows/macos-latest.yml @@ -18,11 +18,20 @@ name: macos-latest on: push: + tags: + - 'v*' + pull_request: branches: - - experimental - - main - 'release/*' - 'support/*' + paths-ignore: + - 'docs/**' + - '.assets/**' + - '**/*.md' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: macos-latest: @@ -31,7 +40,6 @@ jobs: steps: - uses: actions/checkout@v6 with: - submodules: recursive fetch-depth: 0 - name: 'Cache: .fallout/temp, ~/.nuget/packages' uses: actions/cache@v4 diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 5b90bedfa..2045a707f 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -28,6 +28,11 @@ on: - '.assets/**' - '**/*.md' +# Cancel a superseded preview build when a newer commit lands (#322). Never on release.yml. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -44,7 +49,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 # Nerdbank.GitVersioning needs full history - submodules: recursive - name: 'Cache: .fallout/temp, ~/.nuget/packages' uses: actions/cache@v4 with: @@ -52,27 +56,21 @@ jobs: .fallout/temp ~/.nuget/packages key: ${{ runner.os }}-preview-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props', 'version.json') }} + restore-keys: | + ${{ runner.os }}-preview- - name: 'Setup: .NET SDK' uses: actions/setup-dotnet@v4 with: global-json-file: global.json - name: 'Restore: dotnet tools' run: dotnet tool restore - - name: 'Run: Pack' - run: dotnet fallout Pack - - name: 'Push: all *.nupkg to GitHub Packages (preview)' - run: | - set -euo pipefail - shopt -s nullglob - packages=(output/packages/*.nupkg) - if [ ${#packages[@]} -eq 0 ]; then - echo "::error::No packages found — nothing to publish to the preview channel." - exit 1 - fi - for pkg in "${packages[@]}"; do - echo "Pushing $pkg to GitHub Packages (preview)..." - dotnet nuget push "$pkg" \ - --source "https://nuget.pkg.github.com/ChrisonSimtian/index.json" \ - --api-key "${{ secrets.GITHUB_TOKEN }}" \ - --skip-duplicate - done + # Test + Pack + push preview → GitHub Packages, all through our own framework (#333) — + # dogfooding `dotnet fallout Publish` instead of a hand-rolled `dotnet nuget push`. + # `Publish` depends on Test + Pack, so NUKE runs Restore → Compile → Test → Pack → + # Publish as discrete internal stages and fails at the breaking stage; if Test fails + # the job stops before any push. Which packages go where (Fallout.* + Nuke.* → GitHub + # Packages) is decided by Build.cs IPublish.PublishTargets, not here. + - name: 'Publish: preview → GitHub Packages' + run: dotnet fallout Publish --publish-to github-packages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7820252e..10e1e1e4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,7 +94,6 @@ jobs: with: ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 # Nerdbank.GitVersioning needs full history - submodules: recursive # vendor/vs-solutionpersistence - name: 'Cache: .fallout/temp, ~/.nuget/packages' uses: actions/cache@v4 with: @@ -102,6 +101,8 @@ jobs: .fallout/temp ~/.nuget/packages key: ${{ runner.os }}-release-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props', 'version.json') }} + restore-keys: | + ${{ runner.os }}-release- - name: 'Setup: .NET SDK' uses: actions/setup-dotnet@v4 with: @@ -198,7 +199,7 @@ jobs: for pkg in "${packages[@]}"; do echo "Pushing $pkg to GitHub Packages..." dotnet nuget push "$pkg" \ - --source "https://nuget.pkg.github.com/ChrisonSimtian/index.json" \ + --source "https://nuget.pkg.github.com/Fallout-build/index.json" \ --api-key "${{ secrets.GITHUB_TOKEN }}" \ --skip-duplicate done diff --git a/.github/workflows/ubuntu-latest.yml b/.github/workflows/ubuntu-latest.yml index e1e942332..45f548d6e 100644 --- a/.github/workflows/ubuntu-latest.yml +++ b/.github/workflows/ubuntu-latest.yml @@ -28,6 +28,10 @@ on: - '.assets/**' - '**/*.md' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: ubuntu-latest: name: ubuntu-latest @@ -35,7 +39,6 @@ jobs: steps: - uses: actions/checkout@v6 with: - submodules: recursive fetch-depth: 0 repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.head_ref }} diff --git a/.github/workflows/windows-latest.yml b/.github/workflows/windows-latest.yml index abfd9026f..bc97f4c73 100644 --- a/.github/workflows/windows-latest.yml +++ b/.github/workflows/windows-latest.yml @@ -18,11 +18,20 @@ name: windows-latest on: push: + tags: + - 'v*' + pull_request: branches: - - experimental - - main - 'release/*' - 'support/*' + paths-ignore: + - 'docs/**' + - '.assets/**' + - '**/*.md' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: windows-latest: @@ -31,7 +40,6 @@ jobs: steps: - uses: actions/checkout@v6 with: - submodules: recursive fetch-depth: 0 - name: 'Cache: .fallout/temp, ~/.nuget/packages' uses: actions/cache@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac51bd0f..49a482d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,7 +123,7 @@ The NUKE → Fallout hard fork. Originally NUKE by [@matkoch](https://github.com ### Backwards-compat: Nuke.* transition shims - **`Nuke.Common` MVP shim package** (#70): thin wrapper assembly in the `Nuke.*` namespace whose types inherit from the corresponding `Fallout.*` types — lets pre-rename consumers compile against the new packages without source changes. Lives at `src/Shims/Nuke.Common/`. - **`TransitionShimGenerator` source generator** (#69): emits shim type wrappers per-target, covering the Nuke.Common "Easy tier" surface (plain types, interfaces, enums) and static-class method delegation. Hard-tier types (sealed structs, delegates, etc.) raise `SHIM001` warnings with a pointer to `fallout-migrate`. -- **Shim packages publish to GitHub Packages** (#47): `Nuke.*` package IDs belong to matkoch on nuget.org, so the transition shim feed lives at GH Packages (`https://nuget.pkg.github.com/ChrisonSimtian/index.json`). Production-build feed is nuget.org with `Fallout.*` only. +- **Shim packages publish to GitHub Packages** (#47): `Nuke.*` package IDs belong to matkoch on nuget.org, so the transition shim feed lives at GH Packages (`https://nuget.pkg.github.com/Fallout-build/index.json`). Production-build feed is nuget.org with `Fallout.*` only. ### Vendored fork of `Microsoft.VisualStudio.SolutionPersistence` - **Replaced `matkoch.Microsoft.VisualStudio.SolutionPersistence` with a vendored fork** (#86). The upstream Microsoft package only ships `net472` + `net8.0`; our source generators need `netstandard2.0`. Matt's fork had the netstandard2.0 patches — we forked his repo at [`ChrisonSimtian/vs-solutionpersistence`](https://github.com/ChrisonSimtian/vs-solutionpersistence) (preserves the MIT license + upstream Microsoft history + attribution chain). Sources are a submodule at `vendor/vs-solutionpersistence/`; wrapper csproj at `src/Fallout.VisualStudio.SolutionPersistence/` compiles them with the TFMs we need. Assembly name stays `Microsoft.VisualStudio.SolutionPersistence` so type identity is preserved. (Note: in 10.2 the wrapper packed as `IsPackable=false` and caused the restore bug fixed in 10.3.0 above — known issue, fix-forward.) diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs index 835e50ea9..6a272c419 100644 --- a/build/Build.CI.GitHubActions.cs +++ b/build/Build.CI.GitHubActions.cs @@ -1,37 +1,51 @@ using Fallout.Common.CI.GitHubActions; using Fallout.Components; -// macOS and Windows runs are reserved for post-merge validation on the -// long-lived branches (experimental, main, release/YYYY, support/*). PRs and -// feature-branch pushes get Linux-only for fast, cheap feedback. Cross-platform -// regressions on those branches surface as a red commit — same fail-fast model. +// Cross-platform (macOS/Windows) full Test+Pack is gated to RELEASE INTENT +// (#318/#326): it runs only on a PR into a production branch (release/YYYY, +// support/*) and on a release tag push (v*) — never on routine pushes to +// main/experimental, never on a per-merge basis. On main/experimental "we've +// got our edge": the ubuntu-latest PR gate + the alpha/preview pipelines. +// (workflow_dispatch as a manual cross-platform trigger isn't emitted here — +// the generator only writes workflow_dispatch when it has inputs; GitHub's +// built-in run re-run covers the on-demand case.) +// +// concurrency cancel-in-progress (#322): superseded runs are cancelled rather +// than stacked. Never applied to release.yml (a publish must not be cancelled). [GitHubActions( "macos-latest", GitHubActionsImage.MacOsLatest, FetchDepth = 0, - Submodules = GitHubActionsSubmodules.Recursive, - OnPushBranches = new[] { ExperimentalBranch, MainBranch, ReleaseBranchPattern, SupportBranchPattern }, + ConcurrencyGroup = "${{ github.workflow }}-${{ github.ref }}", + ConcurrencyCancelInProgress = true, + OnPushTags = new[] { "v*" }, + OnPullRequestBranches = new[] { ReleaseBranchPattern, SupportBranchPattern }, + OnPullRequestExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" }, InvokedTargets = new[] { nameof(ITest.Test), nameof(IPack.Pack) }, PublishArtifacts = false)] [GitHubActions( "windows-latest", GitHubActionsImage.WindowsLatest, FetchDepth = 0, - Submodules = GitHubActionsSubmodules.Recursive, - OnPushBranches = new[] { ExperimentalBranch, MainBranch, ReleaseBranchPattern, SupportBranchPattern }, + ConcurrencyGroup = "${{ github.workflow }}-${{ github.ref }}", + ConcurrencyCancelInProgress = true, + OnPushTags = new[] { "v*" }, + OnPullRequestBranches = new[] { ReleaseBranchPattern, SupportBranchPattern }, + OnPullRequestExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" }, InvokedTargets = new[] { nameof(ITest.Test), nameof(IPack.Pack) }, PublishArtifacts = false)] -// pull_request only — same-repo branches would otherwise fire both push and -// pull_request events on every push, double-running the validation. -// -// CheckoutRef = github.head_ref pins checkout to the PR source branch instead of the merge SHA, -// keeping HEAD attached so GitHubTasksTest.GitHubRepositoryFromLocalDirectoryTest (which reads -// .git/HEAD via GitRepository.FromLocalDirectory) resolves a non-null branch. +// The Linux PR gate — the only required status check. pull_request only: +// feature-branch pushes run zero CI until a PR is opened against a long-lived +// branch (#327). CheckoutRef = github.head_ref pins checkout to the PR source +// branch instead of the merge SHA, keeping HEAD attached so +// GitHubTasksTest.GitHubRepositoryFromLocalDirectoryTest (which reads .git/HEAD +// via GitRepository.FromLocalDirectory) resolves a non-null branch. [GitHubActions( "ubuntu-latest", GitHubActionsImage.UbuntuLatest, FetchDepth = 0, - Submodules = GitHubActionsSubmodules.Recursive, + ConcurrencyGroup = "${{ github.workflow }}-${{ github.ref }}", + ConcurrencyCancelInProgress = true, CheckoutRef = "${{ github.head_ref }}", // Trigger for PRs targeting experimental, main, or any release/YYYY / support/* // branch — all are long-lived and protected; all require the ubuntu-latest check. diff --git a/build/Build.cs b/build/Build.cs index 823c3535f..b1ecb3288 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -127,31 +127,41 @@ from framework in project.GetTargetFrameworks() [Parameter] [Secret] readonly string NuGetApiKey; - // Publishing to nuget.org now that the Fallout.* rename has landed (#54). - // Requires NUGET_API_KEY in repo secrets — see release.yml. - string IPublish.NuGetSource => "https://api.nuget.org/v3/index.json"; - string IPublish.NuGetApiKey => NuGetApiKey; - + // Two publish channels (FALLOUT001 — see IPublish.PublishTargets). Routing replaces the + // old single-feed push + the hand-rolled `dotnet nuget push` in the workflows (#333): + // - github-packages: EVERY package, incl. the Nuke.* shims — that ID is owned by the + // original NUKE maintainer (#47), so the shims only ever go here. Keyed by the GitHub token. + // - nuget.org: Fallout.* ONLY, never the Nuke.* shims. Keyed by NUGET_API_KEY. + // Select per run from CI with `dotnet fallout Publish --publish-to `. PublishTarget.SkipDuplicate + // (default true) keeps re-runs idempotent if a version already exists on a feed. +#pragma warning disable FALLOUT001 // opting our own build into the experimental multi-channel publish surface + IEnumerable IPublish.PublishTargets => new[] + { + new PublishTarget + { + Name = "github-packages", + Source = "https://nuget.pkg.github.com/Fallout-build/index.json", + ApiKey = From().GitHubToken, + }, + new PublishTarget + { + Name = "nuget.org", + Source = "https://api.nuget.org/v3/index.json", + ApiKey = NuGetApiKey, + IncludePackages = new[] { "Fallout.*" }, + ExcludePackages = new[] { "Nuke.*" }, + }, + }; +#pragma warning restore FALLOUT001 + + // The workflows now gate which channel publishes (via --publish-to); no on-branch + // requirement here. Missing keys fail per-target inside Publish, so a stray local + // `dotnet fallout Publish` is safe (no key → clear error, no push). Target IPublish.Publish => _ => _ .Inherit() .Consumes(From().Pack) - .Requires(() => GitRepository.IsOnMainBranch() && Host is GitHubActions && GitHubActions.Workflow == ReleaseWorkflow) .WhenSkipped(DependencyBehavior.Execute); - // Filter `Nuke.*` shim packages out of the nuget.org push — that ID is owned by - // the original NUKE maintainer. The shims still build and pack as artifacts; they - // are pushed to GitHub Packages by a follow-up step in .github/workflows/release.yml - // (#47). - IEnumerable IPublish.PushPackageFiles - => From().PackagesDirectory.GlobFiles("*.nupkg") - .Where(x => !x.NameWithoutExtension.StartsWith("Nuke.", StringComparison.OrdinalIgnoreCase)); - - // `--skip-duplicate` makes nuget push idempotent: if a version is already on the feed, - // skip it instead of erroring. Lets us rerun release.yml safely when a single package - // fails mid-batch without nuking the whole pipeline on retry. - Configure IPublish.PushSettings => _ => _ - .EnableSkipDuplicate(); - IEnumerable NuGetPackageFiles => From().PackagesDirectory.GlobFiles("*.nupkg"); diff --git a/docs/adr/0005-ci-host-integration-ports-and-adapters.md b/docs/adr/0005-ci-host-integration-ports-and-adapters.md new file mode 100644 index 000000000..97a02171c --- /dev/null +++ b/docs/adr/0005-ci-host-integration-ports-and-adapters.md @@ -0,0 +1,99 @@ +# ADR-0005 — CI host integration as ports & adapters (hexagonal seam) + +- **Status:** Proposed +- **Date:** 2026-05-31 +- **Deciders:** Fallout maintainers +- **Relates to:** ADR-[0001](0001-cd-primitives-attributes-vs-tasks.md) (CD primitives — attributes vs tasks), ADR-[0004](0004-calendar-versioning-and-dual-pace-channels.md) (calendar versioning + channels), milestone [#6](https://github.com/ChrisonSimtian/Fallout/milestone/6) (plugin foundation — internal), milestone [#7](https://github.com/ChrisonSimtian/Fallout/milestone/7) (public plugin SDK), RFCs [#97](https://github.com/ChrisonSimtian/Fallout/issues/97)–[#101](https://github.com/ChrisonSimtian/Fallout/issues/101) +- **Spike:** [docs/spikes/0001-ci-ports-and-adapters.md](../spikes/0001-ci-ports-and-adapters.md) + +## Context + +CI host integration is the framework's most-replicated extension point — eleven providers today (`AppVeyor`, `AzurePipelines`, `Bamboo`, `Bitbucket`, `Bitrise`, `GitHubActions`, `GitLab`, `Jenkins`, `SpaceAutomation`, `TeamCity`, `TravisCI`), each a folder under `src/Fallout.Common/CI//`. The public plugin SDK (milestone #7) explicitly names **"CI host adapters"** as an extension point it will expose. Before that surface goes public — additive-only, forever — the seam behind it needs to be **named, de-anemic-ed, and enforced**. This ADR records the shape we commit to. + +### What's actually there today + +A single provider folder conflates **two distinct concerns** with two distinct lifecycles: + +1. **Config generation (design-time / output).** `[GitHubActions(...)]` on the build class is reflected into a POCO and serialized to `.github/workflows/*.yml`. The port already exists and is healthy: `IConfigurationGenerator` (`src/Fallout.Build/CICD/IConfigurationGenerator.cs`), implemented by `ConfigurationAttributeBase`. This is the pattern ADR-0001 builds on. + +2. **Runtime host integration (execution-time).** Detecting we're running *inside* a host (`IsRunningGitHubActions => HasVariable("GITHUB_ACTIONS")`), emitting host commands (`::group::`, `::error::`), exposing `Branch`/`Commit`, and routing warnings/errors to host-native annotations. The "port" here is the `Host` base class (`src/Fallout.Build/Host.cs` + `Host.Activation.cs`) plus the **anemic** `IBuildServer` interface — currently just `Branch` and `Commit` (`src/Fallout.Build/CICD/IBuildServer.cs`). + +### The latent structure is already correct + +The dependency direction already obeys the hexagon's hardest rule: **ports live in `Fallout.Build`; the eleven adapters in `Fallout.Common.CI.*` depend inward on them.** Nothing needs re-plumbing. Three things are nonetheless wrong-shaped for a public extension point: + +- **The runtime port is anemic and entangled.** `IBuildServer` says almost nothing, and the real host contract is fused into `Host`, a base class that *also* owns logging, theming, and console-output formatting (`WriteLogo`, `WriteBlock`, `WriteTargetOutcome`, …). "Am I a CI host" and "how do I render a build summary" are different jobs welded together. +- **The boundary is unenforced.** Nothing prevents core code from reaching for a concrete `GitHubActions` type. Today it's clean by discipline; a public SDK needs it clean by construction. +- **Adapters aren't named as adapters.** Discovery is reflection-by-convention (`Host.Default` scans public `Host` subclasses for a static `IsRunning{Name}` property). It works, but it's implicit — there's no declared "this type is the GitHub adapter for these ports" contract for an external plugin author to implement against. + +### Onion or hexagonal? + +For a subsystem whose entire value is *N interchangeable providers behind one contract*, **ports-and-adapters (hexagonal) is the right model** — it names the symmetry between the driven side (write a workflow file, push an annotation) and the driving side (the host environment that drives our run). Onion's concentric inward-dependency rule is the *constraint we keep*; hexagonal is the *vocabulary we adopt*. They compose: a hexagon obeys the onion dependency rule at its boundary. We are **not** adopting a `Fallout.Application.*` / `Fallout.Infrastructure.*` project renaming — that is gratuitous public churn (see Alternatives) that buys nothing the seam doesn't already give us. + +## Decision + +**1. Model CI host integration as two named ports, not one.** + +| Port | Concern | Status | Lives in | +|---|---|---|---| +| **Config-generation port** — `IConfigurationGenerator` | Design-time: emit `.yml`/`.xml`/`.toml` committed to git | **Exists, keep as-is** | `Fallout.Build/CICD/` | +| **Runtime-host context port** — `IBuildHost` | Execution-time facts: branch, commit, is-PR (universal subset; provider-specific facts stay on the adapter) | **Formalized** | `Fallout.Build/CICD/` | +| **Runtime-host reporting port** — `IBuildReporter` | Execution-time output: surface warnings/errors/grouping through the host's native channels | **Formalized** | `Fallout.Build/CICD/` | + +The runtime-host concern is **two ports, not one** — a refinement the spike (0001) confirmed with a concrete reason: their *implementor sets differ*. Every host reports (so the local `Terminal` is an `IBuildReporter`), but only CI hosts carry run context (so `Terminal` is **not** an `IBuildHost`). `IBuildHost` supersedes the anemic `IBuildServer`. Both are deliberately **separated from the `Host` logging/theming base**: `IBuildReporter` is implemented by the `Host` base (backed by its existing protected-virtual hooks, so adapters override exactly as before), while the port surfaces stay free of `Host`'s console-rendering responsibilities (`WriteLogo`, `WriteTargetOutcome`, …). + +**2. The eleven `Fallout.Common.CI.` types are adapters.** Document and treat them as such. **Do not relocate or rename them in this work** — naming the role is the deliverable, not moving the files. + +**3. The public consumer surface stays, as a facade over the ports.** `[GitHubActions(...)]`, `GitHubActions.Instance`, `[CI]` injection (`CIAttribute`), and the `Host` API all keep working unchanged. Statics delegate *inward* to the ports / resolved instances — the same pattern a static `Log` facade uses over DI-resolved logging. **This delegation discipline is the rule that prevents a half-and-half mess** (ports bolted on while a static singleton still does the real work). + +**4. Enforce the boundary with an architecture fitness test.** Assert that `Fallout.Build` (ports + kernel) never references a concrete provider type under `Fallout.Common.CI.*`, and that adapters depend inward only. This is what *holds* the hexagon once it exists, and it is a prerequisite for exposing the seam publicly. + +**5. Compatibility strategy: additive now, deletions batched to the year cut.** + +This is the load-bearing decision and the answer to "clean break or backwards-compatible?": **they are separable, and we take both — in sequence.** + +- **All seam work is additive and ships continuously** through `main` → `-preview`: new port interface(s), adapters implementing them, the fitness test, and the delegating facades are all non-breaking. They land mid-year on the `2026` line. +- **Legacy paths are marked, not deleted:** `[Obsolete]` for plain deprecation, `[Experimental("FALLOUT0xx")]` (see [docs/experimental-apis.md](../experimental-apis.md) for the registry — allocate a fresh ID, do not reuse) for not-yet-stable replacements. +- **The genuinely breaking steps are deferred and batched.** Removing deprecated surface, dropping the `Nuke.*` shims, or reshaping a *consumer-implemented* interface land on `experimental` and ship at the next yearly major (`2027.0.0`), per ADR-0004. Mid-year `main`/production stays strictly non-breaking. +- **Type relocation, if ever needed, is non-breaking too:** `[TypeForwardedTo]` plus the existing `Fallout.SourceGenerators.TransitionShimGenerator` (`src/Shims/`) forward old namespaces, so even moving a public type to its own adapter assembly does not break callers. + +Net: the better architecture arrives **immediately**, the dead weight is shed **on schedule** at the cut, and the trunk stays **green throughout**. Crucially, per ADR-0004 a deliberate break could only *ship* at the yearly major anyway — so "additive now" costs zero timeline versus "clean break now," and avoids months of an un-shippable trunk. + +## Consequences + +### Positive + +- **Stable seam before it goes public.** Milestone #7 can expose "implement `IBuildHost` + `IConfigurationGenerator` to add a CI host" against a contract that's been dogfooded by the eleven in-tree adapters first. +- **Separation untangles the `Host` god-base.** Splitting "am I a CI host" from "how do I render output" makes both independently testable and lets a host adapter exist without inheriting console-formatting machinery. +- **No flag day, no broken trunk.** Every interim build compiles, passes, and is dogfoodable via `./build.sh`. +- **Generalizes cleanly.** Proving the port shape on GitHub Actions first (the spike) de-risks the other ten before any of them are touched. + +### Negative + +- **A transition period with two ways to express the runtime host** (the old `Host`/`IBuildServer` path and the new port). Mitigated by the facade discipline (§3) and a dated removal entry batched to `2027.0.0`. +- **Fitness test maintenance.** The boundary assertion must be kept honest as new providers land; a too-strict rule could false-positive on shared helpers. Scope it to "core → concrete provider type," not "core → `Fallout.Common.CI` namespace at all." +- **Naming risk.** `IBuildHost` vs `IBuildServer` vs `ICiHost` is a public name we'll live with. The name is provisional until the spike validates the shape; do not treat it as locked by this ADR. + +## Alternatives considered + +### A. Clean break — rebuild the CI subsystem fresh on the 2027 line + +Rejected. The break buys only *deletion* of compatibility surface, not any *capability* the additive path lacks — the new ports sit cleanly over the existing facade. A clean break would mean a broken, un-shippable trunk for months in exchange for **zero** earlier delivery (ADR-0004 gates the break to the year cut regardless). "Drag no dead weight" is satisfied by scheduling the deletions at the cut, not by starting over. + +### B. Full Domain/Application/Infrastructure project renaming + +Rejected. Renaming `Fallout.Common.CI.*` → `Fallout.Infrastructure.*` etc. is a large public namespace/package break (mitigable with shims, but still churn) that delivers nothing the named-ports-in-place approach doesn't. It also fights the repo's established by-provider convention (AGENTS.md: prefer existing patterns). Onion layer *names* are not the goal; the enforced seam is. + +### C. Status quo — keep `Host` + `IBuildServer` as the de facto contract + +Rejected. The runtime port is too anemic and too entangled with console rendering to expose as a public plugin extension point. Shipping milestone #7 on top of it would lock in the entanglement as public API. + +## References + +- Config-generation port: `src/Fallout.Build/CICD/IConfigurationGenerator.cs`, `ConfigurationAttributeBase.cs` +- Runtime host (today): `src/Fallout.Build/Host.cs`, `Host.Activation.cs`, `src/Fallout.Build/CICD/IBuildServer.cs` +- Consumer injection: `src/Fallout.Build/CICD/CIAttribute.cs` +- Canonical adapter: `src/Fallout.Common/CI/GitHubActions/` (`GitHubActions.cs`, `GitHubActionsAttribute.cs`, `GitHubActions.Client.cs`) +- Live dogfood usage: `build/Build.CI.GitHubActions.cs` +- Compatibility machinery: `src/Shims/`, `Fallout.SourceGenerators.TransitionShimGenerator`, [docs/experimental-apis.md](../experimental-apis.md) +- Spike plan: [docs/spikes/0001-ci-ports-and-adapters.md](../spikes/0001-ci-ports-and-adapters.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index c38f1e8d8..e2b4ece29 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -38,3 +38,4 @@ If you change a decision, do NOT silently rewrite the old ADR — add a new one | [0002](0002-v11-off-nuget-by-default.md) | v11 publishes to GitHub Packages by default; nuget.org opt-in | Accepted | | [0003](0003-variables-and-substitution.md) | Variables and `${…}` substitution layer | Proposed | | [0004](0004-calendar-versioning-and-dual-pace-channels.md) | Calendar versioning + dual-pace channels (edge/stable) + experimental APIs | Accepted | +| [0005](0005-ci-host-integration-ports-and-adapters.md) | CI host integration as ports & adapters (hexagonal seam) | Proposed | diff --git a/docs/agents/conventions.md b/docs/agents/conventions.md index b85d0e49f..555ce8125 100644 --- a/docs/agents/conventions.md +++ b/docs/agents/conventions.md @@ -34,9 +34,25 @@ public sealed class NewPluginHost - **Channel discipline differs.** On the `experimental` (alpha) / `main` (preview) test lanes, churn is expected and the attribute is a courtesy. On a `release/YYYY` **production line**, any risky-but-shipped public surface **must** wear `[Experimental]` — that contract is what keeps the stable line trustworthy while still carrying new work. - **Don't apply it speculatively.** Because the diagnostic is error-by-default, marking an API that's already used internally breaks the build everywhere it's referenced. Only add `[Experimental]` to a genuinely not-yet-stable API, and suppress every internal usage in the same change so the build stays green. +## CI pipeline & triggers + +Shaped by [milestone #18](https://github.com/ChrisonSimtian/Fallout/milestone/18) and the [ADR-0004](../adr/0004-calendar-versioning-and-dual-pace-channels.md) ladder. Invariants: + +- **Feature branches run zero CI until a PR is opened.** Push triggers list **only** long-lived branches; nothing fires on `feature/*`, `bugfix/*`, etc. until they're PR'd against `experimental`/`main`/`release/*`/`support/*`. Do **not** add a working-branch pattern to any `OnPush*`/`branches:` trigger. +- **The Linux PR gate (`ubuntu-latest`) is the only required check** — runs on PRs to the four long-lived branches. +- **`experimental` (push) → `-alpha`, `main` (push) → `-preview`** to GitHub Packages (`experimental.yml` / `preview.yml`). +- **Cross-platform `windows`/`macos` are gated to release intent** — PR-to-`release/*`/`support/*` or a `v*` tag push only. They do **not** run on `main`/`experimental` pushes. ("On `main` we've got our edge.") +- **`concurrency: cancel-in-progress` on every build workflow except `release.yml`** — never cancel a publish mid-flight. +- **Canonical CI-ignore paths:** `docs/**`, `.assets/**`, `**/*.md` — applied to every PR/push trigger. +- The `ubuntu-latest` / `windows-latest` / `macos-latest` workflows are **generated** from `build/Build.CI.GitHubActions.cs` — edit the attributes + constants there and regenerate (`./build.sh`), never hand-edit the `.yml`. `experimental.yml` / `preview.yml` / `release.yml` are hand-written. +- **Every publishing lane runs `Test` before it publishes** (#324). `experimental.yml`, `preview.yml`, and `release.yml` all run a single `dotnet fallout Test Pack` invocation — NUKE executes it as discrete internal stages (Restore → Compile → Test → Pack) and fails at the breaking stage, so a test failure stops the job before the push step. Don't split a lane into separate `dotnet fallout Compile`/`Test`/`Pack` steps — each invocation re-runs the dependency graph (double-compile); the single invocation *is* the staged build. +- **Caching** (#328): every workflow caches `~/.nuget/packages` + `.fallout/temp`, keyed on `global.json` + `**/*.csproj` + `Directory.Packages.props` (the dependency-affecting set), with a `restore-keys:` prefix fallback for partial restores. There is no `packages.lock.json` to add to the key, and build outputs (`bin`/`obj`) are deliberately **not** cached (stale-artifact correctness risk). + ## What not to do - Don't reintroduce `source/` — production code lives under `src/`, tests under `tests/`. Same for `images/` (now `.assets/`). +- Don't add `main`/`experimental` (or any working-branch pattern) to the **push** triggers of the cross-platform workflows — they're release-intent-gated on purpose (milestone #18 / #318 / #326). +- Don't add `submodules: recursive` to checkouts — there are no submodules (no `.gitmodules`); it's a dead init step. - Don't add `secret`/`default` defaults to tool JSON files (see CONTRIBUTING.md). - Don't introduce a new test framework or assertion library — stay on xUnit + FluentAssertions + Verify. - Don't commit `output/` or any `bin/`/`obj/` directory. diff --git a/docs/agents/release-and-versioning.md b/docs/agents/release-and-versioning.md index 64493bf57..b6ab8dec4 100644 --- a/docs/agents/release-and-versioning.md +++ b/docs/agents/release-and-versioning.md @@ -92,7 +92,7 @@ If you only discover the breaking nature mid-review, apply all relevant steps be | Job | Environment | Fires on tag push? | What ships | Gating | |---|---|---|---|---| | `publish-nuget-org` | `nuget-org` | **No — opt-in only** via `workflow_dispatch` flag | `Fallout.*.nupkg` to https://api.nuget.org/v3/index.json | Workflow flag + approval-gated env | -| `publish-github-packages` | `github-packages` | Yes | **All** `*.nupkg` (Fallout.* + Nuke.*) to https://nuget.pkg.github.com/ChrisonSimtian/index.json | None | +| `publish-github-packages` | `github-packages` | Yes | **All** `*.nupkg` (Fallout.* + Nuke.*) to https://nuget.pkg.github.com/Fallout-build/index.json | None | | `publish-github-releases` | `github-releases` | Yes | All `*.nupkg` attached to a GitHub Release on the tag, auto-generated notes | None | ### Test lanes (from `experimental` and `main`) diff --git a/docs/experimental-apis.md b/docs/experimental-apis.md index e51405716..3bf307a2d 100644 --- a/docs/experimental-apis.md +++ b/docs/experimental-apis.md @@ -48,7 +48,7 @@ Status values: **Experimental** (live, opt-in), **Promoted** (attribute removed, | ID | Surface | Introduced | Status | Notes | |----|---------|------------|--------|-------| -| _none allocated yet_ | — | — | — | First experimental API to land claims `FALLOUT001`. | +| `FALLOUT001` | `Fallout.Components.IPublish.PublishTargets` / `.PublishTo` (multi-channel publishing) | 2026.1 | Experimental | Fan a single `Pack` output across multiple feeds with per-feed package-ID routing (`PublishTarget`). Shape may change while the CD model firms up (epic #332). Promote by deleting the attribute once `ReleaseChannel`/`DeploymentTarget` (#334) settle. |