diff --git a/.editorconfig b/.editorconfig index 75d4ef234..1ff946f1a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ root = true #################################################################### [*] -end_of_line = crlf +end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json index fde15dc2b..9dc2c0b76 100644 --- a/.fallout/build.schema.json +++ b/.fallout/build.schema.json @@ -38,7 +38,6 @@ "References", "ReportCoverage", "Restore", - "RunTargetInDockerImageTest", "Test", "UpdateContributors", "UpdateStargazers" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44bca8e20..89ee5c8af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Fallout welcomes contributions. As a community, we want to help each other, prov - **Write functional commit and PR titles** — describe what the change accomplishes, not how it's categorised. Do not use conventional-commit prefixes (`feat:`, `fix:`, `chore:`, `refactor:`, etc.). Good examples: "Add retry logic to the HTTP tool wrapper", "Fix null-reference in target dependency resolution". The `!` suffix (e.g. `fix(security)!: …`) is recognised only as a breaking-change detection signal, not a general style requirement. - Aim for qualitative, readable code that matches the surrounding style. -- There's no committed `.editorconfig` or ReSharper/`*.DotSettings` file — they were removed during the takeover. Rely on `dotnet format` defaults and review; don't reintroduce them without a maintainer-level decision. +- Style is codified in a committed `.editorconfig` (plus `fallout.slnx.DotSettings` for Rider/ReSharper). Match it and keep `dotnet format` green; don't delete or bypass it — change a rule deliberately if needed. - Add tests when meaningful — every `Foo` project has a sibling `Foo.Tests`. - Don't commit code generated by `./build.ps1 GenerateTools` — generated `.cs` files are regenerated manually once per release. - **Label the PR `target/vCurrent`** for the current release line (use `target/vNext` for work held to next year's major). Legacy `support/v10` maintenance work uses `target/v10`. **Breaking changes are batched to the yearly major cut**: they land on `main` gated behind `[Experimental("FALLOUT0xx")]` (or, when they can't be gated, on a short-lived topic branch off `main`) — never on a `release/YYYY` production train — are held for next year's `YYYY+1.0.0`, and additionally get a `breaking-change` label, a `⚠️ Breaking change` callout, and a `CHANGELOG.md` entry under the next-major `[Unreleased]` heading. Surface that isn't ready to commit to can ship behind `[Experimental("FALLOUT0xx")]` instead of being held back. See the [PR-creation flow](docs/agents/release-and-versioning.md#pr-creation-flow) for the full procedure. diff --git a/build/Build.RunTargetInDockerTest.cs b/build/Build.RunTargetInDockerTest.cs deleted file mode 100644 index 2fc40032e..000000000 --- a/build/Build.RunTargetInDockerTest.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using Fallout.Common; -using Fallout.Common.Tools.Docker; -using Fallout.Common.Utilities; -using Serilog; - -partial class Build -{ - Target RunTargetInDockerImageTest => _ => _ - .DockerRun(_ => _ - .EnableBuildCaching() - .SetImage("mcr.microsoft.com/dotnet/sdk:6.0") - .When(EnvironmentInfo.IsArm64, _ => _ - .SetPlatform("linux/arm64") - .SetDotNetRuntime("linux-arm64")) - .When(EnvironmentInfo.IsWin, _ => _ - .SetPlatform("windows/amd64") - .SetDotNetRuntime("win-x64")) - .When(EnvironmentInfo.IsLinux, _ => _ - .SetPlatform("linux/amd64") - .SetDotNetRuntime("linux-x64"))) - .Executes(() => - { - Log.Information("Hello, the computer name is {Name}", Environment.MachineName); - }); -} diff --git a/build/Build.cs b/build/Build.cs index fdf7dae5b..c79c808eb 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -1,19 +1,19 @@ using System; using System.Collections.Generic; using System.Linq; -using NuGet.Packaging; using Fallout.Common; using Fallout.Common.CI; using Fallout.Common.CI.GitHubActions; using Fallout.Common.Execution; using Fallout.Common.Git; using Fallout.Common.IO; -using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Tools.GitHub; using Fallout.Common.Utilities; using Fallout.Components; +using Fallout.Solutions; +using NuGet.Packaging; using static Fallout.Common.ControlFlow; using static Fallout.Common.Tools.DotNet.DotNetTasks; @@ -30,7 +30,14 @@ partial class Build ITest, IReportCoverage, IPublish, - ICreateGitHubRelease + ICreateGitHubRelease, + // Build-local steps (build/Steps/) — each concern is its own component. + IGenerateTools, + IGeneratePublicApi, + IDownloadLicenses, + IHandleExternalRepositories, + IUpdateContributors, + IUpdateStargazers { public static int Main() => Execute(x => ((IPack)x).Pack); @@ -70,9 +77,8 @@ partial class Build string MajorMinorPatchVersion => Major ? $"{ParseMajor(ThisAssembly.AssemblyInformationalVersion) + 1}.0.0" : ThisAssembly.AssemblyInformationalVersion.Split('+')[0]; - string MilestoneTitle => $"v{MajorMinorPatchVersion}"; - private static int ParseMajor(string informationalVersion) + static int ParseMajor(string informationalVersion) => int.Parse(informationalVersion.Split('.')[0]); AbsolutePath IHasArtifacts.ArtifactsDirectory => RootDirectory / "output"; @@ -122,7 +128,7 @@ from framework in project.GetTargetFrameworks() string DefaultDeploymentVersion => "9999.0.0"; - [Parameter] [Secret] readonly string NuGetApiKey; + [Parameter][Secret] readonly string 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): @@ -131,8 +137,8 @@ from framework in project.GetTargetFrameworks() // - 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[] +#pragma warning disable FALLOUT001, FALLOUT005 // opting our own build into the experimental multi-channel publish surface (ADR-0009) + IEnumerable IPublish.PublishTargets => new IPublishTarget[] { new PublishTarget { @@ -149,7 +155,7 @@ from framework in project.GetTargetFrameworks() ExcludePackages = new[] { "Nuke.*" }, }, }; -#pragma warning restore FALLOUT001 +#pragma warning restore FALLOUT001, FALLOUT005 // The workflows now gate *which* channel publishes (via --publish-to); the on-branch // requirement is gone. We keep a CI guard, though: GitHubToken binds from the diff --git a/build/Build.Licenses.cs b/build/Steps/IDownloadLicenses.cs similarity index 88% rename from build/Build.Licenses.cs rename to build/Steps/IDownloadLicenses.cs index 833e45b38..dee4eeed9 100644 --- a/build/Build.Licenses.cs +++ b/build/Steps/IDownloadLicenses.cs @@ -1,13 +1,15 @@ -using Fallout.Common; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Fallout.Common; using Fallout.Common.IO; using Fallout.Components; using Serilog; using static Fallout.Common.IO.HttpTasks; -partial class Build +// Step: fetch the third-party licenses bundled into the packages. Hooks into Pack via +// DependentFor so it runs as part of the default pipeline, not just on demand. +interface IDownloadLicenses : IFalloutBuild { AbsolutePath LicensesDirectory => TemporaryDirectory / "licenses"; diff --git a/build/Build.PublicApi.cs b/build/Steps/IGeneratePublicApi.cs similarity index 96% rename from build/Build.PublicApi.cs rename to build/Steps/IGeneratePublicApi.cs index c14305a40..ca9295d14 100644 --- a/build/Build.PublicApi.cs +++ b/build/Steps/IGeneratePublicApi.cs @@ -7,7 +7,8 @@ using Fallout.Common.Utilities; using Fallout.Common.Utilities.Collections; -partial class Build +// Step: dump the framework's public API surface to PUBLIC_API.md. +interface IGeneratePublicApi : IFalloutBuild { AbsolutePath PublicApiFile => RootDirectory / "PUBLIC_API.md"; diff --git a/build/Build.CodeGeneration.cs b/build/Steps/IGenerateTools.cs similarity index 68% rename from build/Build.CodeGeneration.cs rename to build/Steps/IGenerateTools.cs index f8ab28165..9ece9aefe 100644 --- a/build/Build.CodeGeneration.cs +++ b/build/Steps/IGenerateTools.cs @@ -1,17 +1,23 @@ -using System; +using System; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Tools.GitHub; using Fallout.Common.Utilities.Collections; +using Fallout.Components; using static Fallout.CodeGeneration.CodeGenerator; using static Fallout.CodeGeneration.ReferenceUpdater; using static Fallout.Common.Tools.Git.GitTasks; -partial class Build +// Step: (re)generate the tool wrappers from their JSON specs and the CLI reference docs. +interface IGenerateTools : IFalloutBuild, IHasGitRepository { AbsolutePath SpecificationsDirectory => RootDirectory / "src" / "Fallout.Common" / "Tools"; AbsolutePath ReferencesDirectory => RootDirectory / "docs" / "cli-tools"; + // Branch that generated source links point at. Mirrors Build.MainBranch, which must + // stay a const there (it feeds a [GitHubActions] attribute and so can't be shared here). + string ToolsSourceBranch => "main"; + Target References => _ => _ .Requires(() => GitHasCleanWorkingCopy()) .Executes(() => @@ -28,6 +34,6 @@ partial class Build GenerateCode( x, namespaceProvider: x => $"Fallout.Common.Tools.{x.Name}", - sourceFileProvider: x => GitRepository.SetBranch(MainBranch).GetGitHubBrowseUrl(x.SpecificationFile))); + sourceFileProvider: x => GitRepository.SetBranch(ToolsSourceBranch).GetGitHubBrowseUrl(x.SpecificationFile))); }); } diff --git a/build/Build.GlobalSolution.cs b/build/Steps/IHandleExternalRepositories.cs similarity index 75% rename from build/Build.GlobalSolution.cs rename to build/Steps/IHandleExternalRepositories.cs index f6878b4b3..b9c00adf3 100644 --- a/build/Build.GlobalSolution.cs +++ b/build/Steps/IHandleExternalRepositories.cs @@ -5,27 +5,20 @@ using Fallout.Common; using Fallout.Common.Git; using Fallout.Common.IO; -using Fallout.Solutions; using Fallout.Common.Tools.GitHub; -using Fallout.Common.Utilities; using Fallout.Utilities.Text.Yaml; using static Fallout.Common.ControlFlow; using static Fallout.Common.Tools.Git.GitTasks; -partial class Build +// Step: check out the companion repositories declared in external/repositories.yml and +// expose their solutions. Depended on by IUpdateContributors, which mines their git log. +interface IHandleExternalRepositories : IFalloutBuild { - [Parameter] readonly bool UseHttps; + [Parameter] bool UseHttps => TryGetValue(() => UseHttps) ?? false; - AbsolutePath GlobalSolution => RootDirectory / "fallout-global.sln"; AbsolutePath ExternalRepositoriesDirectory => RootDirectory / "external"; AbsolutePath ExternalRepositoriesFile => ExternalRepositoriesDirectory / "repositories.yml"; - IEnumerable ExternalSolutions - => ExternalRepositories - .Select(x => ExternalRepositoriesDirectory / x.GetGitHubName()) - .Select(x => x.GlobFiles("*.sln").Single()) - .Select(x => x.ReadSolution()); - IEnumerable ExternalRepositories => ExternalRepositoriesFile.ReadYaml().Select(x => GitRepository.FromUrl(x)); diff --git a/build/Build.Contributors.cs b/build/Steps/IUpdateContributors.cs similarity index 86% rename from build/Build.Contributors.cs rename to build/Steps/IUpdateContributors.cs index a3910c293..c0e76c5cb 100644 --- a/build/Build.Contributors.cs +++ b/build/Steps/IUpdateContributors.cs @@ -6,7 +6,9 @@ using Fallout.Common.Utilities.Collections; using static Fallout.Common.Tools.Git.GitTasks; -partial class Build +// Step: append newly-seen authors from this repo (and the external companions) to +// CONTRIBUTORS.md. Inherits IHandleExternalRepositories for ExternalRepositoriesDirectory. +interface IUpdateContributors : IFalloutBuild, IHandleExternalRepositories { AbsolutePath ContributorsFile => RootDirectory / "CONTRIBUTORS.md"; AbsolutePath ContributorsCacheFile => TemporaryDirectory / "contributors.dat"; @@ -14,7 +16,7 @@ partial class Build Target UpdateContributors => _ => _ .Executes(() => { - var previousContributors = ContributorsCacheFile.Existing()?.ReadAllLines() ?? new string[0]; + var previousContributors = ContributorsCacheFile.Existing()?.ReadAllLines() ?? []; var repositoryDirectories = new[] { RootDirectory / ".git" } .Concat(ExternalRepositoriesDirectory.GlobDirectories("*/.git")); @@ -30,7 +32,7 @@ partial class Build foreach (var newContributor in newContributors) { - var content = (ContributorsFile.Existing()?.ReadAllLines() ?? new string[0]) + var content = (ContributorsFile.Existing()?.ReadAllLines() ?? []) .Concat($"- {newContributor.Name}").OrderBy(x => x); ContributorsFile.WriteAllLines(content, Encoding.Default); Git($"add {ContributorsFile}"); diff --git a/build/Build.Stargazers.cs b/build/Steps/IUpdateStargazers.cs similarity index 88% rename from build/Build.Stargazers.cs rename to build/Steps/IUpdateStargazers.cs index 72d5449ba..d4f52d738 100644 --- a/build/Build.Stargazers.cs +++ b/build/Steps/IUpdateStargazers.cs @@ -1,11 +1,13 @@ -using System.Linq; +using System.Linq; using System.Threading.Tasks; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Tools.GitHub; using Fallout.Common.Utilities; +using Fallout.Components; -partial class Build +// Step: snapshot the repository's stargazers to a CSV in the temp directory. +interface IUpdateStargazers : IFalloutBuild, IHasGitRepository { AbsolutePath StargazersFile => TemporaryDirectory / "stargazers.csv"; diff --git a/build/_build.csproj b/build/_build.csproj index 0491b6ce2..ca6479beb 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -18,35 +18,8 @@ False $(MSBuildThisFileDirectory)\..\src\Fallout.MSBuildTasks\bin\Debug\net8.0\publish - - - - - - - - - - - - - - - - - - - - - - - - - - - $(Configuration.ToUpper()) $(DefineConstants);WIN diff --git a/docs/adr/0009-continuous-delivery-model.md b/docs/adr/0009-continuous-delivery-model.md new file mode 100644 index 000000000..e81b8ab38 --- /dev/null +++ b/docs/adr/0009-continuous-delivery-model.md @@ -0,0 +1,174 @@ +# ADR-0009 — Continuous delivery model: releases, channels, environments, targets, deployments + +- **Status:** Proposed +- **Date:** 2026-07-17 +- **Deciders:** Fallout maintainers +- **Builds on:** [ADR-0001](0001-cd-primitives-attributes-vs-tasks.md) (CD *mechanism* — attributes vs tasks vs hybrid). **Complements, does not supersede.** +- **Relates to:** RFC [#106](https://github.com/Fallout-build/Fallout/issues/106) (CD platform vision), RFC [#113](https://github.com/Fallout-build/Fallout/issues/113) (deployment agent), epic [#332](https://github.com/Fallout-build/Fallout/issues/332) (CD model), issue [#334](https://github.com/Fallout-build/Fallout/issues/334) (`ReleaseChannel` / `DeploymentTarget`), [ADR-0004](0004-calendar-versioning-and-dual-pace-channels.md) / [ADR-0008](0008-collapse-experimental-into-main.md) (versioning + channels). + +## Context + +ADR-0001 answered *how* Fallout talks to a CD system: config that is file-shaped uses the `[Attribute] → writer` pattern (as CI does), state that lives in an external database uses `*Tasks → REST`, and stable external config uses the hybrid `[Attribute] + Sync` target. That is the **mechanism** layer, and it stands. + +What it did **not** define is the **domain model** — the vocabulary a Fallout build reasons in when it releases software: what a *release* is, how *channels* gate reach, what an *environment* and a *target* are and how they differ, and what a *deployment* records. Today the only concrete piece is `Fallout.Components.PublishTarget` — a sealed, **NuGet-feed-shaped** class (`Source` = a v3 index) consumed by `IPublish.Publish`, which hardcodes `DotNetNuGetPush`. It cannot express a GitHub Release, a Homebrew tap, or a documentation site, and it has no notion of promotion, gating, or deployment history. The `FALLOUT001` registry row says as much: *"Promote by deleting the attribute once `ReleaseChannel` / `DeploymentTarget` (#334) settle."* This ADR settles them. + +Fallout is unusual as a CD *subject*: it is a library/CLI that others consume as packages, so **the package feed is simultaneously the artifact store and the deployment destination**. There is no long-running server to deploy onto; "deploying" means publishing built artifacts to a registry. The model below is shaped for that reality while staying general enough for the server-deploy case ADR-0001 sketched (Octopus, the RFC #113 agent). + +### The mental model (Octopus, adapted) + +- **Release** — an immutable snapshot of the code: a `v*` **tag**. The version is derived from it. A release is built **once**. +- **Artifact** — what the build produces *from* a release: `packages` (`.nupkg`), `assemblies`, `releasenotes`, `symbols`, `source`, `documentation`, `homebrew-formula`. Immutable and versioned. +- **Target** — a distribution *endpoint* that accepts certain artifact kinds: `nuget.org`, `github-packages`, `github-releases`, `homebrew`, `docs`. Implements `IPublishTarget`. +- **Environment** — a promotion **stage** (`Dev` → `Staging` → `Production`) that **groups** targets and carries a gate. This matches the dev-world/Octopus reading ("Production is an environment; `prod-db-01` is a target within it"). +- **Channel** — which environments a release is *eligible* for. `preview` → {Dev, Staging}; `release` → {Dev, Staging, Production}. +- **Deployment** — one (artifact → target) push, tracked and retryable. + +## Decision + +### D1 — The promotion invariant (the consistency lynchpin) + +> **A release is built once; that single immutable artifact promotes through its channel's eligible environments in order, gated between stages. Promotion never crosses channels — a `preview` artifact is never re-versioned into a `release` artifact.** + +This is what makes "build once" and "promote through Dev→Staging→Prod" both true at the same time. NuGet package identity is immutable (the version is embedded in the bits), so you *cannot* promote `X-preview.5` into `X` — you would rebuild, which is a different release. So maturity (`preview`/`rc`/GA) is fixed **at build time by the channel**; environments only control **distribution reach**, not version. Within one `release`-channel run the identical `.nupkg` set flows Dev→Staging→Prod, so the bits on `github-packages` equal the bits on `nuget.org` — exactly the reproducibility guarantee a package publisher needs. + +### D2 — Two layers: domain model vs GitHub realization + +The domain model (D1's vocabulary) is provider-neutral. GitHub Actions is one **realization** of it, and the two are bridged by Fallout's generator (the same way `[GitHubActions]` already generates workflow YAML): + +| Domain concept | GitHub Actions realization | +|---|---| +| Release | git tag (+ the GitHub Release object) | +| Artifact | Actions artifact / release asset | +| **Environment (stage)** | *(see D3)* | +| **Target (endpoint)** | a GitHub **Environment** — gives a per-target **Deployment** record | +| Deployment | a GitHub Deployment (emitted by a job's `environment:`) | +| Channel | the version string + which trigger fires | + +GitHub Environments are flat (no nesting), so a Fallout **Environment** (stage, a *group* of targets) does not map 1:1 to a GitHub Environment. That is the source of the "is env a stage or a target?" confusion, and D3 resolves it. + +### D3 — Environments group targets; the GitHub realization is per-target envs + a stage gate + +- **Domain:** `Production` is an environment that *contains* the targets `nuget.org`, `github-releases`, `homebrew`, `docs`. +- **GitHub realization (chosen):** each **target** is its own GitHub Environment (`nuget`, `github-releases`, `homebrew`, `docs`, `github-packages`) — this is what produces a granular per-target **Deployment** record, and it matches what the repo does today. The **stage gate** ("enter Production") is realized as **one `production` GitHub Environment carrying the required-reviewer rule, on a lightweight gate job** that the Production target jobs `needs:`. Staging targets are ungated; `preview` is ungated end-to-end. + +This gives all three properties at once: granular per-target deployment tracking, a single approval to promote to Production, and a faithful stage→targets grouping. Deployment granularity is **(artifact-kind × target)** — one record per publish arm — **not** per individual `.nupkg` (22 packages × N targets would be deployment noise; the release is the atomic unit). + +### D4 — `IPublishTarget` (this is #334's `DeploymentTarget`) + +Generalize the NuGet-feed-shaped `PublishTarget` into an interface with per-provider implementations (extends the existing `FALLOUT001` publish surface; new CD types are gated `FALLOUT005`): + +```csharp +public enum ArtifactKind { Packages, Assemblies, ReleaseNotes, Symbols, SourceArchive, Documentation, HomebrewFormula } + +public sealed record Artifact(ArtifactKind Kind, string Version, IReadOnlyList Files); + +public interface IPublishTarget +{ + string Name { get; } // "nuget.org", "github-packages", "homebrew" + bool Accepts(Artifact artifact); // which artifact kinds this endpoint takes + Task DeployAsync(IReadOnlyList artifacts, DeploymentContext context); +} + +public sealed record Channel(string Name, IReadOnlyList Environments); // preview→[Dev,Staging]; release→[Dev,Staging,Production] +public sealed record DeploymentEnvironment(string Name, int Order, bool RequiresApproval, IReadOnlyList Targets); +``` + +- `PublishTarget` becomes the **NuGet-feed implementation** (`Source`, `ApiKey`, include/exclude routing, `SkipDuplicate` — unchanged), and `IPublish.PublishTargets` returns `IEnumerable`. `github-packages` and `nuget.org` are two instances of it. +- `github-releases` (creating the Release + attaching `releasenotes` / `packages` / `symbols` / `source`), `homebrew`, and `docs` are further implementations. Per ADR-0001, `github-releases` and `homebrew` are built on `*Tasks → REST` (`GitHubReleaseTasks` is already sketched there); a target's `DeployAsync` is the seam that calls them. +- A target may declare `Accepts` for **several** artifact kinds (`github-releases` takes release notes *and* packages *and* symbols). + +### D5 — A target may belong to more than one environment + +`github-packages` is a member of **both** `Staging` (canary / pre-release push) **and** `Production` (the GA feed) — re-pushing the same version is a no-op via `SkipDuplicate`, so this is safe. `github-releases` is `Production`-only, with an *optional* pre-release variant in `Staging` (GitHub marks the Release `prerelease: true`). Environments own a *set* of targets; targets are not exclusive to one stage. + +### D6 — Cross-repo targets start in-pipeline, evolve to downstream-owned + +`docs` (Docusaurus) and `homebrew` (a tap) live in **other repositories**, so drift is the risk. Modelling each as a first-class `IPublishTarget` is the mitigation: the release pipeline is the **single source of truth**, those repos are *written to* by the release (version-pinned), never hand-maintained, and each push is a **tracked, retryable deployment** — a failed docs/tap update shows red on its environment exactly like a failed nuget push. **First cut:** the deploy runs *in-pipeline* (direct cross-repo push). **Evolution:** the downstream repo owns its own publish, triggered by `repository_dispatch` from the release — an implementation swap behind `IPublishTarget`, no model change. + +### D7 — Workflow shape: reusable workflows named `{action}-{artifact}-{target}` + +Build once, deploy many. A release-pipeline workflow packs once, uploads the artifact, then calls **reusable workflows** (`workflow_call`), one per `{artifact}-{target}` arm — each bound to its target's GitHub Environment (so each is a tracked deployment) and each downloading the shared artifact (so the bits are identical everywhere): + +- `publish-packages-github`, `publish-packages-nuget`, `publish-releasenotes-github`, `publish-formula-homebrew`, `publish-docs-github`. +- Produce/verify actions (no target) stay `{action}-{artifact}`: `test`, `build-assemblies`, `pack-packages`. The three-token pattern governs the **delivery** family only. + +Fallout already generates CI YAML from `[GitHubActions]`; generating this fan-out — **and the promotion diagram below** — from the `Channel`/`DeploymentEnvironment`/`IPublishTarget` config is the natural next capability. + +### D8 — Fallout's own line is green-fielded off `main` + +The first consumer is Fallout's own `v10.4` bridge release, cut from `main`, modelled exactly this way; all future releases follow. The version-scheme reconciliation (`v10.4` vs the `2026.x` CalVer in `version.json` / ADR-0004) is a **separate maintainer decision** and does not gate this model — the model is version-scheme-agnostic. + +## The diagram + +```mermaid +flowchart TB + T(["Release · tag v10.4.0 on main"]) --> B["Build ONCE"] + B --> A{{"Artifacts (immutable, versioned)\npackages · assemblies · releasenotes\nsymbols · source · docs · homebrew-formula"}} + A --> DEV + + subgraph DEV["Environment · Dev (ungated)"] + V["verify: run tests + produce results"] + end + subgraph STG["Environment · Staging (ungated)"] + GP["github-packages"] + end + subgraph PRD["Environment · Production (gated: approval)"] + NG["nuget.org"] + GR["github-releases\n(releasenotes + packages + symbols + source)"] + HB["homebrew tap"] + DC["docs site"] + end + + DEV -->|auto| STG + STG -->|"gate: single approval (production env)"| PRD + + CP["Channel: preview"] -.->|"eligible: Dev, Staging"| STG + CR["Channel: release"] -.->|"eligible: Dev, Staging, Production"| PRD +``` + +A `preview` release stops at Staging (`github-packages` gets `X-preview.N`); a `release` release promotes the identical GA `X` bits through to Production's targets after one approval. + +## Consequences + +### Positive + +- **One coherent vocabulary** (release / channel / environment / target / deployment) that maps cleanly onto both a package publisher (feeds-as-targets) and the server-deploy case ADR-0001 sketched. +- **Reproducibility by construction** — D1 guarantees the bits on every target within a release are identical. +- **`IPublishTarget` unblocks promoting `FALLOUT001`** — settles the `DeploymentTarget` half of #334; providers slot in without touching the build kernel. +- **Deployment history for free** — per-target GitHub Environments emit Deployment records; cross-repo targets get the same retry/visibility as feed pushes (D6), which is the multi-repo drift mitigation. + +### Negative + +- **Two layers to hold in your head** (domain model vs GitHub realization). Mitigated by the generator being the only place the mapping lives. +- **Per-target GitHub Environments proliferate** (one per target) plus a `production` gate env. Accepted for the granular tracking; the gate env keeps approvals to one click. +- **`IPublishTarget` is a breaking change to the `FALLOUT001` experimental surface** (`PublishTargets` return type changes `PublishTarget` → `IPublishTarget`). Acceptable under the experimental contract; adding/removing `[Experimental]` is not a breaking change and the surface was explicitly flagged as unstable pending #334. + +### Neutral + +- ADR-0001 (mechanism) and ADR-0004/0008 (versioning + channels) are unchanged. This ADR adds the model *on top* of ADR-0001 and reuses ADR-0004/0008's `preview`/`rc`/GA maturity as the `Channel` axis. + +## Alternatives considered + +### A. Environments model maturity tiers, promote the artifact across them (rejected in this form) + +An earlier framing made `github-packages`="staging" and `nuget.org`="production" as tiers you promote a *version* through. **Rejected** because it implies promoting `-preview` bits into GA bits, which NuGet immutability forbids. D1 keeps the tiers but scopes promotion to *within a single channel's build* (same bits, different reach), which is consistent. + +### B. Per-package deployment records + +Track every `.nupkg × target` as its own deployment. **Rejected** — 22 packages × N targets is deployment noise, and the release is the atomic unit. Granularity is (artifact-kind × target). + +### C. Independent per-target workflow files (no shared build) + +Each `publish-*-*` as a standalone workflow. **Rejected** — either each rebuilds (breaks D1's identical-bits guarantee) or shares an artifact awkwardly across runs. Reusable workflows called by one build-once orchestrator (D7) give the named arms without losing immutability. + +### D. Keep `PublishTarget` NuGet-only, add separate mechanisms for releases/homebrew + +Leave the feed model alone and bolt on ad-hoc targets. **Rejected** — it forgoes the single `IPublishTarget` seam, so every new destination reinvents routing/gating/tracking. The interface is the point. + +## References + +- [ADR-0001 — CD primitives: attributes for config, tasks for state](0001-cd-primitives-attributes-vs-tasks.md) (mechanism; complemented here) +- [ADR-0004](0004-calendar-versioning-and-dual-pace-channels.md) / [ADR-0008](0008-collapse-experimental-into-main.md) — versioning + `preview`/`rc`/GA channels +- RFC [#106](https://github.com/Fallout-build/Fallout/issues/106), RFC [#113](https://github.com/Fallout-build/Fallout/issues/113), epic [#332](https://github.com/Fallout-build/Fallout/issues/332), issue [#334](https://github.com/Fallout-build/Fallout/issues/334) +- Existing surface: `src/Fallout.Components/IPublish.cs`, `src/Fallout.Components/PublishTarget.cs` +- [docs/experimental-apis.md](../experimental-apis.md) — `FALLOUT001` (publish surface), `FALLOUT005` (CD model, allocated here) diff --git a/docs/adr/README.md b/docs/adr/README.md index a932561f3..d91642624 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,3 +40,4 @@ If you change a decision, do NOT silently rewrite the old ADR — add a new one | [0004](0004-calendar-versioning-and-dual-pace-channels.md) | Calendar versioning + dual-pace channels (edge/stable) + experimental APIs | Accepted (§3 amended by 0007; channel ladder §2 superseded by 0008) | | [0007](0007-cut-release-branch-on-demand.md) | Cut `release/YYYY` on demand, not preemptively | Accepted | | [0008](0008-collapse-experimental-into-main.md) | Collapse `experimental` into `main`; `main` is the sole prerelease lane | Accepted | +| [0009](0009-continuous-delivery-model.md) | Continuous delivery model — releases, channels, environments, targets, deployments | Proposed | diff --git a/docs/agents/conventions.md b/docs/agents/conventions.md index d14b74b7f..4f31d1520 100644 --- a/docs/agents/conventions.md +++ b/docs/agents/conventions.md @@ -9,7 +9,7 @@ Three groups: conventions to respect, things never to do, and the tool-wrapper r - **Test files and classes use `Specs` suffix.** Name test projects `Foo.Specs`, test files `FooSpecs.cs`, and test classes `FooSpecs`. Use `Specs` not `Test` or `Tests` — it clarifies that the class describes the expected behavior (specification) of the subject under test. - **Tool wrappers**: copy/paste from neighbours; cover full commands; use ``, ``, `
    `/`
      `, ``, `` in `help`; don't write `secret: false` or `default: xxx`. See [Tool wrapper recipe](#tool-wrapper-recipe) below. - **Tests next to code, separate folder**: every `Foo` project under `src/` has a sibling `Foo.Tests` project under `tests/`. Mirror the namespace. -- **No IDE-specific style files committed.** `.editorconfig` and `*.DotSettings` were removed during the takeover — relying on `dotnet format` defaults and review. +- **Style is codified in a committed `.editorconfig`** (plus `fallout.slnx.DotSettings` for Rider/ReSharper) — it's the source of truth for formatting, naming, and analyzer severities. The `build/` dogfood scripts get a relaxed `[build/**.cs]` section (explicit accessibility modifiers optional). Line endings are LF (matches `.gitattributes`); keep `dotnet format` runnable by not fighting it. - **Telemetry opt-out is set in test runs** (`FALLOUT_TELEMETRY_OPTOUT=true`). Keep it that way. - **No per-file license headers.** The MIT notice lives in [`LICENSE`](https://github.com/Fallout-build/Fallout/blob/main/LICENSE) at the repo root, and NuGet packages declare MIT via `PackageLicenseExpression`. Per-file headers were stripped in v11 (one source of truth + the header URL would have rotted on the repo-org transfer). Vendored third-party code keeps its own copyright headers — don't touch those (e.g. files under `src/Persistence/Fallout.Persistence.Solution/` retain Microsoft's MIT notice). - **`[Experimental]` for opt-in unstable public APIs.** Not-yet-stable public surface is marked with `[Experimental("FALLOUT0xx")]` rather than held back or shipped silently. See [the `[Experimental]` convention](#experimental-for-opt-in-unstable-apis) below and the [diagnostic-ID registry](../experimental-apis.md). @@ -94,7 +94,7 @@ Shaped by [milestone #18](https://github.com/Fallout-build/Fallout/milestone/18) - Don't commit `output/` or any `bin/`/`obj/` directory. - Don't commit `fallout-global.sln` or other `fallout-global.*` files — they're generated by `GenerateGlobalSolution`. - Don't bypass `Directory.Packages.props` or `Directory.Build.targets`. -- Don't reintroduce `.editorconfig` or `*.DotSettings` without a maintainer-level decision — they were intentionally removed. +- Don't remove or bypass the committed `.editorconfig` / `fallout.slnx.DotSettings` — they're the style source of truth. Change a rule deliberately, not by deleting the file. ## Tool wrapper recipe diff --git a/docs/experimental-apis.md b/docs/experimental-apis.md index 8b7b2d8bf..c845d1f56 100644 --- a/docs/experimental-apis.md +++ b/docs/experimental-apis.md @@ -48,7 +48,8 @@ Status values: **Experimental** (live, opt-in), **Promoted** (attribute removed, | ID | Surface | Introduced | Status | Notes | |----|---------|------------|--------|-------| -| `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. | +| `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. `PublishTargets` now returns `IEnumerable` (see `FALLOUT005`). | +| `FALLOUT005` | `Fallout.Components` CD model — `IPublishTarget`, `Artifact`/`ArtifactKind`, `Channel`, `DeploymentEnvironment`, `DeploymentContext`, `GitHubReleaseTarget` | 2026.1 | Experimental | The continuous-delivery domain model (releases → channels → environments → targets → deployments) from [ADR-0009](adr/0009-continuous-delivery-model.md); `IPublishTarget` is the `DeploymentTarget` of #334. Shape will change while the model and its provider implementations firm up. Promote alongside `FALLOUT001` once the model settles. |