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
48 changes: 39 additions & 9 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ jobs:
# Unique per build: the date alone is not. Multiple dispatches a day are normal here - over
# 2026-08-24..09-12, 13 of 20 days published two or more distinct source trees under one
# version string, peaking at five - and nothing in the artifact then distinguishes them.
# `FileVersion` / `ProductVersion` / `InformationalVersion` and the asset names all carry
# this string, and the apphost exe is byte-identical across builds, so with a colliding
# stamp there is no read that answers "which nightly is this".
# `InformationalVersion` — and the Win32 ProductVersion built from it — and the asset names
# all carry this string (#3455 is what actually threads it into the binaries; FileVersion
# stays the numeric prefix), and the apphost exe is byte-identical across builds, so with a
# colliding stamp there is no read that answers "which nightly is this".
#
# run_number is monotonic and numeric, so SemVer 2.0 compares these prereleases in build
# order; the date stays leftmost and human-readable. A short sha would identify the tree
Expand Down Expand Up @@ -142,6 +143,27 @@ jobs:
cache: true
cache-dependency-path: '**/packages.lock.json'

# The stamp must reach the BINARIES, not just the names around them. This step's output feeds
# every artifact name and the vpk pack below, but a name is not a stamp: each assembly records
# whatever the msbuild version property resolves to at build time, and that is the clean
# release string Directory.Build.props declares unless the build overrides it. Until #3455
# nothing did, so a zip named 3.7.0-nightly.20260915.438 shipped a service whose informational
# version read bare 3.7.0 — and the informational version is the one surface a RUNNING service
# answers build questions from: DarlingCliCommands.ProductVersion() reads that attribute for
# --version, and #3453's get_collection_health service block deliberately serves the same
# method's result. The night #3453 shipped, its first live reading came back bare on a .438
# nightly — the question "which nightly is this" answered with silence at the read built to
# answer it.
#
# So every dotnet publish below passes -p:Version with this value. The SDK derives the
# informational version from it verbatim, while AssemblyVersion and FileVersion derive as the
# numeric prefix (3.7.0.0) — identical to a release build, so nothing assembly-identity-shaped
# differs between the two. No +sha metadata rides along: Directory.Build.props suppresses the
# source-revision append tree-wide (#3222), the stamp's run_number already names the build
# uniquely, and ProductVersion() strips any +suffix from display regardless. Release builds
# keep their clean string by CONSTRUCTION rather than by restraint — build.yml passes no
# version property — and NightlyVersionInjectionTests pins both halves, so the next copied-in
# publish step cannot quietly ship a bare-versioned nightly again.
- name: Set nightly version
id: version
shell: pwsh
Expand Down Expand Up @@ -190,20 +212,20 @@ jobs:
# Lite/PerformanceMonitorLite.csproj declares RuntimeIdentifiers so the committed lock
# file covers this RID; see the comment there before removing either half.
- name: Publish Lite
run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite
run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite -p:Version=${{ steps.version.outputs.VERSION }}

- name: Publish Darling Service
run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -o publish/DarlingService
run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -o publish/DarlingService -p:Version=${{ steps.version.outputs.VERSION }}

- name: Publish Darling Viewer
run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -o publish/DarlingViewer
run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -o publish/DarlingViewer -p:Version=${{ steps.version.outputs.VERSION }}

# Self-contained viewer publish that feeds the remote-seat Velopack Setup.exe (#1555), the same
# publish shape build.yml uses for the Dashboard/Lite Velopack packs. This is IN ADDITION to the
# framework-dependent "Publish Darling Viewer" above, which still feeds the co-located viewer\
# folder inside PerformanceMonitorDarling-*.zip — that zip is unchanged.
- name: Publish Darling Viewer (self-contained for Velopack)
run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -r win-x64 --self-contained -o publish/DarlingViewer-velopack
run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -r win-x64 --self-contained -o publish/DarlingViewer-velopack -p:Version=${{ steps.version.outputs.VERSION }}

# Same cache key as build.yml's release path: the fetch script's content hash (the SHA256
# pins live inside it). The ~340MB EDB/TimescaleDB fetch runs at most once per pin-set per
Expand Down Expand Up @@ -518,8 +540,11 @@ jobs:
echo "VERSION=${base}-nightly.${NIGHTLY_STAMP}" >> "$GITHUB_OUTPUT"
echo "Nightly version: ${base}-nightly.${NIGHTLY_STAMP}"

# -p:Version for the reason the windows build job's version step documents (#3455): the name
# on the tarball is not the stamp in the service, and the informational version is what
# --version prints and what the get_collection_health service block serves.
- name: Publish service (linux-x64)
run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -r linux-x64 --self-contained false -o publish/DarlingService-linux
run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -r linux-x64 --self-contained false -o publish/DarlingService-linux -p:Version=${{ steps.version.outputs.VERSION }}

