Skip to content
Open
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
11 changes: 10 additions & 1 deletion .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ on:
branches: [ "main", "release/**" ]
pull_request:
workflow_dispatch:
# The release workflow calls this file, so a tag publishes only after every lane here has passed
# on that exact commit (release.yml, job `ci`; #342).
workflow_call:

permissions:
contents: read
Expand All @@ -54,8 +57,14 @@ defaults:
shell: bash

# A new push to a PR makes the in-flight run pointless; main is never cancelled.
#
# The `dotnet-` prefix is there for the release workflow's call (#342). In a called workflow
# `github.workflow` is the caller's name, so without the prefix this group would be
# `Release-<ref>`, and group names are compared without case: it would be the group release.yml
# itself holds (`release-<ref>`), and the called jobs would wait for their own caller for ever.
# ReleaseWiringConventionTests evaluates both groups and fails when they can be equal.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: dotnet-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
Expand Down
36 changes: 23 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@
# nuget.org validates it against a policy you configure there, and issues an API key valid for
# one hour. A compromise of this repository's secrets yields nothing that can publish.
#
# Three jobs on purpose: everything that can fail runs unattended, only the push waits behind
# the environment's approval gate, and the GitHub release is created afterwards. A reviewer is
# asked once the full suite has already passed, and no OIDC token is minted until they approve.
# Four jobs on purpose: everything that can fail runs unattended (`ci`, then `verify`), only the
# push waits behind the environment's approval gate, and the GitHub release is created afterwards.
# A reviewer is asked once the full suite has already passed, and no OIDC token is minted until
# they approve.
#
# `ci` is the .NET workflow itself (dotnet.yml), called on the commit being released: both
# PostgreSQL legs, the consumer smoke test against a feed of its own, the pack checks, and on
# release/10.x that branch's EF Core float legs. A tag is not trusted to sit on a commit whose
# pull request ran them, or whose push run passed (#342). `verify` then packs what is published
# and tests those very packages.
# The job that can mint a token cannot write to the repository, and the job that can write to
# the repository cannot mint a token.
#
Expand Down Expand Up @@ -69,9 +76,19 @@ env:
NUGET_SOURCE: https://api.nuget.org/v3/index.json

jobs:
# Every lane of dotnet.yml as it is on this commit. The called workflow has a concurrency group
# of its own (`dotnet-…`), which must never equal this file's: the called jobs would then queue
# behind the run that is waiting for them.
ci:
name: CI
uses: ./.github/workflows/dotnet.yml
permissions:
contents: read

verify:
name: Verify and pack
runs-on: ubuntu-latest
needs: ci

permissions:
contents: read # deliberately no id-token here: nothing in this job may mint a token
Expand Down Expand Up @@ -113,16 +130,9 @@ jobs:
- name: Build
run: dotnet build -c Release

# The full suite gates the release, which also means the conventions project runs: a
# version with no changelog entry at the root or in a package changelog fails here, as
# does a package that lost its README or its symbols.
# CI=true keeps the live-PostgreSQL layer mandatory. It runs on the default server, PostgreSQL 16,
# the 10.x floor; every pull request already ran both integration projects on 16 and 18.
- name: Test
env:
CI: true
run: dotnet test -c Release --no-build

# No test step here: `ci` ran the whole suite on this commit, on PostgreSQL 16 and 18. That
# includes the conventions project, so a version with no changelog entry at the root or in a
# package changelog has already failed, as has a package that lost its README or its symbols.
- name: Pack
run: dotnet pack -c Release --no-build -o dist

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,83 @@ public void TheIntegrationGate_PassesOnlyWhenEveryLegPassed()
StringAssert.Contains(gate, "needs.integration-legs.result }}\" = success");
}

