From 5deebdfd6dfbb01cb77d1dceaa00ad0af0e5edf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 11 Nov 2026 08:12:04 +0100 Subject: [PATCH] ci: the release workflow runs every lane of dotnet.yml on the tagged commit before it packs (#342) release.yml ran the suite once, on PostgreSQL 16, and relied on the commit's pull request for PostgreSQL 18, the own-feed smoke test and, on release/10.x, the EF Core float legs. Nothing ties a tag to a commit whose pull request or push run was green. - dotnet.yml gains workflow_call, and release.yml a `ci` job that uses it. `verify` needs `ci` and loses its own test step. - dotnet.yml's concurrency group gets a `dotnet-` prefix. Called from the release workflow it was `Release-`, which GitHub compares without case to release.yml's own `release-`: the called jobs would have waited for their caller. - ReleaseWiringConventionTests holds both: the call and the dependency, and that the two groups cannot be equal. Co-Authored-By: Claude Fable 5.2 --- .github/workflows/dotnet.yml | 11 ++- .github/workflows/release.yml | 36 +++++---- .../ReleaseWiringConventionTests.cs | 77 +++++++++++++++++++ 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 840ce4b5..9e85cd82 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -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 @@ -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-`, and group names are compared without case: it would be the group release.yml +# itself holds (`release-`), 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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36938403..781e8ba4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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. # @@ -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 @@ -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 diff --git a/test/CodoMetis.ValueRanges.Conventions.Tests/ReleaseWiringConventionTests.cs b/test/CodoMetis.ValueRanges.Conventions.Tests/ReleaseWiringConventionTests.cs index 07f79caf..67ff7a0c 100644 --- a/test/CodoMetis.ValueRanges.Conventions.Tests/ReleaseWiringConventionTests.cs +++ b/test/CodoMetis.ValueRanges.Conventions.Tests/ReleaseWiringConventionTests.cs @@ -254,6 +254,83 @@ public void TheIntegrationGate_PassesOnlyWhenEveryLegPassed() StringAssert.Contains(gate, "needs.integration-legs.result }}\" = success"); } + /// + /// 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. + /// + /// + /// 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. + /// + [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)^ (?[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*(?.+?)\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."); + } + + /// + /// The called .NET workflow must not share a concurrency group with the release run that calls it. + /// + /// + /// In a called workflow github.workflow is the caller's name, and GitHub compares group + /// names without case. A group of ${{ github.workflow }}-${{ github.ref }} in dotnet.yml + /// is therefore Release-<ref> under the release workflow, the very group release.yml + /// holds as release-<ref>: 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. + /// + [TestMethod] + public void TheCalledBuildWorkflow_NeverSharesTheReleaseWorkflowsConcurrencyGroup() + { + const string reference = "refs/tags/v1.2.3"; + + var releaseName = Regex.Match(ReleaseWorkflowText, @"(?m)^name:\s*(?.+?)\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*(?.+?)\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."); + } + /// /// The settings of one trigger under on:, comments removed: whatever follows its colon on /// the same line (a flow mapping such as { branches: [ main ] }), and every following line