- name: Package linux artifact + checksum
shell: bash
Expand All @@ -544,7 +569,12 @@ jobs:
version="${{ steps.version.outputs.VERSION }}"
image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling"
echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker build -f Darling/Dockerfile -t "${image}:nightly" -t "${image}:${version}" .
# VERSION reaches the publish that runs INSIDE the image build (#3455): an image tagged
# with the nightly string whose service answers the clean release string is the zip
# defect again, one artifact over. The Dockerfile defaults the arg to empty, so
# build.yml's release image and any hand-run docker build keep publishing against the
# clean declared version.
docker build -f Darling/Dockerfile --build-arg VERSION="${version}" -t "${image}:nightly" -t "${image}:${version}" .
docker push "${image}:nightly"
docker push "${image}:${version}"

Expand Down
137 changes: 137 additions & 0 deletions Darling/Darling.Tests/NightlyVersionInjectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
using static Darling.Tests.RepoFile;

namespace Darling.Tests;

/// <summary>
/// Every build the nightly workflow publishes receives the derived nightly version as an msbuild
/// property, and no release build does (#3455).
///
/// <para><b>The defect this closes was a name without a stamp.</b> <c>nightly.yml</c> derives its
/// prerelease string once per run (#3390) and named every artifact with it, but no publish ever passed
/// it to msbuild, so the assemblies inside those artifacts stamped the clean release string
/// <c>Directory.Build.props</c> declares. The first live reading of #3453's <c>service</c> block —
/// built precisely to answer "is the running service the build that carries fix X" — came back bare
/// <c>3.7.0</c> on a <c>.438</c> nightly, on every store that had installed it. The informational
/// version is the one surface that read and the CLI's <c>--version</c> share, and it derives from the
/// version property at build time, so a publish that does not pass the property cannot stamp the
/// nightly, however correct the artifact's name is.</para>
///
/// <para><b>Why a pin and not the workflow comment alone.</b> The stamp's own #3390 rationale already
/// CLAIMED the binaries carried it while they did not — prose describing intent outlives the mechanics
/// it describes, which is the drift these source-parsing tests exist to catch. The routes enumerated:
/// a publish step arrives in <c>nightly.yml</c> without the flag, the ordinary fate of hand-copied
/// steps (#2384's checksum fix landing in one workflow and not the other is the local precedent); the
/// container's inner publish loses the arg threading on either side of the <c>docker build</c>
/// boundary, where each half alone reads as complete at its own file; and the inverse, a version
/// property leaking into <c>build.yml</c>, which would take the release binaries' stamp away from the
/// one declaration the release process bumps and check-version-bump.yml gates (#3222).</para>
/// </summary>
public sealed class NightlyVersionInjectionTests
{
private const string NightlyWorkflow = ".github/workflows/nightly.yml";
private const string ReleaseWorkflow = ".github/workflows/build.yml";
private const string ContainerFile = "Darling/Dockerfile";

/// <summary>The exact spelling every nightly publish must carry: the version step's own output,
/// not a re-derivation beside it — the same one-authority rule the version-set steps already obey
/// for the artifact names.</summary>
private const string VersionFlag = "-p:Version=${{ steps.version.outputs.VERSION }}";

[Fact]
public void EveryNightlyPublish_PassesTheDerivedVersionToMsBuild()
{
var offending = PublishLines(NightlyWorkflow)
.Where(line => !line.Contains(VersionFlag, StringComparison.Ordinal))
.ToList();

Assert.True(
offending.Count == 0,
"A nightly publish that does not pass the derived version to msbuild builds assemblies "
+ "stamping the clean release string inside an artifact named nightly, and two same-day "
+ "nightlies become indistinguishable at the read built to distinguish them (#3455):\n "
+ string.Join("\n ", offending));
}

[Fact]
public void NoReleasePublish_PassesAVersionProperty()
{
var offending = PublishLines(ReleaseWorkflow)
.Where(line => line.Contains("-p:Version", StringComparison.Ordinal))
.ToList();

Assert.True(
offending.Count == 0,
"A release publish passing a version property takes the release binaries' stamp away from "
+ "the one declaration the release process bumps (#3222). Release builds stay clean by "
+ "CONSTRUCTION — the property flows only where a prerelease stamp exists to flow:\n "
+ string.Join("\n ", offending));
}

[Fact]
public void TheNightlyContainerBuild_ThreadsTheVersionThroughTheImageBoundary()
{
/* Both halves or neither. The workflow passing an arg the Dockerfile does not declare stamps
nothing; the Dockerfile declaring an arg the workflow does not pass stamps the clean string.
Either failure leaves a :nightly image whose service answers with the release string, and
each half alone reads as complete at its own file. */
var dockerBuilds = ReadRepoFileLf(NightlyWorkflow)
.Split('\n')
.Select(line => line.Trim())
.Where(line => line.StartsWith("docker build", StringComparison.Ordinal))
.ToList();

var build = Assert.Single(dockerBuilds);
Assert.Contains("--build-arg VERSION=\"${version}\"", build, StringComparison.Ordinal);

/* Continuations joined before the line scan: the publish RUN is split across lines, and a
per-line scan would read the flag's line as not containing a publish and vice versa. */
var containerFile = ReadRepoFileLf(ContainerFile).Replace("\\\n", " ", StringComparison.Ordinal);

Assert.Contains("ARG VERSION=", containerFile, StringComparison.Ordinal);

var publishes = containerFile
.Split('\n')
.Where(line => line.Contains("dotnet publish", StringComparison.Ordinal))
.ToList();

var inner = Assert.Single(publishes);

/* The conditional expansion, not a bare flag: an empty arg must add NOTHING, so a release or
hand-run docker build keeps publishing against the declared version rather than against an
empty property that would override it with nothing. */
Assert.Contains("${VERSION:+-p:Version=$VERSION}", inner, StringComparison.Ordinal);
}

/// <summary>The dotnet publish commands in one workflow, comment lines excluded so a rationale
/// quoting a command is not judged as one.</summary>
private static List<string> PublishLines(string workflow)
{
var lines = ReadRepoFileLf(workflow)
.Split('\n')
.Select(line => line.Trim())
.Where(line => !line.StartsWith("#", StringComparison.Ordinal))
.Where(line => line.Contains("dotnet publish ", StringComparison.Ordinal))
.ToList();

/* Non-vacuity: nightly.yml publishes four windows trees and one linux tree, build.yml six.
A scan reading nothing must fail here rather than satisfy every assertion above by having
nothing to judge. */
Assert.True(
lines.Count >= 5,
$"only {lines.Count} publish lines found in {workflow}; the scan is not reading the file");

return lines;
}
}
1 change: 1 addition & 0 deletions Darling/Darling.Tests/RepoFileAdoptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public sealed class RepoFileAdoptionTests
"FleetCardCollectionStaleNamesItsPopulationTests.cs",
"FleetPageAttentionFilterTests.cs",
"LockedModeRestoreCoverageTests.cs",
"NightlyVersionInjectionTests.cs",
"PgCpuCapacityHeadroomTests.cs",
"PgIndexBloatGridReachTests.cs",
"PgLoggingCollectorOffTests.cs",
Expand Down
13 changes: 12 additions & 1 deletion Darling/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,21 @@
# container had an independent opinion about which SDK to use. Pinning removes that second opinion: bump
# this line and global.json together, deliberately, instead of being bumped by an upstream tag move.
FROM mcr.microsoft.com/dotnet/sdk:10.0.302 AS build

