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
2 changes: 1 addition & 1 deletion .fallout/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
"GitHubRelease",
"Images",
"Push",
"PushDevelop",
"PushDockerHub",
"PushEdge",
"PushGhcr",
"ReleaseBundle",
"Test",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
#
# - To trigger manual generation invoke:
#
# fallout --generate-configuration GitHubActions_publish-edge --host GitHubActions
# fallout --generate-configuration GitHubActions_publish-develop --host GitHubActions
#
# </auto-generated>
# ------------------------------------------------------------------------------

name: publish-edge
name: publish-develop

on:
push:
Expand Down Expand Up @@ -49,8 +49,8 @@ jobs:
global-json-file: global.json
- name: 'Restore: dotnet tools'
run: dotnet tool restore
- name: 'Run: PushEdge'
run: dotnet fallout PushEdge
- name: 'Run: PushDevelop'
run: dotnet fallout PushDevelop
env:
RegistryUser: ${{ secrets.REGISTRY_USER }}
RegistryPassword: ${{ secrets.REGISTRY_PASSWORD }}
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ dotnet fallout Test # same thing via the tool, which is what CI invokes
> Change the attribute, then regenerate — once per workflow:
> ```bash
> dotnet fallout --generate-configuration GitHubActions_build --host GitHubActions
> # …and publish-edge · publish-ghcr · publish-release · publish-dockerhub
> # …and publish-develop · publish-ghcr · publish-release · publish-dockerhub
> ```

### Branching — GitFlow (2026-08-16)
Expand All @@ -200,7 +200,7 @@ tagged `v*`. Work goes `feat|fix|chore|docs/*` → PR into `develop`. A release
`release/*` window) **fast-forwarded** into `main`, then tagged. `hotfix/*` is cut from `main` and **must**
be ported back to `develop`.

- Every non-docs push to `develop` republishes the images as `:edge` (`PushEdge`).
- Every non-docs push to `develop` republishes the images as `:develop` (`PushDevelop`).
- `GitHubRelease` **refuses** a tag that is not reachable from `main` or `support/*` — the trunk is
never tagged for release.
- **Never merge a release PR with GitHub's button.** It rewrites the commits, which severs the
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ pinned as a local dotnet tool, so run `dotnet tool restore` once on a fresh clon
**The whole CI/CD pipeline is Fallout.** Every workflow under `.github/workflows/` is *generated*
from the `[GitHubActions]` attributes in [`build/Build.CI.GitHubActions.cs`](build/Build.CI.GitHubActions.cs),
and each one only provisions a runner and invokes a target — the gate runs `Test`, the trunk runs
`PushEdge`, a tag runs `PushGhcr` and `GitHubRelease`. Edit the attribute, not the YAML, or your
`PushDevelop`, a tag runs `PushGhcr` and `GitHubRelease`. Edit the attribute, not the YAML, or your
change is overwritten:

