What runs, when, and why it's shaped this way.
The governing principle: the build is defined in C#, not in YAML. Every workflow here provisions toolchains and routes channels; every step that actually does something invokes a Fallout target from build/Build.cs driving the Fallout.Vsce plugin. That's why the same commands work identically on a laptop and on a runner.
| File | Trigger | Does | Generated? |
|---|---|---|---|
build.yml |
PR to develop, main, support/* |
PackVsix — the required check |
Yes, from [GitHubActions] |
build-skip.yml |
PR touching only **/*.md |
Nothing; reports the same check | No |
preview.yml |
push to develop |
Packs, publishes the rolling preview release |
No |
publish.yml |
v* tag push, or dispatch |
Packs, releases, optionally promotes to marketplaces | No |
flowchart LR
PR["Pull request"] --> G{"Only<br/>*.md?"}
G -->|No| B["build.yml<br/>PackVsix"]
G -->|Yes| S["build-skip.yml<br/>no-op"]
B --> C(["ubuntu-latest ✓"])
S --> C
D["Push to develop"] --> P["preview.yml"]
P --> PR2["rolling 'preview'<br/>pre-release"]
T["Tag v*"] --> V["publish.yml"]
V --> R["GitHub release"]
V -.->|"dispatch flag<br/>+ approval"| M["marketplaces"]
style C fill:#2d6a4f,color:#fff
style M fill:#7f4f24,color:#fff
Generated from the [GitHubActions] attribute in build/Build.CI.GitHubActions.cs. Never hand-edit .github/workflows/build.yml — edit the attribute and run ./build.ps1 (or ./build.sh), which regenerates it. The file carries an <auto-generated> header saying so.
Branch protection requires a status check named ubuntu-latest. That's the job name, not the workflow name — protection keys on jobs, which is why the job is named after the image.
build.yml ignores **/*.md, so a docs-only PR doesn't burn CI on a build that can't be affected. But a required check that never reports leaves the PR blocked forever — GitHub waits for a check that will never arrive.
build-skip.yml fires on the exact inverse path set, does nothing, and reports success under the same ubuntu-latest context. Three things must stay in lockstep between the two files:
- the job name (
ubuntu-latest) — it is the status-check context; - the path sets, exact complements of one another;
- the branch lists.
This is why OnPullRequestExcludePaths is a single **/*.md pattern. An earlier version used a negation carve-out (!.github/workflows/**), which made the complement impossible to state — and a gap between the two sets is exactly how a PR ends up permanently blocked.
A PR touching both a .md and a source file runs both workflows. Both report ubuntu-latest, both pass. That's fine.
Every push to develop packs a .vsix, uploads it as a per-run artifact, and replaces the asset on a rolling preview GitHub pre-release. Details and rationale in releasing.md.
Two deliberate choices worth knowing when reading it:
- Concurrency queues, never cancels. A cancelled run could leave the release holding a half-uploaded asset.
- The tag is
preview, which does not matchv*. So it neither triggerspublish.ymlnor falls under thev*tag-protection ruleset — necessary, because the workflow force-moves that tag on every push.
Hand-written, and it stays that way for two reasons the generator can't address:
- It needs
actions/setup-nodewith npm caching. The generator emits checkout, cache and setup-dotnet, with no hook for extra steps. - It fans out into per-channel jobs bound to GitHub Environments with approval gates, passing one artifact between them. The generator emits one job per image and has no concept of that.
The framework repo splits it the same way and for the same category of reason — generated CI, hand-written publish.
flowchart TD
T["v* tag push"] --> VR["validate-ref"]
D["workflow_dispatch"] -.->|skipped| VR
VR --> PK["pack<br/>PackVsix + artifact"]
PK --> GR["publish → github-releases<br/><i>ungated</i>"]
PK -.->|"flag + approval"| VM["publish → vs-marketplace"]
PK -.->|"flag + approval"| OV["publish → open-vsx"]
style GR fill:#2d6a4f,color:#fff
style VM fill:#7f4f24,color:#fff
style OV fill:#7f4f24,color:#fff
validate-ref confirms the tag is reachable from main or a support/vX.Y branch. Tags on develop are rejected: under GitFlow the trunk is never tagged for release — it ships through the preview channel instead.
It runs only on tag pushes. On workflow_dispatch it's skipped by design, which is why every downstream job is guarded with always() && needs.<job>.result == 'success' rather than a bare dependency — a skipped job otherwise propagates through the graph and silently skips everything after it while the run still reports success.
The promotion jobs publish the exact artifact pack produced (--skip PackVsix) rather than rebuilding, so what gets approved is what ships.
There is no separate CI script. The runner invokes the same targets you do:
./build.ps1 PackVsix # what the PR gate runs
dotnet fallout --plan # what would run, without running it
dotnet fallout VerifyVsixCredentials # proves marketplace tokens, publishes nothingPublicRelease: trueis set on any job that checks out a tag. A detached HEAD matches none ofversion.json's branch refspecs, so without it Nerdbank.GitVersioning appends a git-height suffix and the marketplace version stops being a clean triple.fetch-depth: 0is required wherever the version is computed — NB.GV needs full history.- Boolean workflow inputs arrive as strings from the REST API, and therefore from
gh workflow run. Conditions compare against bothtrueand'true'; a bare== truesilently skips while reporting success.