# The msbuild version property for the publish below, threaded in by nightly.yml (#3455) so the
# service compiled into a :nightly image stamps the same 3.7.0-nightly.<date>.<run> string its image
# tag carries. The informational version is what --version prints and what get_collection_health's
# service block serves, so a container answering with the clean release string while running a
# nightly is the defect that issue documents on the zip, one artifact over. Empty by default ON
# PURPOSE: the shell expansion below then adds no flag at all, the publish inherits the version
# Directory.Build.props declares, and every other consumer of this Dockerfile — build.yml's release
# image, a hand-run docker build — produces byte-for-byte the command it produced before the ARG
# existed.
ARG VERSION=
WORKDIR /src
COPY . .
RUN dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj \
-c Release -o /app/publish
-c Release -o /app/publish ${VERSION:+-p:Version=$VERSION}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor robustness nit: ${VERSION:+-p:Version=$VERSION} expands unquoted on the RUN command line. With the only current caller (nightly.yml's derived date.run_number stamp) this is safe, but it relies on that invariant — a future caller passing --build-arg VERSION with a space or shell metacharacter (e.g. someone hand-testing with a branch name or free-form string) would get word-split/glob-expanded in the /bin/sh -c that runs this RUN. Quoting the expansion is a no-op for the current value and closes that off:

Suggested change
-c Release -o /app/publish ${VERSION:+-p:Version=$VERSION}
RUN dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj \
-c Release -o /app/publish "${VERSION:+-p:Version=$VERSION}"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining as theoretical for now: the sole caller passes a token that cannot contain whitespace (date.run_number), and the suggested fix — quoting the whole expansion — is itself wrong, since an empty VERSION would then pass a literal empty-string argument and break the byte-identical-command property this file's own comment pins. The correct form when this line is next touched is the inner-quoted ${VERSION:+"-p:Version=$VERSION"} (expansion unquoted, payload quoted), which passes zero args when unset and one arg when set. Recording it here so the next editor inherits the right fix instead of the plausible one.


FROM mcr.microsoft.com/dotnet/aspnet:10.0
# Microsoft.Data.SqlClient probes the Kerberos GSSAPI library at connect time on Linux — even for SQL
Expand Down
Loading