```bash
Expand Down Expand Up @@ -504,8 +504,8 @@ gh pr create --base develop --label enhancement # one category label — th

### Running the trunk

Every push to `develop` republishes the images as `:edge` (multi-arch, GHCR). Point an existing
deployment's `.env` at that tag to follow along — it moves under you, migrations included, and
Every push to `develop` republishes the images as `:develop` (multi-arch, GHCR and Docker Hub).
Point an existing deployment's `.env` at that tag to follow along — it moves under you, migrations included, and
downgrading back to a release is not supported, so back up first.

---
Expand Down
15 changes: 8 additions & 7 deletions build/Build.CI.GitHubActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,26 +43,27 @@
},
InvokedTargets = new[] { nameof(Test) })]

// ── The edge channel (GitFlow's preview channel) ──────────────────────────────
// ── The develop channel (GitFlow's preview channel) ───────────────────────────
//
// Every push to the trunk republishes the six images under `:edge`, so a tester can run the next
// Every push to the trunk republishes the six images under `:develop` — named after the branch
// they are built from — so a tester can run the next
// release before it is a release. Mirrors the extension repo's rolling `preview` VSIX; the shape
// differs only because our artefact is a registry tag rather than a GitHub release asset.
//
// Path exclusions ARE safe here — this is not a required check, so a skipped run blocks nothing,
// and rebuilding six multi-arch images because a markdown file changed is pure waste.
//
// Concurrency QUEUES rather than cancels (ConcurrencyCancelInProgress is left at its default
// false): cancelling a push mid-way can leave `:edge` pointing at a half-written manifest list,
// which is worse than an edge build running a few minutes behind.
// false): cancelling a push mid-way can leave `:develop` pointing at a half-written manifest
// list, which is worse than the channel running a few minutes behind the branch.
[GitHubActions(
"publish-edge",
"publish-develop",
GitHubActionsImage.UbuntuLatest,
FetchDepth = 0,
OnPushBranches = new[] { DevelopBranch },
OnPushExcludePaths = new[] { "**/*.md", "docs/**" },
ConcurrencyGroup = "${{ github.workflow }}",
InvokedTargets = new[] { nameof(PushEdge) },
InvokedTargets = new[] { nameof(PushDevelop) },
EnvironmentName = "ghcr",
ImportSecrets = new[] { nameof(RegistryUser), nameof(RegistryPassword) })]

Expand Down Expand Up @@ -100,7 +101,7 @@
//
// It also refuses to go out from the wrong branch: GitHubRelease asserts the tag is reachable from
// main or a support line (see Build.Release.cs). Under GitFlow the trunk is never tagged for
// release — it ships through the edge channel instead.
// release — it ships through the develop channel instead.
[GitHubActions(
"publish-release",
GitHubActionsImage.UbuntuLatest,
Expand Down
20 changes: 10 additions & 10 deletions build/Build.Publish.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ partial class Build

AbsolutePath ComposeDirectory => RootDirectory / ".artifacts" / "compose";

/// <summary>Set by <see cref="PushEdge"/>; wins over every other tag resolution.</summary>
/// <summary>Set by <see cref="PushDevelop"/>; wins over every other tag resolution.</summary>
string _tagOverride;

/// <summary>
Expand All @@ -94,7 +94,7 @@ string EffectiveTag
{
get
{
// An edge build runs off a branch, where the resolutions below would answer "dev" —
// A trunk build runs off a branch, where the resolutions below would answer "dev" —
// the point of the channel is that it has its own, stable, name.
if (!string.IsNullOrWhiteSpace(_tagOverride))
return _tagOverride;
Expand Down Expand Up @@ -208,26 +208,26 @@ string RemoteImage(string service) =>
PushImages();
});

/// <summary>The rolling tag the trunk publishes under.</summary>
/// <summary>The rolling tag the trunk publishes under — named after the branch it comes from.</summary>
/// <remarks>
/// Not "latest": that name is conventionally the newest <i>stable</i> image, and a compose file
/// left on the default tag would silently follow the trunk. Anyone running <c>:edge</c> has
/// typed the word.
/// left on the default tag would silently follow the trunk. Anyone running <c>:develop</c> has
/// typed the name of a branch, which says exactly what they are getting.
/// </remarks>
const string EdgeTag = "edge";
const string DevelopTag = "develop";

Target PushEdge => _ => _
.Description($"Push the rolling ':{EdgeTag}' images to GHCR — CI target for pushes to the trunk")
Target PushDevelop => _ => _
.Description($"Push the rolling ':{DevelopTag}' images to GHCR — CI target for pushes to the trunk")
.DependsOn(Compile)
.Requires(() => RegistryUser)
.Requires(() => RegistryPassword)
.Executes(() =>
{
// The images are the whole channel — there is no separate release artefact to attach,
// as there would be for a versioned release. A tester points their existing .env at
// :edge (docs/releasing.md#the-edge-channel) and pulls.
// :develop (docs/releasing.md#the-develop-channel) and pulls.
_targetRegistry = "ghcr.io";
_tagOverride = EdgeTag;
_tagOverride = DevelopTag;
PushImages();
});

Expand Down
4 changes: 2 additions & 2 deletions build/Build.Release.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
logOutput: false);
probe.WaitForExit();

return probe.ExitCode == 0

Check warning on line 49 in build/Build.Release.cs

View workflow job for this annotation

GitHub Actions / ubuntu-latest

Possible null reference return.

Check warning on line 49 in build/Build.Release.cs

View workflow job for this annotation

GitHub Actions / ubuntu-latest

Possible null reference return.
? probe.Output.Select(x => x.Text).FirstOrDefault()?.Trim()
: null;
}
Expand Down Expand Up @@ -110,7 +110,7 @@
/// </summary>
/// <remarks>
/// <para>
/// Under GitFlow the trunk is never tagged for release — <c>develop</c> ships through the edge
/// Under GitFlow the trunk is never tagged for release — <c>develop</c> ships through its own
/// channel, and a release comes from <c>main</c> after a stabilisation window
/// (docs/branching-and-release.md). Without this check that rule is documentation only, and
/// breaking it is silent: a <c>v*</c> tag anywhere publishes real images and a real release
Expand Down Expand Up @@ -149,7 +149,7 @@
Assert.True(releasable,
$"{ReleaseTag} is not reachable from origin/{MainBranch} or a support line — it is on " +
$"[{string.Join(", ", branches)}]. Releases are cut from {MainBranch}; the trunk ships " +
"through the edge channel. See docs/branching-and-release.md.");
"through the develop channel. See docs/branching-and-release.md.");

Log.Information("{Tag} is reachable from {Branches}", ReleaseTag, string.Join(", ", branches));
}
Expand Down Expand Up @@ -334,7 +334,7 @@
/// with "_", so this reverses that rather than duplicating the list of services.
/// </remarks>
static string ServiceForEnvKey(string key) =>
Services.Select(s => s.Service).FirstOrDefault(service =>

Check warning on line 337 in build/Build.Release.cs

View workflow job for this annotation

GitHub Actions / ubuntu-latest

Possible null reference return.

Check warning on line 337 in build/Build.Release.cs

View workflow job for this annotation

GitHub Actions / ubuntu-latest

Possible null reference return.
$"{service.ToUpperInvariant().Replace('-', '_')}_IMAGE" == key);

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions docs/branching-and-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ runs.

| Branch | Purpose | Lifetime | Tagged? |
|---|---|---|---|
| `develop` | **Integration trunk. Default branch.** All finished work lands here first. Every push republishes the [`:edge` images](releasing.md#the-edge-channel). | Permanent | No |
| `develop` | **Integration trunk. Default branch.** All finished work lands here first. Every push republishes the [`:develop` images](releasing.md#the-develop-channel). | Permanent | No |
| `main` | **Production.** Only receives merges from `release/*` and `hotfix/*`, and every one of those is tagged. Never committed to directly. | Permanent | **Yes** |
| `release/*` | **Stabilisation window** for a release being prepared. Cut from `develop`; takes only fixes and release prep. Merges to `main` *and back to* `develop`, then deleted. | Short-lived | No (the merge into `main` is) |
| `hotfix/*` | **Urgent production fix.** Cut from `main`. Merges to `main` *and* `develop`, then deleted. | Short-lived | No (the merge into `main` is) |
Expand Down Expand Up @@ -89,7 +89,7 @@ flowchart TD
A["A change needs to ship"] --> B{"Is a released version<br/>broken right now?"}
B -->|No| C["feat/* or fix/*<br/>off develop"]
C --> D["PR → develop"]
D --> E["Ships on the next release<br/>(and on :edge immediately)"]
D --> E["Ships on the next release<br/>(and on :develop immediately)"]
B -->|Yes| F{"Does it affect the<br/>version main is on?"}
F -->|Yes| G["hotfix/* off main"]
G --> H["PR → main, tag,<br/>then merge back to develop"]
Expand Down
29 changes: 15 additions & 14 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ it.
| File | Trigger | Invokes | Environment |
|---|---|---|---|
| `build.yml` | push to `develop`/`main`; PR into `develop`, `main`, `release/*`, `hotfix/*`, `support/*` | `Test` — the required check | — |
| `publish-edge.yml` | push to `develop` (non-docs) | `PushEdge` — rolling `:edge` images | `ghcr` |
| `publish-develop.yml` | push to `develop` (non-docs) | `PushDevelop` — rolling `:develop` images | `ghcr` |
| `publish-ghcr.yml` | `v*` tag | `PushGhcr` — versioned multi-arch images | `ghcr` |
| `publish-release.yml` | `v*` tag | `GitHubRelease` — compose bundle + notes | `github-release` |
| `publish-dockerhub.yml` | dispatch only | `PushDockerHub` — mirror | `dockerhub` |
Expand All @@ -29,8 +29,8 @@ flowchart LR
B --> C(["ubuntu-latest ✓"])

D["Push to develop"] --> B
D --> E["publish-edge.yml<br/>PushEdge"]
E --> ET["ghcr.io/…:edge"]
D --> E["publish-develop.yml<br/>PushDevelop"]
E --> ET["ghcr.io/…:develop"]

T["Tag v* on main"] --> G["publish-ghcr.yml<br/>PushGhcr"]
T --> R["publish-release.yml<br/>GitHubRelease"]
Expand Down Expand Up @@ -63,7 +63,7 @@ The generator names a job after its runner image, so **all five workflows produc

In practice it only shows up in one place. A PR from a working branch has a head SHA that only the
gate ran on, so its check list is clean. A **release PR from `develop`** has a head SHA that
`publish-edge` also ran on, so the PR lists the gate's check *and* the edge publish's, both under
`publish-develop` also ran on, so the PR lists the gate's check *and* the edge publish's, both under
the same name — and GitHub requires every check with that name to pass.

That is a defensible thing to be blocked by (don't cut a release from a commit whose images
Expand All @@ -82,23 +82,24 @@ complements forever.
The extension repo pays that price because its gate packages a VSIX. Ours is a five-minute
`dotnet test`. We pay the five minutes.

`publish-edge.yml` *does* filter paths, and safely: it is not a required check, so a skipped run
`publish-develop.yml` *does* filter paths, and safely: it is not a required check, so a skipped run
blocks nothing.

## publish-edge.yml — the trunk channel
## publish-develop.yml — the trunk channel

Every non-docs push to `develop` rebuilds the six service images for `linux/amd64` and
`linux/arm64` and pushes them to GHCR under `:edge`, replacing what was there. See
[releasing.md](releasing.md#the-edge-channel) for how to run it.
`linux/arm64` and pushes them to GHCR under `:develop`, replacing what was there. See
[releasing.md](releasing.md#the-develop-channel) for how to run it.

Two deliberate choices worth knowing when reading the attribute:

- **Concurrency queues, never cancels.** `ConcurrencyCancelInProgress` stays at its default
`false`. A cancelled push can leave `:edge` pointing at a half-written manifest list, which is
worse than an edge image running a few minutes behind the trunk.
- **`edge`, not `latest`.** `latest` is conventionally the newest *stable* image, and it is what a
compose file falls back to when a tag is omitted — so naming the trunk channel `latest` would
silently upgrade people who never asked for it. Anyone on `:edge` typed the word.
`false`. A cancelled push can leave `:develop` pointing at a half-written manifest list, which is
worse than the channel running a few minutes behind the branch.
- **`develop`, not `latest`.** `latest` is conventionally the newest *stable* image, and it is what
a compose file falls back to when a tag is omitted — so naming the trunk channel `latest` would
silently upgrade people who never asked for it. Anyone on `:develop` typed the name of a branch,
which says exactly what they are getting.

## The tag pipeline

Expand Down Expand Up @@ -165,7 +166,7 @@ After changing any `[GitHubActions]` attribute:

```bash
./build.sh --generate-configuration GitHubActions_build --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-edge --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-develop --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-ghcr --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-release --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-dockerhub --host GitHubActions
Expand Down
19 changes: 10 additions & 9 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Channels, versioning, and the runbook for every kind of release. The branch mode

```mermaid
flowchart LR
DEV["develop"] -->|every push| EDGE["ghcr.io/…:edge<br/><i>rolling</i>"]
DEV["develop"] -->|every push| EDGE["…:develop<br/><i>rolling</i>"]
MAIN["main / support/*"] -->|"v* tag"| GHCR["ghcr.io/…:0.3.0"]
MAIN -->|"v* tag"| REL["GitHub release<br/>compose + env template"]
GHCR -.->|"manual dispatch"| DH["docker.io mirror"]
Expand All @@ -21,7 +21,7 @@ flowchart LR

| Channel | Trigger | Gating |
|---|---|---|
| `:edge` images | every non-docs push to `develop` | none |
| `:develop` images | every non-docs push to `develop` | none |
| versioned images + GitHub release | any `v*` tag reachable from `main`/`support/*` | the [release guard](ci.md#the-release-guard) |
| Docker Hub mirror | manual dispatch | manual by definition |

Expand All @@ -36,24 +36,25 @@ same string with the `v` stripped (`v0.3.0` → `:0.3.0`). Pre-1.0, so:

There is deliberately **no Nerdbank.GitVersioning here**, unlike the Fallout repos. They need a
computed monotonic version because every preview build publishes a numbered package to a registry
that will not accept the same number twice. Our preview channel is a single rolling `:edge` tag with
that will not accept the same number twice. Our preview channel is a single rolling `:develop` tag with
no number in it, so the machinery would buy nothing and cost a `version.json` to keep honest.

## The edge channel
## The develop channel

Every non-docs push to `develop` republishes all six images under `:edge`. That is the whole
Every non-docs push to `develop` republishes all six images under `:develop`, named after the branch
they are built from. That is the whole
channel: no release object, no version, just the trunk in runnable form.

To follow it, point an existing deployment's image tags at `edge`:
To follow it, point an existing deployment's image tags at `develop`:

```bash
sed -i '' 's/:[0-9]\+\.[0-9]\+\.[0-9]\+$/:edge/' .env # in a release bundle's .env
sed -i '' 's/:[0-9]\+\.[0-9]\+\.[0-9]\+$/:develop/' .env # in a release bundle's .env
docker compose pull && docker compose up -d
```

`:edge` moves under you — that is the point, and it is why the tag is not called `latest`. Expect a
`:develop` moves under you — that is the point, and it is why the tag is not called `latest`. Expect a
schema migration to land there before it lands in a release; the Migrator runs to completion on
every start, so an edge deployment upgrades itself, and **downgrading back to a release is not
every start, so a develop deployment upgrades itself, and **downgrading back to a release is not
supported**. Take a database backup before following the trunk with data you care about.

## Cutting a release
Expand Down
Loading