/// <summary>
/// A tag publishes only after every lane of the .NET workflow has passed on the tagged commit
/// (#342). The release workflow used to run the suite once, on PostgreSQL 16, and rely on the
/// commit's pull request for PostgreSQL 18, the own-feed smoke test and the 10.x float legs.
/// Nothing ties a tag to a commit whose pull request, or whose push run, was green.
/// </summary>
/// <remarks>
/// Three parts, and each one alone leaves the release unguarded without an error: the .NET
/// workflow must be callable, the release workflow must call it, and the job that packs must
/// wait for the call.
/// </remarks>
[TestMethod]
public void TheReleaseWorkflow_RunsEveryLaneBeforeItPacks()
{
Assert.IsNotNull(
TriggerSettings(BuildWorkflowText(), "workflow_call"),
"dotnet.yml has no workflow_call trigger, so the release workflow cannot run its lanes.");

var caller = Regex.Match(
ReleaseWorkflowText,
@"(?ms)^ (?<job>[A-Za-z0-9_-]+):\n(?:(?!^ \S).)*?^ uses:\s*\./\.github/workflows/dotnet\.yml\s*$");

Assert.IsTrue(caller.Success, "No job in release.yml uses ./.github/workflows/dotnet.yml.");

var verifyJob = Regex.Match(ReleaseWorkflowText, @"(?ms)^ verify:.*?(?=^ \S)").Value;
var needs = Regex.Match(verifyJob, @"(?m)^ needs:\s*(?<jobs>.+?)\s*$").Groups["jobs"].Value;

Assert.IsTrue(
Regex.IsMatch(needs, $@"(^|[\[\s,]){Regex.Escape(caller.Groups["job"].Value)}($|[\]\s,])"),
$"The verify job (needs: {(needs.Length == 0 ? "nothing" : needs)}) does not wait for "
+ $"'{caller.Groups["job"].Value}', the job that runs dotnet.yml. The packages would be packed "
+ "and offered for approval whatever the lanes did.");
}

/// <summary>
/// The called .NET workflow must not share a concurrency group with the release run that calls it.
/// </summary>
/// <remarks>
/// In a called workflow <c>github.workflow</c> is the caller's name, and GitHub compares group
/// names without case. A group of <c>${{ github.workflow }}-${{ github.ref }}</c> in dotnet.yml
/// is therefore <c>Release-&lt;ref&gt;</c> under the release workflow, the very group release.yml
/// holds as <c>release-&lt;ref&gt;</c>: the called jobs queue behind their own caller and the run
/// never ends. Both groups are evaluated here for the release workflow's name and one ref.
/// </remarks>
[TestMethod]
public void TheCalledBuildWorkflow_NeverSharesTheReleaseWorkflowsConcurrencyGroup()
{
const string reference = "refs/tags/v1.2.3";

var releaseName = Regex.Match(ReleaseWorkflowText, @"(?m)^name:\s*(?<name>.+?)\s*$").Groups["name"].Value;
Assert.AreNotEqual(0, releaseName.Length, "release.yml has no name.");

string GroupOf(string workflow, string file)
{
var group = Regex.Match(workflow, @"(?m)^concurrency:\s*\n(?:\s*#.*\n)*\s+group:\s*(?<group>.+?)\s*$").Groups["group"].Value;
Assert.AreNotEqual(0, group.Length, $"{file} has no workflow-level concurrency group.");

var evaluated = group.Replace("${{ github.workflow }}", releaseName, StringComparison.Ordinal)
.Replace("${{ github.ref }}", reference, StringComparison.Ordinal);

Assert.IsFalse(
evaluated.Contains("${{", StringComparison.Ordinal),
$"{file}'s concurrency group ({group}) uses an expression this test does not evaluate; teach it.");

return evaluated;
}

var called = GroupOf(BuildWorkflowText(), "dotnet.yml");
var calling = GroupOf(ReleaseWorkflowText, "release.yml");

Assert.IsFalse(
string.Equals(called, calling, StringComparison.OrdinalIgnoreCase),
$"Called from the release workflow, dotnet.yml's concurrency group is '{called}', and release.yml "
+ $"holds '{calling}'. GitHub compares group names without case, so the called jobs would wait for "
+ "the run that is waiting for them.");
}

/// <summary>
/// The settings of one trigger under <c>on:</c>, comments removed: whatever follows its colon on
/// the same line (a flow mapping such as <c>{ branches: [ main ] }</c>), and every following line
Expand Down
Loading