From e93b6c8907ed58912c83647d1d3349c0805151d0 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Thu, 22 Oct 2026 11:24:39 +0200 Subject: [PATCH 01/12] Move github.tokenEnv to hosts.github, and add repository settings buildvana.jsonc names no repository for bv release, so a release goes to whatever repository the origin remote of the clone names. repository.url and repository.host name the target repository and its kind of Git host. hosts.github replaces the top-level github section, so that each kind of host gets a section of its own. release.allowUnknownHost and --allow-unknown-host let a release proceed on a host that bv does not know. Nothing reads repository.url, repository.host, or release.allowUnknownHost yet. buildvana.jsonc drops its github section, whose value is the default, so that buildvana.next.jsonc can state hosts.github and still pass RepositoryConfigFilesTests. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 2 +- buildvana.example.jsonc | 24 ++++++++-- buildvana.jsonc | 10 +--- buildvana.next.jsonc | 21 ++++++--- docs/configuration-file.md | 8 +++- docs/environment-variables.md | 2 +- docs/tool-commands/release.md | 3 +- schemas/buildvana.schema.json | 46 ++++++++++++++++--- .../BuildvanaConfigFactory.cs | 20 +++++++- .../BuildvanaJsonConfig.cs | 11 +++-- .../CommandLineOverrides.cs | 3 ++ .../GitHubJsonConfig.cs | 2 +- .../HostsJsonConfig.cs | 20 ++++++++ .../ReleaseJsonConfig.cs | 4 ++ .../RepositoryJsonConfig.cs | 30 ++++++++++++ src/Buildvana.Runtime/BuildvanaConfig.cs | 8 ++-- src/Buildvana.Runtime/GitHubConfig.cs | 2 +- src/Buildvana.Runtime/HostKind.cs | 20 ++++++++ src/Buildvana.Runtime/HostsConfig.cs | 21 +++++++++ src/Buildvana.Runtime/PublicAPI.Unshipped.txt | 38 ++++++++++++++- src/Buildvana.Runtime/ReleaseConfig.cs | 6 +++ src/Buildvana.Runtime/RepositoryConfig.cs | 27 +++++++++++ .../CommandLine/CommandLineOverridesParser.cs | 1 + .../Internal/GitHub/GitHubServerAdapter.cs | 2 +- .../Subcommands/ReleaseSettings.cs | 10 +++- .../BuildvanaConfigFactoryTests.cs | 34 ++++++++++---- .../HookArgsSecretsTests.cs | 2 +- .../CommandLineOverridesParserTests.cs | 4 +- .../ReleaseSettingsTests.cs | 4 +- .../SettingsHelpReflectionTests.cs | 2 +- 32 files changed, 332 insertions(+), 59 deletions(-) create mode 100644 src/Buildvana.Core.Configuration/HostsJsonConfig.cs create mode 100644 src/Buildvana.Core.Configuration/RepositoryJsonConfig.cs create mode 100644 src/Buildvana.Runtime/HostKind.cs create mode 100644 src/Buildvana.Runtime/HostsConfig.cs create mode 100644 src/Buildvana.Runtime/RepositoryConfig.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5576c7dd..726ab3aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' DOTNET_CLI_UI_LANGUAGE: 'en-US' FEEDZ_IO_PRIVATE_TOKEN: ${{ secrets.FEEDZ_IO_PRIVATE_TOKEN }} - # bv reads its GitHub token from the environment variable named by github.tokenEnv in buildvana.jsonc. + # bv reads its GitHub token from the environment variable named by hosts.github.tokenEnv in buildvana.jsonc. # The automatic token is enough here: no release operation is performed. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78e43265..3f43382d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' DOTNET_CLI_UI_LANGUAGE: 'en-US' # Secrets consumed by bv. Which environment variable carries each one is named in buildvana.jsonc: - # - github.tokenEnv (implicitly GITHUB_TOKEN) holds the token used for release operations and Git pushes; + # - hosts.github.tokenEnv (implicitly GITHUB_TOKEN) holds the token used for release operations and Git pushes; # here it is the release PAT, which - unlike the automatic token - can push to a protected branch. # - nuget.feeds.prerelease.apiKeyEnv is FEEDZ_IO_PRIVATE_TOKEN (public Feedz.io preview feed); # - nuget.feeds.release.apiKeyEnv is NUGET_DEPLOYMENT_KEY (nuget.org). diff --git a/CHANGELOG.md b/CHANGELOG.md index 52da17ed..e456df63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - `release.checkPublicApi` and `release.dogfood` set the defaults of [`--check-public-api` and `--dogfood`](docs/tool-commands/release.md#options). - A setting with an option [resolves](docs/configuration-file.md#resolution) as the option, then `buildvana.jsonc`, then the built-in default, and a blank string counts as not stated. - `nuget.feeds.release` and `nuget.feeds.prerelease` name the [push feeds of `bv release`](docs/tool-commands/release.md#publishing), each with a `source` and an `apiKeyEnv`. -- `github.tokenEnv` names the [environment variable holding the GitHub token](docs/environment-variables.md#secret-carrying-variables-named-by-the-configuration-file), `GITHUB_TOKEN` by default, and `buildvana.jsonc` holds no secret. +- `hosts.github.tokenEnv` names the [environment variable holding the GitHub token](docs/environment-variables.md#secret-carrying-variables-named-by-the-configuration-file), `GITHUB_TOKEN` by default, and `buildvana.jsonc` holds no secret. - `git.identity` names the [author of the commits of `bv release`](docs/tool-commands/release.md#preconditions), with `name` and `email` both required. - `buildvana.jsonc` accepts a [`dependencies` section](docs/tool-commands/dependencies.md#update-policies), which sets the update policy of each dependency and names the files that hold additional package pins. - The [`ThisAssemblyClass` module](docs/sdk-modules/this-assembly-class.md) generates a `ThisAssembly` class in a C# project that sets `GenerateThisAssemblyClass` to `true`. diff --git a/buildvana.example.jsonc b/buildvana.example.jsonc index 5e8275f2..3c597d87 100644 --- a/buildvana.example.jsonc +++ b/buildvana.example.jsonc @@ -12,6 +12,15 @@ // URI of the JSON schema describing this file. Pin the bv version you use. "$schema": "https://raw.githubusercontent.com/Tenacom/Buildvana/main/schemas/buildvana.schema.json", + // The repository that releases go to. + "repository": { + // URL of the repository that releases go to. Required by bv release. + "url": "https://github.com/Tenacom/Buildvana", + + // Kind of Git host of the repository. + "host": "auto", + }, + // Configuration for the bv release workflow. "release": { // Anchored regular expressions matching public-release branches. @@ -31,6 +40,10 @@ // Whether self-references are updated (dogfooding) during a release. "dogfood": true, + + // Whether a release tags and pushes on an unknown Git host, without a + // release page. + "allowUnknownHost": false, }, // Configuration for version computation. @@ -126,10 +139,13 @@ }, }, - // Configuration for GitHub integration. - "github": { - // Name of the environment variable that holds the GitHub access token. - "tokenEnv": "GITHUB_TOKEN", + // Configuration for each kind of Git host. + "hosts": { + // Configuration for the GitHub host. + "github": { + // Name of the environment variable that holds the GitHub access token. + "tokenEnv": "GITHUB_TOKEN", + }, }, // Configuration for Git-related behavior. diff --git a/buildvana.jsonc b/buildvana.jsonc index b84aa9c7..e6dd581d 100644 --- a/buildvana.jsonc +++ b/buildvana.jsonc @@ -73,15 +73,7 @@ }, }, - "github": { - // Also the default. The release workflow points it at a PAT that can push - // to a protected branch; the build workflow leaves it at the automatic - // token, which pull requests from forks also get. - "tokenEnv": "GITHUB_TOKEN", - }, - - // No "git": automated commits use the CI bot identity supplied by the - // server adapter. + // No "git": automated commits use the bot identity of the CI platform. "dependencies": { // No "scopes": the built-in defaults are the ones this repository wants. diff --git a/buildvana.next.jsonc b/buildvana.next.jsonc index 68f709af..070727ef 100644 --- a/buildvana.next.jsonc +++ b/buildvana.next.jsonc @@ -25,6 +25,12 @@ // buildvana.next.jsonc, main: the version it describes does not exist yet. "$schema": "https://raw.githubusercontent.com/Tenacom/Buildvana/main/schemas/buildvana.schema.json", + "repository": { + "url": "https://github.com/Tenacom/Buildvana", + + // No "host": the default, "auto", recognizes github.com. + }, + "release": { "branches": ["^main$", "^v\\d+\\.\\d+$"], @@ -73,15 +79,16 @@ }, }, - "github": { - // Also the default. The release workflow points it at a PAT that can push - // to a protected branch; the build workflow leaves it at the automatic - // token, which pull requests from forks also get. - "tokenEnv": "GITHUB_TOKEN", + "hosts": { + "github": { + // Also the default. The release workflow points it at a PAT that can + // push to a protected branch; the build workflow leaves it at the + // automatic token, which pull requests from forks also get. + "tokenEnv": "GITHUB_TOKEN", + }, }, - // No "git": automated commits use the CI bot identity supplied by the - // server adapter. + // No "git": automated commits use the bot identity of the CI platform. "dependencies": { // No "scopes": the built-in defaults are the ones this repository wants. diff --git a/docs/configuration-file.md b/docs/configuration-file.md index dd540d85..779e5763 100644 --- a/docs/configuration-file.md +++ b/docs/configuration-file.md @@ -38,13 +38,14 @@ A hook reads the settings of its run from [its args](hooks.md#the-repository-con Every setting has a built-in default, and a repository with no configuration file runs on the defaults. A setting the file states replaces its default. -Four settings also have a command-line option, and the option replaces both: +Five settings also have a command-line option, and the option replaces both: | Option | Setting | | ----------------------- | -------------------------------------------------- | | `-c`, `--configuration` | `dotnet.configuration` and `release.configuration` | | `--check-public-api` | `release.checkPublicApi` | | `--dogfood` | `release.dogfood` | +| `--allow-unknown-host` | `release.allowUnknownHost` | A blank or all-whitespace string is not a value. A blank optional setting counts as not stated, so the next tier applies. @@ -84,12 +85,15 @@ A setting whose member names are data, such as `dependencies.policies` and `dotn | Setting | Default | Meaning | | ------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `$schema` | none | The URL of the JSON schema, whose version segment [`bv self-update`](tool-commands/self-update.md#what-the-command-updates) moves with the other pins. | +| `repository.url` | none | The URL of the repository that [`bv release`](tool-commands/release.md#preconditions) publishes to, and without it `bv release` fails. | +| `repository.host` | `auto` | The [kind of Git host](tool-commands/release.md#publishing) of the repository: `auto` or `github`. | | `release.branches` | none | The regular expressions of the branches that produce [public releases](sdk-modules/versioning.md#releasebranches-setting), and by default no branch does. | | `release.configuration` | `dotnet.configuration` | The [build configuration of `bv release`](tool-commands/release.md#the-build-configuration). | | `release.checkPublicApi` | `true` | Whether the public API files take part in [the version spec change](tool-commands/release.md#the-version-spec-change) of `bv release`. | | `release.changelogUpdates` | `stable` | Which releases [update the changelog](tool-commands/release.md#the-changelog): `none`, `stable`, or `all`. | | `release.emptyChangelog` | none | The text of the [new changelog section](tool-commands/release.md#the-changelog) when "Unreleased changes" is empty, and without it such a release fails. | | `release.dogfood` | `true` | Whether `bv release` rewrites the self-references in [the post-release commit](tool-commands/release.md#the-post-release-commit). | +| `release.allowUnknownHost` | `false` | Whether `bv release` [tags and pushes without a release page](tool-commands/release.md#publishing) on a Git host it does not know. | | `versioning.prereleaseTag` | none | The [prerelease tag](sdk-modules/versioning.md#versioningprereleasetag-setting) of a prerelease version, and without it no prerelease version is allowed. | | `versioning.assemblyVersionPrecision` | `major` | How many [version components](sdk-modules/versioning.md#versioningassemblyversionprecision-setting) the assembly version carries. | | `dotnet.configuration` | `Release` | The [build configuration](tool-commands/build-pipeline.md#the-build-configuration) of the build pipeline. | @@ -109,7 +113,7 @@ A setting whose member names are data, such as `dependencies.policies` and `dotn | `nuget.feeds.prerelease.apiKeyEnv` | `nuget.feeds.release.apiKeyEnv` | The environment variable holding the [API key](environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) of the prerelease feed. | | `nuget.feeds.release.source` | none | The URL of the feed that [stable versions](tool-commands/release.md#publishing) are pushed to. | | `nuget.feeds.release.apiKeyEnv` | none | The environment variable holding the [API key](environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) of the release feed. | -| `github.tokenEnv` | `GITHUB_TOKEN` | The environment variable holding the [GitHub token](environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) of the release operations. | +| `hosts.github.tokenEnv` | `GITHUB_TOKEN` | The environment variable holding the [GitHub token](environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) of the release operations. | | `git.identity.name` | none | The name of the [author of the commits of `bv release`](tool-commands/release.md#preconditions). | | `git.identity.email` | none | The email address of the [author of the commits of `bv release`](tool-commands/release.md#preconditions). | | `dependencies.scopes.netsdk` | `major` | The [update policy](tool-commands/dependencies.md#update-policies) of the `netsdk` scope, the .NET SDK version in `global.json`. | diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 4b211008..6f9c5a76 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -128,7 +128,7 @@ On Windows, a console mode answers the question, and `bv` does not read `TERM`. `bv` stores no secret. The [configuration file](configuration-file.md) names the environment variable that carries each one, and `bv` reads the value where it uses it. -- `github.tokenEnv` names the variable holding the GitHub token of the release operations. +- `hosts.github.tokenEnv` names the variable holding the GitHub token of the release operations. The default name is `GITHUB_TOKEN`. - `nuget.feeds.release.apiKeyEnv` and `nuget.feeds.prerelease.apiKeyEnv` name the variables holding the API keys of the NuGet push feeds. diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index b52329bc..d166dde5 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -42,7 +42,7 @@ Before building anything, `bv release` checks what a build cannot change: On GitHub Actions, the bot identity is `github-actions[bot]`. On GitLab CI, it is `GitLab CI`, with the address `gitlab-ci@noreply.` built from [`CI_SERVER_HOST`](../environment-variables.md#ci_server_host). 3. The platform supplies push credentials. - On GitHub Actions, they are the token that [`github.tokenEnv`](../environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) names. + On GitHub Actions, they are the token that [`hosts.github.tokenEnv`](../environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) names. Without them, `bv` warns that the push may fail, and goes on. 4. The published versions are consistent: the latest tag is not below the latest stable tag, and the current version is below neither. 5. The version spec change is computed from `--bump` and the public API files, as [`bv version advance`](version.md#bv-version-advance) computes it. @@ -201,6 +201,7 @@ Once the release is published and the step output is written, nothing is undone. | `--bump ` | The version spec change to request: `none`, `unstable`, `stable`, `minor`, or `major`. | | `--check-public-api ` | Whether the public API files take part in the version spec change. Defaults to `release.checkPublicApi`. | | `--dogfood ` | Whether the self-references are rewritten. Defaults to `release.dogfood`. | +| `--allow-unknown-host ` | Whether a release proceeds on an unknown Git host. Defaults to `release.allowUnknownHost`. | `bv release` refuses a `--` separator and everything after it, because it forwards nothing. diff --git a/schemas/buildvana.schema.json b/schemas/buildvana.schema.json index 6ed9b122..0624c874 100644 --- a/schemas/buildvana.schema.json +++ b/schemas/buildvana.schema.json @@ -10,6 +10,28 @@ ], "type": "string" }, + "repository": { + "description": "The repository that releases go to.", + "type": "object", + "properties": { + "url": { + "description": "URL of the repository that releases go to. Required by bv release.", + "examples": [ + "https://github.com/Tenacom/Buildvana" + ], + "type": "string" + }, + "host": { + "description": "Kind of Git host of the repository.", + "enum": [ + "auto", + "github" + ], + "default": "auto" + } + }, + "additionalProperties": false + }, "release": { "description": "Configuration for the bv release workflow.", "type": "object", @@ -59,6 +81,11 @@ "description": "Whether self-references are updated (dogfooding) during a release.", "type": "boolean", "default": true + }, + "allowUnknownHost": { + "description": "Whether a release tags and pushes on an unknown Git host, without a release page.", + "type": "boolean", + "default": false } }, "additionalProperties": false @@ -329,14 +356,21 @@ }, "additionalProperties": false }, - "github": { - "description": "Configuration for GitHub integration.", + "hosts": { + "description": "Configuration for each kind of Git host.", "type": "object", "properties": { - "tokenEnv": { - "description": "Name of the environment variable that holds the GitHub access token.", - "type": "string", - "default": "GITHUB_TOKEN" + "github": { + "description": "Configuration for the GitHub host.", + "type": "object", + "properties": { + "tokenEnv": { + "description": "Name of the environment variable that holds the GitHub access token.", + "type": "string", + "default": "GITHUB_TOKEN" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/src/Buildvana.Core.Configuration/BuildvanaConfigFactory.cs b/src/Buildvana.Core.Configuration/BuildvanaConfigFactory.cs index a53c1a9a..348753e7 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaConfigFactory.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaConfigFactory.cs @@ -32,11 +32,12 @@ public static BuildvanaConfig Create(BuildvanaJsonConfig? json, CommandLineOverr { var dotNet = ComposeDotNet(json?.DotNet, commandLine); return new BuildvanaConfig { + Repository = ComposeRepository(json?.Repository), Release = ComposeRelease(json?.Release, commandLine, dotNet), Versioning = ComposeVersioning(json?.Versioning), DotNet = dotNet, NuGet = ComposeNuGet(json?.NuGet), - GitHub = ComposeGitHub(json?.GitHub), + Hosts = ComposeHosts(json?.Hosts), Git = ComposeGit(json?.Git), Dependencies = ComposeDependencies(json?.Dependencies), FileBasedApps = ComposeFileBasedApps(json?.FileBasedApps), @@ -96,6 +97,17 @@ private static DotNetInvocationConfig ComposeInvocation( return result; } + // A blank URL names no repository, so it counts as not stated: bv release refuses to run, as it does when + // the section is missing. + private static RepositoryConfig ComposeRepository(RepositoryJsonConfig? json) + { + var defaults = new RepositoryConfig(); + return new RepositoryConfig { + Url = NormalizeBlankToNull(json?.Url), + Host = json?.Host ?? defaults.Host, + }; + } + // release.configuration falls back to the resolved dotnet.configuration, so a release builds with the // general build configuration unless one is stated for releases specifically. private static ReleaseConfig ComposeRelease( @@ -116,6 +128,7 @@ private static ReleaseConfig ComposeRelease( // release with the same actionable message as a missing release.emptyChangelog. EmptyChangelog = NormalizeBlankToNull(json?.EmptyChangelog), Dogfood = commandLine?.Dogfood ?? json?.Dogfood ?? defaults.Dogfood, + AllowUnknownHost = commandLine?.AllowUnknownHost ?? json?.AllowUnknownHost ?? defaults.AllowUnknownHost, }; } @@ -153,6 +166,11 @@ private static NuGetConfig ComposeNuGet(NuGetJsonConfig? json) ApiKeyEnv = json.ApiKeyEnv, }; + private static HostsConfig ComposeHosts(HostsJsonConfig? json) + => new() { + GitHub = ComposeGitHub(json?.GitHub), + }; + // A blank tokenEnv cannot name a variable, so it counts as not stated at all. private static GitHubConfig ComposeGitHub(GitHubJsonConfig? json) { diff --git a/src/Buildvana.Core.Configuration/BuildvanaJsonConfig.cs b/src/Buildvana.Core.Configuration/BuildvanaJsonConfig.cs index 2646795a..5838f2e3 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaJsonConfig.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaJsonConfig.cs @@ -49,6 +49,10 @@ public sealed record BuildvanaJsonConfig [Description("URI of the JSON schema describing this file. Pin the bv version you use.")] public string? Schema { get; init; } + /// Gets the repository section. + [Description("The repository that releases go to.")] + public RepositoryJsonConfig? Repository { get; init; } + /// Gets the release section. [Description("Configuration for the bv release workflow.")] public ReleaseJsonConfig? Release { get; init; } @@ -67,10 +71,9 @@ public sealed record BuildvanaJsonConfig [Description("Configuration for NuGet package publishing.")] public NuGetJsonConfig? NuGet { get; init; } - /// Gets the github section. - [JsonPropertyName("github")] - [Description("Configuration for GitHub integration.")] - public GitHubJsonConfig? GitHub { get; init; } + /// Gets the hosts section. + [Description("Configuration for each kind of Git host.")] + public HostsJsonConfig? Hosts { get; init; } /// Gets the git section. [Description("Configuration for Git-related behavior.")] diff --git a/src/Buildvana.Core.Configuration/CommandLineOverrides.cs b/src/Buildvana.Core.Configuration/CommandLineOverrides.cs index 3355c044..7e0a26f8 100644 --- a/src/Buildvana.Core.Configuration/CommandLineOverrides.cs +++ b/src/Buildvana.Core.Configuration/CommandLineOverrides.cs @@ -30,6 +30,9 @@ public sealed record CommandLineOverrides /// Gets the dogfooding override (--dogfood). public bool? Dogfood { get; init; } + /// Gets the unknown-host override (--allow-unknown-host). + public bool? AllowUnknownHost { get; init; } + /// /// Gets the arguments forwarded after the -- separator, or when none were. /// The factory appends them to the resolved arguments of the pipeline commands (restore, diff --git a/src/Buildvana.Core.Configuration/GitHubJsonConfig.cs b/src/Buildvana.Core.Configuration/GitHubJsonConfig.cs index 390ce472..3193110d 100644 --- a/src/Buildvana.Core.Configuration/GitHubJsonConfig.cs +++ b/src/Buildvana.Core.Configuration/GitHubJsonConfig.cs @@ -7,7 +7,7 @@ namespace Buildvana.Core.Configuration; /// -/// Models the github section of a Buildvana configuration file. +/// Models the hosts.github section of a Buildvana configuration file. /// [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public sealed record GitHubJsonConfig diff --git a/src/Buildvana.Core.Configuration/HostsJsonConfig.cs b/src/Buildvana.Core.Configuration/HostsJsonConfig.cs new file mode 100644 index 00000000..b285100e --- /dev/null +++ b/src/Buildvana.Core.Configuration/HostsJsonConfig.cs @@ -0,0 +1,20 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.ComponentModel; +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace Buildvana.Core.Configuration; + +/// +/// Models the hosts section of a Buildvana configuration file. +/// +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public sealed record HostsJsonConfig +{ + /// Gets the hosts.github section. + [JsonPropertyName("github")] + [Description("Configuration for the GitHub host.")] + public GitHubJsonConfig? GitHub { get; init; } +} diff --git a/src/Buildvana.Core.Configuration/ReleaseJsonConfig.cs b/src/Buildvana.Core.Configuration/ReleaseJsonConfig.cs index 0859b148..6aa88afa 100644 --- a/src/Buildvana.Core.Configuration/ReleaseJsonConfig.cs +++ b/src/Buildvana.Core.Configuration/ReleaseJsonConfig.cs @@ -42,4 +42,8 @@ public sealed record ReleaseJsonConfig /// Gets a value indicating whether self-references are updated (dogfooding) during a release. [Description("Whether self-references are updated (dogfooding) during a release.")] public bool? Dogfood { get; init; } + + /// Gets a value indicating whether a release proceeds on a Git host bv does not know. + [Description("Whether a release tags and pushes on an unknown Git host, without a release page.")] + public bool? AllowUnknownHost { get; init; } } diff --git a/src/Buildvana.Core.Configuration/RepositoryJsonConfig.cs b/src/Buildvana.Core.Configuration/RepositoryJsonConfig.cs new file mode 100644 index 00000000..d6552df7 --- /dev/null +++ b/src/Buildvana.Core.Configuration/RepositoryJsonConfig.cs @@ -0,0 +1,30 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Buildvana.Core.Json.Schema; +using Buildvana.Runtime; +using JetBrains.Annotations; + +namespace Buildvana.Core.Configuration; + +/// +/// Models the repository section of a Buildvana configuration file. +/// +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public sealed record RepositoryJsonConfig +{ + /// Gets the URL of the repository that the releases go to. + [JsonSchemaExample("\"https://github.com/Tenacom/Buildvana\"")] + [Description("URL of the repository that releases go to. Required by bv release.")] + [SuppressMessage( + "Design", + "CA1056:URI-like properties should not be strings", + Justification = "Git accepts the scp-like syntax, such as git@host:owner/repo, which System.Uri cannot hold.")] + public string? Url { get; init; } + + /// Gets the kind of Git host of the repository. + [Description("Kind of Git host of the repository.")] + public HostKind? Host { get; init; } +} diff --git a/src/Buildvana.Runtime/BuildvanaConfig.cs b/src/Buildvana.Runtime/BuildvanaConfig.cs index 9cb73fc6..7d88661c 100644 --- a/src/Buildvana.Runtime/BuildvanaConfig.cs +++ b/src/Buildvana.Runtime/BuildvanaConfig.cs @@ -21,6 +21,9 @@ namespace Buildvana.Runtime; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public sealed record BuildvanaConfig { + /// Gets the resolved configuration of the repository that the releases go to. + public RepositoryConfig Repository { get; init; } = new(); + /// Gets the resolved release-workflow configuration. public ReleaseConfig Release { get; init; } = new(); @@ -35,9 +38,8 @@ public sealed record BuildvanaConfig [JsonPropertyName("nuget")] public NuGetConfig NuGet { get; init; } = new(); - /// Gets the resolved GitHub integration configuration. - [JsonPropertyName("github")] - public GitHubConfig GitHub { get; init; } = new(); + /// Gets the resolved configuration of the Git hosts. + public HostsConfig Hosts { get; init; } = new(); /// Gets the resolved Git configuration. public GitConfig Git { get; init; } = new(); diff --git a/src/Buildvana.Runtime/GitHubConfig.cs b/src/Buildvana.Runtime/GitHubConfig.cs index 4b896086..2f5ef5ec 100644 --- a/src/Buildvana.Runtime/GitHubConfig.cs +++ b/src/Buildvana.Runtime/GitHubConfig.cs @@ -6,7 +6,7 @@ namespace Buildvana.Runtime; /// -/// The resolved GitHub integration configuration. +/// The resolved configuration of the GitHub host, from the hosts.github section. /// [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public sealed record GitHubConfig diff --git a/src/Buildvana.Runtime/HostKind.cs b/src/Buildvana.Runtime/HostKind.cs new file mode 100644 index 00000000..1d3668a5 --- /dev/null +++ b/src/Buildvana.Runtime/HostKind.cs @@ -0,0 +1,20 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; + +namespace Buildvana.Runtime; + +/// +/// Specifies the kind of Git host that the releases of a repository go to. +/// +public enum HostKind +{ + /// The kind of host is detected from the repository URL and from the CI platform. + [JsonStringEnumMemberName("auto")] + Auto, + + /// The host is GitHub: github.com, or a GitHub Enterprise Server. + [JsonStringEnumMemberName("github")] + GitHub, +} diff --git a/src/Buildvana.Runtime/HostsConfig.cs b/src/Buildvana.Runtime/HostsConfig.cs new file mode 100644 index 00000000..29a66d5c --- /dev/null +++ b/src/Buildvana.Runtime/HostsConfig.cs @@ -0,0 +1,21 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace Buildvana.Runtime; + +/// +/// The resolved configuration of the Git hosts, one section per kind of host. +/// +/// +/// Each host reads its own section alone, so a configuration can state the sections of several hosts. +/// +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public sealed record HostsConfig +{ + /// Gets the resolved GitHub configuration. + [JsonPropertyName("github")] + public GitHubConfig GitHub { get; init; } = new(); +} diff --git a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt index 5161df26..0aa8441e 100644 --- a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt +++ b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt @@ -35,12 +35,14 @@ Buildvana.Runtime.BuildvanaConfig.FileBasedApps.get -> System.Collections.Generi Buildvana.Runtime.BuildvanaConfig.FileBasedApps.init -> void Buildvana.Runtime.BuildvanaConfig.Git.get -> Buildvana.Runtime.GitConfig! Buildvana.Runtime.BuildvanaConfig.Git.init -> void -Buildvana.Runtime.BuildvanaConfig.GitHub.get -> Buildvana.Runtime.GitHubConfig! -Buildvana.Runtime.BuildvanaConfig.GitHub.init -> void +Buildvana.Runtime.BuildvanaConfig.Hosts.get -> Buildvana.Runtime.HostsConfig! +Buildvana.Runtime.BuildvanaConfig.Hosts.init -> void Buildvana.Runtime.BuildvanaConfig.NuGet.get -> Buildvana.Runtime.NuGetConfig! Buildvana.Runtime.BuildvanaConfig.NuGet.init -> void Buildvana.Runtime.BuildvanaConfig.Release.get -> Buildvana.Runtime.ReleaseConfig! Buildvana.Runtime.BuildvanaConfig.Release.init -> void +Buildvana.Runtime.BuildvanaConfig.Repository.get -> Buildvana.Runtime.RepositoryConfig! +Buildvana.Runtime.BuildvanaConfig.Repository.init -> void Buildvana.Runtime.BuildvanaConfig.Versioning.get -> Buildvana.Runtime.VersioningConfig! Buildvana.Runtime.BuildvanaConfig.Versioning.init -> void Buildvana.Runtime.BuildvanaJsonContext @@ -157,6 +159,15 @@ Buildvana.Runtime.HookArgs.HookArgs() -> void Buildvana.Runtime.HookArgs.HookArgs(Buildvana.Runtime.HookArgs! original) -> void Buildvana.Runtime.HookArgs.RuntimeInfo.get -> Buildvana.Runtime.RuntimeInfo! Buildvana.Runtime.HookArgs.RuntimeInfo.init -> void +Buildvana.Runtime.HostKind +Buildvana.Runtime.HostKind.Auto = 0 -> Buildvana.Runtime.HostKind +Buildvana.Runtime.HostKind.GitHub = 1 -> Buildvana.Runtime.HostKind +Buildvana.Runtime.HostsConfig +Buildvana.Runtime.HostsConfig.$() -> Buildvana.Runtime.HostsConfig! +Buildvana.Runtime.HostsConfig.Equals(Buildvana.Runtime.HostsConfig? other) -> bool +Buildvana.Runtime.HostsConfig.GitHub.get -> Buildvana.Runtime.GitHubConfig! +Buildvana.Runtime.HostsConfig.GitHub.init -> void +Buildvana.Runtime.HostsConfig.HostsConfig() -> void Buildvana.Runtime.IHookEvent Buildvana.Runtime.IHookEvent.Context.get -> string! Buildvana.Runtime.IHookEvent.Event.get -> string! @@ -213,6 +224,8 @@ Buildvana.Runtime.PostUpdateHookArgs.Tools.get -> System.Collections.Generic.IRe Buildvana.Runtime.PostUpdateHookArgs.Tools.init -> void Buildvana.Runtime.ReleaseConfig Buildvana.Runtime.ReleaseConfig.$() -> Buildvana.Runtime.ReleaseConfig! +Buildvana.Runtime.ReleaseConfig.AllowUnknownHost.get -> bool +Buildvana.Runtime.ReleaseConfig.AllowUnknownHost.init -> void Buildvana.Runtime.ReleaseConfig.Branches.get -> System.Collections.Generic.IReadOnlyList! Buildvana.Runtime.ReleaseConfig.Branches.init -> void Buildvana.Runtime.ReleaseConfig.ChangelogUpdates.get -> Buildvana.Runtime.ChangelogUpdates @@ -241,6 +254,14 @@ Buildvana.Runtime.ReleaseInfo.SemVer.get -> string! Buildvana.Runtime.ReleaseInfo.SemVer.init -> void Buildvana.Runtime.ReleaseInfo.Version.get -> string! Buildvana.Runtime.ReleaseInfo.Version.init -> void +Buildvana.Runtime.RepositoryConfig +Buildvana.Runtime.RepositoryConfig.$() -> Buildvana.Runtime.RepositoryConfig! +Buildvana.Runtime.RepositoryConfig.Equals(Buildvana.Runtime.RepositoryConfig? other) -> bool +Buildvana.Runtime.RepositoryConfig.Host.get -> Buildvana.Runtime.HostKind +Buildvana.Runtime.RepositoryConfig.Host.init -> void +Buildvana.Runtime.RepositoryConfig.RepositoryConfig() -> void +Buildvana.Runtime.RepositoryConfig.Url.get -> string? +Buildvana.Runtime.RepositoryConfig.Url.init -> void Buildvana.Runtime.RuntimeInfo Buildvana.Runtime.RuntimeInfo.$() -> Buildvana.Runtime.RuntimeInfo! Buildvana.Runtime.RuntimeInfo.ArtifactsDirectory.get -> string! @@ -332,6 +353,9 @@ override Buildvana.Runtime.GitIdentityConfig.ToString() -> string! override Buildvana.Runtime.HookArgs.Equals(object? obj) -> bool override Buildvana.Runtime.HookArgs.GetHashCode() -> int override Buildvana.Runtime.HookArgs.ToString() -> string! +override Buildvana.Runtime.HostsConfig.Equals(object? obj) -> bool +override Buildvana.Runtime.HostsConfig.GetHashCode() -> int +override Buildvana.Runtime.HostsConfig.ToString() -> string! override Buildvana.Runtime.NuGetConfig.Equals(object? obj) -> bool override Buildvana.Runtime.NuGetConfig.GetHashCode() -> int override Buildvana.Runtime.NuGetConfig.ToString() -> string! @@ -355,6 +379,9 @@ override Buildvana.Runtime.ReleaseConfig.ToString() -> string! override Buildvana.Runtime.ReleaseInfo.Equals(object? obj) -> bool override Buildvana.Runtime.ReleaseInfo.GetHashCode() -> int override Buildvana.Runtime.ReleaseInfo.ToString() -> string! +override Buildvana.Runtime.RepositoryConfig.Equals(object? obj) -> bool +override Buildvana.Runtime.RepositoryConfig.GetHashCode() -> int +override Buildvana.Runtime.RepositoryConfig.ToString() -> string! override Buildvana.Runtime.RuntimeInfo.Equals(object? obj) -> bool override Buildvana.Runtime.RuntimeInfo.GetHashCode() -> int override Buildvana.Runtime.RuntimeInfo.ToString() -> string! @@ -396,6 +423,8 @@ static Buildvana.Runtime.GitIdentityConfig.operator ==(Buildvana.Runtime.GitIden static Buildvana.Runtime.HookArgs.Load(System.Text.Json.Serialization.Metadata.JsonTypeInfo! jsonTypeInfo, string? homeDirectory) -> TArgs! static Buildvana.Runtime.HookArgs.operator !=(Buildvana.Runtime.HookArgs? left, Buildvana.Runtime.HookArgs? right) -> bool static Buildvana.Runtime.HookArgs.operator ==(Buildvana.Runtime.HookArgs? left, Buildvana.Runtime.HookArgs? right) -> bool +static Buildvana.Runtime.HostsConfig.operator !=(Buildvana.Runtime.HostsConfig? left, Buildvana.Runtime.HostsConfig? right) -> bool +static Buildvana.Runtime.HostsConfig.operator ==(Buildvana.Runtime.HostsConfig? left, Buildvana.Runtime.HostsConfig? right) -> bool static Buildvana.Runtime.NuGetConfig.operator !=(Buildvana.Runtime.NuGetConfig? left, Buildvana.Runtime.NuGetConfig? right) -> bool static Buildvana.Runtime.NuGetConfig.operator ==(Buildvana.Runtime.NuGetConfig? left, Buildvana.Runtime.NuGetConfig? right) -> bool static Buildvana.Runtime.NuGetFeedConfig.operator !=(Buildvana.Runtime.NuGetFeedConfig? left, Buildvana.Runtime.NuGetFeedConfig? right) -> bool @@ -413,6 +442,8 @@ static Buildvana.Runtime.ReleaseConfig.operator !=(Buildvana.Runtime.ReleaseConf static Buildvana.Runtime.ReleaseConfig.operator ==(Buildvana.Runtime.ReleaseConfig? left, Buildvana.Runtime.ReleaseConfig? right) -> bool static Buildvana.Runtime.ReleaseInfo.operator !=(Buildvana.Runtime.ReleaseInfo? left, Buildvana.Runtime.ReleaseInfo? right) -> bool static Buildvana.Runtime.ReleaseInfo.operator ==(Buildvana.Runtime.ReleaseInfo? left, Buildvana.Runtime.ReleaseInfo? right) -> bool +static Buildvana.Runtime.RepositoryConfig.operator !=(Buildvana.Runtime.RepositoryConfig? left, Buildvana.Runtime.RepositoryConfig? right) -> bool +static Buildvana.Runtime.RepositoryConfig.operator ==(Buildvana.Runtime.RepositoryConfig? left, Buildvana.Runtime.RepositoryConfig? right) -> bool static Buildvana.Runtime.RuntimeInfo.operator !=(Buildvana.Runtime.RuntimeInfo? left, Buildvana.Runtime.RuntimeInfo? right) -> bool static Buildvana.Runtime.RuntimeInfo.operator ==(Buildvana.Runtime.RuntimeInfo? left, Buildvana.Runtime.RuntimeInfo? right) -> bool static Buildvana.Runtime.TransitiveOverride.operator !=(Buildvana.Runtime.TransitiveOverride? left, Buildvana.Runtime.TransitiveOverride? right) -> bool @@ -441,6 +472,8 @@ virtual Buildvana.Runtime.HookArgs.PrintMembers(System.Text.StringBuilder! build ~Buildvana.Runtime.BuildvanaJsonContext.GitConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.GitHubConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.GitIdentityConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo +~Buildvana.Runtime.BuildvanaJsonContext.HostKind.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo +~Buildvana.Runtime.BuildvanaJsonContext.HostsConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.IReadOnlyDictionaryStringString.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo> ~Buildvana.Runtime.BuildvanaJsonContext.IReadOnlyListAdditionalPackagesConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo> ~Buildvana.Runtime.BuildvanaJsonContext.IReadOnlyListAdditionalPackagesResult.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo> @@ -455,6 +488,7 @@ virtual Buildvana.Runtime.HookArgs.PrintMembers(System.Text.StringBuilder! build ~Buildvana.Runtime.BuildvanaJsonContext.PostUpdateHookArgs.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.ReleaseConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.ReleaseInfo.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo +~Buildvana.Runtime.BuildvanaJsonContext.RepositoryConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.RuntimeInfo.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.String.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.TransitiveOverride.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo diff --git a/src/Buildvana.Runtime/ReleaseConfig.cs b/src/Buildvana.Runtime/ReleaseConfig.cs index 0dbc3544..06ef033a 100644 --- a/src/Buildvana.Runtime/ReleaseConfig.cs +++ b/src/Buildvana.Runtime/ReleaseConfig.cs @@ -39,4 +39,10 @@ public sealed record ReleaseConfig /// Gets a value indicating whether self-references are updated (dogfooding) during a release. public bool Dogfood { get; init; } = true; + + /// + /// Gets a value indicating whether a release proceeds on a Git host bv does not know, where it tags + /// and pushes but publishes no release page. + /// + public bool AllowUnknownHost { get; init; } } diff --git a/src/Buildvana.Runtime/RepositoryConfig.cs b/src/Buildvana.Runtime/RepositoryConfig.cs new file mode 100644 index 00000000..1110139c --- /dev/null +++ b/src/Buildvana.Runtime/RepositoryConfig.cs @@ -0,0 +1,27 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; + +namespace Buildvana.Runtime; + +/// +/// The resolved configuration of the repository that the releases go to. +/// +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public sealed record RepositoryConfig +{ + /// + /// Gets the URL of the repository that the releases go to, or when the configuration + /// does not state one, and bv release cannot run. + /// + [SuppressMessage( + "Design", + "CA1056:URI-like properties should not be strings", + Justification = "Git accepts the scp-like syntax, such as git@host:owner/repo, which System.Uri cannot hold.")] + public string? Url { get; init; } + + /// Gets the kind of Git host of the repository. + public HostKind Host { get; init; } = HostKind.Auto; +} diff --git a/src/Buildvana.Tool/CommandLine/CommandLineOverridesParser.cs b/src/Buildvana.Tool/CommandLine/CommandLineOverridesParser.cs index ff2a591f..2b98f6c0 100644 --- a/src/Buildvana.Tool/CommandLine/CommandLineOverridesParser.cs +++ b/src/Buildvana.Tool/CommandLine/CommandLineOverridesParser.cs @@ -42,6 +42,7 @@ public static CommandLineOverrides Parse(CommandParameters parameters) Configuration = reader.ReadValue("--configuration", "-c") ?? forwardedConfiguration, CheckPublicApi = reader.ReadBoolValue("--check-public-api"), Dogfood = reader.ReadBoolValue("--dogfood"), + AllowUnknownHost = reader.ReadBoolValue("--allow-unknown-host"), ForwardedArgs = forwardedReader.Remaining is { Count: > 0 } remaining ? remaining : null, }; } diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs index cc0939d7..0fe48a42 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs @@ -46,7 +46,7 @@ private GitHubServerAdapter(IServiceProvider services) RepositoryOwner = originInfo.PathSegments[0]; RepositoryName = originInfo.PathSegments[1]; _urls = new GitHubRepositoryUrls(HostName, RepositoryOwner, RepositoryName); - _token = RuntimeAccess.Translate(services.GetRequiredService().GitHub.GetToken); + _token = RuntimeAccess.Translate(services.GetRequiredService().Hosts.GitHub.GetToken); _client = new GitHubApiClient(HostName, _token, new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }); } diff --git a/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs b/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs index 8dd4e8e7..8bf6ef2e 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs @@ -15,7 +15,7 @@ namespace Buildvana.Tool.Subcommands; /// Options for the release command, parsed from the command-line option tokens by . /// Decorated with / for the help renderer and /// the argument validator. The configuration-overriding flags (--configuration, --check-public-api, -/// --dogfood) reach resolution through CommandLineOverridesParser and the configuration factory, +/// --dogfood, --allow-unknown-host) reach resolution through CommandLineOverridesParser and the configuration factory, /// so commands read their effective values from the resolved BuildvanaConfig; only , /// which is not configuration, is consumed from here. /// @@ -56,6 +56,13 @@ internal sealed class ReleaseSettings [Description("Update in-tree references to packages produced by this release. Defaults to true.")] public bool? Dogfood { get; init; } + /// + /// Gets a value indicating whether the release proceeds on a Git host bv does not know. + /// + [BvOption("--allow-unknown-host ")] + [Description("Tag and push on an unknown Git host, without a release page. Defaults to false.")] + public bool? AllowUnknownHost { get; init; } + /// /// Parses the command's option tokens into a . Unknown options have already /// been rejected by CommandArgumentValidator, so every option token is one the command declares. @@ -72,6 +79,7 @@ public static ReleaseSettings Parse(IReadOnlyList options) Bump = reader.ReadValue("--bump"), CheckPublicApi = reader.ReadBoolValue("--check-public-api"), Dogfood = reader.ReadBoolValue("--dogfood"), + AllowUnknownHost = reader.ReadBoolValue("--allow-unknown-host"), }; } diff --git a/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs index 4cfd4108..e3b396bd 100644 --- a/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs @@ -13,12 +13,15 @@ public async Task Create_NoSources_YieldsDomainDefaults() { var config = BuildvanaConfigFactory.Create(null, null); + await Assert.That(config.Repository.Url).IsNull(); + await Assert.That(config.Repository.Host).IsEqualTo(HostKind.Auto); await Assert.That(config.Release.Branches.Count).IsEqualTo(0); await Assert.That(config.Release.Configuration).IsEqualTo("Release"); await Assert.That(config.Release.CheckPublicApi).IsTrue(); await Assert.That(config.Release.ChangelogUpdates).IsEqualTo(ChangelogUpdates.Stable); await Assert.That(config.Release.EmptyChangelog).IsNull(); await Assert.That(config.Release.Dogfood).IsTrue(); + await Assert.That(config.Release.AllowUnknownHost).IsFalse(); await Assert.That(config.Versioning.PrereleaseTag).IsNull(); await Assert.That(config.Versioning.AssemblyVersionPrecision).IsEqualTo(AssemblyVersionPrecision.Major); await Assert.That(config.DotNet.Configuration).IsEqualTo("Release"); @@ -26,7 +29,7 @@ public async Task Create_NoSources_YieldsDomainDefaults() await Assert.That(config.DotNet.All.Env.Count).IsEqualTo(0); await Assert.That(config.NuGet.Feeds.Prerelease).IsNull(); await Assert.That(config.NuGet.Feeds.Release).IsNull(); - await Assert.That(config.GitHub.TokenEnv).IsEqualTo("GITHUB_TOKEN"); + await Assert.That(config.Hosts.GitHub.TokenEnv).IsEqualTo("GITHUB_TOKEN"); await Assert.That(config.Git.Identity).IsNull(); await Assert.That(config.Dependencies.Scopes.NetSdk).IsEqualTo("major"); await Assert.That(config.Dependencies.Scopes.Sdks).IsEqualTo("minor"); @@ -41,46 +44,52 @@ public async Task Create_NoSources_YieldsDomainDefaults() public async Task Create_FileValues_OverrideDefaults() { var json = new BuildvanaJsonConfig { + Repository = new() { Url = "https://github.com/Tenacom/Buildvana", Host = HostKind.GitHub }, Release = new() { Branches = ["^main$"], CheckPublicApi = false, ChangelogUpdates = ChangelogUpdates.All, EmptyChangelog = "No changes.", Dogfood = false, + AllowUnknownHost = true, }, Versioning = new() { PrereleaseTag = "preview", AssemblyVersionPrecision = AssemblyVersionPrecision.Build, }, DotNet = new() { Configuration = "Debug" }, - GitHub = new() { TokenEnv = "MY_TOKEN" }, + Hosts = new() { GitHub = new() { TokenEnv = "MY_TOKEN" } }, }; var config = BuildvanaConfigFactory.Create(json, null); + await Assert.That(config.Repository.Url).IsEqualTo("https://github.com/Tenacom/Buildvana"); + await Assert.That(config.Repository.Host).IsEqualTo(HostKind.GitHub); await Assert.That(config.Release.Branches.Count).IsEqualTo(1); await Assert.That(config.Release.Branches[0]).IsEqualTo("^main$"); await Assert.That(config.Release.CheckPublicApi).IsFalse(); await Assert.That(config.Release.ChangelogUpdates).IsEqualTo(ChangelogUpdates.All); await Assert.That(config.Release.EmptyChangelog).IsEqualTo("No changes."); await Assert.That(config.Release.Dogfood).IsFalse(); + await Assert.That(config.Release.AllowUnknownHost).IsTrue(); await Assert.That(config.Versioning.PrereleaseTag).IsEqualTo("preview"); await Assert.That(config.Versioning.AssemblyVersionPrecision).IsEqualTo(AssemblyVersionPrecision.Build); await Assert.That(config.DotNet.Configuration).IsEqualTo("Debug"); - await Assert.That(config.GitHub.TokenEnv).IsEqualTo("MY_TOKEN"); + await Assert.That(config.Hosts.GitHub.TokenEnv).IsEqualTo("MY_TOKEN"); } [Test] public async Task Create_CommandLine_BeatsFileAndDefaults() { var json = new BuildvanaJsonConfig { - Release = new() { CheckPublicApi = true, Dogfood = true }, + Release = new() { CheckPublicApi = true, Dogfood = true, AllowUnknownHost = false }, DotNet = new() { Configuration = "Debug" }, }; var commandLine = new CommandLineOverrides { Configuration = "Custom", CheckPublicApi = false, Dogfood = false, + AllowUnknownHost = true, }; var config = BuildvanaConfigFactory.Create(json, commandLine); @@ -89,6 +98,7 @@ public async Task Create_CommandLine_BeatsFileAndDefaults() await Assert.That(config.Release.Configuration).IsEqualTo("Custom"); await Assert.That(config.Release.CheckPublicApi).IsFalse(); await Assert.That(config.Release.Dogfood).IsFalse(); + await Assert.That(config.Release.AllowUnknownHost).IsTrue(); } [Test] @@ -144,11 +154,19 @@ public async Task Create_PrereleaseTag_BlankCountsAsNotStated() [Test] public async Task Create_TokenEnv_BlankFallsBackToDefault() { - var empty = new BuildvanaJsonConfig { GitHub = new() { TokenEnv = string.Empty } }; - await Assert.That(BuildvanaConfigFactory.Create(empty, null).GitHub.TokenEnv).IsEqualTo("GITHUB_TOKEN"); + var empty = new BuildvanaJsonConfig { Hosts = new() { GitHub = new() { TokenEnv = string.Empty } } }; + await Assert.That(BuildvanaConfigFactory.Create(empty, null).Hosts.GitHub.TokenEnv).IsEqualTo("GITHUB_TOKEN"); - var whitespace = new BuildvanaJsonConfig { GitHub = new() { TokenEnv = " \t " } }; - await Assert.That(BuildvanaConfigFactory.Create(whitespace, null).GitHub.TokenEnv).IsEqualTo("GITHUB_TOKEN"); + var whitespace = new BuildvanaJsonConfig { Hosts = new() { GitHub = new() { TokenEnv = " \t " } } }; + await Assert.That(BuildvanaConfigFactory.Create(whitespace, null).Hosts.GitHub.TokenEnv).IsEqualTo("GITHUB_TOKEN"); + } + + // A blank URL names no repository, so it counts as not stated, and bv release refuses to run. + [Test] + public async Task Create_RepositoryUrl_BlankCountsAsNotStated() + { + var json = new BuildvanaJsonConfig { Repository = new() { Url = " " } }; + await Assert.That(BuildvanaConfigFactory.Create(json, null).Repository.Url).IsNull(); } [Test] diff --git a/tests/Buildvana.Runtime.Tests/HookArgsSecretsTests.cs b/tests/Buildvana.Runtime.Tests/HookArgsSecretsTests.cs index c6d52540..d3cfe42e 100644 --- a/tests/Buildvana.Runtime.Tests/HookArgsSecretsTests.cs +++ b/tests/Buildvana.Runtime.Tests/HookArgsSecretsTests.cs @@ -39,7 +39,7 @@ public async Task Serialize_WritesCredentialVariableNames_NeverTheirValues() ScratchDirectory = "/repo/.buildvana-temp", ConfigFile = null, Configuration = new() { - GitHub = new() { TokenEnv = credentialEnvVar }, + Hosts = new() { GitHub = new() { TokenEnv = credentialEnvVar } }, NuGet = new() { Feeds = new() { Release = new() { Source = "https://nuget.example/v3/index.json", ApiKeyEnv = credentialEnvVar }, diff --git a/tests/Buildvana.Tool.Tests/CommandLineOverridesParserTests.cs b/tests/Buildvana.Tool.Tests/CommandLineOverridesParserTests.cs index b44ff918..cc1f8abf 100644 --- a/tests/Buildvana.Tool.Tests/CommandLineOverridesParserTests.cs +++ b/tests/Buildvana.Tool.Tests/CommandLineOverridesParserTests.cs @@ -14,6 +14,7 @@ public async Task Parse_NoTokens_YieldsNoOverrides() await Assert.That(overrides.Configuration).IsNull(); await Assert.That(overrides.CheckPublicApi).IsNull(); await Assert.That(overrides.Dogfood).IsNull(); + await Assert.That(overrides.AllowUnknownHost).IsNull(); await Assert.That(overrides.ForwardedArgs).IsNull(); } @@ -21,11 +22,12 @@ public async Task Parse_NoTokens_YieldsNoOverrides() public async Task Parse_ReadsFlags_FromOptionTokens() { var overrides = CommandLineOverridesParser.Parse( - Parameters(options: ["-c", "Debug", "--check-public-api", "false", "--dogfood=true"])); + Parameters(options: ["-c", "Debug", "--check-public-api", "false", "--dogfood=true", "--allow-unknown-host", "true"])); await Assert.That(overrides.Configuration).IsEqualTo("Debug"); await Assert.That(overrides.CheckPublicApi).IsFalse(); await Assert.That(overrides.Dogfood).IsTrue(); + await Assert.That(overrides.AllowUnknownHost).IsTrue(); } // A configuration stated among the forwarded arguments decides the actual build, so it must drive diff --git a/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs b/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs index 173de205..488edf1a 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs @@ -15,6 +15,7 @@ public async Task Parse_NoTokens_LeavesFlagsUnset() await Assert.That(settings.Bump).IsNull(); await Assert.That(settings.CheckPublicApi).IsNull(); await Assert.That(settings.Dogfood).IsNull(); + await Assert.That(settings.AllowUnknownHost).IsNull(); await Assert.That(settings.ResolveBump()).IsEqualTo(VersionSpecChange.None); } @@ -41,9 +42,10 @@ public async Task ResolveBump_Throws_OnInvalidValue() [Test] public async Task Parse_ReadsBoolOptions_SpaceAndInlineForms() { - var settings = ReleaseSettings.Parse(["--check-public-api", "false", "--dogfood=false"]); + var settings = ReleaseSettings.Parse(["--check-public-api", "false", "--dogfood=false", "--allow-unknown-host", "true"]); await Assert.That(settings.CheckPublicApi).IsFalse(); await Assert.That(settings.Dogfood).IsFalse(); + await Assert.That(settings.AllowUnknownHost).IsTrue(); } [Test] diff --git a/tests/Buildvana.Tool.Tests/SettingsHelpReflectionTests.cs b/tests/Buildvana.Tool.Tests/SettingsHelpReflectionTests.cs index eccde117..1ea6d99c 100644 --- a/tests/Buildvana.Tool.Tests/SettingsHelpReflectionTests.cs +++ b/tests/Buildvana.Tool.Tests/SettingsHelpReflectionTests.cs @@ -18,7 +18,7 @@ public async Task GlobalSettings_ExposesOptionsInHelpOrder() public async Task ReleaseSettings_ExposesOptionsInHelpOrder() { var names = OptionLongNames(typeof(ReleaseSettings)); - await Assert.That(names).IsEqualTo("--configuration,--bump,--check-public-api,--dogfood"); + await Assert.That(names).IsEqualTo("--configuration,--bump,--check-public-api,--dogfood,--allow-unknown-host"); } [Test] From 6c6df2b4fb83d625f7059d9fc067ca665f09f02d Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Thu, 22 Oct 2026 21:48:24 +0200 Subject: [PATCH 02/12] Take ContinuousIntegrationBuild from CIPlatform, not ServerAdapter DotNetService took ServerAdapter, whose GitHubServerAdapter opens the Git repository in its constructor. On GitHub Actions, bv build then failed in a home directory without an origin remote. ServerAdapter.IsCloudBuild also knew GitHub Actions and GitLab CI alone. CIPlatform reads the environment and nothing else. Its GenericCIPlatform detector follows CIEnvironmentDetectorForTelemetry of the .NET CLI, so that bv and dotnet agree on what a CI runner is. GitHubActionsStepOutputWriter writes the GITHUB_OUTPUT file without a byte order mark, and writes a multi-line value in the delimiter form GitHub documents. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/project/terminology.md | 1 + CHANGELOG.md | 3 +- docs/environment-variables.md | 19 ++- docs/introduction.md | 14 ++ docs/tool-commands/build-pipeline.md | 2 +- docs/tool-commands/release.md | 6 +- .../ServiceCollectionExtensions.cs | 2 + src/Buildvana.Tool/Services/DotNetService.cs | 12 +- .../Services/Platforms/CIPlatform.cs | 86 +++++++++++ .../Services/Platforms/GenericCIPlatform.cs | 68 +++++++++ .../Platforms/GitHubActionsPlatform.cs | 61 ++++++++ .../GitHubActionsStepOutputWriter.cs | 55 +++++++ .../Services/Platforms/GitLabCIPlatform.cs | 44 ++++++ .../Services/Platforms/LocalPlatform.cs | 23 +++ .../Services/Platforms/StepOutputWriter.cs | 29 ++++ .../Internal/GitHub/GitHubServerAdapter.cs | 25 --- .../Internal/GitHub/GitHubServerRelease.cs | 19 +-- .../Internal/GitLab/GitLabServerAdapter.cs | 9 -- .../Internal/UnknownServerAdapter.cs | 14 +- .../Services/ServerAdapters/ServerAdapter.cs | 17 --- .../Subcommands/ReleaseCommand.cs | 12 +- src/Buildvana.Tool/Utilities/EnvVarHelper.cs | 24 --- tests/Buildvana.Tool.Tests/CIPlatformTests.cs | 143 ++++++++++++++++++ .../DotNetServiceResolutionTests.cs | 94 ++++++++++-- tests/Buildvana.Tool.Tests/FakeCIPlatform.cs | 30 ++++ .../GitHubActionsStepOutputWriterTests.cs | 66 ++++++++ .../RecordingServerAdapter.cs | 27 +--- .../ReleaseCommandFailureTests.cs | 4 +- tests/Buildvana.Tool.Tests/ReleaseHarness.cs | 10 +- .../ReleaseHarnessOptions.cs | 8 +- 30 files changed, 764 insertions(+), 163 deletions(-) create mode 100644 src/Buildvana.Tool/Services/Platforms/CIPlatform.cs create mode 100644 src/Buildvana.Tool/Services/Platforms/GenericCIPlatform.cs create mode 100644 src/Buildvana.Tool/Services/Platforms/GitHubActionsPlatform.cs create mode 100644 src/Buildvana.Tool/Services/Platforms/GitHubActionsStepOutputWriter.cs create mode 100644 src/Buildvana.Tool/Services/Platforms/GitLabCIPlatform.cs create mode 100644 src/Buildvana.Tool/Services/Platforms/LocalPlatform.cs create mode 100644 src/Buildvana.Tool/Services/Platforms/StepOutputWriter.cs delete mode 100644 src/Buildvana.Tool/Utilities/EnvVarHelper.cs create mode 100644 tests/Buildvana.Tool.Tests/CIPlatformTests.cs create mode 100644 tests/Buildvana.Tool.Tests/FakeCIPlatform.cs create mode 100644 tests/Buildvana.Tool.Tests/GitHubActionsStepOutputWriterTests.cs diff --git a/.claude/rules/project/terminology.md b/.claude/rules/project/terminology.md index 15fbec8b..a3f8a5e6 100644 --- a/.claude/rules/project/terminology.md +++ b/.claude/rules/project/terminology.md @@ -11,6 +11,7 @@ One name per thing, in every kind of prose: documentation, chat, commit messages | The CLI tool | `bv` | the tool, Buildvana CLI tool, the Buildvana tool | | The shared library package | `Buildvana.Runtime` | the runtime | | The directory Buildvana works from | home directory | repository root, solution directory | +| Where `bv` runs, as `CIPlatform` detects it: GitHub Actions, GitLab CI, Generic CI, or Local | CI platform | build platform, cloud build, CI provider | | The configuration file, in either spelling | `buildvana.jsonc` | `buildvana.json`, the configuration file | | A `.buildvana/hooks//.cs` file | hook | | | A program the .NET CLI runs from a single `.cs` file, a hook included | file-based app | file-based program, single-file app | diff --git a/CHANGELOG.md b/CHANGELOG.md index e456df63..5bd8d200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - Buildvana SDK [contributes two things to `bv dependencies`](docs/tool-commands/dependencies.md#what-buildvana-sdk-contributes): the target that dumps the package items of a project, and the import of the transitive override files. - The `StandardAnalyzers` module passes the [internal API files](docs/sdk-modules/standard-analyzers.md#internal-api-files) to the compilation, one pair per target framework when `UseTfmSpecificInternalApiFiles` is `true`. - On a stable version, `bv release` [moves the unshipped internal API](docs/tool-commands/release.md#the-steps) into the shipped one, as it does for the public API. +- `bv` recognizes a CI runner [as the .NET CLI does](docs/environment-variables.md#variables-that-mark-a-ci-runner), and passes `ContinuousIntegrationBuild=true` to the build pipeline on every one. ### Changes to existing features @@ -117,7 +118,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - `bv` reports a failed or denied file access as one error line naming the operation, the path, and the reason, not as an unhandled exception. - The release date `bv release` writes in a changelog section title uses the Gregorian calendar and the invariant format, whatever the culture of the machine. - The `Wine` module [sets `UseWine`](docs/sdk-modules/wine.md#usewine-property) and raises BVSDK2200 on macOS, where `UseWine` stayed off. -- Outside GitHub Actions, `bv restore`, `bv build`, `bv test`, and `bv pack` no longer fail when the [home directory](docs/command-line.md#home-directory) has no `origin` remote. +- `bv restore`, `bv build`, `bv test`, and `bv pack` no longer fail when the [home directory](docs/command-line.md#home-directory) has no `origin` remote. - The [`NuGetPack` module](docs/sdk-modules/nuget-pack.md#readme) finds the README file that `PackageReadmeFile` names, where it used to look up the name that `PackageLicenseFile` holds and raise [BVSDK1510](docs/sdk-diagnostics.md#nugetpack-module-1500-1599). - The [`AssemblySigning` module](docs/sdk-modules/assembly-signing.md) reads the RSA private key of a `.pfx` file from any key provider, where [BVSDK1202](docs/sdk-diagnostics.md#assemblysigning-module-1200-1299) fired on most keys. - The [`AssemblySigning` module](docs/sdk-modules/assembly-signing.md) converts the `.pfx` file before `ResolveKeySource` reads `AssemblyOriginatorKeyFile`, where `dotnet build` failed with `PFX signing not supported on .NET Core`. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 6f9c5a76..f252949f 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -21,6 +21,7 @@ The variables that the .NET SDK, MSBuild, and NuGet read are out of scope, and t - [`GITLAB_CI`](#gitlab_ci) - [`NO_COLOR`](#no_color) - [`TERM`](#term) + - [Variables that mark a CI runner](#variables-that-mark-a-ci-runner) - [Secret-carrying variables named by the configuration file](#secret-carrying-variables-named-by-the-configuration-file) - [Variables set by `bv`](#variables-set-by-bv) @@ -89,7 +90,7 @@ The variable is unset when `bv` runs through the native shim of a global tool. ### `GITHUB_ACTIONS` GitHub Actions sets it to `true` on every step. -`bv` reads it to recognize a GitHub Actions run and act through the GitHub adapter. +`bv` reads it to recognize the GitHub Actions [CI platform](introduction.md#ci-platforms), and to act through the GitHub adapter. Releases then go to the GitHub API, and the step output goes to the file `GITHUB_OUTPUT` names. The `github-actions[bot]` identity authors the commits of `bv release`, unless `buildvana.jsonc` states a `git.identity`. The comparison ignores case, so `TRUE` and `True` count too. @@ -123,6 +124,22 @@ An unset, empty, or `dumb` value says that the terminal does not interpret ANSI `--color` still forces color on. On Windows, a console mode answers the question, and `bv` does not read `TERM`. +### Variables that mark a CI runner + +`bv` recognizes a CI runner as the .NET CLI does. +When neither `GITLAB_CI` nor `GITHUB_ACTIONS` selects a [CI platform](introduction.md#ci-platforms), any of these makes the run a Generic CI run: + +- `TF_BUILD`, `GITHUB_ACTIONS`, `APPVEYOR`, `CI`, `TRAVIS`, or `CIRCLECI`, set to `1`, `true`, `yes`, or `on`, in any case; +- `CODEBUILD_BUILD_ID` and `AWS_REGION`, both non-empty; +- `BUILD_ID` and `BUILD_URL`, both non-empty; +- `BUILD_ID` and `PROJECT_ID`, both non-empty; +- `TEAMCITY_VERSION` or `JB_SPACE_API_URL`, non-empty. + +On Generic CI, the build pipeline passes `ContinuousIntegrationBuild=true` to `dotnet`. +Generic CI has no bot identity and no step outputs. +A runner that sets `CI` to any other value, such as its own name, is not recognized. +To have it recognized, set `CI` to `true` in its environment. + ### Secret-carrying variables named by the configuration file `bv` stores no secret. diff --git a/docs/introduction.md b/docs/introduction.md index f4c389a1..49368537 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -16,6 +16,7 @@ This page says what Buildvana is made of, what it does for a project, and what i - [Project types](#project-types) - [Programming languages](#programming-languages) - [Git servers](#git-servers) + - [CI platforms](#ci-platforms) - [Toolchain](#toolchain) --- @@ -93,6 +94,19 @@ Each table below states a status for every item. | GitHub and GitHub Enterprise | Supported | | | Other servers | Supported with limits | Buildvana SDK does not configure SourceLink. | +### CI platforms + +`bv` detects the CI platform it runs on from the environment. +Generic CI is any other runner that the .NET CLI recognizes as one. +Local is a machine where `bv` recognizes no CI runner, such as a developer machine. + +| CI platform | Status | Limits | +| ---------------------------------------------------------------------- | --------------------- | ------------------- | +| GitHub Actions | Supported | | +| GitLab CI | Supported with limits | `bv release` fails. | +| [Generic CI](environment-variables.md#variables-that-mark-a-ci-runner) | Supported with limits | `bv release` fails. | +| Local | Supported with limits | `bv release` fails. | + ### Toolchain The table lists the minimum version of each tool that can build a project with Buildvana. diff --git a/docs/tool-commands/build-pipeline.md b/docs/tool-commands/build-pipeline.md index ca567e0a..64258cb2 100644 --- a/docs/tool-commands/build-pipeline.md +++ b/docs/tool-commands/build-pipeline.md @@ -36,7 +36,7 @@ Every `dotnet` command of a step gets these arguments: - `-nologo`, except `dotnet test`; - the build configuration, as [The build configuration](#the-build-configuration) says, except `dotnet restore`, which rejects it; - the arguments of the `dotnet.all` section of `buildvana.jsonc`, then the arguments of the `dotnet.` section, then the [forwarded arguments](#forwarded-arguments); -- `ContinuousIntegrationBuild=true` on GitHub Actions and GitLab CI, and `ContinuousIntegrationBuild=false` elsewhere; +- `ContinuousIntegrationBuild=false` on the Local [CI platform](../introduction.md#ci-platforms), and `ContinuousIntegrationBuild=true` on every other; - the verbosity of `bv`, as `--verbosity`. The last two come last, so that no configured or forwarded argument overrides them. diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index d166dde5..544dde1e 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -30,15 +30,15 @@ bv release [OPTIONS] ``` `bv release` creates releases on GitHub, from a GitHub Actions runner. -On GitLab CI, `bv` recognizes the runner and fails when it creates the release, because the GitLab adapter creates none. -On any other machine, the command fails at once with `A release can only be created on a known cloud build platform.` +On GitLab CI and on Generic CI, `bv` fails when it creates the release. +On the Local [CI platform](../introduction.md#ci-platforms), the command fails at once with `A release can only be created on a CI runner.` Before building anything, `bv release` checks what a build cannot change: 1. `HEAD` is on a branch, and the branch produces [public releases](../sdk-modules/versioning.md#public-releases) under `release.branches`. 2. A committer identity exists for the commits of the release. `bv` takes the first of three that exists. - The three are the `git.identity` setting of `buildvana.jsonc`, the CI bot identity of the platform, and the `user.name` and `user.email` of the Git configuration. + The three are the `git.identity` setting of `buildvana.jsonc`, the bot identity of the CI platform, and the `user.name` and `user.email` of the Git configuration. On GitHub Actions, the bot identity is `github-actions[bot]`. On GitLab CI, it is `GitLab CI`, with the address `gitlab-ci@noreply.` built from [`CI_SERVER_HOST`](../environment-variables.md#ci_server_host). 3. The platform supplies push credentials. diff --git a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 998713b8..b4585ce4 100644 --- a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ using Buildvana.Tool.Services.Dependencies; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Hooks; +using Buildvana.Tool.Services.Platforms; using Buildvana.Tool.Services.SelfUpdate; using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.Solution; @@ -79,6 +80,7 @@ public IServiceCollection AddBvServices() .AddSingleton(static sp => sp.GetRequiredService().Create()) .AddSingleton() .AddSingleton() + .AddSingleton(static _ => CIPlatform.Detect(Environment.GetEnvironmentVariable)) .AddSingleton(ServerAdapter.Create) .AddSingleton() .AddSingleton() diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index 6fc05d55..e424ca54 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -15,7 +15,7 @@ using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Infrastructure.Delegation; using Buildvana.Tool.Services.Dependencies; -using Buildvana.Tool.Services.ServerAdapters; +using Buildvana.Tool.Services.Platforms; using Buildvana.Tool.Services.Solution; using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Utilities; @@ -30,7 +30,7 @@ internal sealed partial class DotNetService : IFileBasedAppRunner, IDependencyRe { private readonly IReporter _reporter; private readonly IProcessRunner _processRunner; - private readonly ServerAdapter _server; + private readonly CIPlatform _platform; // Resolved on first use, which is the NuGet push alone. Constructing the version service opens the Git // repository and reads its origin remote, and a build needs neither: the SDK computes the version itself. @@ -43,18 +43,18 @@ internal sealed partial class DotNetService : IFileBasedAppRunner, IDependencyRe public DotNetService( IReporter reporter, IProcessRunner processRunner, - ServerAdapter server, + CIPlatform platform, Lazy version, BuildvanaConfig config) { Guard.IsNotNull(reporter); Guard.IsNotNull(processRunner); - Guard.IsNotNull(server); + Guard.IsNotNull(platform); Guard.IsNotNull(version); Guard.IsNotNull(config); _reporter = reporter; _processRunner = processRunner; - _server = server; + _platform = platform; _version = version; _config = config; } @@ -412,7 +412,7 @@ internal static string[] MergeInvocation( private string ContinuousIntegrationBuildArg(bool asMSBuildPassthrough) { var prefix = asMSBuildPassthrough ? "-p:" : "--property:"; - return $"{prefix}ContinuousIntegrationBuild={(_server.IsCloudBuild ? "true" : "false")}"; + return $"{prefix}ContinuousIntegrationBuild={(_platform.IsCI ? "true" : "false")}"; } /// diff --git a/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs b/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs new file mode 100644 index 00000000..5262e62b --- /dev/null +++ b/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs @@ -0,0 +1,86 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using Buildvana.Tool.Services.Git; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Platforms; + +/// +/// Base class for the platforms bv runs on: a CI runner, or a machine with none. +/// +/// +/// A platform is detected from the environment alone. It never opens the Git repository and never reads +/// the configuration file, so that every command can depend on it, wherever it runs. +/// +internal abstract class CIPlatform +{ + // The order matters: each detector runs only when the ones above it found nothing. GenericCIPlatform + // recognizes every runner the .NET CLI recognizes, the more specific ones included. + private static readonly Func, CIPlatform?>[] Detectors = [ + GitLabCIPlatform.DetectIfApplicable, + GitHubActionsPlatform.DetectIfApplicable, + GenericCIPlatform.DetectIfApplicable, + ]; + + private protected CIPlatform() + { + } + + /// + /// Gets the display name of the platform. + /// + public abstract string Name { get; } + + /// + /// Gets a value indicating whether the platform is a CI runner. + /// + /// on every platform but . + public abstract bool IsCI { get; } + + /// + /// Gets the identity the platform commits with, or if the platform has none. + /// + public abstract GitIdentity? BotIdentity { get; } + + /// + /// Gets a value indicating whether the platform collects named outputs of a job step. + /// + /// If can be called, ; + /// otherwise, . + public virtual bool HasStepOutputs => false; + + /// + /// Detects the platform bv runs on. + /// + /// A function that returns the value of an environment variable, + /// or when the variable is not set. + /// The first platform whose detector recognizes the environment, or a . + public static CIPlatform Detect(Func getEnvironmentVariable) + { + Guard.IsNotNull(getEnvironmentVariable); + foreach (var detector in Detectors) + { + if (detector(getEnvironmentVariable) is { } platform) + { + return platform; + } + } + + return new LocalPlatform(); + } + + /// + /// Returns a writer of named step outputs. + /// + /// A newly-created . + /// is . + /// The environment does not name the file that collects the outputs. + /// + /// Call this method before changing anything that needs undoing: a missing file then fails the + /// operation while there is nothing to roll back. + /// + public virtual StepOutputWriter OpenStepOutputs() + => ThrowHelper.ThrowInvalidOperationException($"Internal error: {Name} has no step outputs."); +} diff --git a/src/Buildvana.Tool/Services/Platforms/GenericCIPlatform.cs b/src/Buildvana.Tool/Services/Platforms/GenericCIPlatform.cs new file mode 100644 index 00000000..de290bf7 --- /dev/null +++ b/src/Buildvana.Tool/Services/Platforms/GenericCIPlatform.cs @@ -0,0 +1,68 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Linq; +using Buildvana.Tool.Services.Git; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Platforms; + +/// +/// A CI runner that no other platform recognizes. +/// +/// +/// The runner is recognized as the .NET CLI recognizes a CI environment, for the default of its +/// --interactive option: see CIEnvironmentDetectorForTelemetry and EnvironmentVariableParser.ParseBool +/// in the dotnet/sdk repository. The variables and the values that read as true are taken from there. +/// +internal sealed class GenericCIPlatform : CIPlatform +{ + // Variables that mark a CI runner when their value reads as true. + private static readonly string[] BooleanVariables = ["TF_BUILD", "GITHUB_ACTIONS", "APPVEYOR", "CI", "TRAVIS", "CIRCLECI"]; + + // The values that read as true, in any case. + private static readonly string[] TrueValues = ["1", "true", "yes", "on"]; + + // Sets of variables that mark a CI runner when every variable of the set is non-empty. + private static readonly string[][] PresentVariableSets = [ + ["CODEBUILD_BUILD_ID", "AWS_REGION"], + ["BUILD_ID", "BUILD_URL"], + ["BUILD_ID", "PROJECT_ID"], + ["TEAMCITY_VERSION"], + ["JB_SPACE_API_URL"], + ]; + + private GenericCIPlatform() + { + } + + /// + public override string Name => "Generic CI"; + + /// + /// Always . + public override bool IsCI => true; + + /// + /// Always : a runner bv knows nothing about has no known bot. + public override GitIdentity? BotIdentity => null; + + /// + /// Creates and returns an instance of if bv runs in a CI runner + /// the .NET CLI recognizes. + /// + /// A function that returns the value of an environment variable, + /// or when the variable is not set. + /// If the environment marks a CI runner, a newly-created ; + /// otherwise, . + public static CIPlatform? DetectIfApplicable(Func getEnvironmentVariable) + { + Guard.IsNotNull(getEnvironmentVariable); + var isCI = BooleanVariables.Any(x => IsTrue(getEnvironmentVariable(x))) + || PresentVariableSets.Any(set => set.All(x => !string.IsNullOrEmpty(getEnvironmentVariable(x)))); + return isCI ? new GenericCIPlatform() : null; + } + + private static bool IsTrue(string? value) => value is not null && TrueValues.Contains(value, StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Buildvana.Tool/Services/Platforms/GitHubActionsPlatform.cs b/src/Buildvana.Tool/Services/Platforms/GitHubActionsPlatform.cs new file mode 100644 index 00000000..ce2c26e9 --- /dev/null +++ b/src/Buildvana.Tool/Services/Platforms/GitHubActionsPlatform.cs @@ -0,0 +1,61 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using Buildvana.Core; +using Buildvana.Tool.Services.Git; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Platforms; + +/// +/// The GitHub Actions platform. +/// +internal sealed class GitHubActionsPlatform : CIPlatform +{ + private const string StepOutputsVariable = "GITHUB_OUTPUT"; + + private readonly string? _stepOutputsPath; + + private GitHubActionsPlatform(string? stepOutputsPath) + { + _stepOutputsPath = stepOutputsPath; + } + + /// + public override string Name => "GitHub Actions"; + + /// + /// Always . + public override bool IsCI => true; + + /// + public override GitIdentity? BotIdentity { get; } = new( + "github-actions[bot]", + "41898282+github-actions[bot]@users.noreply.github.com"); + + /// + /// Always . + public override bool HasStepOutputs => true; + + /// + /// Creates and returns an instance of if bv runs in a GitHub Actions runner. + /// + /// A function that returns the value of an environment variable, + /// or when the variable is not set. + /// If GITHUB_ACTIONS is true, in any case, a newly-created ; + /// otherwise, . + public static CIPlatform? DetectIfApplicable(Func getEnvironmentVariable) + { + Guard.IsNotNull(getEnvironmentVariable); + return string.Equals(getEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) + ? new GitHubActionsPlatform(getEnvironmentVariable(StepOutputsVariable)) + : null; + } + + /// + public override StepOutputWriter OpenStepOutputs() + => _stepOutputsPath is { Length: > 0 } path + ? new GitHubActionsStepOutputWriter(path) + : throw new BuildFailedException($"Required environment variable {StepOutputsVariable} is missing or empty."); +} diff --git a/src/Buildvana.Tool/Services/Platforms/GitHubActionsStepOutputWriter.cs b/src/Buildvana.Tool/Services/Platforms/GitHubActionsStepOutputWriter.cs new file mode 100644 index 00000000..0eb8e7bf --- /dev/null +++ b/src/Buildvana.Tool/Services/Platforms/GitHubActionsStepOutputWriter.cs @@ -0,0 +1,55 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Globalization; +using Buildvana.Core.IO; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Platforms; + +/// +/// Writes named step outputs to the file a GitHub Actions runner reads them from. +/// +/// +/// The file format is the one GitHub documents for GITHUB_OUTPUT: a single-line value goes in +/// a name=value line, and a multi-line value goes between a name<<delimiter line and a +/// delimiter line. +/// +internal sealed class GitHubActionsStepOutputWriter : StepOutputWriter +{ + private readonly string _path; + + /// + /// Initializes a new instance of the class. + /// + /// The path of the file that collects the step outputs. + public GitHubActionsStepOutputWriter(string path) + { + Guard.IsNotNullOrEmpty(path); + _path = path; + } + + /// + /// is empty, or contains a character that + /// would end the name before its end: =, <, or a line break. + public override void Write(string name, string value) + { + Guard.IsNotNullOrEmpty(name); + Guard.IsNotNull(value); + if (name.AsSpan().IndexOfAny("=<\r\n") >= 0) + { + ThrowHelper.ThrowArgumentException(nameof(name), $"Step output name '{name}' contains '=', '<', or a line break."); + } + + if (value.AsSpan().IndexOfAny('\r', '\n') < 0) + { + UserFile.AppendAllLines(_path, [$"{name}={value}"]); + return; + } + + // A random delimiter cannot occur in the value by accident. + var delimiter = string.Create(CultureInfo.InvariantCulture, $"bv_{Guid.NewGuid():N}"); + UserFile.AppendAllLines(_path, [$"{name}<<{delimiter}", value, delimiter]); + } +} diff --git a/src/Buildvana.Tool/Services/Platforms/GitLabCIPlatform.cs b/src/Buildvana.Tool/Services/Platforms/GitLabCIPlatform.cs new file mode 100644 index 00000000..5aaf71d5 --- /dev/null +++ b/src/Buildvana.Tool/Services/Platforms/GitLabCIPlatform.cs @@ -0,0 +1,44 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using Buildvana.Tool.Services.Git; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Platforms; + +/// +/// The GitLab CI platform. +/// +internal sealed class GitLabCIPlatform : CIPlatform +{ + private GitLabCIPlatform(string? serverHost) + { + BotIdentity = new("GitLab CI", $"gitlab-ci@noreply.{serverHost}"); + } + + /// + public override string Name => "GitLab CI"; + + /// + /// Always . + public override bool IsCI => true; + + /// + public override GitIdentity? BotIdentity { get; } + + /// + /// Creates and returns an instance of if bv runs in a GitLab CI runner. + /// + /// A function that returns the value of an environment variable, + /// or when the variable is not set. + /// If GITLAB_CI is set, whatever its value, a newly-created ; + /// otherwise, . + public static CIPlatform? DetectIfApplicable(Func getEnvironmentVariable) + { + Guard.IsNotNull(getEnvironmentVariable); + return getEnvironmentVariable("GITLAB_CI") is not null + ? new GitLabCIPlatform(getEnvironmentVariable("CI_SERVER_HOST")) + : null; + } +} diff --git a/src/Buildvana.Tool/Services/Platforms/LocalPlatform.cs b/src/Buildvana.Tool/Services/Platforms/LocalPlatform.cs new file mode 100644 index 00000000..c3a4f17c --- /dev/null +++ b/src/Buildvana.Tool/Services/Platforms/LocalPlatform.cs @@ -0,0 +1,23 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Tool.Services.Git; + +namespace Buildvana.Tool.Services.Platforms; + +/// +/// A machine with no CI runner, such as a developer machine. +/// +internal sealed class LocalPlatform : CIPlatform +{ + /// + public override string Name => "Local"; + + /// + /// Always . + public override bool IsCI => false; + + /// + /// Always : a local run commits as the user. + public override GitIdentity? BotIdentity => null; +} diff --git a/src/Buildvana.Tool/Services/Platforms/StepOutputWriter.cs b/src/Buildvana.Tool/Services/Platforms/StepOutputWriter.cs new file mode 100644 index 00000000..d8d69695 --- /dev/null +++ b/src/Buildvana.Tool/Services/Platforms/StepOutputWriter.cs @@ -0,0 +1,29 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; + +namespace Buildvana.Tool.Services.Platforms; + +/// +/// Base class for the writers of named step outputs, which later steps of a CI job read. +/// +/// +/// The medium and the format belong to the platform: a file, a command on standard output, or anything +/// else the platform reads. returns the writer of its own platform. +/// +internal abstract class StepOutputWriter +{ + private protected StepOutputWriter() + { + } + + /// + /// Writes a step output. + /// + /// The name of the output. + /// The value of the output. + /// The platform cannot carry or . + /// The output could not be written. + public abstract void Write(string name, string value); +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs index 0fe48a42..94fce789 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs @@ -5,7 +5,6 @@ using System.IO; using System.Net; using System.Net.Http; -using System.Text; using System.Threading.Tasks; using Buildvana.Core; using Buildvana.Core.ConsoleOutput; @@ -65,15 +64,6 @@ private GitHubServerAdapter(IServiceProvider services) /// public override Uri RepositoryUrl => _urls.Repository; - /// - /// Always . - public override bool IsCloudBuild => true; - - /// - public override GitIdentity? CIBotIdentity { get; } = new( - "github-actions[bot]", - "41898282+github-actions[bot]@users.noreply.github.com"); - /// public override string PushUsername => "x-access-token"; @@ -95,21 +85,6 @@ private GitHubServerAdapter(IServiceProvider services) : null; } - /// - /// Sets a GitHub Actions step output. - /// - /// The path of the file that collects the step's outputs, i.e. the value of - /// the GITHUB_OUTPUT environment variable. - /// The output name. - /// The output value. - /// - /// The caller provides the path, instead of this method reading the environment variable itself, - /// so that a missing variable is discovered while there is still nothing to undo. See - /// . - /// - public static void SetActionsStepOutput(string outputFile, string name, string value) - => UserFile.AppendAllLines(outputFile, [$"{name}={value}"], Encoding.UTF8); - /// public override async Task IsPrivateRepositoryAsync() { diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs index 771575f9..2dae52d6 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs @@ -7,8 +7,8 @@ using System.IO; using System.Threading.Tasks; using Buildvana.Core.ConsoleOutput; +using Buildvana.Tool.Services.Platforms; using Buildvana.Tool.Services.Versioning; -using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.DependencyInjection; @@ -23,7 +23,7 @@ internal sealed class GitHubServerRelease : ServerRelease private readonly IReporter _reporter; private readonly VersionService _version; private readonly GitHubRelease _gitHubRelease; - private readonly string _actionsStepOutputFile; + private readonly StepOutputWriter? _stepOutputs; private bool _gitHubReleaseDeleted; @@ -31,7 +31,7 @@ private GitHubServerRelease( GitHubServerAdapter server, IServiceProvider services, GitHubRelease gitHubRelease, - string actionsStepOutputFile) + StepOutputWriter? stepOutputs) : base(services) { Guard.IsNotNull(server); @@ -42,7 +42,7 @@ private GitHubServerRelease( _reporter = services.GetRequiredService(); _version = services.GetRequiredService(); _gitHubRelease = gitHubRelease; - _actionsStepOutputFile = actionsStepOutputFile; + _stepOutputs = stepOutputs; OnRollback(async () => { // Do this only if the release has not been previously deleted by rolling back its publication @@ -62,13 +62,14 @@ public static async Task CreateAsync( Guard.IsNotNull(services); Guard.IsNotNull(createGitHubReleaseAsync); - // GITHUB_OUTPUT is read here, before the draft release exists, and remembered for the whole - // life of the release. Reading it where it is used - after publication, to write the step + // The step outputs are opened here, before the draft release exists, and kept for the whole + // life of the release. Opening them where they are used - after publication, to write the step // output - would let a variable that was never set fail a release that had otherwise // succeeded, and roll back everything it had just done. - var actionsStepOutputFile = EnvVarHelper.Require("GITHUB_OUTPUT"); + var platform = services.GetRequiredService(); + var stepOutputs = platform.HasStepOutputs ? platform.OpenStepOutputs() : null; var gitHubRelease = await createGitHubReleaseAsync().ConfigureAwait(false); - return new(server, services, gitHubRelease, actionsStepOutputFile); + return new(server, services, gitHubRelease, stepOutputs); } protected override async Task DoPublishAsync(IReadOnlyList assets) @@ -105,7 +106,7 @@ protected override async Task UndoPublishAsync() protected override Task OnPublishedAsync() { - GitHubServerAdapter.SetActionsStepOutput(_actionsStepOutputFile, "version", _version.CurrentStr); + _stepOutputs?.Write("version", _version.CurrentStr); return Task.CompletedTask; } } diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitLab/GitLabServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitLab/GitLabServerAdapter.cs index 305c85d9..7041b21e 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitLab/GitLabServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitLab/GitLabServerAdapter.cs @@ -4,7 +4,6 @@ using System; using System.Threading.Tasks; using Buildvana.Core; -using Buildvana.Tool.Services.Git; using CommunityToolkit.Diagnostics; namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitLab; @@ -16,7 +15,6 @@ internal sealed class GitLabServerAdapter : ServerAdapter { private GitLabServerAdapter() { - CIBotIdentity = new("GitLab CI", $"gitlab-ci@noreply.{Environment.GetEnvironmentVariable("CI_SERVER_HOST")}"); } /// @@ -34,13 +32,6 @@ private GitLabServerAdapter() /// public override Uri RepositoryUrl => BuildFailedException.ThrowOnUnsupportedProperty(); - /// - /// Always . - public override bool IsCloudBuild => true; - - /// - public override GitIdentity? CIBotIdentity { get; } - /// public override string PushUsername => "oauth2"; diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/UnknownServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/UnknownServerAdapter.cs index 57130653..023ec1ee 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/UnknownServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/UnknownServerAdapter.cs @@ -4,15 +4,13 @@ using System; using System.Threading.Tasks; using Buildvana.Core; -using Buildvana.Tool.Services.Git; namespace Buildvana.Tool.Services.ServerAdapters.Internal; /// /// Implements a dummy Continuous Integration adapter for unknown system / local build. -/// Members that answer a question a local build has an honest answer to (, -/// , , ) answer it; -/// every other property and method fails the build when called. +/// Members that answer a question a local build has an honest answer to (, +/// ) answer it; every other property and method fails the build when called. /// internal sealed class UnknownServerAdapter : ServerAdapter { @@ -43,20 +41,12 @@ internal sealed class UnknownServerAdapter : ServerAdapter /// public override Uri RepositoryUrl => BuildFailedException.ThrowOnUnsupportedProperty(); - /// - /// Always : a local build has no CI bot. - public override GitIdentity? CIBotIdentity => null; - /// public override string? PushUsername => null; /// public override string? PushPassword => null; - /// - /// Always . - public override bool IsCloudBuild => false; - /// /// /// This method is not supported on this adapter and will always throw. diff --git a/src/Buildvana.Tool/Services/ServerAdapters/ServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/ServerAdapter.cs index 706a52a5..9bed9b9e 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/ServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/ServerAdapter.cs @@ -3,7 +3,6 @@ using System; using System.Threading.Tasks; -using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.ServerAdapters.Internal; using Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; using Buildvana.Tool.Services.ServerAdapters.Internal.GitLab; @@ -47,22 +46,6 @@ private protected ServerAdapter() /// public abstract Uri RepositoryUrl { get; } - /// - /// Gets a value indicating whether the current build is taking place - /// on a Continuous Integration server. - /// - /// If the build is taking place on a Continuous Integration server, ; - /// otherwise (i.e. if it is a local build), . - public abstract bool IsCloudBuild { get; } - - /// - /// Gets the identity of the bot user, if any, used for CI operations such as release creation. - /// - /// Depending on the specific build platform, this property may return - /// if the platform does not have a specific bot user for CI operations, or if the bot user is not identifiable. - /// - public abstract GitIdentity? CIBotIdentity { get; } - /// /// Gets the username used for pushing to the remote repository. /// diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index ba9efd07..28e9b520 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -22,6 +22,7 @@ using Buildvana.Tool.Services.DeclaredApiFiles; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Hooks; +using Buildvana.Tool.Services.Platforms; using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Utilities; @@ -47,6 +48,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var home = services.GetRequiredService(); var versioningSettings = services.GetRequiredService(); + var platform = services.GetRequiredService(); var server = services.GetRequiredService(); var version = services.GetRequiredService(); var dotnet = services.GetRequiredService(); @@ -62,17 +64,17 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) // repository's version history, and the public API files. A build changes none of them, so it // all runs first: a release that cannot succeed is refused at once, before there is anything // to build or to roll back. - BuildFailedException.ThrowIfNot(server.IsCloudBuild, "A release can only be created on a known cloud build platform."); + BuildFailedException.ThrowIfNot(platform.IsCI, "A release can only be created on a CI runner."); BuildFailedException.ThrowIf(string.IsNullOrEmpty(git.CurrentBranch), "A release can only be created from a branch."); BuildFailedException.ThrowIfNot(version.IsPublicRelease, "Cannot create a release from the current branch."); - // Resolve the committer identity for release commits: the configured git.identity outranks the CI - // bot identity supplied by the server adapter, which outranks whatever the repository's own Git - // configuration happens to state; with none of the three, the release cannot commit. + // Resolve the committer identity for release commits: the configured git.identity outranks the bot + // identity of the CI platform, which outranks whatever the repository's own Git configuration + // happens to state; with none of the three, the release cannot commit. var configuredIdentity = config.Git.Identity; var identity = configuredIdentity is not null ? new GitIdentity(configuredIdentity.Name, configuredIdentity.Email) - : server.CIBotIdentity; + : platform.BotIdentity; if (identity is not null) { git.CommitterIdentity = identity; diff --git a/src/Buildvana.Tool/Utilities/EnvVarHelper.cs b/src/Buildvana.Tool/Utilities/EnvVarHelper.cs deleted file mode 100644 index 05586ff2..00000000 --- a/src/Buildvana.Tool/Utilities/EnvVarHelper.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using Buildvana.Core; - -namespace Buildvana.Tool.Utilities; - -/// -/// Provides helper methods for accessing environment variables. -/// -internal static class EnvVarHelper -{ - /// - /// Returns the value of the named environment variable, or fails the build if it is missing or empty. - /// - /// The environment variable name. - /// The non-empty value of the environment variable. - /// The environment variable is missing or empty. - public static string Require(string name) - => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v - ? v - : throw new BuildFailedException($"Required environment variable {name} is missing or empty."); -} diff --git a/tests/Buildvana.Tool.Tests/CIPlatformTests.cs b/tests/Buildvana.Tool.Tests/CIPlatformTests.cs new file mode 100644 index 00000000..932e3a1e --- /dev/null +++ b/tests/Buildvana.Tool.Tests/CIPlatformTests.cs @@ -0,0 +1,143 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Core; +using Buildvana.Core.Testing; +using Buildvana.Tool.Services.Platforms; + +internal sealed class CIPlatformTests +{ + [Test] + public async Task Detect_WithEmptyEnvironment_ReturnsLocalPlatform() + { + var platform = Detect(); + + await Assert.That(platform).IsTypeOf(); + await Assert.That(platform.IsCI).IsFalse(); + await Assert.That(platform.BotIdentity).IsNull(); + await Assert.That(platform.HasStepOutputs).IsFalse(); + } + + [Test] + [Arguments("true")] + [Arguments("TRUE")] + public async Task Detect_WithGitHubActionsTrue_ReturnsGitHubActionsPlatform(string value) + { + var platform = Detect(("GITHUB_ACTIONS", value), ("CI", "true")); + + await Assert.That(platform).IsTypeOf(); + await Assert.That(platform.IsCI).IsTrue(); + await Assert.That(platform.BotIdentity!.Name).IsEqualTo("github-actions[bot]"); + await Assert.That(platform.HasStepOutputs).IsTrue(); + } + + // GitLab CI sets CI to true, which the generic detector would match too. + [Test] + [Arguments("true")] + [Arguments("")] + public async Task Detect_WithGitLabCISet_ReturnsGitLabCIPlatform(string value) + { + var platform = Detect(("GITLAB_CI", value), ("CI", "true"), ("CI_SERVER_HOST", "gitlab.example.invalid")); + + await Assert.That(platform).IsTypeOf(); + await Assert.That(platform.IsCI).IsTrue(); + await Assert.That(platform.BotIdentity!.Email).IsEqualTo("gitlab-ci@noreply.gitlab.example.invalid"); + await Assert.That(platform.HasStepOutputs).IsFalse(); + } + + [Test] + public async Task Detect_WithGitLabCIAndGitHubActions_ReturnsGitLabCIPlatform() + { + var platform = Detect(("GITLAB_CI", "true"), ("GITHUB_ACTIONS", "true")); + + await Assert.That(platform).IsTypeOf(); + } + + [Test] + [Arguments("CI", "true")] + [Arguments("CI", "1")] + [Arguments("CI", "Yes")] + [Arguments("CI", "ON")] + [Arguments("TF_BUILD", "True")] + [Arguments("GITHUB_ACTIONS", "1")] + [Arguments("APPVEYOR", "true")] + [Arguments("TRAVIS", "true")] + [Arguments("CIRCLECI", "true")] + [Arguments("TEAMCITY_VERSION", "2025.1")] + [Arguments("JB_SPACE_API_URL", "https://space.example.invalid")] + public async Task Detect_WithOneCIMarker_ReturnsGenericCIPlatform(string name, string value) + { + var platform = Detect((name, value)); + + await Assert.That(platform).IsTypeOf(); + await Assert.That(platform.IsCI).IsTrue(); + await Assert.That(platform.BotIdentity).IsNull(); + await Assert.That(platform.HasStepOutputs).IsFalse(); + } + + [Test] + [Arguments("CODEBUILD_BUILD_ID", "AWS_REGION")] + [Arguments("BUILD_ID", "BUILD_URL")] + [Arguments("BUILD_ID", "PROJECT_ID")] + public async Task Detect_WithBothVariablesOfAPair_ReturnsGenericCIPlatform(string first, string second) + { + await Assert.That(Detect((first, "x"), (second, "y"))).IsTypeOf(); + await Assert.That(Detect((first, "x"))).IsTypeOf(); + await Assert.That(Detect((second, "y"))).IsTypeOf(); + } + + // A value the .NET CLI does not read as true marks no CI runner, whatever the runner meant by it. + [Test] + [Arguments("false")] + [Arguments("0")] + [Arguments("")] + [Arguments("woodpecker")] + [Arguments(" true")] + public async Task Detect_WithCINotTrue_ReturnsLocalPlatform(string value) + { + await Assert.That(Detect(("CI", value))).IsTypeOf(); + } + + [Test] + public async Task Detect_WithEmptyTeamCityVersion_ReturnsLocalPlatform() + { + await Assert.That(Detect(("TEAMCITY_VERSION", string.Empty))).IsTypeOf(); + } + + [Test] + public async Task OpenStepOutputs_OnGitHubActionsWithOutputFile_WritesToTheFile() + { + using var home = new TempHome(); + var path = home.GetFullPath("output.txt"); + var platform = Detect(("GITHUB_ACTIONS", "true"), ("GITHUB_OUTPUT", path)); + + platform.OpenStepOutputs().Write("version", "1.2.3"); + + await Assert.That(await File.ReadAllTextAsync(path).ConfigureAwait(false)).IsEqualTo("version=1.2.3" + Environment.NewLine); + } + + [Test] + [Arguments(null)] + [Arguments("")] + public async Task OpenStepOutputs_OnGitHubActionsWithoutOutputFile_Fails(string? path) + { + var platform = Detect(("GITHUB_ACTIONS", "true"), ("GITHUB_OUTPUT", path)); + + var exception = await Assert.That(platform.OpenStepOutputs).Throws(); + await Assert.That(exception!.Message).Contains("GITHUB_OUTPUT"); + } + + [Test] + public async Task OpenStepOutputs_OnPlatformWithoutStepOutputs_Throws() + { + var platform = Detect(("CI", "true")); + + await Assert.That(platform.OpenStepOutputs).Throws(); + } + + private static CIPlatform Detect(params (string Name, string? Value)[] variables) + { + var environment = variables.ToDictionary(x => x.Name, x => x.Value, StringComparer.Ordinal); + return CIPlatform.Detect(name => environment.GetValueOrDefault(name)); + } +} diff --git a/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs b/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs index cb05d527..27e5bcba 100644 --- a/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs +++ b/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs @@ -9,36 +9,93 @@ using Buildvana.Tool.CommandLine; using Buildvana.Tool.Infrastructure.DependencyInjection; using Buildvana.Tool.Services; -using Buildvana.Tool.Services.ServerAdapters; +using Buildvana.Tool.Services.Platforms; +using Buildvana.Tool.Services.Solution; using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Subcommands; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; using Spectre.Console.Testing; +// A build pipeline command needs no Git repository: Buildvana SDK computes the version itself, and the one member +// of DotNetService that reads the version is the NuGet push. The home directory of these tests is not a Git +// repository, so VersionService cannot be constructed, and the build pipeline resolves all the same. internal sealed class DotNetServiceResolutionTests { - // Outside GitHub Actions, a build pipeline command needs no Git repository: Buildvana SDK computes the version - // itself, and the one member of DotNetService that reads the version is the NuGet push. The home directory - // below is not a Git repository, so VersionService cannot be constructed, and DotNetService resolves all the - // same. RecordingServerAdapter replaces ServerAdapter.Create, whose GitHubServerAdapter opens the repository - // in its constructor, so the test covers the Lazy dependency alone, and not what a GitHub - // Actions runner resolves. + private static readonly string[] PlatformVariables = ["GITHUB_ACTIONS", "GITLAB_CI"]; + [Test] - public async Task GetRequiredService_HomeDirectoryIsNotGitRepository_ResolvesDotNetService() + public async Task GetRequiredService_OnLocalPlatform_ResolvesBuildPipelineWithoutGitRepository() { - using var home = new TempHome(); + using var home = CreateHome(); using var console = new TestConsole(); - var services = BuildServiceProvider(home, console); + var processRunner = new FakeProcessRunner(); + var services = BuildServiceProvider(home, console, processRunner, new LocalPlatform()); await using (services.ConfigureAwait(false)) { await Assert.That(services.GetRequiredService).Throws(); - await Assert.That(services.GetRequiredService()).IsNotNull(); + await Assert.That(services.GetRequiredService()).IsNotNull(); + + await BuildAsync(services).ConfigureAwait(false); + await Assert.That(processRunner.Runs[0].Args).Contains("-p:ContinuousIntegrationBuild=false"); + } + } + + // GitHubServerAdapter, which ServerAdapter.Create selects on GitHub Actions, opens the Git repository in its + // constructor. The build pipeline depends on CIPlatform alone, so no adapter is ever constructed. + [Test] + [NotInParallel] + public async Task GetRequiredService_OnGitHubActions_ResolvesBuildPipelineWithoutGitRepository() + { + var originals = PlatformVariables.ToDictionary(x => x, Environment.GetEnvironmentVariable); + Environment.SetEnvironmentVariable("GITHUB_ACTIONS", "true"); + Environment.SetEnvironmentVariable("GITLAB_CI", null); + try + { + using var home = CreateHome(); + using var console = new TestConsole(); + var processRunner = new FakeProcessRunner(); + var services = BuildServiceProvider(home, console, processRunner, platform: null); + await using (services.ConfigureAwait(false)) + { + await Assert.That(services.GetRequiredService()).IsTypeOf(); + await Assert.That(services.GetRequiredService).Throws(); + await Assert.That(services.GetRequiredService()).IsNotNull(); + + await BuildAsync(services).ConfigureAwait(false); + await Assert.That(processRunner.Runs[0].Args).Contains("-p:ContinuousIntegrationBuild=true"); + } + } + finally + { + foreach (var (name, value) in originals) + { + Environment.SetEnvironmentVariable(name, value); + } } } - private static ServiceProvider BuildServiceProvider(TempHome home, IAnsiConsole console) - => new ServiceCollection() + private static TempHome CreateHome() + { + var home = new TempHome(); + home.WriteFile("Test.slnx", "\n"); + return home; + } + + private static Task BuildAsync(ServiceProvider services) + => services.GetRequiredService().BuildSolutionAsync( + services.GetRequiredService(), + "Release", + restore: false); + + // A null platform leaves the detection of AddBvServices in place. + private static ServiceProvider BuildServiceProvider( + TempHome home, + IAnsiConsole console, + FakeProcessRunner processRunner, + CIPlatform? platform) + { + var collection = new ServiceCollection() .AddSingleton(console) .AddSingleton(new CaptureReporter()) .AddSingleton(new GlobalSettings(null, false, false, true, true, true, false)) @@ -47,7 +104,12 @@ private static ServiceProvider BuildServiceProvider(TempHome home, IAnsiConsole .AddBvServices() // The boundaries, faked. Registered last, so that they win over what AddBvServices registers. - .AddSingleton(new FakeProcessRunner()) - .AddSingleton(static sp => new RecordingServerAdapter(sp) { CloudBuild = false }) - .BuildServiceProvider(); + .AddSingleton(processRunner); + if (platform is not null) + { + _ = collection.AddSingleton(platform); + } + + return collection.BuildServiceProvider(); + } } diff --git a/tests/Buildvana.Tool.Tests/FakeCIPlatform.cs b/tests/Buildvana.Tool.Tests/FakeCIPlatform.cs new file mode 100644 index 00000000..dd12d499 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/FakeCIPlatform.cs @@ -0,0 +1,30 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Platforms; + +/// +/// A script fake for : every member answers with a constant. The platform has no step outputs. +/// +internal sealed class FakeCIPlatform : CIPlatform +{ + /// + /// Gets the value of . + /// + public bool RunsOnCI { get; init; } = true; + + /// + /// Gets the value of . + /// + public GitIdentity? Identity { get; init; } = new("Buildvana Test Bot", "bot@buildvana.invalid"); + + /// + public override string Name => "Test CI platform"; + + /// + public override bool IsCI => RunsOnCI; + + /// + public override GitIdentity? BotIdentity => Identity; +} diff --git a/tests/Buildvana.Tool.Tests/GitHubActionsStepOutputWriterTests.cs b/tests/Buildvana.Tool.Tests/GitHubActionsStepOutputWriterTests.cs new file mode 100644 index 00000000..0056e122 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/GitHubActionsStepOutputWriterTests.cs @@ -0,0 +1,66 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Core.Testing; +using Buildvana.Tool.Services.Platforms; + +internal sealed class GitHubActionsStepOutputWriterTests +{ + [Test] + public async Task Write_AppendsOneLinePerSingleLineValue() + { + using var home = new TempHome(); + var path = home.GetFullPath("output.txt"); + await File.WriteAllTextAsync(path, "earlier=output\n").ConfigureAwait(false); + var writer = new GitHubActionsStepOutputWriter(path); + + writer.Write("version", "1.2.3"); + writer.Write("empty", string.Empty); + + string[] expected = ["earlier=output", "version=1.2.3", "empty="]; + await Assert.That(await File.ReadAllLinesAsync(path).ConfigureAwait(false)).IsEquivalentTo(expected); + } + + // A byte order mark would come before the first output name. + [Test] + public async Task Write_ToNewFile_WritesNoByteOrderMark() + { + using var home = new TempHome(); + var path = home.GetFullPath("output.txt"); + + new GitHubActionsStepOutputWriter(path).Write("version", "1.2.3"); + + var bytes = await File.ReadAllBytesAsync(path).ConfigureAwait(false); + await Assert.That(bytes[0]).IsEqualTo((byte)'v'); + } + + [Test] + public async Task Write_MultiLineValue_UsesDelimiterForm() + { + using var home = new TempHome(); + var path = home.GetFullPath("output.txt"); + + new GitHubActionsStepOutputWriter(path).Write("notes", "first\nsecond"); + + var lines = await File.ReadAllLinesAsync(path).ConfigureAwait(false); + await Assert.That(lines.Length).IsEqualTo(4); + await Assert.That(lines[0]).StartsWith("notes<<"); + var delimiter = lines[0]["notes<<".Length..]; + await Assert.That(delimiter.Length).IsGreaterThan(0); + await Assert.That(lines[1]).IsEqualTo("first"); + await Assert.That(lines[2]).IsEqualTo("second"); + await Assert.That(lines[3]).IsEqualTo(delimiter); + } + + [Test] + [Arguments("a=b")] + [Arguments("a writer.Write(name, "value")).Throws(); + } +} diff --git a/tests/Buildvana.Tool.Tests/RecordingServerAdapter.cs b/tests/Buildvana.Tool.Tests/RecordingServerAdapter.cs index 861501be..ac8ffede 100644 --- a/tests/Buildvana.Tool.Tests/RecordingServerAdapter.cs +++ b/tests/Buildvana.Tool.Tests/RecordingServerAdapter.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; @@ -11,9 +10,8 @@ /// /// The service provider handed to the created release. /// -/// The fake deliberately carries no logic. ServerAdapter is to be split into a CI-platform adapter -/// and a Git-host adapter; the knobs below are grouped by the side each one will end up on, so that the split -/// can cut this class in two without rewriting what it answers. +/// The fake deliberately carries no logic. answers what the CI platform +/// answers. /// The URLs come from , so that a release over the fake sees the same /// rejections a release on GitHub does: a file URL for a path outside the repository is an error, not a URL. /// @@ -21,27 +19,12 @@ internal sealed class RecordingServerAdapter(IServiceProvider services) : Server { private static readonly GitHubRepositoryUrls Urls = new("git.example.invalid", "tenacom", "test-repo"); - // --- Platform side --- - - /// - /// Gets a value indicating whether the build is a cloud build (). - /// - public bool CloudBuild { get; init; } = true; - - /// - /// Gets the identity the CI bot commits with (), or - /// for a platform that has none. - /// - public GitIdentity? BotIdentity { get; init; } = new("Buildvana Test Bot", "bot@buildvana.invalid"); - /// /// Gets the secret half of the push credentials (), or /// for a platform that provides none. /// public string? PushSecret { get; init; } = "test-token"; - // --- Host side --- - /// /// Gets the username half of the push credentials (), or /// for a host with no username convention. @@ -73,12 +56,6 @@ internal sealed class RecordingServerAdapter(IServiceProvider services) : Server /// public override Uri RepositoryUrl => Urls.Repository; - /// - public override bool IsCloudBuild => CloudBuild; - - /// - public override GitIdentity? CIBotIdentity => BotIdentity; - /// public override string? PushUsername => PushUser; diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs index 43d969f5..bf09d44e 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs @@ -75,13 +75,13 @@ public async Task Release_WhenPublicationFails_ResetsAndForcePushesTheBranch() [Test] public async Task Release_OnLocalBuild_Fails() { - using var harness = new ReleaseHarness(new() { CloudBuild = false }); + using var harness = new ReleaseHarness(new() { IsCI = false }); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("cloud build"); + await Assert.That(exception!.Message).Contains("CI runner"); // The preliminary checks run before the pipeline, so a release that can never succeed costs // nothing: no clean, no build, no test run. diff --git a/tests/Buildvana.Tool.Tests/ReleaseHarness.cs b/tests/Buildvana.Tool.Tests/ReleaseHarness.cs index 033d2a71..41124e5d 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseHarness.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseHarness.cs @@ -14,6 +14,7 @@ using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Infrastructure.DependencyInjection; using Buildvana.Tool.Services; +using Buildvana.Tool.Services.Platforms; using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Subcommands; using Microsoft.Extensions.DependencyInjection; @@ -23,7 +24,8 @@ /// /// Runs bv release end to end over a real Git repository in a temporary directory, with only the /// process and platform boundaries faked: child processes (), hooks -/// (), and the server (). Everything +/// (), the CI platform (), and the server +/// (). Everything /// else — the service graph, the versioning, the changelog, the commits, and the push — is the real thing. /// /// @@ -229,9 +231,11 @@ private ServiceProvider BuildServiceProvider() // The boundaries, faked. Registered last, so that they win over what AddBvServices registers. .AddSingleton(ProcessRunner) .AddSingleton(AppRunner) + .AddSingleton(new FakeCIPlatform { + RunsOnCI = _options.IsCI, + Identity = _options.WithBotIdentity ? new("Buildvana Test Bot", "bot@buildvana.invalid") : null, + }) .AddSingleton(sp => new RecordingServerAdapter(sp) { - CloudBuild = _options.CloudBuild, - BotIdentity = _options.WithBotIdentity ? new("Buildvana Test Bot", "bot@buildvana.invalid") : null, PushUser = _options.PushUser, PushSecret = _options.PushSecret, OnPublishing = () => { diff --git a/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs b/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs index f41367c6..0760107c 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs @@ -5,7 +5,7 @@ /// /// The knobs of a : what the repository contains and how the configuration file, -/// the server adapter, and the hook are set up. Every one of them has the value a plain prerelease release +/// the CI platform, the server adapter, and the hook are set up. Every one of them has the value a plain prerelease release /// needs, so a test only names what its own scenario changes. /// internal sealed record ReleaseHarnessOptions @@ -73,9 +73,9 @@ internal sealed record ReleaseHarnessOptions public bool WithHook { get; init; } /// - /// Gets a value indicating whether the build is a cloud build. + /// Gets a value indicating whether the release runs on a CI runner. /// - public bool CloudBuild { get; init; } = true; + public bool IsCI { get; init; } = true; /// /// Gets the identity stated as git.identity in the configuration file, or @@ -84,7 +84,7 @@ internal sealed record ReleaseHarnessOptions public GitIdentity? ConfiguredIdentity { get; init; } /// - /// Gets a value indicating whether the server adapter provides a CI bot identity. + /// Gets a value indicating whether the CI platform provides a bot identity. /// public bool WithBotIdentity { get; init; } = true; From 3f552bcc95c9d5c476c58520150a4e7c601fd877 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 00:43:08 +0200 Subject: [PATCH 03/12] Replace IGitService.Push with remote-explicit branch and tag operations Push always went to the remote HEAD tracks, and there was no way to create, push, or fetch a tag. Each operation now takes the remote name. A push the remote rejects now fails, where it used to pass as a success. CommitterIdentity no longer writes to .git/config. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + docs/tool-commands/release.md | 2 + src/Buildvana.Core.Testing/TempGitRepo.cs | 39 +++ src/Buildvana.Tool/Services/Git/GitService.cs | 159 ++++++++++-- .../Services/Git/IGitService.cs | 60 ++++- .../Services/ServerAdapters/ServerRelease.cs | 4 +- .../Subcommands/ReleaseCommand.cs | 2 +- tests/Buildvana.Tool.Tests/GitServiceTests.cs | 239 ++++++++++++++++++ 8 files changed, 472 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd8d200..08ed3411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - `bv clean` no longer deletes the `TestResults` directory of each test project. - `bv release` moves the self-reference rewrites out of the release commit into a [post-release commit](docs/tool-commands/release.md#the-post-release-commit), so the release tag names the source state that was built. - `bv release` takes the [identity of its commits](docs/tool-commands/release.md#preconditions) from `git.identity`, then from the CI bot identity, then from the Git configuration. +- `bv release` no longer writes `user.name` and `user.email` to the Git configuration of the repository, and keeps [its committer identity](docs/tool-commands/release.md#preconditions) in memory. - `bv` renders a message as a [level label and a colored line](docs/command-line.md#verbosity), where it used to prefix a log level and a class-name category. - `bv` streams the [output of `dotnet`](docs/command-line.md#output-streams) through as it arrives, where it used to hide the output unless the build failed. - The build pipeline commands and `bv release` [observe cancellation](docs/command-line.md#cancellation), terminate the running `dotnet` process, and exit with code 130. @@ -133,6 +134,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - The [`AdditionalAssemblyInfo` module](docs/sdk-modules/additional-assembly-info.md#clscompliant-property) generates `CLSCompliant(true)` and `ComVisible(true)` for any value MSBuild reads as true, such as `yes`, where it generated `false`. - [`bv test`](docs/tool-commands/build-pipeline.md#bv-test) runs the tests of a project that sets `IsTestingPlatformApplication` to a value MSBuild reads as true, such as `yes`, where it skipped the project. - The [`ConvertToWinePaths` task](docs/sdk-modules/wine.md#converttowinepaths-task) converts an item when MSBuild reads the metadata named by `OnlyIfMetadata` as true, such as `yes`, where it skipped the item. +- `bv release` fails when the remote rejects the [push of its commits](docs/tool-commands/release.md#publishing), where it used to go on as if the push had succeeded. - When a `PackageVersion` item versions a package Buildvana SDK injects, the reference [is an ordinary `PackageReference`](docs/sdk-modules/standard-analyzers.md#package-references), where NuGet rejected the item with NU1009. ### Known problems introduced by this release diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index 544dde1e..183bb68c 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -41,6 +41,7 @@ Before building anything, `bv release` checks what a build cannot change: The three are the `git.identity` setting of `buildvana.jsonc`, the bot identity of the CI platform, and the `user.name` and `user.email` of the Git configuration. On GitHub Actions, the bot identity is `github-actions[bot]`. On GitLab CI, it is `GitLab CI`, with the address `gitlab-ci@noreply.` built from [`CI_SERVER_HOST`](../environment-variables.md#ci_server_host). + `bv` commits with that identity, and writes nothing to the Git configuration. 3. The platform supplies push credentials. On GitHub Actions, they are the token that [`hosts.github.tokenEnv`](../environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) names. Without them, `bv` warns that the push may fail, and goes on. @@ -154,6 +155,7 @@ When neither the hook nor the rewrites changed a file, there is no post-release ## Publishing `bv release` pushes the commits, then every `.nupkg` file of `artifacts\\`, with `dotnet nuget push --skip-duplicate`. +When the remote rejects the push of the commits, the release fails. `dotnet nuget push` gets the arguments and environment variables of the `dotnet.all` and `dotnet.nugetPush` sections of `buildvana.jsonc`, and no forwarded argument. A prerelease goes to the `nuget.feeds.prerelease` feed, and a stable version to the `nuget.feeds.release` feed. A repository that states no prerelease feed pushes prereleases to the release feed. diff --git a/src/Buildvana.Core.Testing/TempGitRepo.cs b/src/Buildvana.Core.Testing/TempGitRepo.cs index b95b4abb..743d2665 100644 --- a/src/Buildvana.Core.Testing/TempGitRepo.cs +++ b/src/Buildvana.Core.Testing/TempGitRepo.cs @@ -225,6 +225,45 @@ public IReadOnlyList GetCommits(int count) return remote.Branches[CurrentBranchName]?.Tip?.Sha; } + /// + /// Gets the SHA of the commit a tag points to on a remote added by . + /// + /// The name of the tag. + /// The name of the remote. + /// The SHA of the commit, or if the remote has no such tag. + public string? GetRemoteTagSha(string tag, string name = "origin") + { + using var remote = new Repository(_remotePaths[name]); + return remote.Tags[tag]?.Target.Sha; + } + + /// + /// Creates a lightweight tag on a remote added by , at the tip of the branch + /// homonymous to the current one. + /// + /// The name of the tag. + /// The name of the remote. + /// + /// The branch must already have been pushed to the remote. + /// + public void CreateRemoteTag(string tag, string name = "origin") + { + var branchName = CurrentBranchName; + using var remote = new Repository(_remotePaths[name]); + _ = remote.Tags.Add(tag, remote.Branches[branchName].Tip); + } + + /// + /// Gets a value from the repository's local configuration. + /// + /// The configuration key, such as user.name. + /// The value, or if the local configuration does not hold the key. + public string? GetLocalConfigValue(string key) + { + using var repository = new Repository(RootPath); + return repository.Config.Get(key, ConfigurationLevel.Local)?.Value; + } + /// /// Stages all changes and creates a commit, which may be empty. /// diff --git a/src/Buildvana.Tool/Services/Git/GitService.cs b/src/Buildvana.Tool/Services/Git/GitService.cs index 40b173cf..7b4a5ca8 100644 --- a/src/Buildvana.Tool/Services/Git/GitService.cs +++ b/src/Buildvana.Tool/Services/Git/GitService.cs @@ -15,6 +15,7 @@ using CommunityToolkit.Diagnostics; using JetBrains.Annotations; using LibGit2Sharp; +using LibGit2Sharp.Handlers; using NuGet.Versioning; namespace Buildvana.Tool.Services.Git; @@ -62,21 +63,18 @@ public GitIdentity? CommitterIdentity { get { - var signature = _repository.Config.BuildSignature(DateTimeOffset.Now); - return signature is null ? null : new(signature.Name, signature.Email); + return field ?? GetConfiguredIdentity(); } set { Guard.IsNotNull(value); - - _repository.Config.Set("user.name", value.Name); - _repository.Config.Set("user.email", value.Email); + field = value; } } /// - public GitCredentials? PushCredentialsFallback { get; set; } + public GitCredentials? RemoteCredentialsFallback { get; set; } /// public string HeadSha => _repository.Head.Tip.Sha; @@ -188,8 +186,9 @@ public void Stage(params string[] paths) /// public void Commit(string message, bool amend = false, bool allowEmpty = false) { - var signature = _repository.Config.BuildSignature(DateTimeOffset.Now); - BuildFailedException.ThrowIf(signature is null, "Git: committer identity not set."); + var identity = CommitterIdentity; + BuildFailedException.ThrowIf(identity is null, "Git: committer identity not set."); + var signature = new Signature(identity.Name, identity.Email, DateTimeOffset.Now); var options = new CommitOptions { AmendPreviousCommit = amend, AllowEmptyCommit = allowEmpty }; _ = _repository.Commit(message, signature, signature, options); } @@ -204,39 +203,96 @@ public void UndoLastCommit() } /// - public void Push(bool force = false) + public void PushBranch(string remote, bool force = false) { + Guard.IsNotNullOrEmpty(remote); var head = _repository.Head; - var remote = head.RemoteName; - BuildFailedException.ThrowIf(string.IsNullOrEmpty(remote), "Git: cannot push, HEAD is not tracking any remote."); - var pushOptions = new PushOptions(); - var pushCredentialsFallback = PushCredentialsFallback; - if (pushCredentialsFallback is not null) - { - pushOptions.CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials { - Username = pushCredentialsFallback.Username, - Password = pushCredentialsFallback.Password, - }; - } - + var isOnBranch = head.CanonicalName.StartsWith("refs/heads/", StringComparison.Ordinal); + BuildFailedException.ThrowIfNot(isOnBranch, "Git: cannot push, HEAD is not on a branch."); if (force) { // https://stackoverflow.com/a/47295101/5753412 // https://github.com/libgit2/libgit2sharp/blob/5085a0c617/LibGit2Sharp.Tests/PushFixture.cs#L183-L187 // https://github.com/libgit2/libgit2sharp/issues/104#issuecomment-1553347893 - _reporter.Info($"Force pushing changes to '{remote}'..."); - var pushRefSpec = string.Format(CultureInfo.InvariantCulture, "+{0}:{0}", _repository.Head.CanonicalName); - _repository.Network.Push(_repository.Network.Remotes[remote], pushRefSpec, pushOptions); + _reporter.Info($"Force pushing '{head.FriendlyName}' to '{remote}'..."); + Push(remote, $"+{head.CanonicalName}:{head.CanonicalName}"); _reporter.Notice($"Force-pushed '{head.FriendlyName}' to '{remote}'."); } else { - _reporter.Info($"Pushing changes to '{remote}'..."); - _repository.Network.Push(head, pushOptions); + _reporter.Info($"Pushing '{head.FriendlyName}' to '{remote}'..."); + Push(remote, $"{head.CanonicalName}:{head.CanonicalName}"); _reporter.Notice($"Pushed '{head.FriendlyName}' to '{remote}'."); } } + /// + public void CreateTag(string tag, string sha) + { + Guard.IsNotNullOrEmpty(tag); + Guard.IsNotNullOrEmpty(sha); + _ = _repository.Tags.Add(tag, sha); + _reporter.Notice($"Created tag '{tag}' at {sha}."); + } + + /// + public void PushTag(string remote, string tag) + { + Guard.IsNotNullOrEmpty(remote); + Guard.IsNotNullOrEmpty(tag); + var tagRef = $"refs/tags/{tag}"; + var localTarget = _repository.Refs[tagRef]?.TargetIdentifier; + BuildFailedException.ThrowIf(localTarget is null, $"Git: cannot push tag '{tag}', because it does not exist."); + + // LibGit2Sharp moves a remote tag forward as if it were a branch, where Git refuses to update a tag without + // force. Asking the remote first gives a tag the treatment Git gives it. + _reporter.Info($"Looking for tag '{tag}' on '{remote}'..."); + var remoteTarget = ListRemoteReferences(remote) + .FirstOrDefault(x => string.Equals(x.CanonicalName, tagRef, StringComparison.Ordinal)) + ?.TargetIdentifier; + if (remoteTarget is not null) + { + BuildFailedException.ThrowIfNot( + string.Equals(remoteTarget, localTarget, StringComparison.Ordinal), + $"Git: cannot push tag '{tag}', because '{remote}' has a tag of the same name at {remoteTarget}."); + _reporter.Notice($"Tag '{tag}' is already on '{remote}'."); + return; + } + + _reporter.Info($"Pushing tag '{tag}' to '{remote}'..."); + Push(remote, $"{tagRef}:{tagRef}"); + _reporter.Notice($"Pushed tag '{tag}' to '{remote}'."); + } + + /// + public void DeleteRemoteTag(string remote, string tag) + { + Guard.IsNotNullOrEmpty(remote); + Guard.IsNotNullOrEmpty(tag); + _reporter.Info($"Deleting tag '{tag}' from '{remote}'..."); + Push(remote, $":refs/tags/{tag}"); + _reporter.Notice($"Deleted tag '{tag}' from '{remote}'."); + } + + /// + public void DeleteTag(string tag) + { + Guard.IsNotNullOrEmpty(tag); + _repository.Tags.Remove(tag); + _reporter.Notice($"Deleted local tag '{tag}'."); + } + + /// + public void FetchTags(string remote) + { + Guard.IsNotNullOrEmpty(remote); + using var gitRemote = GetRemote(remote); + _reporter.Info($"Fetching tags from '{remote}'..."); + var options = new FetchOptions { CredentialsProvider = CreateCredentialsProvider() }; + Commands.Fetch(_repository, gitRemote.Name, ["refs/tags/*:refs/tags/*"], options, logMessage: null); + _reporter.Notice($"Fetched tags from '{remote}'."); + } + private bool TryGetOriginInfo([MaybeNullWhen(false)] out string name, [MaybeNullWhen(false)] out string url) { name = null!; @@ -292,4 +348,55 @@ private bool TryGetOriginInfo([MaybeNullWhen(false)] out string name, [MaybeNull _reporter.Detail($"Origin remote is '{name}' ({url})"); return true; } + + private GitIdentity? GetConfiguredIdentity() + { + var signature = _repository.Config.BuildSignature(DateTimeOffset.Now); + return signature is null ? null : new(signature.Name, signature.Email); + } + + private void Push(string remote, string refSpec) + { + using var gitRemote = GetRemote(remote); + + // LibGit2Sharp reports a reference the remote refused through OnPushStatusError, and nowhere else: + // without the handler, a push that the server rejects returns as if it had succeeded. + // The handler only collects, because an exception thrown from it would have to cross native code. + var rejections = new List(); + var options = new PushOptions { + CredentialsProvider = CreateCredentialsProvider(), + OnPushStatusError = rejections.Add, + }; + _repository.Network.Push(gitRemote, refSpec, options); + if (rejections.Count > 0) + { + var details = string.Join("; ", rejections.Select(x => $"{x.Reference}: {x.Message}")); + throw new BuildFailedException($"Git: '{remote}' rejected the push. {details}"); + } + } + + private IReadOnlyList ListRemoteReferences(string remote) + { + using var gitRemote = GetRemote(remote); + var credentialsProvider = CreateCredentialsProvider(); + var references = credentialsProvider is null + ? _repository.Network.ListReferences(gitRemote) + : _repository.Network.ListReferences(gitRemote, credentialsProvider); + return [.. references]; + } + + private Remote GetRemote(string name) + { + var remote = _repository.Network.Remotes[name]; + BuildFailedException.ThrowIf(remote is null, $"Git: there is no remote named '{name}'."); + return remote; + } + + private CredentialsHandler? CreateCredentialsProvider() + { + var credentials = RemoteCredentialsFallback; + return credentials is null + ? null + : (_, _, _) => new UsernamePasswordCredentials { Username = credentials.Username, Password = credentials.Password }; + } } diff --git a/src/Buildvana.Tool/Services/Git/IGitService.cs b/src/Buildvana.Tool/Services/Git/IGitService.cs index a5bd2961..c1a7996d 100644 --- a/src/Buildvana.Tool/Services/Git/IGitService.cs +++ b/src/Buildvana.Tool/Services/Git/IGitService.cs @@ -36,18 +36,23 @@ internal interface IGitService /// /// Gets or sets the identity of the Git committer. /// + /// The identity last set on this instance, if any; otherwise, the identity that the Git configuration + /// states, or if it states none. + /// + /// A set identity lives in this instance alone, and nothing writes it to the Git configuration. + /// [DisallowNull] GitIdentity? CommitterIdentity { get; set; } /// - /// Gets or sets the credentials used for pushing to the Git repository if ambient credentials are not sufficient. + /// Gets or sets the credentials used for fetching from and pushing to a remote if ambient credentials are not sufficient. /// /// /// Set this property when ambient mechanisms (`http.extraheader` written by CI checkout actions, /// URL-embedded credentials, OS credential helpers) are absent or insufficient. - /// The provided credentials are tried only after the server returns a 401 challenge to the initial push request. + /// The provided credentials are tried only after the server returns a 401 challenge to the initial request. /// - GitCredentials? PushCredentialsFallback { get; set; } + GitCredentials? RemoteCredentialsFallback { get; set; } /// /// Gets the SHA of the current HEAD commit. @@ -120,8 +125,51 @@ internal interface IGitService void UndoLastCommit(); /// - /// Pushes changes made to HEAD to the tracked remote. Fails if HEAD is not tracking any remote. + /// Pushes the current branch to the branch of the same name on a remote. + /// + /// The name of the remote. + /// If , replaces the remote branch even if the push is not a fast-forward. + /// + /// This method fails if HEAD is not on a branch, and if the remote rejects the push. + /// The tracking information of the branch plays no part in the push. + /// + void PushBranch(string remote, bool force = false); + + /// + /// Creates a lightweight tag in the local Git repository. + /// + /// The name of the tag. + /// The SHA of the commit the tag points to. + void CreateTag(string tag, string sha); + + /// + /// Pushes a tag of the local Git repository to a remote. + /// + /// The name of the remote. + /// The name of the tag. + /// + /// As Git does, this method never moves a tag of the remote. When the remote has a tag of the same name, + /// this method does nothing if the two tags point to the same object, and fails otherwise. + /// This method also fails if the local repository has no such tag, and if the remote rejects the push. + /// + void PushTag(string remote, string tag); + + /// + /// Deletes a tag from a remote. + /// + /// The name of the remote. + /// The name of the tag. + void DeleteRemoteTag(string remote, string tag); + + /// + /// Deletes a tag from the local Git repository. + /// + /// The name of the tag. + void DeleteTag(string tag); + + /// + /// Fetches every tag of a remote into the local Git repository. /// - /// If , force-pushes HEAD to the tracked remote. - void Push(bool force = false); + /// The name of the remote. + void FetchTags(string remote); } diff --git a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs index d5f17dfb..c84ad0a3 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs @@ -113,7 +113,7 @@ public void EnsureReleaseCommit() { // "Undo" the push by force pushing the previous commit // (to which we have just reset). - _git.Push(force: true); + _git.PushBranch(_git.Origin, force: true); } }); } @@ -204,7 +204,7 @@ public void PushUpdates() return; } - _git.Push(); + _git.PushBranch(_git.Origin); _updatesPushed = true; // The rollback action is defined in EnsureReleaseCommit, because diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index 28e9b520..8aeea448 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -94,7 +94,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) if (pushUsername is not null && pushPassword is not null) { reporter.Info($"Fallback push credentials provided by the server adapter (protocol username: '{pushUsername}')."); - git.PushCredentialsFallback = new(pushUsername, pushPassword); + git.RemoteCredentialsFallback = new(pushUsername, pushPassword); } else { diff --git a/tests/Buildvana.Tool.Tests/GitServiceTests.cs b/tests/Buildvana.Tool.Tests/GitServiceTests.cs index 8e97e251..4e5ca4d9 100644 --- a/tests/Buildvana.Tool.Tests/GitServiceTests.cs +++ b/tests/Buildvana.Tool.Tests/GitServiceTests.cs @@ -122,6 +122,221 @@ public async Task TrackChangesAsync_WhenOperationThrows_Propagates() _ = await Assert.That(Act).Throws(); } + [Test] + public async Task CommitterIdentity_WhenSet_CommitsWithIt_AndWritesNothingToConfiguration() + { + using var repo = CreateRepoWithCommit(); + using var git = CreateGitService(repo); + + git.CommitterIdentity = new("Release Bot", "bot@example.invalid"); + git.Commit("Release commit", allowEmpty: true); + + var commit = repo.GetCommits(1)[0]; + await Assert.That(commit.CommitterName).IsEqualTo("Release Bot"); + await Assert.That(commit.CommitterEmail).IsEqualTo("bot@example.invalid"); + await Assert.That(repo.GetLocalConfigValue("user.name")).IsNull(); + await Assert.That(repo.GetLocalConfigValue("user.email")).IsNull(); + } + + [Test] + public async Task CommitterIdentity_WhenNotSet_ReadsGitConfiguration() + { + using var repo = CreateRepoWithCommit(); + repo.SetCommitterIdentity("Local Committer", "local@example.invalid"); + using var git = CreateGitService(repo); + + await Assert.That(git.CommitterIdentity).IsEqualTo(new GitIdentity("Local Committer", "local@example.invalid")); + } + + [Test] + public async Task CommitterIdentity_WhenNeitherSetNorConfigured_IsNull() + { + using var repo = CreateRepoWithCommit(); + using var git = CreateGitService(repo); + + await Assert.That(git.CommitterIdentity).IsNull(); + } + + [Test] + public async Task PushBranch_PushesCurrentBranch_ToHomonymOnRemote() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + + git.PushBranch("origin"); + + await Assert.That(repo.GetRemoteTipSha()).IsEqualTo(repo.HeadSha); + } + + [Test] + public async Task PushBranch_WhenNotFastForward_Throws() + { + using var repo = CreateRepoWithDivergedRemote(out var git); + using (git) + { + var remoteTip = repo.GetRemoteTipSha(); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + _ = await Assert.That(() => git.PushBranch("origin")).ThrowsException(); + await Assert.That(repo.GetRemoteTipSha()).IsEqualTo(remoteTip); + } + } + + [Test] + public async Task PushBranch_WithForce_ReplacesDivergedRemoteBranch() + { + using var repo = CreateRepoWithDivergedRemote(out var git); + using (git) + { + git.PushBranch("origin", force: true); + + await Assert.That(repo.GetRemoteTipSha()).IsEqualTo(repo.HeadSha); + } + } + + [Test] + public async Task PushBranch_WhenHeadIsDetached_Throws() + { + using var repo = CreateRepoWithBareRemote(); + repo.CheckoutDetached(); + using var git = CreateGitService(repo); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + _ = await Assert.That(() => git.PushBranch("origin")).Throws(); + } + + [Test] + public async Task PushBranch_ToMissingRemote_Throws() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + var exception = await Assert.That(() => git.PushBranch("upstream")).Throws(); + await Assert.That(exception!.Message).Contains("'upstream'"); + } + + [Test] + public async Task PushTag_PutsTagOnRemote_AtTheCommitItWasCreatedAt() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + var taggedSha = repo.HeadSha; + git.CommitterIdentity = new("Release Bot", "bot@example.invalid"); + git.Commit("Post-release commit", allowEmpty: true); + git.PushBranch("origin"); + + git.CreateTag("1.0.0", taggedSha); + git.PushTag("origin", "1.0.0"); + + await Assert.That(repo.TagNames).IsEquivalentTo(["1.0.0"]); + await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsEqualTo(taggedSha); + } + + [Test] + public async Task PushTag_WhenRemoteTagPointsToDescendant_Throws() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + var firstSha = repo.HeadSha; + git.CommitterIdentity = new("Release Bot", "bot@example.invalid"); + git.Commit("Second commit", allowEmpty: true); + git.PushBranch("origin"); + repo.CreateRemoteTag("1.0.0"); + git.CreateTag("1.0.0", firstSha); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + _ = await Assert.That(() => git.PushTag("origin", "1.0.0")).Throws(); + await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsEqualTo(repo.HeadSha); + } + + [Test] + public async Task PushTag_WhenRemoteTagPointsToAncestor_Throws_AndLeavesRemoteTagAlone() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + git.PushBranch("origin"); + var firstSha = repo.HeadSha; + repo.CreateRemoteTag("1.0.0"); + git.CommitterIdentity = new("Release Bot", "bot@example.invalid"); + git.Commit("Second commit", allowEmpty: true); + git.PushBranch("origin"); + git.CreateTag("1.0.0", repo.HeadSha); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + var exception = await Assert.That(() => git.PushTag("origin", "1.0.0")).Throws(); + await Assert.That(exception!.Message).Contains(firstSha); + await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsEqualTo(firstSha); + } + + [Test] + public async Task PushTag_WhenRemoteTagPointsToSameCommit_Succeeds() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + git.PushBranch("origin"); + repo.CreateRemoteTag("1.0.0"); + git.CreateTag("1.0.0", repo.HeadSha); + + git.PushTag("origin", "1.0.0"); + + await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsEqualTo(repo.HeadSha); + } + + [Test] + public async Task PushTag_WhenLocalTagDoesNotExist_Throws() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + git.PushBranch("origin"); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + _ = await Assert.That(() => git.PushTag("origin", "1.0.0")).Throws(); + await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsNull(); + } + + [Test] + public async Task DeleteRemoteTag_RemovesTagFromRemote_AndKeepsLocalTag() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + git.PushBranch("origin"); + git.CreateTag("1.0.0", repo.HeadSha); + git.PushTag("origin", "1.0.0"); + + git.DeleteRemoteTag("origin", "1.0.0"); + + await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsNull(); + await Assert.That(repo.TagNames).IsEquivalentTo(["1.0.0"]); + } + + [Test] + public async Task DeleteTag_RemovesLocalTag() + { + using var repo = CreateRepoWithCommit(); + using var git = CreateGitService(repo); + git.CreateTag("1.0.0", repo.HeadSha); + + git.DeleteTag("1.0.0"); + + await Assert.That(repo.TagNames.Count).IsEqualTo(0); + await Assert.That(git.TagExists("1.0.0")).IsFalse(); + } + + [Test] + public async Task FetchTags_BringsTagsOfRemote_IntoLocalRepository() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + git.PushBranch("origin"); + repo.CreateRemoteTag("1.0.0"); + + git.FetchTags("origin"); + + await Assert.That(git.TagExists("1.0.0")).IsTrue(); + await Assert.That(repo.TagNames).IsEquivalentTo(["1.0.0"]); + } + private static TempGitRepo CreateRepoWithCommit() { var repo = new TempGitRepo(); @@ -131,6 +346,30 @@ private static TempGitRepo CreateRepoWithCommit() return repo; } + private static TempGitRepo CreateRepoWithBareRemote() + { + var repo = new TempGitRepo(); + repo.WriteFile("a.txt", "content"); + repo.CommitAll(); + _ = repo.AddBareRemote(); + return repo; + } + + // The remote ends up with a commit that the local branch does not have, and the local branch with one that the + // remote does not have. + private static TempGitRepo CreateRepoWithDivergedRemote(out GitService git) + { + var repo = CreateRepoWithBareRemote(); + git = CreateGitService(repo); + git.CommitterIdentity = new("Release Bot", "bot@example.invalid"); + git.Commit("Pushed commit", allowEmpty: true); + git.PushBranch("origin"); + git.UndoLastCommit(); + repo.WriteFile("a.txt", "diverged"); + repo.CommitAll("Diverged commit"); + return repo; + } + private static GitService CreateGitService(TempGitRepo repo) => new(NullReporter.Instance, new FixedHomeDirectoryProvider(repo.RootPath)); } From 0e912b3e31c345e54a2783bdef01a02806dafd44 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 02:18:19 +0200 Subject: [PATCH 04/12] Replace ServerAdapter with GitHost and ReleasePublisher bv release ran on GitHub Actions alone, pushed to the remote HEAD tracks, and left the tag to GitHub. It now runs on any CI platform, finds the release remote from repository.url, and creates and pushes the tag itself. An unknown host releases without a release page when release.allowUnknownHost is true. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + docs/environment-variables.md | 24 +- docs/introduction.md | 23 +- docs/tool-commands/release.md | 96 ++++++-- src/Buildvana.Core.Testing/TempGitRepo.cs | 11 + .../ServiceCollectionExtensions.cs | 5 +- .../Services/ChangelogService.cs | 34 ++- .../Services/ChangelogUpdater.cs | 18 +- src/Buildvana.Tool/Services/DotNetService.cs | 18 +- .../Services/Git/GitRemoteInfo.cs | 11 + src/Buildvana.Tool/Services/Git/GitService.cs | 79 ++----- .../Services/Git/IGitService.cs | 9 +- src/Buildvana.Tool/Services/Hosts/GitHost.cs | 182 ++++++++++++++ .../GitHub/GitHubApiClient.cs | 2 +- .../GitHub/GitHubErrorBody.cs | 2 +- .../Services/Hosts/GitHub/GitHubHost.cs | 121 ++++++++++ .../GitHub/GitHubJsonContext.cs | 2 +- .../GitHub/GitHubNewRelease.cs | 2 +- .../GitHub/GitHubRelease.cs | 2 +- .../GitHub/GitHubReleaseNotes.cs | 2 +- .../GitHub/GitHubReleaseNotesRequest.cs | 2 +- .../Hosts/GitHub/GitHubReleasePublisher.cs | 144 +++++++++++ .../GitHub/GitHubReleaseUpdate.cs | 2 +- .../GitHub/GitHubRepository.cs | 2 +- .../GitHub/GitHubRepositoryUrls.cs | 2 +- src/Buildvana.Tool/Services/Hosts/GitRef.cs | 65 +++++ .../Services/Hosts/GitRefKind.cs | 19 ++ .../Services/Hosts/ReleasePublisher.cs | 46 ++++ .../Services/Hosts/ReleaseRemoteResolver.cs | 23 ++ .../Services/Hosts/UnknownHost.cs | 55 +++++ .../Hosts/UnknownHostReleasePublisher.cs | 30 +++ .../Hosts/UrlMatchingReleaseRemoteResolver.cs | 82 +++++++ .../Services/Platforms/CIPlatform.cs | 23 ++ .../Platforms/GitHubActionsPlatform.cs | 16 +- .../Services/Platforms/GitLabCIPlatform.cs | 9 +- .../Services/Release/ReleaseAsset.cs | 12 + .../ServerRelease.cs | 154 +++++++++--- .../Services/SelfReferenceUpdater.cs | 2 +- .../Internal/GitHub/GitHubServerAdapter.cs | 223 ------------------ .../Internal/GitHub/GitHubServerRelease.cs | 112 --------- .../Internal/GitLab/GitLabServerAdapter.cs | 75 ------ .../Internal/UnknownServerAdapter.cs | 73 ------ .../Services/ServerAdapters/ServerAdapter.cs | 105 --------- .../ServerAdapters/ServerRelease.AssetData.cs | 9 - .../Services/Versioning/VersionService.cs | 11 +- .../Subcommands/ReleaseCommand.cs | 77 ++++-- tests/Buildvana.Tool.Tests/CIPlatformTests.cs | 47 +++- .../ChangelogUpdaterTests.cs | 42 +++- .../DotNetServiceResolutionTests.cs | 4 +- tests/Buildvana.Tool.Tests/FakeCIPlatform.cs | 17 ++ .../FakeReleaseRemoteResolver.cs | 18 ++ tests/Buildvana.Tool.Tests/GitHostTests.cs | 170 +++++++++++++ .../GitHubApiClientTests.cs | 2 +- tests/Buildvana.Tool.Tests/GitHubHostTests.cs | 118 +++++++++ .../GitHubRepositoryUrlsTests.cs | 2 +- tests/Buildvana.Tool.Tests/GitServiceTests.cs | 25 ++ tests/Buildvana.Tool.Tests/RecordedAsset.cs | 14 -- tests/Buildvana.Tool.Tests/RecordingHost.cs | 86 +++++++ .../RecordingReleasePublisher.cs | 77 ++++++ .../RecordingServerAdapter.cs | 80 ------- .../RecordingServerRelease.cs | 53 ----- .../ReleaseCommandChangelogTests.cs | 23 ++ .../ReleaseCommandFailureTests.cs | 49 +++- .../ReleaseCommandGitHubTests.cs | 86 +++++++ .../ReleaseCommandTests.cs | 64 ++++- tests/Buildvana.Tool.Tests/ReleaseHarness.cs | 89 +++++-- .../ReleaseHarnessOptions.cs | 37 ++- .../ServerReleaseTests.cs | 106 ++++++++- .../UrlMatchingReleaseRemoteResolverTests.cs | 97 ++++++++ 69 files changed, 2314 insertions(+), 1015 deletions(-) create mode 100644 src/Buildvana.Tool/Services/Git/GitRemoteInfo.cs create mode 100644 src/Buildvana.Tool/Services/Hosts/GitHost.cs rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubApiClient.cs (99%) rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubErrorBody.cs (86%) create mode 100644 src/Buildvana.Tool/Services/Hosts/GitHub/GitHubHost.cs rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubJsonContext.cs (94%) rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubNewRelease.cs (93%) rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubRelease.cs (90%) rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubReleaseNotes.cs (86%) rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubReleaseNotesRequest.cs (90%) create mode 100644 src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleasePublisher.cs rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubReleaseUpdate.cs (94%) rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubRepository.cs (86%) rename src/Buildvana.Tool/Services/{ServerAdapters/Internal => Hosts}/GitHub/GitHubRepositoryUrls.cs (98%) create mode 100644 src/Buildvana.Tool/Services/Hosts/GitRef.cs create mode 100644 src/Buildvana.Tool/Services/Hosts/GitRefKind.cs create mode 100644 src/Buildvana.Tool/Services/Hosts/ReleasePublisher.cs create mode 100644 src/Buildvana.Tool/Services/Hosts/ReleaseRemoteResolver.cs create mode 100644 src/Buildvana.Tool/Services/Hosts/UnknownHost.cs create mode 100644 src/Buildvana.Tool/Services/Hosts/UnknownHostReleasePublisher.cs create mode 100644 src/Buildvana.Tool/Services/Hosts/UrlMatchingReleaseRemoteResolver.cs create mode 100644 src/Buildvana.Tool/Services/Release/ReleaseAsset.cs rename src/Buildvana.Tool/Services/{ServerAdapters => Release}/ServerRelease.cs (66%) delete mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs delete mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs delete mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitLab/GitLabServerAdapter.cs delete mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/UnknownServerAdapter.cs delete mode 100644 src/Buildvana.Tool/Services/ServerAdapters/ServerAdapter.cs delete mode 100644 src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.AssetData.cs create mode 100644 tests/Buildvana.Tool.Tests/FakeReleaseRemoteResolver.cs create mode 100644 tests/Buildvana.Tool.Tests/GitHostTests.cs create mode 100644 tests/Buildvana.Tool.Tests/GitHubHostTests.cs delete mode 100644 tests/Buildvana.Tool.Tests/RecordedAsset.cs create mode 100644 tests/Buildvana.Tool.Tests/RecordingHost.cs create mode 100644 tests/Buildvana.Tool.Tests/RecordingReleasePublisher.cs delete mode 100644 tests/Buildvana.Tool.Tests/RecordingServerAdapter.cs delete mode 100644 tests/Buildvana.Tool.Tests/RecordingServerRelease.cs create mode 100644 tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs create mode 100644 tests/Buildvana.Tool.Tests/UrlMatchingReleaseRemoteResolverTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ed3411..4d0367d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,9 +49,15 @@ The patch number restarted from 1, below the 2.0.x versions already published. - The `StandardAnalyzers` module passes the [internal API files](docs/sdk-modules/standard-analyzers.md#internal-api-files) to the compilation, one pair per target framework when `UseTfmSpecificInternalApiFiles` is `true`. - On a stable version, `bv release` [moves the unshipped internal API](docs/tool-commands/release.md#the-steps) into the shipped one, as it does for the public API. - `bv` recognizes a CI runner [as the .NET CLI does](docs/environment-variables.md#variables-that-mark-a-ci-runner), and passes `ContinuousIntegrationBuild=true` to the build pipeline on every one. +- `bv release` runs [on any machine](docs/tool-commands/release.md#preconditions), with `repository.url` naming the repository the release goes to. +- `bv release` fetches the tags of the [release remote](docs/tool-commands/release.md#the-release-remote) before it checks the versions. +- `release.allowUnknownHost` lets `bv release` [tag and push without a release page](docs/tool-commands/release.md#publishing) on a server `bv` does not know. ### Changes to existing features +- **BREAKING CHANGE**: `bv release` no longer runs without [`repository.url`](docs/configuration-file.md#settings) in `buildvana.jsonc`. +- **BREAKING CHANGE**: `bv release` no longer leaves the release tag to the host, and [creates and pushes it](docs/tool-commands/release.md#the-release-tag) before it publishes the release. + - **BREAKING CHANGE**: `BV_MinRoslynVersion` moves from 5.3 to 5.9, so Buildvana SDK raises BVSDK1101 on [Roslyn older than 5.9](docs/sdk-diagnostics.md#source-generators-1100-1199). - **BREAKING CHANGE**: `BV_MinMSBuildVersion` moves from 17.4 to 18.9, so Buildvana SDK raises BVSDK1004 on [MSBuild older than 18.9](docs/introduction.md#toolchain). - **BREAKING CHANGE**: The `AlternatePack` module no longer adds `Tools.InnoDownloadPlugin` to an Inno Setup project, and the built-in [`download` and `issigverify` flags](https://jrsoftware.org/ishelp/topic_filessection.htm) replace it. @@ -105,6 +111,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - [The pages under `docs/`](docs/README.md) moved to kebab-case names, `docs/modules/` to `docs/sdk-modules/`, and `docs/DependencyManagement.md` to `docs/tool-commands/dependencies.md`, so a link to an old name breaks. - The `StandardAnalyzers` module adds [`StyleCop.Analyzers` 1.3.133-beta](docs/sdk-modules/standard-analyzers.md#package-references), which supports C# 8 to 15 and adds several new rules. - `bv release` reaches the [GitHub API of the host of the repository](docs/tool-commands/release.md#publishing), `api.github.com` or `/api/v3`, where it reached `api.github.com` alone. +- `bv release` passes `ContinuousIntegrationBuild=true` to [its pipeline run](docs/tool-commands/release.md#the-steps) on every platform. ### Bugs fixed in this release diff --git a/docs/environment-variables.md b/docs/environment-variables.md index f252949f..ca5e8795 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -13,11 +13,13 @@ The variables that the .NET SDK, MSBuild, and NuGet read are out of scope, and t - [`BV_DELEGATED`](#bv_delegated) - [`BV_SELF_UPDATE_FROM`](#bv_self_update_from) - [`CI_SERVER_HOST`](#ci_server_host) + - [`CI_SERVER_URL`](#ci_server_url) - [`DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING`](#dotnet_cli_console_use_default_encoding) - [`DOTNET_CLI_HOME`](#dotnet_cli_home) - [`DOTNET_HOST_PATH`](#dotnet_host_path) - [`GITHUB_ACTIONS`](#github_actions) - [`GITHUB_OUTPUT`](#github_output) + - [`GITHUB_SERVER_URL`](#github_server_url) - [`GITLAB_CI`](#gitlab_ci) - [`NO_COLOR`](#no_color) - [`TERM`](#term) @@ -58,7 +60,13 @@ Do not set the variable by hand. GitLab CI sets it to the host name of the GitLab instance that runs the job. `bv` reads it to build the e-mail address of the CI bot identity, `gitlab-ci@noreply.`. That identity authors the commits of `bv release`, unless `buildvana.jsonc` states a `git.identity`. -The variable matters only together with `GITLAB_CI`, which is what selects the GitLab adapter. +The variable matters only together with `GITLAB_CI`, which is what selects the GitLab CI platform. + +### `CI_SERVER_URL` + +GitLab CI sets it to the URL of the GitLab instance that runs the job. +`bv` reads it as the server of the GitLab CI platform. +`bv` knows no GitLab host, so the variable has no effect on a release. ### `DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING` @@ -90,8 +98,8 @@ The variable is unset when `bv` runs through the native shim of a global tool. ### `GITHUB_ACTIONS` GitHub Actions sets it to `true` on every step. -`bv` reads it to recognize the GitHub Actions [CI platform](introduction.md#ci-platforms), and to act through the GitHub adapter. -Releases then go to the GitHub API, and the step output goes to the file `GITHUB_OUTPUT` names. +`bv` reads it to recognize the GitHub Actions [CI platform](introduction.md#ci-platforms). +The step output of `bv release` then goes to the file `GITHUB_OUTPUT` names. The `github-actions[bot]` identity authors the commits of `bv release`, unless `buildvana.jsonc` states a `git.identity`. The comparison ignores case, so `TRUE` and `True` count too. Any other value, or no value, means no GitHub Actions. @@ -103,10 +111,17 @@ GitHub Actions sets it to the path of the file that collects the outputs of a st When the variable is unset, the release fails before creating anything. `bv` never sets the variable. +### `GITHUB_SERVER_URL` + +GitHub Actions sets it to the URL of the GitHub instance that runs the job, such as `https://github.com`. +When `repository.url` names a repository on the host of that URL, `bv release` takes the host for GitHub. +A GitHub Enterprise Server is recognized this way on its own runners. +The variable matters only together with `GITHUB_ACTIONS`. + ### `GITLAB_CI` GitLab CI sets it on every job. -Its presence, whatever the value, makes `bv` recognize a GitLab CI run and act through the GitLab adapter, with the bot identity built from `CI_SERVER_HOST`. +Its presence, whatever the value, makes `bv` recognize a GitLab CI run, with the bot identity built from `CI_SERVER_HOST`. ### `NO_COLOR` @@ -146,6 +161,7 @@ To have it recognized, set `CI` to `true` in its environment. The [configuration file](configuration-file.md) names the environment variable that carries each one, and `bv` reads the value where it uses it. - `hosts.github.tokenEnv` names the variable holding the GitHub token of the release operations. + The token is also the password of the fetch and the push of `bv release`. The default name is `GITHUB_TOKEN`. - `nuget.feeds.release.apiKeyEnv` and `nuget.feeds.prerelease.apiKeyEnv` name the variables holding the API keys of the NuGet push feeds. diff --git a/docs/introduction.md b/docs/introduction.md index 49368537..d9d68816 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -89,23 +89,24 @@ Each table below states a status for every item. ### Git servers -| Server | Status | Limits | -| ---------------------------- | --------------------- | -------------------------------------------- | -| GitHub and GitHub Enterprise | Supported | | -| Other servers | Supported with limits | Buildvana SDK does not configure SourceLink. | +| Server | Status | Limits | +| ---------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GitHub and GitHub Enterprise | Supported | | +| Other servers | Supported with limits | Buildvana SDK does not configure SourceLink.
`bv release` creates no release page, and runs only with [`release.allowUnknownHost`](tool-commands/release.md#publishing). | ### CI platforms `bv` detects the CI platform it runs on from the environment. Generic CI is any other runner that the .NET CLI recognizes as one. Local is a machine where `bv` recognizes no CI runner, such as a developer machine. - -| CI platform | Status | Limits | -| ---------------------------------------------------------------------- | --------------------- | ------------------- | -| GitHub Actions | Supported | | -| GitLab CI | Supported with limits | `bv release` fails. | -| [Generic CI](environment-variables.md#variables-that-mark-a-ci-runner) | Supported with limits | `bv release` fails. | -| Local | Supported with limits | `bv release` fails. | +Every CI platform runs every command, `bv release` included. + +| CI platform | Status | +| ---------------------------------------------------------------------- | --------- | +| GitHub Actions | Supported | +| GitLab CI | Supported | +| [Generic CI](environment-variables.md#variables-that-mark-a-ci-runner) | Supported | +| Local | Supported | ### Toolchain diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index 183bb68c..93e50ee1 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -1,6 +1,6 @@ # Release -`bv release` publishes a release of the repository from a CI runner: it builds, tags, pushes the packages, and creates the release on GitHub. +`bv release` publishes a release of the repository: it builds, tags, pushes the packages, and creates the release on the Git host. This page says what the command checks, what it does step by step, and what it undoes when a step fails. --- @@ -10,8 +10,10 @@ This page says what the command checks, what it does step by step, and what it u - [Preconditions](#preconditions) +- [The release remote](#the-release-remote) - [The steps](#the-steps) - [The release commit](#the-release-commit) +- [The release tag](#the-release-tag) - [The changelog](#the-changelog) - [The post-release commit](#the-post-release-commit) - [Publishing](#publishing) @@ -29,34 +31,61 @@ This page says what the command checks, what it does step by step, and what it u bv release [OPTIONS] ``` -`bv release` creates releases on GitHub, from a GitHub Actions runner. -On GitLab CI and on Generic CI, `bv` fails when it creates the release. -On the Local [CI platform](../introduction.md#ci-platforms), the command fails at once with `A release can only be created on a CI runner.` +`bv release` runs on every [CI platform](../introduction.md#ci-platforms), Local included. +A release from a developer machine goes through the same steps as a release from a CI runner. +The Git host decides where the release is published: GitHub, or a host `bv` does not know. Before building anything, `bv release` checks what a build cannot change: -1. `HEAD` is on a branch, and the branch produces [public releases](../sdk-modules/versioning.md#public-releases) under `release.branches`. -2. A committer identity exists for the commits of the release. +1. `buildvana.jsonc` states [`repository.url`](../configuration-file.md#settings), the URL of the repository the releases go to. +2. The clone has a remote that names that repository, as [The release remote](#the-release-remote) says. +3. `bv` knows the Git host of `repository.url`, or `release.allowUnknownHost` is `true`. + `bv` takes `repository.host` when it is not `auto`. + Otherwise, `github.com` is GitHub. + Otherwise, a host whose name is the host name of [`GITHUB_SERVER_URL`](../environment-variables.md#github_server_url) is a GitHub Enterprise Server, on GitHub Actions. + Any other host is unknown. +4. `HEAD` is on a branch, and the branch produces [public releases](../sdk-modules/versioning.md#public-releases) under `release.branches`. +5. A committer identity exists for the commits of the release. `bv` takes the first of three that exists. The three are the `git.identity` setting of `buildvana.jsonc`, the bot identity of the CI platform, and the `user.name` and `user.email` of the Git configuration. On GitHub Actions, the bot identity is `github-actions[bot]`. On GitLab CI, it is `GitLab CI`, with the address `gitlab-ci@noreply.` built from [`CI_SERVER_HOST`](../environment-variables.md#ci_server_host). `bv` commits with that identity, and writes nothing to the Git configuration. -3. The platform supplies push credentials. - On GitHub Actions, they are the token that [`hosts.github.tokenEnv`](../environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) names. - Without them, `bv` warns that the push may fail, and goes on. -4. The published versions are consistent: the latest tag is not below the latest stable tag, and the current version is below neither. -5. The version spec change is computed from `--bump` and the public API files, as [`bv version advance`](version.md#bv-version-advance) computes it. -6. [`GITHUB_OUTPUT`](../environment-variables.md#github_output) is set, on GitHub Actions. - -`bv` reads the latest versions from the tags reachable from `HEAD`, so the runner needs the full history. +6. The host supplies Git credentials. + On GitHub, they are `x-access-token` and the token that [`hosts.github.tokenEnv`](../environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) names. + An unknown host supplies none. + Without them, `bv` warns that the fetch and the push may fail, and goes on. +7. `bv` fetches the tags of the release remote. +8. The published versions are consistent: the latest tag is not below the latest stable tag, and the current version is below neither. +9. The version spec change is computed from `--bump` and the public API files, as [`bv version advance`](version.md#bv-version-advance) computes it. +10. [`GITHUB_OUTPUT`](../environment-variables.md#github_output) is set, on GitHub Actions. + +`bv` reads the latest versions from the tags reachable from `HEAD`, so the clone needs the full history. With `actions/checkout`, that is `fetch-depth: 0`. --- +## The release remote + +The release remote is the remote of the clone that `bv release` fetches the tags from, and pushes the commits and the tag to. +It is the remote whose URL names the repository that `repository.url` names. +In CI, it is usually `origin`. +In a clone of a fork, it is the remote that names the upstream repository, whatever its name. + +Two URLs name the same repository when their host names are equal and their paths are equal, both without regard to case. +The scheme, the port, the user, and a `.git` suffix play no part. +An SSH URL and an HTTPS URL of a repository therefore match. + +`bv` pushes over HTTP and HTTPS alone. +When the only remote that matches has another URL, such as an SSH one, the release fails, and the message names the remote. +Add a remote with the HTTPS URL of the repository. +When no remote matches, the release fails, and the message names the remotes of the clone. + +--- + ## The steps -1. Create a provisional draft release on GitHub, named ` [provisional]`, so that a token without permission fails the release early. +1. On GitHub, create a provisional draft release, named ` [provisional]`, so that a token without permission fails the release early. 2. Rewrite `VERSION` when the version spec change is not `none`, with the tag `versioning.prereleaseTag` names. 3. For a stable version, move the unshipped public and internal API into the shipped one. Every `PublicAPI.Unshipped.txt` next to a `PublicAPI.Shipped.txt` is emptied into it, and a line starting with `*REMOVED*` removes its API from the shipped file. @@ -69,15 +98,16 @@ With `actions/checkout`, that is `fetch-depth: 0`. 8. Print `notice: Releasing version .` From here on, the version is final. 9. Run the build pipeline from `clean` through `pack`, with the release configuration, to build, test, and produce the artifacts. + The run passes `ContinuousIntegrationBuild=true` to `dotnet` on every platform, Local included, because release artifacts are deterministic. 10. Finalize the new changelog section with the released version, as [The changelog](#the-changelog) says. 11. Find the produced packages: every `.nupkg` file in `artifacts\\` whose name ends with `..nupkg`. 12. Run the [`release/post-release` hook](../hooks.md#the-releasepost-release-hook), and record the files it changed. 13. Rewrite the self-references when `release.dogfood` is `true`, as [The post-release commit](#the-post-release-commit) says. 14. Create the post-release commit, when the hook or the rewrites changed a file. -15. Push the commits. +15. Create the release tag, push the commits, and push the tag, as [The release tag](#the-release-tag) says. 16. Push the packages to the NuGet feed, as [Publishing](#publishing) says. 17. Collect the release assets, as [Publishing](#publishing) says. -18. Publish the release, and write the `version` step output. +18. Publish the release on the host, and write the `version` step output. Every step is undone when a later step fails, as [Rollback](#rollback) says. @@ -89,7 +119,6 @@ Every step is undone when a later step fails, as [Rollback](#rollback) says. It creates the commit even when nothing changed, because the commit settles the version. The patch number is the [Git height](../sdk-modules/versioning.md#patch-number) of the version line, and a commit created after the build would move it. The artifacts, the tag, and the release would then disagree by one patch. -The release tag names the release commit, whatever commits follow it. A version whose Git height is 0 is refused, because no commit in the history carries the version line. The usual cause is a `VERSION` file changed and not committed. @@ -108,6 +137,22 @@ When neither remedy fits, open an issue. --- +## The release tag + +`bv release` creates the release tag itself, as a lightweight tag named after the version. +The tag names the release commit, whatever commits follow it. +`bv` creates it after the build, because finalizing the changelog amends the release commit. + +`bv` pushes the branch to the release remote, then the tag. +A tag of the same name on the remote fails the release, unless it names the same commit. +`bv` never moves a tag of the remote. +When the remote rejects a push, the release fails. + +The host publishes the release on the tag that is already there. +A workflow that the push of a tag triggers therefore runs before the release page exists. + +--- + ## The changelog `CHANGELOG.md` in the home directory follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). @@ -154,8 +199,7 @@ When neither the hook nor the rewrites changed a file, there is no post-release ## Publishing -`bv release` pushes the commits, then every `.nupkg` file of `artifacts\\`, with `dotnet nuget push --skip-duplicate`. -When the remote rejects the push of the commits, the release fails. +After the commits and the tag, `bv release` pushes every `.nupkg` file of `artifacts\\`, with `dotnet nuget push --skip-duplicate`. `dotnet nuget push` gets the arguments and environment variables of the `dotnet.all` and `dotnet.nugetPush` sections of `buildvana.jsonc`, and no forwarded argument. A prerelease goes to the `nuget.feeds.prerelease` feed, and a stable version to the `nuget.feeds.release` feed. A repository that states no prerelease feed pushes prereleases to the release feed. @@ -169,14 +213,19 @@ The release assets are collected from two sources: A line with the wrong shape, or one naming a file that does not exist, is a warning, and the asset is skipped. -`bv` reaches the GitHub API of the host of the `origin` remote. +On GitHub, `bv` reaches the API of the host of `repository.url`. For `github.com`, the API is at `https://api.github.com`. `bv` takes any other host for a GitHub Enterprise Server, whose API is at `https:///api/v3`. An asset upload goes to the address that GitHub states in the answer to the creation of the draft release. `bv` then uploads the assets, and turns the draft into the release. -The release is named after the version, its tag names the release commit, and the prerelease flag follows the version. +The release is named after the version, its tag is the release tag, and the prerelease flag follows the version. Its description is a link to `CHANGELOG.md` on the release branch, followed by the notes GitHub generates. + +On a host `bv` does not know, there is no release page, and `bv` says so with a notice. +The tag, the commits, and the packages are pushed all the same. +The new changelog section gets a heading without a link, and its relative file links stay as they are, each with a notice. + On GitHub Actions, `bv` appends `version=` to the file [`GITHUB_OUTPUT`](../environment-variables.md#github_output) names, for the later steps of the job. The last line of a release is `notice: Published release with assets.` @@ -186,9 +235,10 @@ The last line of a release is `notice: Published release with When a step fails after the draft release exists, `bv release` undoes what it did, in reverse order: +- it deletes the release tag from the release remote, when it was pushed, and from the clone; - it resets the branch to the commit before the release commit, post-release commit included; - when the commits were pushed, it force-pushes the previous tip; -- it deletes the release, and the tag when the release was published. +- it deletes the release on the host, draft or published. A rollback action that fails is a warning, and the rollback goes on. Once the release is published and the step output is written, nothing is undone. diff --git a/src/Buildvana.Core.Testing/TempGitRepo.cs b/src/Buildvana.Core.Testing/TempGitRepo.cs index 743d2665..c0415457 100644 --- a/src/Buildvana.Core.Testing/TempGitRepo.cs +++ b/src/Buildvana.Core.Testing/TempGitRepo.cs @@ -225,6 +225,17 @@ public IReadOnlyList GetCommits(int count) return remote.Branches[CurrentBranchName]?.Tip?.Sha; } + /// + /// Pushes the current branch to a remote added by . + /// + /// The name of the remote. + public void PushCurrentBranch(string name = "origin") + { + using var repository = new Repository(RootPath); + var refSpec = $"{repository.Head.CanonicalName}:{repository.Head.CanonicalName}"; + repository.Network.Push(repository.Network.Remotes[name], refSpec); + } + /// /// Gets the SHA of the commit a tag points to on a remote added by . /// diff --git a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index b4585ce4..380b0192 100644 --- a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -17,9 +17,9 @@ using Buildvana.Tool.Services.Dependencies; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Hooks; +using Buildvana.Tool.Services.Hosts; using Buildvana.Tool.Services.Platforms; using Buildvana.Tool.Services.SelfUpdate; -using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.Solution; using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Subcommands; @@ -81,7 +81,8 @@ public IServiceCollection AddBvServices() .AddSingleton() .AddSingleton() .AddSingleton(static _ => CIPlatform.Detect(Environment.GetEnvironmentVariable)) - .AddSingleton(ServerAdapter.Create) + .AddSingleton(GitHost.Create) + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Buildvana.Tool/Services/ChangelogService.cs b/src/Buildvana.Tool/Services/ChangelogService.cs index ec6bd4ed..7c826b8c 100644 --- a/src/Buildvana.Tool/Services/ChangelogService.cs +++ b/src/Buildvana.Tool/Services/ChangelogService.cs @@ -7,7 +7,7 @@ using Buildvana.Core; using Buildvana.Core.ConsoleOutput; using Buildvana.Core.IO; -using Buildvana.Tool.Services.ServerAdapters; +using Buildvana.Tool.Services.Hosts; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; @@ -38,19 +38,19 @@ internal sealed class ChangelogService private static readonly Encoding FileEncoding = new UTF8Encoding(false, true); private readonly IReporter _reporter; - private readonly ServerAdapter _server; + private readonly GitHost _host; private readonly VersionService _version; /// /// Initializes a new instance of the class. /// - public ChangelogService(IReporter reporter, ServerAdapter server, VersionService version) + public ChangelogService(IReporter reporter, GitHost host, VersionService version) { Guard.IsNotNull(reporter); - Guard.IsNotNull(server); + Guard.IsNotNull(host); Guard.IsNotNull(version); _reporter = reporter; - _server = server; + _host = host; _version = version; Exists = File.Exists(FileName); } @@ -95,25 +95,39 @@ public void PrepareForRelease(string? emptyChangelogSubstitute = null) /// Finalizes the first section of the changelog after the "Unreleased changes" section, once the released /// version is known: updates its heading, and pins each relative file link in it to the release tag. ///
+ /// + /// On a host with no URL scheme, the heading has no link and the relative file links stay as they are, + /// each with a notice. + /// public void FinalizeNewSection() { _reporter.Info("Finalizing changelog's new release section..."); var lines = UserFile.ReadAllLines(FileName, FileEncoding); var text = ChangelogUpdater.FinalizeNewSection(lines, MakeSectionTitle, GetFileUrl); UserFile.WriteAllText(FileName, text, FileEncoding); + if (_host.GetReleaseUrl(_version.CurrentStr) is null) + { + _reporter.Notice($"The changelog section of {_version.CurrentStr} has no link: {_host.Name} has no release page."); + } } private string MakeSectionTitle() - => ChangelogUpdater.MakeSectionTitle(_version.CurrentStr, _server.GetReleaseUrl(_version.CurrentStr), DateTime.Now); + => ChangelogUpdater.MakeSectionTitle(_version.CurrentStr, _host.GetReleaseUrl(_version.CurrentStr), DateTime.Now); - // The release tag is named after the version, so the version string is the commitish the links pin to. - // The adapter rejects a path that leaves the repository, or a rooted one, with an ArgumentException. The + // The release tag is named after the version, so the version string is the tag the links pin to. + // The host rejects a path that leaves the repository, or a rooted one, with an ArgumentException. The // path comes from a changelog bullet, so the rejection is reported as a build failure that names it. - private Uri GetFileUrl(string path) + private Uri? GetFileUrl(string path) { try { - return _server.GetFileUrl(path, _version.CurrentStr); + var url = _host.GetFileUrl(path, GitRef.Tag(_version.CurrentStr)); + if (url is null) + { + _reporter.Notice($"{FileName} links '{path}', which stays relative: {_host.Name} has no URL for a file."); + } + + return url; } catch (ArgumentException e) { diff --git a/src/Buildvana.Tool/Services/ChangelogUpdater.cs b/src/Buildvana.Tool/Services/ChangelogUpdater.cs index cddc3ee1..0e84180a 100644 --- a/src/Buildvana.Tool/Services/ChangelogUpdater.cs +++ b/src/Buildvana.Tool/Services/ChangelogUpdater.cs @@ -225,7 +225,8 @@ List CollectNewSectionLines() /// A callback that produces the new title of the section. It is called /// at most once, and only if a section heading is actually replaced. /// A callback that produces the URL, at the release tag, of the file at a path - /// relative to the home directory. It is called once per relative file link in the section. + /// relative to the home directory. It is called once per relative file link in the section. A + /// result leaves the link as it is. /// The rewritten changelog, with lines separated by "\n". /// The changelog contains no sections, or only one section. /// @@ -239,7 +240,7 @@ List CollectNewSectionLines() public static string FinalizeNewSection( IReadOnlyList lines, Func makeSectionTitle, - Func getFileUrl) + Func getFileUrl) { Guard.IsNotNull(lines); Guard.IsNotNull(makeSectionTitle); @@ -321,18 +322,19 @@ public static string FinalizeNewSection( /// Composes the title of the changelog section for a release. ///
/// The version being released. - /// The URL of the release. + /// The URL of the release, or for a title without a link. /// The date of the release. /// The section title, without the leading "## ". /// /// The date ends up in the changelog's contents, so it is formatted with the invariant culture: it must /// come out the same on every machine, whatever calendar and date format the current culture prescribes. /// - public static string MakeSectionTitle(string version, Uri releaseUrl, DateTime date) + public static string MakeSectionTitle(string version, Uri? releaseUrl, DateTime date) { Guard.IsNotNull(version); - Guard.IsNotNull(releaseUrl); - return string.Create(CultureInfo.InvariantCulture, $"[{version}]({releaseUrl}) ({date:yyyy-MM-dd})"); + return releaseUrl is null + ? string.Create(CultureInfo.InvariantCulture, $"{version} ({date:yyyy-MM-dd})") + : string.Create(CultureInfo.InvariantCulture, $"[{version}]({releaseUrl}) ({date:yyyy-MM-dd})"); } // Reading past the last line yields null, just as TextReader.ReadLine does at end of file: @@ -346,7 +348,7 @@ public static string MakeSectionTitle(string version, Uri releaseUrl, DateTime d // a target starting with '#' is an anchor in the changelog itself: both are left alone. Anything else is a // path relative to the home directory, followed by an optional anchor of its own, which is kept. // AbsoluteUri keeps the URL escaped, where ToString would turn "%20" back into a space and break the link. - private static string PinFileLinks(string line, Func getFileUrl) + private static string PinFileLinks(string line, Func getFileUrl) { return GetInlineLinkTargetRegex().Replace(line, PinTarget); @@ -361,7 +363,7 @@ string PinTarget(Match match) var anchorIndex = target.IndexOf('#', StringComparison.Ordinal); var path = anchorIndex < 0 ? target : target[..anchorIndex]; var anchor = anchorIndex < 0 ? string.Empty : target[anchorIndex..]; - return "](" + getFileUrl(path).AbsoluteUri + anchor; + return getFileUrl(path) is { } url ? "](" + url.AbsoluteUri + anchor : match.Value; } } diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index e424ca54..6077ec20 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -30,10 +30,9 @@ internal sealed partial class DotNetService : IFileBasedAppRunner, IDependencyRe { private readonly IReporter _reporter; private readonly IProcessRunner _processRunner; - private readonly CIPlatform _platform; // Resolved on first use, which is the NuGet push alone. Constructing the version service opens the Git - // repository and reads its origin remote, and a build needs neither: the SDK computes the version itself. + // repository, and a build does not need it: the SDK computes the version itself. private readonly Lazy _version; private readonly BuildvanaConfig _config; @@ -54,11 +53,22 @@ public DotNetService( Guard.IsNotNull(config); _reporter = reporter; _processRunner = processRunner; - _platform = platform; + ContinuousIntegrationBuild = platform.IsCI; _version = version; _config = config; } + /// + /// Gets or sets the value of the ContinuousIntegrationBuild property that the restore, the build, + /// the test run, and the pack pass to MSBuild. + /// + /// Initially, the value of the platform bv runs on. + /// + /// bv release sets it to on every platform, because release artifacts + /// are deterministic by definition. + /// + public bool ContinuousIntegrationBuild { get; set; } + /// /// Asynchronously restores all NuGet packages for the solution. /// @@ -412,7 +422,7 @@ internal static string[] MergeInvocation( private string ContinuousIntegrationBuildArg(bool asMSBuildPassthrough) { var prefix = asMSBuildPassthrough ? "-p:" : "--property:"; - return $"{prefix}ContinuousIntegrationBuild={(_platform.IsCI ? "true" : "false")}"; + return $"{prefix}ContinuousIntegrationBuild={(ContinuousIntegrationBuild ? "true" : "false")}"; } /// diff --git a/src/Buildvana.Tool/Services/Git/GitRemoteInfo.cs b/src/Buildvana.Tool/Services/Git/GitRemoteInfo.cs new file mode 100644 index 00000000..e575dec6 --- /dev/null +++ b/src/Buildvana.Tool/Services/Git/GitRemoteInfo.cs @@ -0,0 +1,11 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.Git; + +/// +/// Describes a remote of a Git repository. +/// +/// The name of the remote. +/// The fetch URL of the remote, as the Git configuration states it. +internal sealed record GitRemoteInfo(string Name, string Url); diff --git a/src/Buildvana.Tool/Services/Git/GitService.cs b/src/Buildvana.Tool/Services/Git/GitService.cs index 7b4a5ca8..19f39e65 100644 --- a/src/Buildvana.Tool/Services/Git/GitService.cs +++ b/src/Buildvana.Tool/Services/Git/GitService.cs @@ -39,20 +39,27 @@ public GitService(IReporter reporter, IHomeDirectoryProvider home) var homeDirectory = home.HomeDirectory; BuildFailedException.ThrowIfNot(Repository.IsValid(homeDirectory), $"There is no Git repository at {homeDirectory}"); _repository = new Repository(homeDirectory); - BuildFailedException.ThrowIfNot( - TryGetOriginInfo(out var origin, out var originUrl), - "No origin remote found in the Git repository."); - Origin = origin; - OriginUrl = new(originUrl); var headName = _repository.Head.CanonicalName; CurrentBranch = headName.StartsWith("refs/heads/", StringComparison.Ordinal) ? _repository.Head.FriendlyName : string.Empty; } /// - public string Origin { get; } + public IReadOnlyList Remotes + { + get + { + var result = new List(); + foreach (var remote in _repository.Network.Remotes) + { + using (remote) + { + result.Add(new(remote.Name, remote.Url)); + } + } - /// - public Uri OriginUrl { get; } + return result; + } + } /// public string CurrentBranch { get; } @@ -293,62 +300,6 @@ public void FetchTags(string remote) _reporter.Notice($"Fetched tags from '{remote}'."); } - private bool TryGetOriginInfo([MaybeNullWhen(false)] out string name, [MaybeNullWhen(false)] out string url) - { - name = null!; - url = null!; - string? originName = null; - string? originUrl = null; - string? onlyRemoteName = null; - string? onlyRemoteUrl = null; - var isFirst = true; - _reporter.Detail("Looking for origin remote..."); - foreach (var remote in _repository.Network.Remotes) - { - using (remote) - { - _reporter.Detail($" '{remote.Name}' ({remote.Url})"); - if (remote.Name == "origin") - { - originName = remote.Name; - originUrl = remote.Url; - break; - } - - if (isFirst) - { - onlyRemoteName = remote.Name; - onlyRemoteUrl = remote.Url; - isFirst = false; - } - else - { - onlyRemoteName = null; - onlyRemoteUrl = null; - } - } - } - - // Name and URL of "origin" if present; otherwise, name and URL of the _only_ remote. - name = originName ?? onlyRemoteName; - url = originUrl ?? onlyRemoteUrl; - if (name is null || url is null) - { - _reporter.Detail("Origin remote not found."); - return false; - } - - // Remove trailing slashes and optional ".git" suffix - url = url.TrimEnd('/'); - if (url.EndsWith(".git", StringComparison.Ordinal)) - { - url = url[..^4]; - } - - _reporter.Detail($"Origin remote is '{name}' ({url})"); - return true; - } - private GitIdentity? GetConfiguredIdentity() { var signature = _repository.Config.BuildSignature(DateTimeOffset.Now); diff --git a/src/Buildvana.Tool/Services/Git/IGitService.cs b/src/Buildvana.Tool/Services/Git/IGitService.cs index c1a7996d..5d438910 100644 --- a/src/Buildvana.Tool/Services/Git/IGitService.cs +++ b/src/Buildvana.Tool/Services/Git/IGitService.cs @@ -18,14 +18,9 @@ namespace Buildvana.Tool.Services.Git; internal interface IGitService { /// - /// Gets the name of the origin remote, i.e. either "origin" if such remote exists, or the name of the only remote if there is only one. + /// Gets the remotes of the repository, in the order the Git configuration states them. /// - string Origin { get; } - - /// - /// Gets the fetch URL of the origin remote. - /// - Uri OriginUrl { get; } + IReadOnlyList Remotes { get; } /// /// Gets the name of the current Git branch. diff --git a/src/Buildvana.Tool/Services/Hosts/GitHost.cs b/src/Buildvana.Tool/Services/Hosts/GitHost.cs new file mode 100644 index 00000000..3da11e00 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/GitHost.cs @@ -0,0 +1,182 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Runtime; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Hosts.GitHub; +using Buildvana.Tool.Services.Platforms; +using CommunityToolkit.Diagnostics; +using Microsoft.Extensions.DependencyInjection; + +namespace Buildvana.Tool.Services.Hosts; + +/// +/// Base class for the server that owns the repository the releases go to, as repository.url names it. +/// +internal abstract class GitHost +{ + private protected GitHost(GitUrlInfo url) + { + Guard.IsNotNull(url); + BuildFailedException.ThrowIf( + url.PathSegments.Count < 2, + $"'{url.Url}' names no repository: the path of a repository URL holds an owner and a name."); + HostName = url.Host; + Owner = string.Join('/', url.PathSegments.Take(url.PathSegments.Count - 1)); + Repository = url.PathSegments[^1]; + } + + /// + /// Gets the display name of the host. + /// + public abstract string Name { get; } + + /// + /// Gets the kind of the host, or for a host bv does not know. + /// + public abstract HostKind? Kind { get; } + + /// + /// Gets the host name of the server. + /// + public string HostName { get; } + + /// + /// Gets the owner of the repository: every segment of the path of the repository URL but the last. + /// + public string Owner { get; } + + /// + /// Gets the name of the repository: the last segment of the path of the repository URL. + /// + public string Repository { get; } + + /// + /// Gets the URL of the web page of the repository, or for a host with no URL scheme. + /// + public abstract Uri? RepositoryUrl { get; } + + /// + /// Gets the name of the environment variable holding the API token of the host, or + /// for a host that has none. + /// + /// + /// The token is also the password bv pushes with. + /// + public abstract string? TokenEnv { get; } + + /// + /// Gets the username a push presents along with the token, or for a host with no + /// such convention. + /// + public abstract string? PushUsername { get; } + + /// + /// Creates the host of the repository that repository.url names. + /// + /// The service provider. + /// A newly-created . + /// The configuration states no repository.url, or one + /// that is not a Git URL. + public static GitHost Create(IServiceProvider services) + { + Guard.IsNotNull(services); + var config = services.GetRequiredService(); + var url = ParseRepositoryUrl(config.Repository); + return DetectKind(config.Repository, url, services.GetRequiredService()) switch { + HostKind.GitHub => new GitHubHost(services, url), + _ => new UnknownHost(url, services.GetRequiredService()), + }; + } + + /// + /// Parses the URL of the repository the releases go to. + /// + /// The repository configuration. + /// The parsed URL. + /// is , + /// or not a Git URL. + public static GitUrlInfo ParseRepositoryUrl(RepositoryConfig repository) + { + Guard.IsNotNull(repository); + var url = repository.Url; + BuildFailedException.ThrowIf( + string.IsNullOrEmpty(url), + "The configuration file states no repository.url: set it to the URL of the repository the releases go to."); + BuildFailedException.ThrowIfNot(GitUrlInfo.TryCreate(url, out var result), $"repository.url '{url}' is not a Git URL."); + return result; + } + + /// + /// Detects the kind of a host. + /// + /// The repository configuration. + /// The parsed URL of the repository. + /// The CI platform bv runs on. + /// The first of these that applies: when it is not + /// ; the kind of a public host that names; the + /// of a platform whose has the host + /// name of ; otherwise, . + public static HostKind? DetectKind(RepositoryConfig repository, GitUrlInfo url, CIPlatform platform) + { + Guard.IsNotNull(repository); + Guard.IsNotNull(url); + Guard.IsNotNull(platform); + if (repository.Host != HostKind.Auto) + { + return repository.Host; + } + + if (string.Equals(url.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + return HostKind.GitHub; + } + + // A GitHub Enterprise Server, or a self-hosted server of another kind, is recognized on its own runners. + var isPlatformServer = string.Equals(platform.ServerUrl?.Host, url.Host, StringComparison.OrdinalIgnoreCase); + return isPlatformServer ? platform.ServerKind : null; + } + + /// + /// Gets the API token of the host: the value of the environment variable names, + /// read at the moment of the call. + /// + /// The token, or if the host has no token variable, or the variable is + /// not set or empty. + public string? GetToken() + => TokenEnv is { } name && Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null; + + /// + /// Gets the URL of the release a tag names. + /// + /// The name of the release tag. + /// The URL of the release, or for a host with no URL scheme. + public abstract Uri? GetReleaseUrl(string tag); + + /// + /// Gets the URL of a file of the repository, as of a reference. + /// + /// The path of the file, relative to the root of the repository. + /// The tag, branch, or commit the file belongs to. + /// The URL of the file, or for a host with no URL scheme. + /// is rooted, or leaves the repository. + public abstract Uri? GetFileUrl(string path, GitRef gitRef); + + /// + /// Asynchronously asks the host whether the repository is private. + /// + /// A representing the ongoing operation, whose result is + /// for a private repository and for a public one. + public abstract Task IsPrivateRepositoryAsync(); + + /// + /// Creates the host side of one release. + /// + /// A newly-created . + public abstract ReleasePublisher CreateReleasePublisher(); +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubApiClient.cs similarity index 99% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubApiClient.cs index 0e68fac6..f0e52670 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubApiClient.cs @@ -17,7 +17,7 @@ using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// Performs the operations of the GitHub REST API that bv needs. diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubErrorBody.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubErrorBody.cs similarity index 86% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubErrorBody.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubErrorBody.cs index aeaa8138..b50a014c 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubErrorBody.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubErrorBody.cs @@ -1,7 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// The members of a GitHub API error answer that bv reads. diff --git a/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubHost.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubHost.cs new file mode 100644 index 00000000..62b5f294 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubHost.cs @@ -0,0 +1,121 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Runtime; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Versioning; +using Buildvana.Tool.Utilities; +using CommunityToolkit.Diagnostics; +using Microsoft.Extensions.DependencyInjection; + +namespace Buildvana.Tool.Services.Hosts.GitHub; + +/// +/// The GitHub host: github.com, or a GitHub Enterprise Server. +/// +internal sealed class GitHubHost : GitHost, IDisposable +{ + private readonly IServiceProvider _services; + private readonly GitHubConfig _config; + private readonly GitHubRepositoryUrls _urls; + private readonly HttpMessageHandler? _handler; + + private GitHubApiClient? _client; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + /// The parsed URL of the repository. + /// The handler that sends the requests of the API client, or + /// for one that reaches the network. It is disposed together with this instance once the client exists. + public GitHubHost(IServiceProvider services, GitUrlInfo url, HttpMessageHandler? handler = null) + : base(url) + { + Guard.IsNotNull(services); + BuildFailedException.ThrowIfNot(url.PathSegments.Count == 2, $"'{url.Url}' is not a valid GitHub repository URL."); + _services = services; + _config = services.GetRequiredService().Hosts.GitHub; + _urls = new GitHubRepositoryUrls(HostName, Owner, Repository); + _handler = handler; + } + + /// + public override string Name => "GitHub"; + + /// + /// Always . + public override HostKind? Kind => HostKind.GitHub; + + /// + public override Uri RepositoryUrl => _urls.Repository; + + /// + public override string TokenEnv => _config.TokenEnv; + + /// + /// Always x-access-token. + public override string PushUsername => "x-access-token"; + + /// + /// Gets the client of the REST API of the host, created on first use. + /// + /// The variable names is not set. + /// + /// The client is created on first use, so that a host whose token is missing can still answer what + /// needs no API call, such as the URLs and the push username. + /// + internal GitHubApiClient Client => _client ??= new GitHubApiClient( + HostName, + RuntimeAccess.Translate(_config.GetToken), + _handler ?? new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }); + + /// + public override Uri GetReleaseUrl(string tag) => _urls.ReleaseTag(tag); + + /// + /// + /// A GitHub file URL takes the name of a tag, a branch, or a commit alike, so the kind of + /// plays no part. + /// + public override Uri GetFileUrl(string path, GitRef gitRef) + { + Guard.IsNotNull(gitRef); + return _urls.File(path, gitRef.Name); + } + + /// + public override async Task IsPrivateRepositoryAsync() + { + _services.GetRequiredService().Info("Fetching repository information..."); + var repository = await Client.GetRepositoryAsync(Owner, Repository).ConfigureAwait(false); + return repository.Private; + } + + /// + public override ReleasePublisher CreateReleasePublisher() + => new GitHubReleasePublisher( + this, + _services.GetRequiredService(), + _services.GetRequiredService(), + _services.GetRequiredService()); + + /// + public void Dispose() + { + if (_client is not null) + { + _client.Dispose(); + } + else + { + _handler?.Dispose(); + } + } +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubJsonContext.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubJsonContext.cs similarity index 94% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubJsonContext.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubJsonContext.cs index 33fefddb..b239ea2f 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubJsonContext.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubJsonContext.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// The source-generated serializer context for the requests and answers of the GitHub REST API. diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubNewRelease.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubNewRelease.cs similarity index 93% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubNewRelease.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubNewRelease.cs index d1f77342..5517a73d 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubNewRelease.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubNewRelease.cs @@ -1,7 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// The members bv sends to create a GitHub release. diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRelease.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubRelease.cs similarity index 90% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRelease.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubRelease.cs index c7756e03..cac959a3 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRelease.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubRelease.cs @@ -1,7 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// The members of a GitHub release that bv reads. diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotes.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleaseNotes.cs similarity index 86% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotes.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleaseNotes.cs index 6df2e7df..fcf7d7de 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotes.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleaseNotes.cs @@ -1,7 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// The members of the release notes GitHub generates that bv reads. diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotesRequest.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleaseNotesRequest.cs similarity index 90% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotesRequest.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleaseNotesRequest.cs index 7703b339..b6d24d14 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotesRequest.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleaseNotesRequest.cs @@ -1,7 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// The members bv sends to have GitHub generate the notes of a release. diff --git a/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleasePublisher.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleasePublisher.cs new file mode 100644 index 00000000..720fc3b3 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleasePublisher.cs @@ -0,0 +1,144 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Core.IO; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Release; +using Buildvana.Tool.Services.Versioning; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Hosts.GitHub; + +/// +/// The release publisher of a : a draft release created first, then published. +/// +internal sealed class GitHubReleasePublisher : ReleasePublisher +{ + private readonly GitHubHost _host; + private readonly IReporter _reporter; + private readonly VersionService _version; + private readonly IGitService _git; + + private GitHubRelease? _release; + private string? _publishedTag; + + /// + /// Initializes a new instance of the class. + /// + /// The host to publish on. + /// The reporter to report to. + /// The version service. + /// The Git service. + public GitHubReleasePublisher(GitHubHost host, IReporter reporter, VersionService version, IGitService git) + { + Guard.IsNotNull(host); + Guard.IsNotNull(reporter); + Guard.IsNotNull(version); + Guard.IsNotNull(git); + _host = host; + _reporter = reporter; + _version = version; + _git = git; + } + + /// + /// + /// The draft is named after the version as it stands before the release commit, which may still move it. + /// + public override async Task BeginAsync() + { + var tag = _version.CurrentStr; + _reporter.Info("Creating a provisional draft release..."); + var newRelease = new GitHubNewRelease { + TagName = tag, + Name = $"{tag} [provisional]", + TargetCommitish = _git.CurrentBranch, + Prerelease = _version.IsPrerelease, + Draft = true, + }; + + _release = await _host.Client.CreateReleaseAsync(_host.Owner, _host.Repository, newRelease).ConfigureAwait(false); + } + + /// + public override async Task PublishAsync(string tag, string sha, IReadOnlyList assets) + { + Guard.IsNotNullOrEmpty(tag); + Guard.IsNotNullOrEmpty(sha); + Guard.IsNotNull(assets); + var release = _release ?? ThrowHelper.ThrowInvalidOperationException( + "Internal error: cannot publish a release before beginning it."); + + var assetCount = assets.Count; + if (assetCount == 0) + { + _reporter.Info("Asset upload skipped: no release assets defined."); + } + + for (var i = 0; i < assetCount; i++) + { + var asset = assets[i]; + _reporter.Info(string.Create( + CultureInfo.InvariantCulture, + $"Uploading asset {i + 1} of {assetCount}: {Path.GetFileName(asset.Path)} ({asset.Description})...")); + await UploadAssetAsync(release, asset).ConfigureAwait(false); + } + + _reporter.Info($"Generating release notes for {tag}..."); + var notesRequest = new GitHubReleaseNotesRequest { TagName = tag, TargetCommitish = sha }; + var notes = await _host.Client.GenerateReleaseNotesAsync(_host.Owner, _host.Repository, notesRequest).ConfigureAwait(false); + var changelogUrl = _host.GetFileUrl("CHANGELOG.md", GitRef.Branch(_git.CurrentBranch)); + var body = $"We also have a [human-curated changelog]({changelogUrl}).\n\n---\n\n" + notes.Body; + + _reporter.Info($"Publishing the provisional draft release as {tag} (target {sha})..."); + var update = new GitHubReleaseUpdate { + TagName = tag, + Name = tag, + TargetCommitish = sha, + Body = body, + Prerelease = _version.IsPrerelease, + Draft = false, + }; + + await _host.Client.UpdateReleaseAsync(_host.Owner, _host.Repository, release.Id, update).ConfigureAwait(false); + _publishedTag = tag; + } + + /// + /// + /// A deleted release is named after the tag it was published under. A draft carries a provisional name, + /// which the release commit may have made wrong, so it is reported as the draft it is. + /// + public override async Task UndoAsync() + { + if (_release is null) + { + return; + } + + _reporter.Info("Deleting the previously created release..."); + await _host.Client.DeleteReleaseAsync(_host.Owner, _host.Repository, _release.Id).ConfigureAwait(false); + _reporter.Notice(_publishedTag is null ? "Deleted the provisional draft release." : $"Deleted release {_publishedTag}."); + _release = null; + } + + private async Task UploadAssetAsync(GitHubRelease release, ReleaseAsset asset) + { + _reporter.Detail($"Uploading asset {asset.Path}..."); + var contents = UserFile.OpenRead(asset.Path); + await using (contents.ConfigureAwait(false)) + { + await _host.Client.UploadReleaseAssetAsync( + release, + Path.GetFileName(asset.Path), + asset.MimeType, + string.IsNullOrEmpty(asset.Description) ? null : asset.Description, + contents).ConfigureAwait(false); + } + } +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseUpdate.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleaseUpdate.cs similarity index 94% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseUpdate.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleaseUpdate.cs index 2cc85fe0..db70b5ab 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseUpdate.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleaseUpdate.cs @@ -1,7 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// The members bv sends to edit a GitHub release. diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepository.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubRepository.cs similarity index 86% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepository.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubRepository.cs index fb2f038b..1993d97d 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepository.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubRepository.cs @@ -1,7 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// The members of a GitHub repository that bv reads. diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepositoryUrls.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubRepositoryUrls.cs similarity index 98% rename from src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepositoryUrls.cs rename to src/Buildvana.Tool/Services/Hosts/GitHub/GitHubRepositoryUrls.cs index fee9bf9d..c8cf0874 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepositoryUrls.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubRepositoryUrls.cs @@ -5,7 +5,7 @@ using System.IO; using CommunityToolkit.Diagnostics; -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +namespace Buildvana.Tool.Services.Hosts.GitHub; /// /// Builds the URLs of a GitHub repository and of the resources it contains. diff --git a/src/Buildvana.Tool/Services/Hosts/GitRef.cs b/src/Buildvana.Tool/Services/Hosts/GitRef.cs new file mode 100644 index 00000000..750cdf25 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/GitRef.cs @@ -0,0 +1,65 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Hosts; + +/// +/// Names a tag, a branch, or a commit of a repository, together with its kind. +/// +/// +/// The kind travels with the name because some hosts put it in the URL of a file, and a name alone +/// cannot tell a tag from a branch of the same name. +/// +internal sealed record GitRef +{ + private GitRef(GitRefKind kind, string name) + { + Kind = kind; + Name = name; + } + + /// + /// Gets the kind of the reference. + /// + public GitRefKind Kind { get; } + + /// + /// Gets the name of the tag or the branch, or the SHA of the commit. + /// + public string Name { get; } + + /// + /// Creates a reference to a tag. + /// + /// The name of the tag. + /// A newly-created . + public static GitRef Tag(string name) + { + Guard.IsNotNullOrEmpty(name); + return new(GitRefKind.Tag, name); + } + + /// + /// Creates a reference to a branch. + /// + /// The name of the branch. + /// A newly-created . + public static GitRef Branch(string name) + { + Guard.IsNotNullOrEmpty(name); + return new(GitRefKind.Branch, name); + } + + /// + /// Creates a reference to a commit. + /// + /// The SHA of the commit. + /// A newly-created . + public static GitRef Commit(string sha) + { + Guard.IsNotNullOrEmpty(sha); + return new(GitRefKind.Commit, sha); + } +} diff --git a/src/Buildvana.Tool/Services/Hosts/GitRefKind.cs b/src/Buildvana.Tool/Services/Hosts/GitRefKind.cs new file mode 100644 index 00000000..a60aa3eb --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/GitRefKind.cs @@ -0,0 +1,19 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.Hosts; + +/// +/// Specifies the kind of a . +/// +internal enum GitRefKind +{ + /// A tag. + Tag, + + /// A branch. + Branch, + + /// A commit, named by its SHA. + Commit, +} diff --git a/src/Buildvana.Tool/Services/Hosts/ReleasePublisher.cs b/src/Buildvana.Tool/Services/Hosts/ReleasePublisher.cs new file mode 100644 index 00000000..1a009cfd --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/ReleasePublisher.cs @@ -0,0 +1,46 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Buildvana.Tool.Services.Release; + +namespace Buildvana.Tool.Services.Hosts; + +/// +/// Base class for the host side of one release: what a creates on its server. +/// +/// +/// The release tag is not the publisher's. creates it, pushes it, and deletes it. +/// +internal abstract class ReleasePublisher +{ + private protected ReleasePublisher() + { + } + + /// + /// Asynchronously prepares the release on the host, before any commit of the release exists. + /// + /// A representing the ongoing operation. + /// + /// A host that can create something provisional creates it here, so that a token without the needed + /// permissions fails the release while there is nothing to roll back. + /// + public abstract Task BeginAsync(); + + /// + /// Asynchronously publishes the release on a tag that already exists on the host. + /// + /// The name of the release tag. + /// The SHA of the commit the tag points to. + /// The files to attach to the release. + /// A representing the ongoing operation. + public abstract Task PublishAsync(string tag, string sha, IReadOnlyList assets); + + /// + /// Asynchronously removes from the host what and created. + /// + /// A representing the ongoing operation. + public abstract Task UndoAsync(); +} diff --git a/src/Buildvana.Tool/Services/Hosts/ReleaseRemoteResolver.cs b/src/Buildvana.Tool/Services/Hosts/ReleaseRemoteResolver.cs new file mode 100644 index 00000000..4b1ed087 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/ReleaseRemoteResolver.cs @@ -0,0 +1,23 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.Hosts; + +/// +/// Base class for the services that find the release remote: the remote of the clone that the releases are +/// pushed to. +/// +internal abstract class ReleaseRemoteResolver +{ + private protected ReleaseRemoteResolver() + { + } + + /// + /// Finds the release remote. + /// + /// The name of the release remote. + /// The clone has no remote bv can push a release to. + /// The message says why. + public abstract string Resolve(); +} diff --git a/src/Buildvana.Tool/Services/Hosts/UnknownHost.cs b/src/Buildvana.Tool/Services/Hosts/UnknownHost.cs new file mode 100644 index 00000000..19cd5288 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/UnknownHost.cs @@ -0,0 +1,55 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Threading.Tasks; +using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Runtime; +using Buildvana.Tool.Services.Git; + +namespace Buildvana.Tool.Services.Hosts; + +/// +/// A Git host bv does not know: it has no URL scheme, no token, and no release page. +/// +/// The parsed URL of the repository. +/// The reporter the release publisher reports to. +internal sealed class UnknownHost(GitUrlInfo url, IReporter reporter) : GitHost(url) +{ + /// + public override string Name => $"unknown host ({HostName})"; + + /// + /// Always . + public override HostKind? Kind => null; + + /// + /// Always . + public override Uri? RepositoryUrl => null; + + /// + /// Always . + public override string? TokenEnv => null; + + /// + /// Always . + public override string? PushUsername => null; + + /// + /// Always . + public override Uri? GetReleaseUrl(string tag) => null; + + /// + /// Always . + public override Uri? GetFileUrl(string path, GitRef gitRef) => null; + + /// + /// + /// This method is not supported on an unknown host and always throws. + /// + public override Task IsPrivateRepositoryAsync() => BuildFailedException.ThrowOnUnsupportedMethod>(); + + /// + public override ReleasePublisher CreateReleasePublisher() => new UnknownHostReleasePublisher(Name, reporter); +} diff --git a/src/Buildvana.Tool/Services/Hosts/UnknownHostReleasePublisher.cs b/src/Buildvana.Tool/Services/Hosts/UnknownHostReleasePublisher.cs new file mode 100644 index 00000000..fea0c3f2 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/UnknownHostReleasePublisher.cs @@ -0,0 +1,30 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Tool.Services.Release; + +namespace Buildvana.Tool.Services.Hosts; + +/// +/// The release publisher of an : it publishes nothing, and says so. +/// +/// The display name of the host. +/// The reporter to report to. +internal sealed class UnknownHostReleasePublisher(string hostName, IReporter reporter) : ReleasePublisher +{ + /// + public override Task BeginAsync() => Task.CompletedTask; + + /// + public override Task PublishAsync(string tag, string sha, IReadOnlyList assets) + { + reporter.Notice($"No release page created for {tag}: bv cannot publish a release on {hostName}."); + return Task.CompletedTask; + } + + /// + public override Task UndoAsync() => Task.CompletedTask; +} diff --git a/src/Buildvana.Tool/Services/Hosts/UrlMatchingReleaseRemoteResolver.cs b/src/Buildvana.Tool/Services/Hosts/UrlMatchingReleaseRemoteResolver.cs new file mode 100644 index 00000000..d48718fa --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/UrlMatchingReleaseRemoteResolver.cs @@ -0,0 +1,82 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Linq; +using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Runtime; +using Buildvana.Tool.Services.Git; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Hosts; + +/// +/// Finds the release remote as the remote whose URL names the repository that repository.url names. +/// +/// +/// Two URLs name the same repository when their host names are equal and their path segments are equal, +/// both case-insensitive. The scheme, the port, the user, and a .git suffix play no part, so that the +/// SSH URL and the HTTPS URL of a repository match. +/// LibGit2Sharp pushes over HTTP and HTTPS alone. A remote with another URL that matches is refused, +/// unless another remote with an HTTP or HTTPS URL matches too. +/// +internal sealed class UrlMatchingReleaseRemoteResolver : ReleaseRemoteResolver +{ + private readonly IReporter _reporter; + private readonly IGitService _git; + private readonly RepositoryConfig _repository; + + /// + /// Initializes a new instance of the class. + /// + /// The reporter to report to. + /// The Git service. + /// The configuration. + public UrlMatchingReleaseRemoteResolver(IReporter reporter, IGitService git, BuildvanaConfig config) + { + Guard.IsNotNull(reporter); + Guard.IsNotNull(git); + Guard.IsNotNull(config); + _reporter = reporter; + _git = git; + _repository = config.Repository; + } + + /// + public override string Resolve() + { + var repositoryUrl = GitHost.ParseRepositoryUrl(_repository); + var remotes = _git.Remotes; + var matches = remotes + .Where(x => GitUrlInfo.TryCreate(x.Url, out var info) && NameSameRepository(info, repositoryUrl)) + .ToArray(); + if (matches.Length == 0) + { + var list = remotes.Count == 0 + ? "The clone has no remotes." + : "The remotes of the clone are " + string.Join(", ", remotes.Select(x => $"'{x.Name}' ({x.Url})")) + "."; + throw new BuildFailedException($"No remote of the clone names repository.url '{_repository.Url}'. {list}"); + } + + var pushable = matches.FirstOrDefault(x => IsHttp(x.Url)); + if (pushable is null) + { + var match = matches[0]; + throw new BuildFailedException( + $"Remote '{match.Name}' names repository.url, but bv cannot push to '{match.Url}': " + + "LibGit2Sharp pushes over HTTP and HTTPS alone. Add a remote with the HTTPS URL of the repository."); + } + + _reporter.Info($"The release remote is '{pushable.Name}' ({pushable.Url})."); + return pushable.Name; + } + + private static bool NameSameRepository(GitUrlInfo a, GitUrlInfo b) + => string.Equals(a.Host, b.Host, StringComparison.OrdinalIgnoreCase) + && a.PathSegments.SequenceEqual(b.PathSegments, StringComparer.OrdinalIgnoreCase); + + private static bool IsHttp(string url) + => url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("http://", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs b/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs index 5262e62b..53706a5f 100644 --- a/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs +++ b/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs @@ -2,6 +2,7 @@ // See the LICENSE file in the project root for full license information. using System; +using Buildvana.Runtime; using Buildvana.Tool.Services.Git; using CommunityToolkit.Diagnostics; @@ -44,6 +45,20 @@ private protected CIPlatform() /// public abstract GitIdentity? BotIdentity { get; } + /// + /// Gets the URL of the server that runs the platform, or if the platform states none. + /// + public virtual Uri? ServerUrl => null; + + /// + /// Gets the kind of Git host of the server that runs the platform, or if the platform + /// belongs to no host bv knows. + /// + /// + /// A repository whose URL has the host name of is on a host of this kind. + /// + public virtual HostKind? ServerKind => null; + /// /// Gets a value indicating whether the platform collects named outputs of a job step. /// @@ -83,4 +98,12 @@ public static CIPlatform Detect(Func getEnvironmentVariable) /// public virtual StepOutputWriter OpenStepOutputs() => ThrowHelper.ThrowInvalidOperationException($"Internal error: {Name} has no step outputs."); + + /// + /// Parses the value of a variable that holds the URL of a server. + /// + /// The value of the variable, or if it is not set. + /// The URL, or if is not an absolute URL. + private protected static Uri? ParseServerUrl(string? value) + => Uri.TryCreate(value, UriKind.Absolute, out var url) ? url : null; } diff --git a/src/Buildvana.Tool/Services/Platforms/GitHubActionsPlatform.cs b/src/Buildvana.Tool/Services/Platforms/GitHubActionsPlatform.cs index ce2c26e9..c5150268 100644 --- a/src/Buildvana.Tool/Services/Platforms/GitHubActionsPlatform.cs +++ b/src/Buildvana.Tool/Services/Platforms/GitHubActionsPlatform.cs @@ -3,6 +3,7 @@ using System; using Buildvana.Core; +using Buildvana.Runtime; using Buildvana.Tool.Services.Git; using CommunityToolkit.Diagnostics; @@ -17,9 +18,10 @@ internal sealed class GitHubActionsPlatform : CIPlatform private readonly string? _stepOutputsPath; - private GitHubActionsPlatform(string? stepOutputsPath) + private GitHubActionsPlatform(string? stepOutputsPath, Uri? serverUrl) { _stepOutputsPath = stepOutputsPath; + ServerUrl = serverUrl; } /// @@ -34,6 +36,14 @@ private GitHubActionsPlatform(string? stepOutputsPath) "github-actions[bot]", "41898282+github-actions[bot]@users.noreply.github.com"); + /// + /// The value of GITHUB_SERVER_URL. + public override Uri? ServerUrl { get; } + + /// + /// Always . + public override HostKind? ServerKind => HostKind.GitHub; + /// /// Always . public override bool HasStepOutputs => true; @@ -49,7 +59,9 @@ private GitHubActionsPlatform(string? stepOutputsPath) { Guard.IsNotNull(getEnvironmentVariable); return string.Equals(getEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) - ? new GitHubActionsPlatform(getEnvironmentVariable(StepOutputsVariable)) + ? new GitHubActionsPlatform( + getEnvironmentVariable(StepOutputsVariable), + ParseServerUrl(getEnvironmentVariable("GITHUB_SERVER_URL"))) : null; } diff --git a/src/Buildvana.Tool/Services/Platforms/GitLabCIPlatform.cs b/src/Buildvana.Tool/Services/Platforms/GitLabCIPlatform.cs index 5aaf71d5..e415a553 100644 --- a/src/Buildvana.Tool/Services/Platforms/GitLabCIPlatform.cs +++ b/src/Buildvana.Tool/Services/Platforms/GitLabCIPlatform.cs @@ -12,9 +12,10 @@ namespace Buildvana.Tool.Services.Platforms; /// internal sealed class GitLabCIPlatform : CIPlatform { - private GitLabCIPlatform(string? serverHost) + private GitLabCIPlatform(string? serverHost, Uri? serverUrl) { BotIdentity = new("GitLab CI", $"gitlab-ci@noreply.{serverHost}"); + ServerUrl = serverUrl; } /// @@ -27,6 +28,10 @@ private GitLabCIPlatform(string? serverHost) /// public override GitIdentity? BotIdentity { get; } + /// + /// The value of CI_SERVER_URL. + public override Uri? ServerUrl { get; } + /// /// Creates and returns an instance of if bv runs in a GitLab CI runner. /// @@ -38,7 +43,7 @@ private GitLabCIPlatform(string? serverHost) { Guard.IsNotNull(getEnvironmentVariable); return getEnvironmentVariable("GITLAB_CI") is not null - ? new GitLabCIPlatform(getEnvironmentVariable("CI_SERVER_HOST")) + ? new GitLabCIPlatform(getEnvironmentVariable("CI_SERVER_HOST"), ParseServerUrl(getEnvironmentVariable("CI_SERVER_URL"))) : null; } } diff --git a/src/Buildvana.Tool/Services/Release/ReleaseAsset.cs b/src/Buildvana.Tool/Services/Release/ReleaseAsset.cs new file mode 100644 index 00000000..f2b43477 --- /dev/null +++ b/src/Buildvana.Tool/Services/Release/ReleaseAsset.cs @@ -0,0 +1,12 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.Release; + +/// +/// Describes a file attached to a release. +/// +/// The path of the file. +/// A short description of the file, which becomes its label on the host. +/// The MIME type of the file. +internal sealed record ReleaseAsset(string Path, string Description, string MimeType); diff --git a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs b/src/Buildvana.Tool/Services/Release/ServerRelease.cs similarity index 66% rename from src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs rename to src/Buildvana.Tool/Services/Release/ServerRelease.cs index c84ad0a3..9084443f 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs +++ b/src/Buildvana.Tool/Services/Release/ServerRelease.cs @@ -8,17 +8,19 @@ using System.Threading.Tasks; using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Hosts; +using Buildvana.Tool.Services.Platforms; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; using Louis.Diagnostics; -using Microsoft.Extensions.DependencyInjection; -namespace Buildvana.Tool.Services.ServerAdapters; +namespace Buildvana.Tool.Services.Release; /// -/// Represents a release in a server-independent way. +/// Represents a release in a host-independent way: the commits, the tag, the push, and the rollback of all of +/// them, with the host side left to a . /// -internal abstract partial class ServerRelease : IAsyncDisposable +internal sealed class ServerRelease : IAsyncDisposable { // The message the release commit carries between being made and being named after the version // computed from it. See NameReleaseCommit. @@ -27,21 +29,34 @@ internal abstract partial class ServerRelease : IAsyncDisposable private readonly IReporter _reporter; private readonly IGitService _git; private readonly VersionService _version; + private readonly ReleasePublisher _publisher; + private readonly string _remote; + private readonly StepOutputWriter? _stepOutputs; private readonly Stack> _rollbackActions = new(); - private readonly List _assets = []; + private readonly List _assets = []; private bool _published; private bool _repositoryUpdated; private int _postReleaseCommits; private bool _updatesPushed; - private protected ServerRelease(IServiceProvider services) - { - Guard.IsNotNull(services); + // The release tag, once it has been pushed to the release remote. + private string? _tag; - _reporter = services.GetRequiredService(); - _git = services.GetRequiredService(); - _version = services.GetRequiredService(); + private ServerRelease( + IReporter reporter, + IGitService git, + VersionService version, + ReleasePublisher publisher, + string remote, + StepOutputWriter? stepOutputs) + { + _reporter = reporter; + _git = git; + _version = version; + _publisher = publisher; + _remote = remote; + _stepOutputs = stepOutputs; } public bool IsDisposed { get; private set; } @@ -50,12 +65,50 @@ private protected ServerRelease(IServiceProvider services) /// Gets the SHA of the "Prepare release" commit, once it has been created. /// /// - /// This is the commit the release tag should point to, regardless of any post-release commits + /// This is the commit the release tag points to, regardless of any post-release commits /// (e.g. self-reference dogfooding) that may be pushed on top of it. /// Returns the empty string before (or any method that calls it, /// such as ) has run. /// - protected string ReleaseCommitSha { get; private set; } = string.Empty; + public string ReleaseCommitSha { get; private set; } = string.Empty; + + /// + /// Asynchronously begins a release. + /// + /// The reporter to report to. + /// The Git service. + /// The version service. + /// The host side of the release. + /// The name of the release remote, which the commits and the tag are pushed to. + /// The writer of the step outputs of the platform, or on a + /// platform that has none. + /// A representing the ongoing operation, whose result is the release. + /// + /// The step outputs are opened by the caller before the release begins, and kept for the whole life of + /// the release. Opening them where they are used, after publication, would let a variable that was never set + /// fail a release that had otherwise succeeded, and roll back everything it had just done. + /// + public static async Task BeginAsync( + IReporter reporter, + IGitService git, + VersionService version, + ReleasePublisher publisher, + string remote, + StepOutputWriter? stepOutputs) + { + Guard.IsNotNull(reporter); + Guard.IsNotNull(git); + Guard.IsNotNull(version); + Guard.IsNotNull(publisher); + Guard.IsNotNullOrEmpty(remote); + await publisher.BeginAsync().ConfigureAwait(false); + var release = new ServerRelease(reporter, git, version, publisher, remote, stepOutputs); + + // The publisher undoes what it created, the draft or the published release alike. This action is the + // first one in, so it is the last one out: the tag and the commits go before the host-side release does. + release.OnRollback(async () => await publisher.UndoAsync().ConfigureAwait(false)); + return release; + } /// /// Ensures that a "Prepare release" commit exists, creating one if necessary. @@ -113,7 +166,7 @@ public void EnsureReleaseCommit() { // "Undo" the push by force pushing the previous commit // (to which we have just reset). - _git.PushBranch(_git.Origin, force: true); + _git.PushBranch(_remote, force: true); } }); } @@ -194,22 +247,49 @@ public void AddPostReleaseCommit(string message, params string[] files) // a single, ordered rollback rather than per-commit rollbacks popped LIFO. } + /// + /// Tags the release commit, then pushes the branch and the tag to the release remote. + /// + /// + /// The tag is created here, and not with the release commit, because the release commit is amended + /// until the changelog is finalized after the build. + /// public void PushUpdates() { EnsurePending(); if (!_repositoryUpdated) { - _reporter.Notice("Repository unchanged, no commit to push."); - return; + ThrowHelper.ThrowInvalidOperationException( + "Internal error: cannot push updates before the release commit has been created."); } - _git.PushBranch(_git.Origin); + if (_updatesPushed) + { + ThrowHelper.ThrowInvalidOperationException("Internal error: updates have already been pushed."); + } + + var tag = _version.CurrentStr; + _git.CreateTag(tag, ReleaseCommitSha); + OnRollback(() => { + // The tag goes first: a tag left on the remote would name a commit the rollback removes. + // _tag is set once the tag has reached the remote. + if (_tag is not null) + { + _git.DeleteRemoteTag(_remote, tag); + } + + _git.DeleteTag(tag); + }); + + _git.PushBranch(_remote); _updatesPushed = true; - // The rollback action is defined in EnsureReleaseCommit, because + // The rollback action of the push is defined in EnsureReleaseCommit, because // commit and push can't be undone in reverse order (as rollback actions are processed): // first we need to undo the commits (a.k.a. reset), then force push to "undo" the push. + _git.PushTag(_remote, tag); + _tag = tag; } public void AddAsset(string path, string? description = null, string? mimeType = null) @@ -230,23 +310,29 @@ public void AddAsset(string path, string? description = null, string? mimeType = _assets.Add(new(path, description, mimeType)); } + /// + /// Asynchronously publishes the release on the tag pushed, and writes the + /// version step output. + /// + /// A representing the ongoing operation. public async Task PublishAsync() { EnsurePending(); + var tag = _tag ?? ThrowHelper.ThrowInvalidOperationException( + "Internal error: cannot publish a release before pushing updates."); - await DoPublishAsync(_assets).ConfigureAwait(false); - OnRollback(async () => await UndoPublishAsync().ConfigureAwait(false)); - await OnPublishedAsync().ConfigureAwait(false); + await _publisher.PublishAsync(tag, ReleaseCommitSha, _assets).ConfigureAwait(false); + _stepOutputs?.Write("version", tag); _published = true; _rollbackActions.Clear(); - // The record of the publication comes last, once nothing can undo it any more: OnPublishedAsync - // is the final step that can still fail, and its failure rolls the whole release back - deleting - // the release and the tag that a notice printed any earlier would already have claimed. + // The record of the publication comes last, once nothing can undo it any more: the step output is the + // final step that can still fail, and its failure rolls the whole release back - deleting the release + // and the tag that a notice printed any earlier would already have claimed. _reporter.Notice(_assets.Count switch { - 0 => $"Published release {_version.CurrentStr} with no assets.", - 1 => $"Published release {_version.CurrentStr} with 1 asset.", - var count => string.Create(CultureInfo.InvariantCulture, $"Published release {_version.CurrentStr} with {count} assets."), + 0 => $"Published release {tag} with no assets.", + 1 => $"Published release {tag} with 1 asset.", + var count => string.Create(CultureInfo.InvariantCulture, $"Published release {tag} with {count} assets."), }); } @@ -273,28 +359,22 @@ public async ValueTask DisposeAsync() _rollbackActions.Clear(); } - protected abstract Task DoPublishAsync(IReadOnlyList assets); - - protected abstract Task UndoPublishAsync(); - - protected virtual Task OnPublishedAsync() => Task.CompletedTask; - - protected void OnRollback(Action action) => OnRollback(() => { + private void OnRollback(Action action) => OnRollback(() => { action(); return ValueTask.CompletedTask; }); - protected void OnRollback(Func actionAsync) + private void OnRollback(Func actionAsync) { EnsurePending(); _rollbackActions.Push(actionAsync); } - protected void EnsurePending() + private void EnsurePending() { if (IsDisposed) { - ThrowHelper.ThrowObjectDisposedException(GetType().Name); + ThrowHelper.ThrowObjectDisposedException(nameof(ServerRelease)); } if (_published) diff --git a/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs b/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs index 8b3b05b9..05a69a4d 100644 --- a/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs +++ b/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs @@ -63,7 +63,7 @@ public SelfReferenceUpdater( /// /// The packages produced by the current build, as a map from package ID to version. /// The list of files that were actually modified. Pass this to - /// to commit them + /// to commit them /// into a separate post-release commit on top of the "Prepare release" commit. /// The home directory keeps its tool manifest under /// .config, where the rewrite would miss it and the post-release commit would leave bv's own pin diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs deleted file mode 100644 index 94fce789..00000000 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Buildvana.Core; -using Buildvana.Core.ConsoleOutput; -using Buildvana.Core.IO; -using Buildvana.Runtime; -using Buildvana.Tool.Services.Git; -using Buildvana.Tool.Services.Versioning; -using Buildvana.Tool.Utilities; -using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.DependencyInjection; - -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; - -/// -/// Continuous Integration adapter for GitHub. -/// -internal sealed class GitHubServerAdapter : ServerAdapter, IDisposable -{ - private readonly IServiceProvider _services; - private readonly IReporter _reporter; - private readonly VersionService _version; - private readonly IGitService _git; - private readonly GitHubRepositoryUrls _urls; - private readonly string _token; - private readonly GitHubApiClient _client; - - private GitHubServerAdapter(IServiceProvider services) - { - _services = services; - _reporter = services.GetRequiredService(); - _version = services.GetRequiredService(); - _git = services.GetRequiredService(); - BuildFailedException.ThrowIfNot( - GitUrlInfo.TryCreate(_git.OriginUrl, out var originInfo), - $"Couldn't get information from origin URL '{_git.OriginUrl}'."); - BuildFailedException.ThrowIfNot(originInfo.PathSegments.Count == 2, $"'{originInfo.Url}' is not a valid GitHub repository URL."); - HostName = originInfo.Host; - RepositoryOwner = originInfo.PathSegments[0]; - RepositoryName = originInfo.PathSegments[1]; - _urls = new GitHubRepositoryUrls(HostName, RepositoryOwner, RepositoryName); - _token = RuntimeAccess.Translate(services.GetRequiredService().Hosts.GitHub.GetToken); - _client = new GitHubApiClient(HostName, _token, new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }); - } - - /// - public override string Name => "GitHub Actions"; - - /// - public override string HostName { get; } - - /// - public override string RepositoryOwner { get; } - - /// - public override string RepositoryName { get; } - - /// - public override Uri RepositoryUrl => _urls.Repository; - - /// - public override string PushUsername => "x-access-token"; - - /// - // ReSharper disable once ConvertToAutoProperty // _token is also the REST API credential; keep it separate from the push contract - public override string PushPassword => _token; - - /// - /// Creates and returns an instance of if the build is running in a GitHub Actions runner. - /// - /// The service provider. - /// If the build is running in a GitHub Actions runner, a newly-created ; - /// otherwise, . - public static ServerAdapter? CreateIfApplicable(IServiceProvider services) - { - Guard.IsNotNull(services); - return string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) - ? new GitHubServerAdapter(services) - : null; - } - - /// - public override async Task IsPrivateRepositoryAsync() - { - _reporter.Info("Fetching repository information..."); - var repository = await _client.GetRepositoryAsync(RepositoryOwner, RepositoryName).ConfigureAwait(false); - return repository.Private; - } - - /// - public override Uri GetReleaseUrl(string version) => _urls.ReleaseTag(version); - - /// - public override Uri GetFileUrl(string path, string commitish) => _urls.File(path, commitish); - - /// - public override async Task CreateReleaseAsync() - => await GitHubServerRelease.CreateAsync( - server: this, - services: _services, - createGitHubReleaseAsync: () => { - // Create the release as a draft first, so if the token has no permissions we can bail out early - var tag = _version.CurrentStr; - _reporter.Info("Creating a provisional draft release..."); - var newRelease = new GitHubNewRelease { - TagName = tag, - Name = $"{tag} [provisional]", - TargetCommitish = _git.CurrentBranch, - Prerelease = _version.IsPrerelease, - Draft = true, - }; - - return _client.CreateReleaseAsync(RepositoryOwner, RepositoryName, newRelease); - }).ConfigureAwait(false); - - /// - /// Asynchronously publishes a draft release on the GitHub repository. - /// - /// An object representing the GitHub release. - /// The commit the release tag should point to. Pass the SHA of the - /// release commit so the tag is anchored to it regardless of any post-release commits that may - /// have been pushed on top of the branch. - /// A representing the ongoing operation. - public async Task PublishReleaseAsync(GitHubRelease release, string targetCommitish) - { - Guard.IsNotNull(release); - Guard.IsNotNullOrEmpty(targetCommitish); - var tag = _version.CurrentStr; - _reporter.Info($"Generating release notes for {tag}..."); - var releaseNotesRequest = new GitHubReleaseNotesRequest { - TagName = tag, - TargetCommitish = targetCommitish, - }; - - var generatedNotes = await _client.GenerateReleaseNotesAsync( - RepositoryOwner, - RepositoryName, - releaseNotesRequest).ConfigureAwait(false); - var body = $"We also have a [human-curated changelog]({GetFileUrl("CHANGELOG.md", _git.CurrentBranch)}).\n\n---\n\n" - + generatedNotes.Body; - - _reporter.Info($"Publishing the previously created release as {tag} (target {targetCommitish})..."); - var update = new GitHubReleaseUpdate { - TagName = tag, - Name = tag, - TargetCommitish = targetCommitish, - Body = body, - Prerelease = _version.IsPrerelease, - Draft = false, - }; - - await _client.UpdateReleaseAsync(RepositoryOwner, RepositoryName, release.Id, update).ConfigureAwait(false); - } - - /// - /// Asynchronously deletes a release and, optionally, the corresponding tag on the GitHub repository. - /// - /// An object representing the release. - /// The tag name, or to not delete a tag. - /// A representing the ongoing operation. - /// - /// A deleted release is named after , never after the tag name of - /// : the draft is created before the release commit exists, and that commit - /// always moves the version, so the tag name the draft carries is never the one the release is published - /// under. A draft, having no tag of its own, is reported as the draft it is. - /// - public async Task DeleteReleaseAsync(GitHubRelease release, string? tagName) - { - Guard.IsNotNull(release); - _reporter.Info("Deleting the previously created release..."); - await _client.DeleteReleaseAsync(RepositoryOwner, RepositoryName, release.Id).ConfigureAwait(false); - if (string.IsNullOrEmpty(tagName)) - { - _reporter.Notice("Deleted the provisional draft release."); - return; - } - - _reporter.Notice($"Deleted release {tagName}."); - - _reporter.Info($"Looking for tag {tagName} in GitHub repository..."); - if (!await _client.TagExistsAsync(RepositoryOwner, RepositoryName, tagName).ConfigureAwait(false)) - { - _reporter.Info($"Tag {tagName} not found in GitHub repository."); - return; - } - - _reporter.Info($"Deleting tag {tagName} in GitHub repository..."); - await _client.DeleteTagAsync(RepositoryOwner, RepositoryName, tagName).ConfigureAwait(false); - _reporter.Notice($"Deleted tag {tagName}."); - } - - /// - /// Asynchronously uploads a release asset. - /// - /// An object representing the release. - /// The full path of the asset file. - /// The MIME type of the asset. - /// A short textual description of the asset, which becomes its label. - /// A representing the ongoing operation. - public async Task UploadReleaseAssetAsync(GitHubRelease release, string path, string mimeType, string description) - { - _reporter.Detail($"Uploading asset {path}..."); - var assetContents = UserFile.OpenRead(path); - await using (assetContents.ConfigureAwait(false)) - { - await _client.UploadReleaseAssetAsync( - release, - Path.GetFileName(path), - mimeType, - string.IsNullOrEmpty(description) ? null : description, - assetContents).ConfigureAwait(false); - } - } - - /// - public void Dispose() => _client.Dispose(); -} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs deleted file mode 100644 index 2dae52d6..00000000 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Threading.Tasks; -using Buildvana.Core.ConsoleOutput; -using Buildvana.Tool.Services.Platforms; -using Buildvana.Tool.Services.Versioning; -using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.DependencyInjection; - -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; - -/// -/// ServerRelease implementation for GitHub. -/// -internal sealed class GitHubServerRelease : ServerRelease -{ - private readonly GitHubServerAdapter _server; - private readonly IReporter _reporter; - private readonly VersionService _version; - private readonly GitHubRelease _gitHubRelease; - private readonly StepOutputWriter? _stepOutputs; - - private bool _gitHubReleaseDeleted; - - private GitHubServerRelease( - GitHubServerAdapter server, - IServiceProvider services, - GitHubRelease gitHubRelease, - StepOutputWriter? stepOutputs) - : base(services) - { - Guard.IsNotNull(server); - Guard.IsNotNull(services); - Guard.IsNotNull(gitHubRelease); - - _server = server; - _reporter = services.GetRequiredService(); - _version = services.GetRequiredService(); - _gitHubRelease = gitHubRelease; - _stepOutputs = stepOutputs; - - OnRollback(async () => { - // Do this only if the release has not been previously deleted by rolling back its publication - if (!_gitHubReleaseDeleted) - { - await _server.DeleteReleaseAsync(_gitHubRelease, null).ConfigureAwait(false); - } - }); - } - - public static async Task CreateAsync( - GitHubServerAdapter server, - IServiceProvider services, - Func> createGitHubReleaseAsync) - { - Guard.IsNotNull(server); - Guard.IsNotNull(services); - Guard.IsNotNull(createGitHubReleaseAsync); - - // The step outputs are opened here, before the draft release exists, and kept for the whole - // life of the release. Opening them where they are used - after publication, to write the step - // output - would let a variable that was never set fail a release that had otherwise - // succeeded, and roll back everything it had just done. - var platform = services.GetRequiredService(); - var stepOutputs = platform.HasStepOutputs ? platform.OpenStepOutputs() : null; - var gitHubRelease = await createGitHubReleaseAsync().ConfigureAwait(false); - return new(server, services, gitHubRelease, stepOutputs); - } - - protected override async Task DoPublishAsync(IReadOnlyList assets) - { - var assetCount = assets.Count; - if (assetCount > 0) - { - var i = 0; - foreach (var asset in assets) - { - i++; - _reporter.Info(string.Create( - CultureInfo.InvariantCulture, - $"Uploading asset {i} of {assetCount}: {Path.GetFileName(asset.Path)} ({asset.Description})...")); - await _server.UploadReleaseAssetAsync(_gitHubRelease, asset.Path, asset.MimeType, asset.Description).ConfigureAwait(false); - } - } - else - { - _reporter.Info("Asset upload skipped: no release assets defined."); - } - - await _server.PublishReleaseAsync(_gitHubRelease, ReleaseCommitSha).ConfigureAwait(false); - } - - protected override async Task UndoPublishAsync() - { - // Delete the release and the created tag - await _server.DeleteReleaseAsync(_gitHubRelease, _version.CurrentStr).ConfigureAwait(false); - - // Prevent the last rollback action from trying to delete the release again - _gitHubReleaseDeleted = true; - } - - protected override Task OnPublishedAsync() - { - _stepOutputs?.Write("version", _version.CurrentStr); - return Task.CompletedTask; - } -} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitLab/GitLabServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitLab/GitLabServerAdapter.cs deleted file mode 100644 index 7041b21e..00000000 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitLab/GitLabServerAdapter.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using System.Threading.Tasks; -using Buildvana.Core; -using CommunityToolkit.Diagnostics; - -namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitLab; - -/// -/// Continuous Integration adapter for GitLab. -/// -internal sealed class GitLabServerAdapter : ServerAdapter -{ - private GitLabServerAdapter() - { - } - - /// - public override string Name => "GitLab CI"; - - /// - public override string HostName => BuildFailedException.ThrowOnUnsupportedProperty(); - - /// - public override string RepositoryOwner => BuildFailedException.ThrowOnUnsupportedProperty(); - - /// - public override string RepositoryName => BuildFailedException.ThrowOnUnsupportedProperty(); - - /// - public override Uri RepositoryUrl => BuildFailedException.ThrowOnUnsupportedProperty(); - - /// - public override string PushUsername => "oauth2"; - - /// - public override string? PushPassword => null; - - /// - /// Creates and returns an instance of if the build is running in a GitLab CI runner. - /// - /// The service provider. - /// If the build is running in GitLab CI, a newly-created ; - /// otherwise, . - public static ServerAdapter? CreateIfApplicable(IServiceProvider services) - { - Guard.IsNotNull(services); - - return Environment.GetEnvironmentVariable("GITLAB_CI") is not null - ? new GitLabServerAdapter() - : null; - } - - /// - public override Task IsPrivateRepositoryAsync() => BuildFailedException.ThrowOnUnsupportedMethod>(); - - /// - /// - /// This method is not supported on this adapter and will always throw. - /// - public override Uri GetReleaseUrl(string version) - => BuildFailedException.ThrowOnUnsupportedMethod(); - - /// - /// - /// This method is not supported on this adapter and will always throw. - /// - public override Uri GetFileUrl(string path, string commitish) - => BuildFailedException.ThrowOnUnsupportedMethod(); - - /// - public override Task CreateReleaseAsync() => BuildFailedException.ThrowOnUnsupportedMethod>(); -} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/UnknownServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/UnknownServerAdapter.cs deleted file mode 100644 index 023ec1ee..00000000 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/UnknownServerAdapter.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using System.Threading.Tasks; -using Buildvana.Core; - -namespace Buildvana.Tool.Services.ServerAdapters.Internal; - -/// -/// Implements a dummy Continuous Integration adapter for unknown system / local build. -/// Members that answer a question a local build has an honest answer to (, -/// ) answer it; every other property and method fails the build when called. -/// -internal sealed class UnknownServerAdapter : ServerAdapter -{ - /// - public override string Name => "(unknown / local build)"; - - /// - /// - /// This property is not supported on this adapter and will always throw. - /// - public override string HostName => BuildFailedException.ThrowOnUnsupportedProperty(); - - /// - /// - /// This property is not supported on this adapter and will always throw. - /// - public override string RepositoryOwner => BuildFailedException.ThrowOnUnsupportedProperty(); - - /// - /// - /// This property is not supported on this adapter and will always throw. - /// - public override string RepositoryName => BuildFailedException.ThrowOnUnsupportedProperty(); - - /// - /// - /// This property is not supported on this adapter and will always throw. - /// - public override Uri RepositoryUrl => BuildFailedException.ThrowOnUnsupportedProperty(); - - /// - public override string? PushUsername => null; - - /// - public override string? PushPassword => null; - - /// - /// - /// This method is not supported on this adapter and will always throw. - /// - public override Task IsPrivateRepositoryAsync() => BuildFailedException.ThrowOnUnsupportedMethod>(); - - /// - /// - /// This method is not supported on this adapter and will always throw. - /// - public override Uri GetReleaseUrl(string version) => BuildFailedException.ThrowOnUnsupportedMethod(); - - /// - /// - /// This method is not supported on this adapter and will always throw. - /// - public override Uri GetFileUrl(string path, string commitish) => BuildFailedException.ThrowOnUnsupportedMethod(); - - /// - /// - /// This method is not supported on this adapter and will always throw. - /// - public override Task CreateReleaseAsync() => BuildFailedException.ThrowOnUnsupportedMethod>(); -} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/ServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/ServerAdapter.cs deleted file mode 100644 index 9bed9b9e..00000000 --- a/src/Buildvana.Tool/Services/ServerAdapters/ServerAdapter.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using System.Threading.Tasks; -using Buildvana.Tool.Services.ServerAdapters.Internal; -using Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; -using Buildvana.Tool.Services.ServerAdapters.Internal.GitLab; - -namespace Buildvana.Tool.Services.ServerAdapters; - -/// -/// Base class for Continuous Integration adapters. -/// -internal abstract class ServerAdapter -{ - private protected ServerAdapter() - { - } - - /// - /// Gets the name of the build platform. - /// - public abstract string Name { get; } - - /// - /// Gets the name of the remote repository's host. - /// - // ReSharper disable once UnusedMemberInSuper.Global - We will need this property to complete the implementation of server adapters - public abstract string HostName { get; } - - /// - /// Gets the owner of the remote repository. - /// - // ReSharper disable once UnusedMemberInSuper.Global - We will need this property to complete the implementation of server adapters - public abstract string RepositoryOwner { get; } - - /// - /// Gets the name of the remote repository. - /// - // ReSharper disable once UnusedMemberInSuper.Global - We will need this property to complete the implementation of server adapters - public abstract string RepositoryName { get; } - - /// - /// Gets the URL of the remote repository. - /// - public abstract Uri RepositoryUrl { get; } - - /// - /// Gets the username used for pushing to the remote repository. - /// - public abstract string? PushUsername { get; } - - /// - /// Gets the password used for pushing to the remote repository. - /// - public abstract string? PushPassword { get; } - - /// - /// Creates and returns an instance of - /// suitable for the repository. - /// - /// The service provider. - /// A newly-created instance of . - public static ServerAdapter Create(IServiceProvider services) - => GitHubServerAdapter.CreateIfApplicable(services) - ?? GitLabServerAdapter.CreateIfApplicable(services) - ?? new UnknownServerAdapter(); - - /// - /// Asynchronously gets a value that indicates whether the current repository is private. - /// - /// A Task, representing the ongoing operation, whose result will be - /// if the current repository is private, if it is public. - public abstract Task IsPrivateRepositoryAsync(); - - /// - /// Gets the URL of a release. - /// - /// The version string. - /// The URL of the release identified by . - public abstract Uri GetReleaseUrl(string version); - - /// - /// Gets the URL of a file on the server. - /// - /// The path to the file. - /// The SHA or reference to which the file belongs. - /// The URL of the file identified by at . - public abstract Uri GetFileUrl(string path, string commitish); - - /// - /// Asynchronously creates a object, based on the current Git branch. - /// - /// A , representing the ongoing operation, - /// whose result will be a newly-created instance of - /// representing the created release. - /// - /// Depending on the specific build platform, this method does not necessarily create - /// a release at once; however, if the platform allows for the creation of draft releases - /// and their subsequent editing and asset uploading (e.g. GitHub Actions) subclasses - /// should take advantage of it. - /// - public abstract Task CreateReleaseAsync(); -} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.AssetData.cs b/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.AssetData.cs deleted file mode 100644 index 1c29249a..00000000 --- a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.AssetData.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -namespace Buildvana.Tool.Services.ServerAdapters; - -partial class ServerRelease -{ - protected record AssetData(string Path, string Description, string MimeType); -} diff --git a/src/Buildvana.Tool/Services/Versioning/VersionService.cs b/src/Buildvana.Tool/Services/Versioning/VersionService.cs index 072cfd39..23842ecc 100644 --- a/src/Buildvana.Tool/Services/Versioning/VersionService.cs +++ b/src/Buildvana.Tool/Services/Versioning/VersionService.cs @@ -22,6 +22,7 @@ internal sealed partial class VersionService private readonly IReporter _reporter; private readonly VersionCalculator _calculator; private readonly DeclaredApiFilesService _declaredApiFiles; + private readonly IGitService _git; private VersionInfo _current; @@ -42,6 +43,7 @@ public VersionService( Guard.IsNotNull(declaredApiFiles); _reporter = reporter; _declaredApiFiles = declaredApiFiles; + _git = git; _calculator = new(home, settings, new GitHeightCalculator(VersionFile.FileName)); _current = _calculator.Calculate(); Current = SemanticVersion.Parse(_current.SemVer); @@ -78,12 +80,12 @@ public VersionService( /// /// Gets the latest published version, if any, as a SemanticVersion object. /// - public SemanticVersion? Latest { get; } + public SemanticVersion? Latest { get; private set; } /// /// Gets the latest published stable version, if any, as a SemanticVersion object. /// - public SemanticVersion? LatestStable { get; } + public SemanticVersion? LatestStable { get; private set; } /// /// Checks the consistency of the current version with respect to latest versions and fails the build if an inconsistency is found. @@ -200,4 +202,9 @@ public void Update() _current = _calculator.Calculate(); Current = SemanticVersion.Parse(_current.SemVer); } + + /// + /// Reads and from the tags again, typically after fetching them. + /// + public void RefreshLatestVersions() => (Latest, LatestStable) = _git.GetLatestVersions(); } diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index 8aeea448..7bbcd75b 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -22,8 +22,9 @@ using Buildvana.Tool.Services.DeclaredApiFiles; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Hooks; +using Buildvana.Tool.Services.Hosts; using Buildvana.Tool.Services.Platforms; -using Buildvana.Tool.Services.ServerAdapters; +using Buildvana.Tool.Services.Release; using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Utilities; using Microsoft.Extensions.DependencyInjection; @@ -31,7 +32,7 @@ namespace Buildvana.Tool.Subcommands; [ImplementsCommand("release", settingsType: typeof(ReleaseSettings), usesSdk: true)] -[Description("Publish a new public release (CI only).")] +[Description("Publish a new public release.")] internal sealed class ReleaseCommand( IServiceProvider services, ReleaseSettings settings, @@ -49,25 +50,40 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var home = services.GetRequiredService(); var versioningSettings = services.GetRequiredService(); var platform = services.GetRequiredService(); - var server = services.GetRequiredService(); + var git = services.GetRequiredService(); + + // Perform some preliminary checks. + // Everything from here to the draft release reads the configuration, the environment, the current + // branch, the repository's version history, and the public API files. A build changes none of them, so + // it all runs first: a release that cannot succeed is refused at once, before there is anything to + // build or to roll back. + reporter.Info($"Running on {platform.Name}."); + _ = GitHost.ParseRepositoryUrl(config.Repository); + var remote = services.GetRequiredService().Resolve(); + var host = services.GetRequiredService(); + if (host.Kind is null) + { + const string unknownHostRemedy = "Set repository.host, or set release.allowUnknownHost to release without a release page."; + BuildFailedException.ThrowIfNot( + config.Release.AllowUnknownHost, + $"bv does not know the Git host of repository.url '{config.Repository.Url}'. {unknownHostRemedy}"); + reporter.Warning($"Releasing on an {host.Name}: bv tags and pushes, and creates no release page."); + } + else + { + reporter.Info($"The Git host is {host.Name} ({host.HostName})."); + } + + BuildFailedException.ThrowIf(string.IsNullOrEmpty(git.CurrentBranch), "A release can only be created from a branch."); var version = services.GetRequiredService(); + BuildFailedException.ThrowIfNot(version.IsPublicRelease, "Cannot create a release from the current branch."); var dotnet = services.GetRequiredService(); - var git = services.GetRequiredService(); var changelog = services.GetRequiredService(); var declaredApiFiles = services.GetRequiredService(); var selfReferenceUpdater = services.GetRequiredService(); var hookRunner = services.GetRequiredService(); var hookArgsFactory = services.GetRequiredService(); - // Perform some preliminary checks. - // Everything from here to the draft release reads the environment, the current branch, the - // repository's version history, and the public API files. A build changes none of them, so it - // all runs first: a release that cannot succeed is refused at once, before there is anything - // to build or to roll back. - BuildFailedException.ThrowIfNot(platform.IsCI, "A release can only be created on a CI runner."); - BuildFailedException.ThrowIf(string.IsNullOrEmpty(git.CurrentBranch), "A release can only be created from a branch."); - BuildFailedException.ThrowIfNot(version.IsPublicRelease, "Cannot create a release from the current branch."); - // Resolve the committer identity for release commits: the configured git.identity outranks the bot // identity of the CI platform, which outranks whatever the repository's own Git configuration // happens to state; with none of the three, the release cannot commit. @@ -88,21 +104,26 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) reporter.Notice($"Using committer identity: {git.CommitterIdentity.Name} <{git.CommitterIdentity.Email}>"); - // Set fallback Git credentials if the server adapter can provide them. - var pushUsername = server.PushUsername; - var pushPassword = server.PushPassword; + // Set fallback Git credentials if the host can provide them. + var pushUsername = host.PushUsername; + var pushPassword = host.GetToken(); if (pushUsername is not null && pushPassword is not null) { - reporter.Info($"Fallback push credentials provided by the server adapter (protocol username: '{pushUsername}')."); + reporter.Info($"Fallback Git credentials provided by {host.Name} (protocol username: '{pushUsername}')."); git.RemoteCredentialsFallback = new(pushUsername, pushPassword); } else { reporter.Warning( - "No push credentials provided by the server adapter. " - + "Push operations may fail if the repository is not already authenticated."); + $"No Git credentials provided by {host.Name}. " + + "Fetch and push operations may fail if the repository is not already authenticated."); } + // The version checks and the tag check have to see the tags the release remote has, which a shallow + // or stale clone may lack. + git.FetchTags(remote); + version.RefreshLatestVersions(); + // Perform an initial versioning consistency check. // This is a tad more relaxed than the final check, as it takes into account that we may still increment the current version // (for example by updating the changelog). @@ -114,7 +135,18 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) requestedChange: settings.ResolveBump(), checkPublicApiFiles: config.Release.CheckPublicApi); - var release = await server.CreateReleaseAsync().ConfigureAwait(false); + var stepOutputs = platform.HasStepOutputs ? platform.OpenStepOutputs() : null; + + // Release artifacts are deterministic by definition, whatever the platform. + dotnet.ContinuousIntegrationBuild = true; + + var release = await ServerRelease.BeginAsync( + reporter, + git, + version, + host.CreateReleasePublisher(), + remote, + stepOutputs).ConfigureAwait(false); await using (release.ConfigureAwait(false)) { // Modify version file if required. @@ -207,9 +239,8 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) // Time for a final consistency check. version.EnsureConsistency(true); - // Ensure that the release tag doesn't already exist. - // This assumes that full repo history has been checked out; - // however, that is already a prerequisite for computing the Git height. + // Ensure that the release tag doesn't already exist, here or on the release remote, whose tags + // were fetched above. BuildFailedException.ThrowIf(git.TagExists(version.CurrentStr), $"Tag '{version.CurrentStr}' already exists in repository."); // The version is now final: everything that could still move it (the version spec change, the diff --git a/tests/Buildvana.Tool.Tests/CIPlatformTests.cs b/tests/Buildvana.Tool.Tests/CIPlatformTests.cs index 932e3a1e..911a47ee 100644 --- a/tests/Buildvana.Tool.Tests/CIPlatformTests.cs +++ b/tests/Buildvana.Tool.Tests/CIPlatformTests.cs @@ -3,6 +3,7 @@ using Buildvana.Core; using Buildvana.Core.Testing; +using Buildvana.Runtime; using Buildvana.Tool.Services.Platforms; internal sealed class CIPlatformTests @@ -135,9 +136,53 @@ public async Task OpenStepOutputs_OnPlatformWithoutStepOutputs_Throws() await Assert.That(platform.OpenStepOutputs).Throws(); } + [Test] + public async Task Detect_OnGitHubActions_TakesTheServerFromGitHubServerUrl() + { + var platform = Detect(("GITHUB_ACTIONS", "true"), ("GITHUB_SERVER_URL", "https://github.example.invalid")); + + await Assert.That(platform.ServerUrl!.Host).IsEqualTo("github.example.invalid"); + await Assert.That(platform.ServerKind).IsEqualTo(HostKind.GitHub); + } + + [Test] + public async Task Detect_OnGitLabCI_TakesTheServerFromCIServerUrl() + { + var platform = Detect(("GITLAB_CI", "true"), ("CI_SERVER_URL", "https://gitlab.example.invalid")); + + await Assert.That(platform.ServerUrl!.Host).IsEqualTo("gitlab.example.invalid"); + await Assert.That(platform.ServerKind).IsNull(); + } + + // A value that is not an absolute URL names no server, and host detection then has no hint to go on. + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments("github.example.invalid")] + public async Task Detect_OnGitHubActions_WithoutAnAbsoluteServerUrl_HasNoServerUrl(string? value) + { + var platform = Detect(("GITHUB_ACTIONS", "true"), ("GITHUB_SERVER_URL", value)); + + await Assert.That(platform.ServerUrl).IsNull(); + } + + [Test] + [Arguments("CI", "true")] + [Arguments("TEAMCITY_VERSION", "2025.1")] + public async Task Detect_OnOtherPlatforms_HasNoServer(string name, string value) + { + var platforms = new[] { Detect(), Detect((name, value)) }; + + foreach (var platform in platforms) + { + await Assert.That(platform.ServerUrl).IsNull(); + await Assert.That(platform.ServerKind).IsNull(); + } + } + private static CIPlatform Detect(params (string Name, string? Value)[] variables) { var environment = variables.ToDictionary(x => x.Name, x => x.Value, StringComparer.Ordinal); - return CIPlatform.Detect(name => environment.GetValueOrDefault(name)); + return CIPlatform.Detect(environment.GetValueOrDefault); } } diff --git a/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs b/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs index 206ea69f..4ec5d9a2 100644 --- a/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs +++ b/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs @@ -487,7 +487,7 @@ await Assert.That(result).IsEqualTo( """.ReplaceLineEndings("\n")); } - // The callback gets the path as written, anchor excluded: a backslash is the server adapter's to normalize, + // The callback gets the path as written, anchor excluded: a backslash is the host's to normalize, // and the anchor is the updater's to put back after the URL. [Test] public async Task FinalizeNewSection_PassesThePathWithoutItsAnchorToGetFileUrl() @@ -553,6 +553,36 @@ string MakeSectionTitle() await Assert.That(titleMade).IsFalse(); } + // A host with no URL scheme has no URL for a file: the link stays as it is, and the rest of the section is + // finalized all the same. + [Test] + public async Task FinalizeNewSection_LeavesALinkAlone_WhenThereIsNoUrlForTheFile() + { + string[] lines = [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "## [1.2.3-preview.1](https://example.com/releases/tag/1.2.3-preview.1) (2026-01-01)", + string.Empty, + "- Released, see [the page](docs/released.md#anchor).", + ]; + + var result = ChangelogUpdater.FinalizeNewSection(lines, () => "1.2.3 (2026-01-01)", static _ => null); + + await Assert.That(result).IsEqualTo( + """ + # Changelog + + ## Unreleased changes + + ## 1.2.3 (2026-01-01) + + - Released, see [the page](docs/released.md#anchor). + + """.ReplaceLineEndings("\n")); + } + [Test] public async Task MakeSectionTitle_ComposesVersionUrlAndDate() { @@ -561,6 +591,14 @@ public async Task MakeSectionTitle_ComposesVersionUrlAndDate() await Assert.That(title).IsEqualTo(NewSectionTitle); } + [Test] + public async Task MakeSectionTitle_WithoutAUrl_ComposesVersionAndDate() + { + var title = ChangelogUpdater.MakeSectionTitle("1.2.3", null, new DateTime(2026, 1, 1)); + + await Assert.That(title).IsEqualTo("1.2.3 (2026-01-01)"); + } + // The title ends up in a file that is read, and released from, on every machine: the date must not follow // the calendar of whoever runs the release. Formatted with the current culture, it would read 2569-01-01 // under th-TH, matching neither the release tag nor the other section titles. @@ -594,7 +632,7 @@ private static string FinalizeNewSection(string[] lines) => ChangelogUpdater.FinalizeNewSection(lines, () => NewSectionTitle, GetFileUrl); // Records the path of each link the updater asks a URL for, so that a test can see what reached the callback. - private static Func RecordingGetFileUrl(List paths) + private static Func RecordingGetFileUrl(List paths) => path => { paths.Add(path); return GetFileUrl(path); diff --git a/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs b/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs index 27e5bcba..84858c5f 100644 --- a/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs +++ b/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs @@ -41,8 +41,8 @@ public async Task GetRequiredService_OnLocalPlatform_ResolvesBuildPipelineWithou } } - // GitHubServerAdapter, which ServerAdapter.Create selects on GitHub Actions, opens the Git repository in its - // constructor. The build pipeline depends on CIPlatform alone, so no adapter is ever constructed. + // The build pipeline depends on CIPlatform alone, which reads the environment and nothing else, so neither + // the Git repository nor the Git host is ever opened. [Test] [NotInParallel] public async Task GetRequiredService_OnGitHubActions_ResolvesBuildPipelineWithoutGitRepository() diff --git a/tests/Buildvana.Tool.Tests/FakeCIPlatform.cs b/tests/Buildvana.Tool.Tests/FakeCIPlatform.cs index dd12d499..60d0d548 100644 --- a/tests/Buildvana.Tool.Tests/FakeCIPlatform.cs +++ b/tests/Buildvana.Tool.Tests/FakeCIPlatform.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using Buildvana.Runtime; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Platforms; @@ -19,6 +20,16 @@ internal sealed class FakeCIPlatform : CIPlatform /// public GitIdentity? Identity { get; init; } = new("Buildvana Test Bot", "bot@buildvana.invalid"); + /// + /// Gets the value of . + /// + public Uri? Server { get; init; } + + /// + /// Gets the value of . + /// + public HostKind? Kind { get; init; } + /// public override string Name => "Test CI platform"; @@ -27,4 +38,10 @@ internal sealed class FakeCIPlatform : CIPlatform /// public override GitIdentity? BotIdentity => Identity; + + /// + public override Uri? ServerUrl => Server; + + /// + public override HostKind? ServerKind => Kind; } diff --git a/tests/Buildvana.Tool.Tests/FakeReleaseRemoteResolver.cs b/tests/Buildvana.Tool.Tests/FakeReleaseRemoteResolver.cs new file mode 100644 index 00000000..03a37c73 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/FakeReleaseRemoteResolver.cs @@ -0,0 +1,18 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Tool.Services.Hosts; + +/// +/// A script fake for : it names one remote, whatever its URL. +/// +/// The name of the remote to return. +/// +/// A remote that a test can push to is a bare repository on disk, whose URL is a path, and a path never +/// matches the URL of a repository on a server. +/// +internal sealed class FakeReleaseRemoteResolver(string remote) : ReleaseRemoteResolver +{ + /// + public override string Resolve() => remote; +} diff --git a/tests/Buildvana.Tool.Tests/GitHostTests.cs b/tests/Buildvana.Tool.Tests/GitHostTests.cs new file mode 100644 index 00000000..008155e7 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/GitHostTests.cs @@ -0,0 +1,170 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Core.Testing; +using Buildvana.Runtime; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Hosts; +using Buildvana.Tool.Services.Hosts.GitHub; +using Buildvana.Tool.Services.Platforms; +using Microsoft.Extensions.DependencyInjection; + +internal sealed class GitHostTests +{ + [Test] + [Arguments("https://github.com/Tenacom/Buildvana")] + [Arguments("https://GitHub.com/Tenacom/Buildvana.git")] + [Arguments("git@github.com:Tenacom/Buildvana.git")] + public async Task DetectKind_ForGitHubCom_IsGitHub(string url) + { + await Assert.That(GitHost.DetectKind(Repository(url), Parse(url), new FakeCIPlatform())).IsEqualTo(HostKind.GitHub); + } + + [Test] + public async Task DetectKind_WhenTheConfigurationStatesTheHost_IsThatHost() + { + const string url = "https://git.example.invalid/tenacom/test-repo"; + var repository = Repository(url) with { Host = HostKind.GitHub }; + + await Assert.That(GitHost.DetectKind(repository, Parse(url), new FakeCIPlatform())).IsEqualTo(HostKind.GitHub); + } + + // A GitHub Enterprise Server is recognized on its own runners, which name it in GITHUB_SERVER_URL. + [Test] + [Arguments("https://git.example.invalid", HostKind.GitHub)] + [Arguments("https://GIT.example.invalid/", HostKind.GitHub)] + [Arguments("https://elsewhere.example.invalid", null)] + public async Task DetectKind_OnAPlatformWithAServer_IsThePlatformKind_WhenTheHostNamesMatch(string serverUrl, HostKind? expected) + { + const string url = "https://git.example.invalid/tenacom/test-repo"; + var platform = new FakeCIPlatform { Server = new Uri(serverUrl), Kind = HostKind.GitHub }; + + await Assert.That(GitHost.DetectKind(Repository(url), Parse(url), platform)).IsEqualTo(expected); + } + + [Test] + public async Task DetectKind_ForAnotherHost_IsNull() + { + const string url = "https://git.example.invalid/tenacom/test-repo"; + + await Assert.That(GitHost.DetectKind(Repository(url), Parse(url), new FakeCIPlatform())).IsNull(); + } + + [Test] + [Arguments(null)] + [Arguments("")] + public async Task ParseRepositoryUrl_WithoutAUrl_Throws(string? url) + { + var exception = await Assert.That(() => GitHost.ParseRepositoryUrl(Repository(url))).Throws(); + await Assert.That(exception!.Message).Contains("repository.url"); + } + + [Test] + public async Task ParseRepositoryUrl_WithAnInvalidUrl_Throws() + { + var exception = await Assert.That(() => GitHost.ParseRepositoryUrl(Repository("not a URL"))).Throws(); + await Assert.That(exception!.Message).Contains("not a Git URL"); + } + + [Test] + public async Task Create_ForGitHubCom_CreatesAGitHubHost() + { + var services = Services("https://github.com/Tenacom/Buildvana"); + await using (services.ConfigureAwait(false)) + { + await Assert.That(GitHost.Create(services)).IsTypeOf(); + } + } + + [Test] + public async Task Create_ForAnotherHost_CreatesAnUnknownHost() + { + var services = Services("https://git.example.invalid/group/subgroup/test-repo"); + await using (services.ConfigureAwait(false)) + { + var host = GitHost.Create(services); + + await Assert.That(host).IsTypeOf(); + await Assert.That(host.Kind).IsNull(); + await Assert.That(host.HostName).IsEqualTo("git.example.invalid"); + await Assert.That(host.Owner).IsEqualTo("group/subgroup"); + await Assert.That(host.Repository).IsEqualTo("test-repo"); + } + } + + [Test] + public async Task Create_ForAUrlWithOneSegment_Throws() + { + var services = Services("https://git.example.invalid/test-repo"); + await using (services.ConfigureAwait(false)) + { + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before services is disposed + var exception = await Assert.That(() => GitHost.Create(services)).Throws(); + await Assert.That(exception!.Message).Contains("names no repository"); + } + } + + [Test] + public async Task UnknownHost_HasNoUrls_NoToken_AndAPublisherThatPublishesNothing() + { + var reporter = new CaptureReporter(); + var host = new UnknownHost(Parse("https://git.example.invalid/tenacom/test-repo"), reporter); + + await Assert.That(host.RepositoryUrl).IsNull(); + await Assert.That(host.GetReleaseUrl("1.0.0")).IsNull(); + await Assert.That(host.GetFileUrl("CHANGELOG.md", GitRef.Tag("1.0.0"))).IsNull(); + await Assert.That(host.TokenEnv).IsNull(); + await Assert.That(host.GetToken()).IsNull(); + await Assert.That(host.PushUsername).IsNull(); + await Assert.That(host.IsPrivateRepositoryAsync).Throws(); + + var publisher = host.CreateReleasePublisher(); + await publisher.BeginAsync().ConfigureAwait(false); + await publisher.PublishAsync("1.0.0", "abc123", []).ConfigureAwait(false); + await publisher.UndoAsync().ConfigureAwait(false); + + var notice = reporter.Messages.Single(x => x.Level == MessageLevel.Notice).Message; + await Assert.That(notice).IsEqualTo("No release page created for 1.0.0: bv cannot publish a release on unknown host (git.example.invalid)."); + } + + [Test] + [NotInParallel] + public async Task GetToken_ReadsTheVariable_AtTheMomentOfTheCall() + { + const string variable = "BV_TEST_GIT_HOST_TOKEN"; + var host = new RecordingHost { TokenVariable = variable }; + try + { + Environment.SetEnvironmentVariable(variable, null); + await Assert.That(host.GetToken()).IsNull(); + Environment.SetEnvironmentVariable(variable, "secret"); + await Assert.That(host.GetToken()).IsEqualTo("secret"); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task GitRef_RejectsAnEmptyName() + { + await Assert.That(() => GitRef.Tag(string.Empty)).Throws(); + await Assert.That(() => GitRef.Branch(string.Empty)).Throws(); + await Assert.That(() => GitRef.Commit(string.Empty)).Throws(); + await Assert.That(GitRef.Commit("abc123").Kind).IsEqualTo(GitRefKind.Commit); + } + + private static RepositoryConfig Repository(string? url) => new() { Url = url }; + + private static GitUrlInfo Parse(string url) => GitUrlInfo.TryCreate(url, out var info) ? info : throw new InvalidOperationException(url); + + private static ServiceProvider Services(string url) + => new ServiceCollection() + .AddSingleton(new BuildvanaConfig { Repository = Repository(url) }) + .AddSingleton(new FakeCIPlatform()) + .AddSingleton(NullReporter.Instance) + .BuildServiceProvider(); +} diff --git a/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs index 5a5a1ce2..9e551260 100644 --- a/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs +++ b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs @@ -3,7 +3,7 @@ using System.Net; using Buildvana.Core; -using Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +using Buildvana.Tool.Services.Hosts.GitHub; using Buildvana.Tool.Utilities; // The answers are canned here: what the client is judged on is what it sends and what it makes of the shape diff --git a/tests/Buildvana.Tool.Tests/GitHubHostTests.cs b/tests/Buildvana.Tool.Tests/GitHubHostTests.cs new file mode 100644 index 00000000..10652df8 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/GitHubHostTests.cs @@ -0,0 +1,118 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Runtime; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Hosts; +using Buildvana.Tool.Services.Hosts.GitHub; +using Microsoft.Extensions.DependencyInjection; + +internal sealed class GitHubHostTests +{ + private const string TokenVariable = "BV_TEST_GITHUB_HOST_TOKEN"; + + [Test] + public async Task Members_DescribeTheRepository() + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + using var host = new GitHubHost(services, Parse("git@github.com:Tenacom/Buildvana.git")); + + await Assert.That(host.Name).IsEqualTo("GitHub"); + await Assert.That(host.Kind).IsEqualTo(HostKind.GitHub); + await Assert.That(host.RepositoryUrl.AbsoluteUri).IsEqualTo("https://github.com/Tenacom/Buildvana"); + await Assert.That(host.TokenEnv).IsEqualTo(TokenVariable); + await Assert.That(host.PushUsername).IsEqualTo("x-access-token"); + await Assert.That(host.GetReleaseUrl("1.0.0").AbsoluteUri).IsEqualTo("https://github.com/Tenacom/Buildvana/releases/tag/1.0.0"); + } + } + + // GitHub puts a tag, a branch, and a commit in the same place of a file URL. + [Test] + [Arguments(GitRefKind.Tag)] + [Arguments(GitRefKind.Branch)] + [Arguments(GitRefKind.Commit)] + public async Task GetFileUrl_TakesTheNameOfAnyKindOfReference(GitRefKind kind) + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + using var host = new GitHubHost(services, Parse("https://github.com/Tenacom/Buildvana")); + var gitRef = kind switch { + GitRefKind.Tag => GitRef.Tag("v1"), + GitRefKind.Branch => GitRef.Branch("v1"), + _ => GitRef.Commit("v1"), + }; + + var url = host.GetFileUrl("docs/README.md", gitRef); + + await Assert.That(url.AbsoluteUri).IsEqualTo("https://github.com/Tenacom/Buildvana/blob/v1/docs/README.md"); + } + } + + [Test] + public async Task Constructor_ForAPathOfThreeSegments_Throws() + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before services is disposed + var exception = await Assert.That(() => new GitHubHost(services, Parse("https://github.com/a/b/c"))).Throws(); + await Assert.That(exception!.Message).Contains("not a valid GitHub repository URL"); + } + } + + [Test] + [NotInParallel] + public async Task IsPrivateRepositoryAsync_AsksTheApi() + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + var api = new StubHttpMessageHandler(new Dictionary { + ["https://api.github.com/repos/Tenacom/Buildvana"] = """{"private":true}""", + }); + using var host = new GitHubHost(services, Parse("https://github.com/Tenacom/Buildvana"), api); + Environment.SetEnvironmentVariable(TokenVariable, "secret"); + try + { + await Assert.That(await host.IsPrivateRepositoryAsync().ConfigureAwait(false)).IsTrue(); + await Assert.That(api.Requests[0].Headers["Authorization"]).IsEqualTo("Bearer secret"); + } + finally + { + Environment.SetEnvironmentVariable(TokenVariable, null); + } + } + } + + // The client is created on first use, so a missing token fails the call that needs the API, and nothing before it. + [Test] + [NotInParallel] + public async Task IsPrivateRepositoryAsync_WithoutAToken_Throws() + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + using var api = new StubHttpMessageHandler(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); + using var host = new GitHubHost(services, Parse("https://github.com/Tenacom/Buildvana"), api); + Environment.SetEnvironmentVariable(TokenVariable, null); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before host is disposed + var exception = await Assert.That(host.IsPrivateRepositoryAsync).Throws(); + await Assert.That(exception!.Message).Contains(TokenVariable); + await Assert.That(api.Requests).IsEmpty(); + } + } + + private static GitUrlInfo Parse(string url) => GitUrlInfo.TryCreate(url, out var info) ? info : throw new InvalidOperationException(url); + + private static ServiceProvider Services() + => new ServiceCollection() + .AddSingleton(new BuildvanaConfig { Hosts = new() { GitHub = new() { TokenEnv = TokenVariable } } }) + .AddSingleton(NullReporter.Instance) + .BuildServiceProvider(); +} diff --git a/tests/Buildvana.Tool.Tests/GitHubRepositoryUrlsTests.cs b/tests/Buildvana.Tool.Tests/GitHubRepositoryUrlsTests.cs index 1cadc89e..de68e84b 100644 --- a/tests/Buildvana.Tool.Tests/GitHubRepositoryUrlsTests.cs +++ b/tests/Buildvana.Tool.Tests/GitHubRepositoryUrlsTests.cs @@ -1,7 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +using Buildvana.Tool.Services.Hosts.GitHub; internal sealed class GitHubRepositoryUrlsTests { diff --git a/tests/Buildvana.Tool.Tests/GitServiceTests.cs b/tests/Buildvana.Tool.Tests/GitServiceTests.cs index 4e5ca4d9..fbf0e6ad 100644 --- a/tests/Buildvana.Tool.Tests/GitServiceTests.cs +++ b/tests/Buildvana.Tool.Tests/GitServiceTests.cs @@ -122,6 +122,31 @@ public async Task TrackChangesAsync_WhenOperationThrows_Propagates() _ = await Assert.That(Act).Throws(); } + [Test] + public async Task Remotes_ListsEveryRemote_WithItsUrl() + { + using var repo = CreateRepoWithCommit(); + repo.AddRemote("upstream", new Uri("https://example.com/upstream.git")); + using var git = CreateGitService(repo); + + await Assert.That(git.Remotes).IsEquivalentTo([ + new GitRemoteInfo("origin", "https://example.com/repo.git"), + new GitRemoteInfo("upstream", "https://example.com/upstream.git"), + ]); + } + + // A clone with no remote is a repository all the same: commands that only read it work there. + [Test] + public async Task Constructor_WithoutRemotes_Succeeds() + { + using var repo = new TempGitRepo(); + repo.WriteFile("a.txt", "content"); + repo.CommitAll(); + using var git = CreateGitService(repo); + + await Assert.That(git.Remotes).IsEmpty(); + } + [Test] public async Task CommitterIdentity_WhenSet_CommitsWithIt_AndWritesNothingToConfiguration() { diff --git a/tests/Buildvana.Tool.Tests/RecordedAsset.cs b/tests/Buildvana.Tool.Tests/RecordedAsset.cs deleted file mode 100644 index 3ce84437..00000000 --- a/tests/Buildvana.Tool.Tests/RecordedAsset.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -/// -/// A release asset as recorded by . -/// -/// The path of the asset's file. -/// The description of the asset. -/// The MIME type of the asset. -/// -/// This mirrors the release's own AssetData, which is a protected nested type and therefore -/// cannot travel through a public member of the fake. -/// -internal sealed record RecordedAsset(string Path, string Description, string MimeType); diff --git a/tests/Buildvana.Tool.Tests/RecordingHost.cs b/tests/Buildvana.Tool.Tests/RecordingHost.cs new file mode 100644 index 00000000..c9c28715 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/RecordingHost.cs @@ -0,0 +1,86 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Runtime; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Hosts; +using Buildvana.Tool.Services.Hosts.GitHub; + +/// +/// A script fake for : every member answers with a constant, and creating a release +/// publisher hands out a . +/// +/// +/// The URLs come from , so that a release over the fake sees the same +/// rejections a release on GitHub does: a file URL for a path outside the repository is an error, not a URL. +/// +internal sealed class RecordingHost : GitHost +{ + /// + /// The URL of the repository the fake stands for. + /// + public const string Url = "https://git.example.invalid/tenacom/test-repo"; + + private static readonly GitHubRepositoryUrls Urls = new("git.example.invalid", "tenacom", "test-repo"); + + /// + /// Initializes a new instance of the class. + /// + public RecordingHost() + : base(ParseUrl()) + { + } + + /// + /// Gets the value of . + /// + public string? TokenVariable { get; init; } + + /// + /// Gets the value of . + /// + public string? PushUser { get; init; } = "x-access-token"; + + /// + /// Gets the callback the created publisher publishes with (). + /// + public Action? OnPublishing { get; init; } + + /// + /// Gets the publisher created by , or if none was created. + /// + public RecordingReleasePublisher? Publisher { get; private set; } + + /// + public override string Name => "Test host"; + + /// + public override HostKind? Kind => HostKind.GitHub; + + /// + public override Uri RepositoryUrl => Urls.Repository; + + /// + public override string? TokenEnv => TokenVariable; + + /// + public override string? PushUsername => PushUser; + + /// + public override Uri GetReleaseUrl(string tag) => Urls.ReleaseTag(tag); + + /// + public override Uri GetFileUrl(string path, GitRef gitRef) => Urls.File(path, gitRef.Name); + + /// + public override Task IsPrivateRepositoryAsync() => Task.FromResult(false); + + /// + public override ReleasePublisher CreateReleasePublisher() + { + Publisher = new RecordingReleasePublisher { OnPublishing = OnPublishing }; + return Publisher; + } + + private static GitUrlInfo ParseUrl() => GitUrlInfo.TryCreate(Url, out var info) ? info : throw new InvalidOperationException(Url); +} diff --git a/tests/Buildvana.Tool.Tests/RecordingReleasePublisher.cs b/tests/Buildvana.Tool.Tests/RecordingReleasePublisher.cs new file mode 100644 index 00000000..25010fdb --- /dev/null +++ b/tests/Buildvana.Tool.Tests/RecordingReleasePublisher.cs @@ -0,0 +1,77 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Tool.Services.Hosts; +using Buildvana.Tool.Services.Release; + +/// +/// A capture-and-script fake for : it records what a release asks of the host, +/// and creates nothing. +/// +internal sealed class RecordingReleasePublisher : ReleasePublisher +{ + private readonly List _publishedAssets = []; + + /// + /// Gets a value indicating whether the release began. + /// + public bool IsBegun { get; private set; } + + /// + /// Gets a value indicating whether the release was published. + /// + public bool IsPublished { get; private set; } + + /// + /// Gets a value indicating whether the release was undone. + /// + public bool IsUndone { get; private set; } + + /// + /// Gets the tag the release was published on, or until it is published. + /// + public string? PublishedTag { get; private set; } + + /// + /// Gets the SHA the release was published at, or until it is published. + /// + public string? PublishedSha { get; private set; } + + /// + /// Gets the assets the release was published with, in the order they were added. + /// Empty until the release is published. + /// + public IReadOnlyList PublishedAssets => _publishedAssets; + + /// + /// Gets a callback invoked while publishing, after the assets have been recorded, simulating the + /// host's behavior. Exceptions thrown by the callback propagate to the caller, simulating a failed + /// publication. + /// + public Action? OnPublishing { get; init; } + + /// + public override Task BeginAsync() + { + IsBegun = true; + return Task.CompletedTask; + } + + /// + public override Task PublishAsync(string tag, string sha, IReadOnlyList assets) + { + _publishedAssets.AddRange(assets); + OnPublishing?.Invoke(); + PublishedTag = tag; + PublishedSha = sha; + IsPublished = true; + return Task.CompletedTask; + } + + /// + public override Task UndoAsync() + { + IsUndone = true; + return Task.CompletedTask; + } +} diff --git a/tests/Buildvana.Tool.Tests/RecordingServerAdapter.cs b/tests/Buildvana.Tool.Tests/RecordingServerAdapter.cs deleted file mode 100644 index ac8ffede..00000000 --- a/tests/Buildvana.Tool.Tests/RecordingServerAdapter.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using Buildvana.Tool.Services.ServerAdapters; -using Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; - -/// -/// A script fake for : every member answers with a constant, and creating a release -/// hands out a . -/// -/// The service provider handed to the created release. -/// -/// The fake deliberately carries no logic. answers what the CI platform -/// answers. -/// The URLs come from , so that a release over the fake sees the same -/// rejections a release on GitHub does: a file URL for a path outside the repository is an error, not a URL. -/// -internal sealed class RecordingServerAdapter(IServiceProvider services) : ServerAdapter -{ - private static readonly GitHubRepositoryUrls Urls = new("git.example.invalid", "tenacom", "test-repo"); - - /// - /// Gets the secret half of the push credentials (), or - /// for a platform that provides none. - /// - public string? PushSecret { get; init; } = "test-token"; - - /// - /// Gets the username half of the push credentials (), or - /// for a host with no username convention. - /// - public string? PushUser { get; init; } = "x-access-token"; - - /// - /// Gets the callback the created release publishes with (). - /// - public Action? OnPublishing { get; init; } - - /// - /// Gets the release created by , or if none was created. - /// - public RecordingServerRelease? Release { get; private set; } - - /// - public override string Name => "Test CI platform"; - - /// - public override string HostName => "git.example.invalid"; - - /// - public override string RepositoryOwner => "tenacom"; - - /// - public override string RepositoryName => "test-repo"; - - /// - public override Uri RepositoryUrl => Urls.Repository; - - /// - public override string? PushUsername => PushUser; - - /// - public override string? PushPassword => PushSecret; - - /// - public override Task IsPrivateRepositoryAsync() => Task.FromResult(false); - - /// - public override Uri GetReleaseUrl(string version) => Urls.ReleaseTag(version); - - /// - public override Uri GetFileUrl(string path, string commitish) => Urls.File(path, commitish); - - /// - public override Task CreateReleaseAsync() - { - Release = new RecordingServerRelease(services) { OnPublishing = OnPublishing }; - return Task.FromResult(Release); - } -} diff --git a/tests/Buildvana.Tool.Tests/RecordingServerRelease.cs b/tests/Buildvana.Tool.Tests/RecordingServerRelease.cs deleted file mode 100644 index dcfd975a..00000000 --- a/tests/Buildvana.Tool.Tests/RecordingServerRelease.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using Buildvana.Tool.Services.ServerAdapters; - -/// -/// A capture-and-script fake for . The base class is the real one, so the -/// repository-side operations (the release commit, post-release commits, and the push) actually happen; -/// only the host-side ones — asset upload and publication — are recorded instead of performed. -/// -internal sealed class RecordingServerRelease(IServiceProvider services) : ServerRelease(services) -{ - private readonly List _publishedAssets = []; - - /// - /// Gets the assets the release was published with, in the order they were added. - /// Empty until the release is published. - /// - public IReadOnlyList PublishedAssets => _publishedAssets; - - /// - /// Gets a value indicating whether the release was published. - /// - public bool IsPublished { get; private set; } - - /// - /// Gets a value indicating whether the release's publication was rolled back. - /// - public bool IsPublishUndone { get; private set; } - - /// - /// Gets a callback invoked while publishing, after the assets have been recorded, simulating the - /// host's behavior. Exceptions thrown by the callback propagate to the caller, simulating a failed - /// publication. - /// - public Action? OnPublishing { get; init; } - - /// - protected override Task DoPublishAsync(IReadOnlyList assets) - { - _publishedAssets.AddRange(assets.Select(x => new RecordedAsset(x.Path, x.Description, x.MimeType))); - OnPublishing?.Invoke(); - IsPublished = true; - return Task.CompletedTask; - } - - /// - protected override Task UndoPublishAsync() - { - IsPublishUndone = true; - return Task.CompletedTask; - } -} diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandChangelogTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandChangelogTests.cs index 6e452913..2ddc7497 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandChangelogTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandChangelogTests.cs @@ -70,6 +70,29 @@ internal sealed class ReleaseCommandChangelogTests """; + // A host with no URL scheme has no release page and no file URLs: the section gets a title without a link, + // its relative links stay as they are, and each of the two says so. + [Test] + public async Task Release_OnAnUnknownHost_LeavesTheNewSectionWithoutUrls() + { + using var harness = new ReleaseHarness(new() { + ChangelogUpdates = "all", + Changelog = ChangelogWithFileLink, + UnknownHost = true, + AllowUnknownHost = true, + }); + + _ = await harness.RunAsync().ConfigureAwait(false); + + var changelog = harness.ReadFile("CHANGELOG.md"); + await Assert.That(changelog).Contains($"## {ReleasedVersion} ("); + await Assert.That(changelog).Contains("- A hook [runs from the home directory](docs/hooks.md#the-build-environment)."); + await Assert.That(harness.Notices).Contains( + "CHANGELOG.md links 'docs/hooks.md', which stays relative: unknown host (git.example.invalid) has no URL for a file."); + await Assert.That(harness.Notices).Contains( + $"The changelog section of {ReleasedVersion} has no link: unknown host (git.example.invalid) has no release page."); + } + [Test] [Arguments("all", "2.3-")] [Arguments("stable", "2.3")] diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs index bf09d44e..ae2f0693 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs @@ -65,23 +65,24 @@ public async Task Release_WhenPublicationFails_ResetsAndForcePushesTheBranch() await Assert.That(head.Sha).IsEqualTo(initialSha); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(initialSha); - // What the repository did is undone; what the server did is not, because it never got that far: - // the publication rollback is only armed once the publication itself has succeeded. - var release = harness.Adapter.Release!; - await Assert.That(release.IsPublished).IsFalse(); - await Assert.That(release.IsPublishUndone).IsFalse(); + // The tag is gone too, locally and on the remote, and the host is asked to undo what it created. + await Assert.That(harness.Repo.TagNames.Count).IsEqualTo(0); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + var publisher = harness.Publisher!; + await Assert.That(publisher.IsPublished).IsFalse(); + await Assert.That(publisher.IsUndone).IsTrue(); } [Test] - public async Task Release_OnLocalBuild_Fails() + public async Task Release_WithoutRepositoryUrl_FailsBeforeBuilding() { - using var harness = new ReleaseHarness(new() { IsCI = false }); + using var harness = new ReleaseHarness(new() { RepositoryUrl = null }); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("CI runner"); + await Assert.That(exception!.Message).Contains("repository.url"); // The preliminary checks run before the pipeline, so a release that can never succeed costs // nothing: no clean, no build, no test run. @@ -89,6 +90,38 @@ public async Task Release_OnLocalBuild_Fails() await Assert.That(harness.Events.Count).IsEqualTo(0); } + [Test] + public async Task Release_OnAnUnknownHost_WithoutAllowUnknownHost_FailsBeforeBuilding() + { + using var harness = new ReleaseHarness(new() { UnknownHost = true }); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed + Task Act() => harness.RunAsync(); + + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).Contains("release.allowUnknownHost"); + await Assert.That(harness.PipelineSteps.Count).IsEqualTo(0); + await Assert.That(harness.Repo.GetRemoteTipSha()).IsNull(); + } + + // A clone that lacks a tag of the release remote would check the versions against an incomplete history. + // The fetch brings the tag in, and the check sees it. + [Test] + public async Task Release_WhenTheReleaseRemoteHasAHigherTag_FailsBeforeBuilding() + { + using var harness = new ReleaseHarness(); + harness.Repo.PushCurrentBranch(); + harness.Repo.CreateRemoteTag("2.4.0"); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed + Task Act() => harness.RunAsync(); + + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).Contains("lower than latest"); + await Assert.That(harness.Repo.TagNames).IsEquivalentTo(["2.4.0"]); + await Assert.That(harness.PipelineSteps.Count).IsEqualTo(0); + } + [Test] public async Task Release_WithoutAnyCommitterIdentity_FailsBeforeBuilding() { diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs new file mode 100644 index 00000000..6ad9a4c2 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs @@ -0,0 +1,86 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Net; +using Buildvana.Core; + +/// +/// End-to-end tests of the release command on a real GitHub host, whose API answers from a table +/// (see ). +/// +/// +/// The repository URL of the harness is not github.com, so the host is taken for a GitHub +/// Enterprise Server, with its API under /api/v3. +/// +[NotInParallel] +internal sealed class ReleaseCommandGitHubTests +{ + private const string ReleasedVersion = "2.3.2-preview"; + private const string ApiRepositoryUrl = "https://git.example.invalid/api/v3/repos/tenacom/test-repo"; + private const string UploadUrl = "https://uploads.example.invalid/repos/tenacom/test-repo/releases/42/assets"; + + [Test] + public async Task Release_CreatesADraft_UploadsTheAssets_AndPublishesOnTheTag() + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.Created, """{"id":7}""")); + using var harness = new ReleaseHarness(new() { GitHubApi = api }); + + var exitCode = await harness.RunAsync().ConfigureAwait(false); + + await Assert.That(exitCode).IsEqualTo(0); + var requests = api.Requests; + await Assert.That(requests[0].Method).IsEqualTo(HttpMethod.Post); + await Assert.That(requests[0].Url).IsEqualTo(ApiRepositoryUrl + "/releases"); + await Assert.That(requests[0].Body).Contains("\"draft\":true"); + + // One upload per asset, then the notes, then the update that publishes the draft. + var uploads = requests.Where(x => x.Url.StartsWith(UploadUrl, StringComparison.Ordinal)).ToArray(); + await Assert.That(uploads.Length).IsEqualTo(5); + var releaseCommitSha = harness.Repo.GetCommits(2)[1].Sha; + var notes = requests[^2]; + await Assert.That(notes.Url).IsEqualTo(ApiRepositoryUrl + "/releases/generate-notes"); + await Assert.That(notes.Body).IsEqualTo($$"""{"tag_name":"{{ReleasedVersion}}","target_commitish":"{{releaseCommitSha}}"}"""); + var update = requests[^1]; + await Assert.That(update.Method).IsEqualTo(HttpMethod.Patch); + await Assert.That(update.Url).IsEqualTo(ApiRepositoryUrl + "/releases/42"); + await Assert.That(update.Body).Contains($"\"tag_name\":\"{ReleasedVersion}\""); + await Assert.That(update.Body).Contains("\"draft\":false"); + await Assert.That(update.Body).Contains("https://git.example.invalid/tenacom/test-repo/blob/release/CHANGELOG.md"); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsEqualTo(releaseCommitSha); + } + + // The uploads get the fallback answer, which refuses them, so the release fails after the tag is pushed. + [Test] + public async Task Release_WhenAnUploadFails_DeletesTheDraft_AndTheTag() + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.UnprocessableEntity, """{"message":"Validation Failed"}""")); + using var harness = new ReleaseHarness(new() { GitHubApi = api }); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed + Task Act() => harness.RunAsync(); + + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).Contains("Validation Failed"); + var delete = api.Requests[^1]; + await Assert.That(delete.Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(delete.Url).IsEqualTo(ApiRepositoryUrl + "/releases/42"); + await Assert.That(harness.Notices).Contains("Deleted the provisional draft release."); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // The draft, the notes, the update, and the deletion have answers. Every other request, the uploads + // included, gets the fallback answer. + private static StubHttpMessageHandler Answering(StubHttpAnswer fallback) + => new( + new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer> { + [(HttpMethod.Post, ApiRepositoryUrl + "/releases")] = new( + HttpStatusCode.Created, + $$"""{"id":42,"upload_url":"{{UploadUrl}}{?name,label}","draft":true}"""), + [(HttpMethod.Post, ApiRepositoryUrl + "/releases/generate-notes")] = new( + HttpStatusCode.OK, + """{"name":"notes","body":"## What's Changed"}"""), + [(HttpMethod.Patch, ApiRepositoryUrl + "/releases/42")] = new(HttpStatusCode.OK, """{"id":42}"""), + [(HttpMethod.Delete, ApiRepositoryUrl + "/releases/42")] = new(HttpStatusCode.NoContent), + }, + fallback); +} diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandTests.cs index bd0c4c5e..f4678cd2 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandTests.cs @@ -70,14 +70,55 @@ public async Task Release_PushesRepository_BeforePushingPackages() } [Test] - public async Task Release_TagsNothing_AndLeavesTaggingToTheServer() + public async Task Release_TagsTheReleaseCommit_PushesTheTag_AndPublishesOnIt() { using var harness = new ReleaseHarness(); _ = await harness.RunAsync().ConfigureAwait(false); - // The release object publishes the tag on the server; bv never creates one locally. - await Assert.That(harness.Repo.TagNames.Count).IsEqualTo(0); + // The tag names the release commit, under the post-release commit at the tip of the branch. + var releaseCommit = harness.Repo.GetCommits(2)[1]; + await Assert.That(releaseCommit.Message).IsEqualTo(ReleaseCommitMessage); + await Assert.That(harness.Repo.TagNames).IsEquivalentTo([ReleasedVersion]); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsEqualTo(releaseCommit.Sha); + var publisher = harness.Publisher!; + await Assert.That(publisher.PublishedTag).IsEqualTo(ReleasedVersion); + await Assert.That(publisher.PublishedSha).IsEqualTo(releaseCommit.Sha); + } + + // A release from a developer machine is a peer of a release from a runner, and its artifacts are built as + // deterministically as a runner builds them. + [Test] + public async Task Release_OnTheLocalPlatform_Releases_WithContinuousIntegrationBuild() + { + using var harness = new ReleaseHarness(new() { IsCI = false }); + + var exitCode = await harness.RunAsync().ConfigureAwait(false); + + await Assert.That(exitCode).IsEqualTo(0); + var pipelineRuns = harness.ProcessRunner.Runs + .Where(x => x.Args.Count > 0 && x.Args[0] is "restore" or "build" or "pack") + .ToArray(); + await Assert.That(pipelineRuns.Length).IsEqualTo(3); + foreach (var run in pipelineRuns) + { + await Assert.That(run.Args).Contains("-p:ContinuousIntegrationBuild=true"); + } + } + + // On a host bv does not know, the release does everything that needs Git alone, and nothing that needs the host. + [Test] + public async Task Release_OnAnUnknownHost_WithAllowUnknownHost_TagsAndPushes_WithoutAReleasePage() + { + using var harness = new ReleaseHarness(new() { UnknownHost = true, AllowUnknownHost = true }); + + var exitCode = await harness.RunAsync().ConfigureAwait(false); + + await Assert.That(exitCode).IsEqualTo(0); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNotNull(); + await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(harness.Repo.HeadSha); + await Assert.That(harness.Events.Count(x => x.Name == "nuget-push")).IsEqualTo(3); + await Assert.That(harness.Notices.Any(x => x.StartsWith("No release page created", StringComparison.Ordinal))).IsTrue(); } [Test] @@ -160,9 +201,9 @@ public async Task Release_PublishesEveryProducedPackage_AndItsSymbols() _ = await harness.RunAsync().ConfigureAwait(false); - var release = harness.Adapter.Release!; - await Assert.That(release.IsPublished).IsTrue(); - var assets = release.PublishedAssets; + var publisher = harness.Publisher!; + await Assert.That(publisher.IsPublished).IsTrue(); + var assets = publisher.PublishedAssets; var names = assets.Select(x => Path.GetFileName(x.Path)).ToArray(); await Assert.That(names).IsEquivalentTo([ "setup.exe", @@ -266,12 +307,11 @@ public async Task Release_UsesRepositoryIdentity_WhenNothingOutranksIt() await Assert.That(commit.CommitterEmail).IsEqualTo("local@example.invalid"); } - // Push credentials are a fallback: without them the push relies on whatever the checkout left in the - // repository's configuration, which may well work. So this is a warning and the release goes on. + // Git credentials are a fallback: without them the fetch and the push rely on whatever the checkout left + // in the repository's configuration, which may well work. So this is a warning and the release goes on. // Half a credential is none: a username with no secret has nothing to authenticate with, and the - // credential object built from it would carry a null password into libgit2. That case is not - // hypothetical - it is the shape GitLab CI's adapter has today - so it is exercised alongside the - // others, and the warning is what says the pair was required rather than either half of it. + // credential object built from it would carry a null password into libgit2. So that case is exercised + // alongside the others, and the warning is what says the pair was required rather than either half of it. [Test] [Arguments(null, null)] [Arguments("x-access-token", null)] @@ -284,7 +324,7 @@ public async Task Release_WithoutCompletePushCredentials_WarnsAndCarriesOn(strin await Assert.That(exitCode).IsEqualTo(0); var warnings = harness.Reporter.Messages.Where(x => x.Level == MessageLevel.Warning).Select(x => x.Message).ToArray(); - await Assert.That(warnings.Any(x => x.Contains("No push credentials", StringComparison.Ordinal))).IsTrue(); + await Assert.That(warnings.Any(x => x.Contains("No Git credentials", StringComparison.Ordinal))).IsTrue(); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(harness.Repo.HeadSha); } diff --git a/tests/Buildvana.Tool.Tests/ReleaseHarness.cs b/tests/Buildvana.Tool.Tests/ReleaseHarness.cs index 41124e5d..12fb0350 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseHarness.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseHarness.cs @@ -14,8 +14,12 @@ using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Infrastructure.DependencyInjection; using Buildvana.Tool.Services; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Hosts; +using Buildvana.Tool.Services.Hosts.GitHub; using Buildvana.Tool.Services.Platforms; -using Buildvana.Tool.Services.ServerAdapters; +using Buildvana.Tool.Services.Release; +using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Subcommands; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; @@ -24,14 +28,16 @@ /// /// Runs bv release end to end over a real Git repository in a temporary directory, with only the /// process and platform boundaries faked: child processes (), hooks -/// (), the CI platform (), and the server -/// (). Everything -/// else — the service graph, the versioning, the changelog, the commits, and the push — is the real thing. +/// (), the CI platform (), the Git host +/// (), and the release remote (, naming +/// the bare remote of the repository). Everything else — the service graph, the versioning, the changelog, +/// the commits, the tag, and the push — is the real thing. /// /// /// The harness anchors the process's current directory to the repository, exactly like a real run does, -/// and sets the environment variable the configured NuGet feed reads its API key from. Both are process-wide, -/// so tests using this harness must be marked [NotInParallel]. Both are restored on disposal. +/// and sets the environment variables the configured NuGet feed and the host read their secrets from. All of +/// them are process-wide, so tests using this harness must be marked [NotInParallel]. All of them are +/// restored on disposal. /// internal sealed class ReleaseHarness : IDisposable { @@ -48,6 +54,8 @@ internal sealed class ReleaseHarness : IDisposable public const string ApiFilesDirectory = "src/Test.Lib"; private const string ApiKeyEnvVar = "BV_TEST_NUGET_API_KEY"; + private const string TokenEnvVar = "BV_TEST_HOST_TOKEN"; + private const string RemoteName = "origin"; private const string BranchName = "release"; private const string Configuration = "Release"; @@ -60,6 +68,7 @@ internal sealed class ReleaseHarness : IDisposable private readonly ServiceProvider _services; private readonly string _originalCurrentDirectory = Environment.CurrentDirectory; private readonly string? _originalApiKey = Environment.GetEnvironmentVariable(ApiKeyEnvVar); + private readonly string? _originalToken = Environment.GetEnvironmentVariable(TokenEnvVar); private IReadOnlyList _commandOptions = []; @@ -78,6 +87,7 @@ public ReleaseHarness(ReleaseHarnessOptions? options = null) }; Environment.SetEnvironmentVariable(ApiKeyEnvVar, "test-api-key"); + Environment.SetEnvironmentVariable(TokenEnvVar, _options.PushSecret); Repo = new TempGitRepo(); PopulateRepository(); Repo.CommitAll("Initial commit"); @@ -147,9 +157,15 @@ public IReadOnlyList PipelineSteps public string ArtifactsPath => Path.Combine(Repo.RootPath, "artifacts", Configuration); /// - /// Gets the server adapter the release drives. + /// Gets the Git host the release drives. /// - public RecordingServerAdapter Adapter => (RecordingServerAdapter)_services.GetRequiredService(); + /// The harness runs on an unknown host (). + public RecordingHost Host => (RecordingHost)_services.GetRequiredService(); + + /// + /// Gets the release publisher the release created, or if no release began. + /// + public RecordingReleasePublisher? Publisher => Host.Publisher; /// /// Gets or sets a callback simulating what the hook does to the working tree. Only ever invoked when @@ -169,6 +185,20 @@ public Task RunAsync(params string[] options) return _services.GetRequiredService().ExecuteAsync(CancellationToken.None); } + /// + /// Begins a release over the service graph of the harness, the way the release command does, with + /// no step outputs. + /// + /// A representing the ongoing operation, whose result is the release. + public Task BeginReleaseAsync() + => ServerRelease.BeginAsync( + _services.GetRequiredService(), + _services.GetRequiredService(), + _services.GetRequiredService(), + _services.GetRequiredService().CreateReleasePublisher(), + RemoteName, + stepOutputs: null); + /// /// Computes the version of the repository as it now stands, the way a build of it would: this is what /// the pack step stamps on the packages, and what a later build of the released commit produces. @@ -212,12 +242,16 @@ public void Dispose() // The current directory has to leave the repository before the repository can be deleted. Environment.CurrentDirectory = _originalCurrentDirectory; Environment.SetEnvironmentVariable(ApiKeyEnvVar, _originalApiKey); + Environment.SetEnvironmentVariable(TokenEnvVar, _originalToken); Repo.Dispose(); } private static ProcessResult Success(string executable, IReadOnlyList args) => new($"{executable} {string.Join(' ', args)}", 0, string.Empty, string.Empty, TimeSpan.Zero); + private static GitUrlInfo ParseRepositoryUrl(IServiceProvider services) + => GitHost.ParseRepositoryUrl(services.GetRequiredService().Repository); + private ServiceProvider BuildServiceProvider() => new ServiceCollection() .AddSingleton(_console) @@ -235,16 +269,32 @@ private ServiceProvider BuildServiceProvider() RunsOnCI = _options.IsCI, Identity = _options.WithBotIdentity ? new("Buildvana Test Bot", "bot@buildvana.invalid") : null, }) - .AddSingleton(sp => new RecordingServerAdapter(sp) { - PushUser = _options.PushUser, - PushSecret = _options.PushSecret, - OnPublishing = () => { - Record("publish"); - _options.OnPublishing?.Invoke(); - }, - }) + .AddSingleton(new FakeReleaseRemoteResolver(RemoteName)) + .AddSingleton(CreateHost) .BuildServiceProvider(); + private GitHost CreateHost(IServiceProvider services) + { + if (_options.UnknownHost) + { + return new UnknownHost(ParseRepositoryUrl(services), Reporter); + } + + if (_options.GitHubApi is { } gitHubApi) + { + return new GitHubHost(services, ParseRepositoryUrl(services), gitHubApi); + } + + return new RecordingHost { + TokenVariable = TokenEnvVar, + PushUser = _options.PushUser, + OnPublishing = () => { + Record("publish"); + _options.OnPublishing?.Invoke(); + }, + }; + } + // Every dotnet invocation is a no-op that succeeds, except `pack`, which leaves behind the artifacts // a real one would: the release reads them back to discover the produced packages and gather assets. private ProcessResult RunDotNet(string executable, IReadOnlyList args) @@ -394,6 +444,7 @@ private string BuildConfiguration() ["checkPublicApi"] = _options.CheckPublicApi, ["changelogUpdates"] = _options.ChangelogUpdates, ["dogfood"] = _options.Dogfood, + ["allowUnknownHost"] = _options.AllowUnknownHost, }; if (_options.EmptyChangelog is { } emptyChangelog) { @@ -409,7 +460,13 @@ private string BuildConfiguration() ["versioning"] = new JsonObject { ["prereleaseTag"] = "preview" }, ["dotnet"] = new JsonObject { ["configuration"] = Configuration }, ["nuget"] = new JsonObject { ["feeds"] = new JsonObject { ["release"] = feed } }, + ["hosts"] = new JsonObject { ["github"] = new JsonObject { ["tokenEnv"] = TokenEnvVar } }, }; + if (_options.RepositoryUrl is { } repositoryUrl) + { + root["repository"] = new JsonObject { ["url"] = repositoryUrl }; + } + if (_options.ConfiguredIdentity is { } identity) { root["git"] = new JsonObject { diff --git a/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs b/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs index 0760107c..c3b0b56c 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs @@ -5,8 +5,8 @@ /// /// The knobs of a : what the repository contains and how the configuration file, -/// the CI platform, the server adapter, and the hook are set up. Every one of them has the value a plain prerelease release -/// needs, so a test only names what its own scenario changes. +/// the CI platform, the Git host, and the hook are set up. Every one of them has the value a plain prerelease +/// release needs, so a test only names what its own scenario changes. /// internal sealed record ReleaseHarnessOptions { @@ -89,18 +89,39 @@ internal sealed record ReleaseHarnessOptions public bool WithBotIdentity { get; init; } = true; /// - /// Gets the protocol username the server adapter provides for pushing, or if it - /// provides none. + /// Gets the value of repository.url, or to leave it unset. + /// + public string? RepositoryUrl { get; init; } = RecordingHost.Url; + + /// + /// Gets a value indicating whether the Git host is one bv does not know. When , + /// the release runs on a real instead of a + /// . + /// + public bool UnknownHost { get; init; } + + /// + /// Gets the handler that answers the requests of a real , + /// or for a . Ignored when is + /// . + /// + public StubHttpMessageHandler? GitHubApi { get; init; } + + /// + /// Gets the value of release.allowUnknownHost. + /// + public bool AllowUnknownHost { get; init; } + + /// + /// Gets the protocol username the host provides for pushing, or if it provides none. /// public string? PushUser { get; init; } = "x-access-token"; /// - /// Gets the secret the server adapter provides for pushing, or if it provides none. + /// Gets the token the host provides for pushing, or if it provides none. /// /// - /// Independent of on purpose: an adapter can provide one and not the other. - /// GitLab CI is that adapter today — a fixed protocol username, and no token until the configuration - /// file grows one. + /// Independent of on purpose: a host can provide one and not the other. /// public string? PushSecret { get; init; } = "test-token"; diff --git a/tests/Buildvana.Tool.Tests/ServerReleaseTests.cs b/tests/Buildvana.Tool.Tests/ServerReleaseTests.cs index a27d7932..835c0541 100644 --- a/tests/Buildvana.Tool.Tests/ServerReleaseTests.cs +++ b/tests/Buildvana.Tool.Tests/ServerReleaseTests.cs @@ -2,14 +2,14 @@ // See the LICENSE file in the project root for full license information. using System.Globalization; -using Buildvana.Tool.Services.ServerAdapters; +using Buildvana.Tool.Services.Release; /// /// Tests of driven directly, over the same repository and service graph a real /// release runs on (see ). /// /// -/// The release command walks one path through this class; the class is a contract for every adapter, +/// The release command walks one path through this class; the class is a contract for every host, /// so what it refuses and what it records are asserted here rather than through the one caller that exists /// today. The order its methods may be called in is part of that contract: a commit after a push, or a /// post-release commit before the release commit, would leave a release the rollback cannot undo, and each @@ -107,23 +107,83 @@ public async Task AddPostReleaseCommit_BeforeTheReleaseCommit_Throws() } } - // Nothing to push is not an error - a release may legitimately change no file at all - so it is recorded - // and the release goes on. The line matters because the push is otherwise invisible: without it, a run - // that pushed nothing and a run that pushed the release commit read exactly alike. + // The release tag names the release commit, so there is nothing to tag before the commit exists. [Test] - public async Task PushUpdates_WithoutAReleaseCommit_RecordsThatThereIsNothingToPush() + public async Task PushUpdates_WithoutAReleaseCommit_Throws() { using var harness = new ReleaseHarness(); var release = await StartReleaseAsync(harness).ConfigureAwait(false); await using (release.ConfigureAwait(false)) { - release.PushUpdates(); + void Act() => release.PushUpdates(); + + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).Contains("before the release commit"); } - await Assert.That(harness.Notices).Contains("Repository unchanged, no commit to push."); await Assert.That(harness.Repo.GetRemoteTipSha()).IsNull(); } + // The tag names the release commit, not the post-release commit that the branch tip carries. + [Test] + public async Task PushUpdates_TagsTheReleaseCommit_AndPushesTheTag() + { + using var harness = new ReleaseHarness(); + var release = await StartReleaseAsync(harness).ConfigureAwait(false); + await using (release.ConfigureAwait(false)) + { + release.EnsureReleaseCommit(); + var releaseCommitSha = release.ReleaseCommitSha; + var tag = harness.ComputeVersion(); + AddPostReleaseCommit(harness, release, 1); + + release.PushUpdates(); + + await Assert.That(harness.Repo.TagNames).IsEquivalentTo([tag]); + await Assert.That(harness.Repo.GetRemoteTagSha(tag)).IsEqualTo(releaseCommitSha); + await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(harness.Repo.HeadSha); + } + } + + [Test] + public async Task PublishAsync_BeforePushingUpdates_Throws() + { + using var harness = new ReleaseHarness(); + var release = await StartReleaseAsync(harness).ConfigureAwait(false); + await using (release.ConfigureAwait(false)) + { + release.EnsureReleaseCommit(); + + Task Act() => release.PublishAsync(); + + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).Contains("before pushing updates"); + await Assert.That(harness.Publisher!.IsPublished).IsFalse(); + } + } + + [Test] + public async Task PublishAsync_PublishesOnTheTag_AtTheReleaseCommit() + { + using var harness = new ReleaseHarness(); + var release = await StartReleaseAsync(harness).ConfigureAwait(false); + await using (release.ConfigureAwait(false)) + { + release.EnsureReleaseCommit(); + release.PushUpdates(); + await release.PublishAsync().ConfigureAwait(false); + + var publisher = harness.Publisher!; + await Assert.That(publisher.IsBegun).IsTrue(); + await Assert.That(publisher.PublishedTag).IsEqualTo(harness.ComputeVersion()); + await Assert.That(publisher.PublishedSha).IsEqualTo(release.ReleaseCommitSha); + } + + // A published release is final: disposing it undoes nothing. + await Assert.That(harness.Publisher!.IsUndone).IsFalse(); + await Assert.That(harness.Repo.TagNames.Count).IsEqualTo(1); + } + [Test] [Arguments(0, "with no assets.")] [Arguments(1, "with 1 asset.")] @@ -134,6 +194,8 @@ public async Task PublishAsync_RecordsTheNumberOfAssets(int assetCount, string e var release = await StartReleaseAsync(harness).ConfigureAwait(false); await using (release.ConfigureAwait(false)) { + release.EnsureReleaseCommit(); + release.PushUpdates(); for (var i = 0; i < assetCount; i++) { release.AddAsset(string.Create(CultureInfo.InvariantCulture, $"artifacts/asset-{i}.bin")); @@ -145,6 +207,30 @@ public async Task PublishAsync_RecordsTheNumberOfAssets(int assetCount, string e await Assert.That(harness.Notices).Contains($"Published release {harness.ComputeVersion()} {expectedEnding}"); } + // The tag goes before the commits it names, locally and on the remote, and the host-side release goes last. + [Test] + public async Task DisposeAsync_AfterPushingUpdates_DeletesTheTag_AndUndoesThePublisher() + { + using var harness = new ReleaseHarness(); + var initialSha = harness.Repo.HeadSha; + var release = await StartReleaseAsync(harness).ConfigureAwait(false); + string tag; + await using (release.ConfigureAwait(false)) + { + release.EnsureReleaseCommit(); + tag = harness.ComputeVersion(); + release.PushUpdates(); + } + + await Assert.That(harness.Repo.TagNames.Count).IsEqualTo(0); + await Assert.That(harness.Repo.GetRemoteTagSha(tag)).IsNull(); + await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(initialSha); + await Assert.That(harness.Publisher!.IsUndone).IsTrue(); + var notices = harness.Notices.ToArray(); + await Assert.That(Array.IndexOf(notices, $"Deleted tag '{tag}' from 'origin'.")) + .IsLessThan(Array.IndexOf(notices, "Undid the release commit.")); + } + // Every commit the release made is walked back by one rollback action, and recorded by one line naming // how many: the release commit is always there, the post-release commits are however many the caller // added. The release command adds at most one of them; the class allows any number, so the ladder is @@ -172,11 +258,11 @@ public async Task DisposeAsync_RecordsEveryUndoneCommitAtOnce(int postReleaseCom } // A release object over the harness's repository, with a committer identity for the commits it makes: - // the command sets one from the server adapter before creating the release, and nothing else does. + // the command sets one before beginning the release, and nothing else does. private static async Task StartReleaseAsync(ReleaseHarness harness) { harness.Repo.SetCommitterIdentity("Buildvana Test Bot", "bot@buildvana.invalid"); - return await harness.Adapter.CreateReleaseAsync().ConfigureAwait(false); + return await harness.BeginReleaseAsync().ConfigureAwait(false); } private static void AddPostReleaseCommit(ReleaseHarness harness, ServerRelease release, int ordinal) diff --git a/tests/Buildvana.Tool.Tests/UrlMatchingReleaseRemoteResolverTests.cs b/tests/Buildvana.Tool.Tests/UrlMatchingReleaseRemoteResolverTests.cs new file mode 100644 index 00000000..a7f1b944 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/UrlMatchingReleaseRemoteResolverTests.cs @@ -0,0 +1,97 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Core.HomeDirectory; +using Buildvana.Core.Testing; +using Buildvana.Runtime; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Hosts; + +internal sealed class UrlMatchingReleaseRemoteResolverTests +{ + private const string RepositoryUrl = "https://github.com/Tenacom/Buildvana"; + + // The scheme, the user, the case, and a .git suffix play no part in the match. + [Test] + [Arguments("https://github.com/Tenacom/Buildvana.git")] + [Arguments("https://someone@GitHub.com/tenacom/buildvana/")] + [Arguments("http://github.com/Tenacom/Buildvana")] + public async Task Resolve_FindsTheRemoteThatNamesTheRepository(string upstreamUrl) + { + using var repo = CreateRepo(("origin", "https://github.com/someone/Buildvana"), ("upstream", upstreamUrl)); + + await Assert.That(Resolve(repo, RepositoryUrl)).IsEqualTo("upstream"); + } + + [Test] + public async Task Resolve_PrefersAnHttpsRemote_OverAnSshRemoteOfTheSameRepository() + { + using var repo = CreateRepo(("ssh", "ssh://git@github.com/Tenacom/Buildvana.git"), ("https", RepositoryUrl)); + + await Assert.That(Resolve(repo, RepositoryUrl)).IsEqualTo("https"); + } + + [Test] + public async Task Resolve_WithAnSshRemoteAlone_Throws() + { + using var repo = CreateRepo(("origin", "ssh://git@github.com/Tenacom/Buildvana.git")); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before repo is disposed + var exception = await Assert.That(() => Resolve(repo, RepositoryUrl)).Throws(); + await Assert.That(exception!.Message).Contains("Remote 'origin' names repository.url"); + await Assert.That(exception.Message).Contains("HTTP and HTTPS"); + } + + [Test] + public async Task Resolve_WithoutAMatchingRemote_ThrowsNamingTheRemotes() + { + using var repo = CreateRepo(("origin", "https://github.com/someone/Buildvana")); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before repo is disposed + var exception = await Assert.That(() => Resolve(repo, RepositoryUrl)).Throws(); + await Assert.That(exception!.Message).Contains($"No remote of the clone names repository.url '{RepositoryUrl}'"); + await Assert.That(exception.Message).Contains("'origin' (https://github.com/someone/Buildvana)"); + } + + [Test] + public async Task Resolve_WithoutRemotes_Throws() + { + using var repo = CreateRepo(); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before repo is disposed + var exception = await Assert.That(() => Resolve(repo, RepositoryUrl)).Throws(); + await Assert.That(exception!.Message).Contains("The clone has no remotes."); + } + + [Test] + public async Task Resolve_WithoutRepositoryUrl_Throws() + { + using var repo = CreateRepo(("origin", RepositoryUrl)); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before repo is disposed + var exception = await Assert.That(() => Resolve(repo, null)).Throws(); + await Assert.That(exception!.Message).Contains("repository.url"); + } + + private static TempGitRepo CreateRepo(params (string Name, string Url)[] remotes) + { + var repo = new TempGitRepo(); + repo.WriteFile("a.txt", "content"); + repo.CommitAll(); + foreach (var (name, url) in remotes) + { + repo.AddRemote(name, new Uri(url)); + } + + return repo; + } + + private static string Resolve(TempGitRepo repo, string? repositoryUrl) + { + using var git = new GitService(NullReporter.Instance, new FixedHomeDirectoryProvider(repo.RootPath)); + var config = new BuildvanaConfig { Repository = new() { Url = repositoryUrl } }; + return new UrlMatchingReleaseRemoteResolver(NullReporter.Instance, git, config).Resolve(); + } +} From 2c3292ac5eb5a4f2d28b77026b26e8870d5f173e Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 02:46:37 +0200 Subject: [PATCH 05/12] Add bv info, which reports what bv release would use Before bv release, a user had no way to see which CI platform, Git host, release remote, token variable, and committer identity bv detected. A wrong guess showed up as a failed release. InfoCommand prints them, and states why a value cannot be resolved. ReleaseCommitterIdentity resolves the committer identity for both InfoCommand and ReleaseCommand, so that the two cannot disagree. InfoCommand writes to IAnsiConsole.Profile.Out.Writer, because a redirected console wraps text at 80 columns. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/README.md | 1 + docs/command-line.md | 3 +- docs/tool-commands/info.md | 80 +++++++++ docs/tool-commands/release.md | 2 + .../Release/ReleaseCommitterIdentity.cs | 45 +++++ src/Buildvana.Tool/Subcommands/InfoCommand.cs | 90 ++++++++++ .../Subcommands/ReleaseCommand.cs | 22 +-- .../Buildvana.Tool.Tests/CommandPagesTests.cs | 1 + .../CommandRegistryTests.cs | 4 +- .../Buildvana.Tool.Tests/InfoCommandTests.cs | 168 ++++++++++++++++++ 11 files changed, 397 insertions(+), 20 deletions(-) create mode 100644 docs/tool-commands/info.md create mode 100644 src/Buildvana.Tool/Services/Release/ReleaseCommitterIdentity.cs create mode 100644 src/Buildvana.Tool/Subcommands/InfoCommand.cs create mode 100644 tests/Buildvana.Tool.Tests/InfoCommandTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d0367d3..08a063c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - `bv release` runs [on any machine](docs/tool-commands/release.md#preconditions), with `repository.url` naming the repository the release goes to. - `bv release` fetches the tags of the [release remote](docs/tool-commands/release.md#the-release-remote) before it checks the versions. - `release.allowUnknownHost` lets `bv release` [tag and push without a release page](docs/tool-commands/release.md#publishing) on a server `bv` does not know. +- [`bv info`](docs/tool-commands/info.md) prints the CI platform, the Git host, the release remote, and the identity a release would use. ### Changes to existing features diff --git a/docs/README.md b/docs/README.md index 8e444a19..27518e8d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,7 @@ Every page of the Buildvana user documentation is listed here, in one group per - [Build pipeline](tool-commands/build-pipeline.md): `bv clean`, `bv restore`, `bv build`, `bv test`, and `bv pack`, the arguments all but `bv clean` forward to `dotnet`, and the build configuration. - [Dependency management](tool-commands/dependencies.md): `bv dependencies`, its subcommands, its update policies, and the transitive overrides it writes. +- [Info](tool-commands/info.md): the report of `bv info`, from the CI platform to the identity `bv release` would commit with. - [Release](tool-commands/release.md): what `bv release` checks, what it does step by step, and what it undoes when a step fails. - [Self-update](tool-commands/self-update.md): the version `bv self-update` picks, the pins it moves, the summary it prints, and when it refuses to run. - [Version](tool-commands/version.md): the report of `bv version show`, and the version spec change `bv version advance` applies. diff --git a/docs/command-line.md b/docs/command-line.md index eb654ba9..1473fed8 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -62,6 +62,7 @@ A command line holds a command, the arguments and options of the command, and th | `dependencies show` | `dependencies`, `deps show`, `deps` | yes | [Dependency management](tool-commands/dependencies.md) | | `dependencies update` | `deps update` | yes | [Dependency management](tool-commands/dependencies.md) | | `dependencies prune` | `deps prune` | yes | [Dependency management](tool-commands/dependencies.md) | +| `info` | | no | [Info](tool-commands/info.md) | | `release` | | yes | [Release](tool-commands/release.md) | | `self-update` | | no | [Self-update](tool-commands/self-update.md) | | `version show` | `version` | no | [Version](tool-commands/version.md) | @@ -158,7 +159,7 @@ A message logged by a task and one printed by `bv` therefore appear together. `bv` writes its narration to standard error, and keeps standard output for the result of a command. The narration is the leveled lines, the activity lines, and the startup logo. -The result is the report of `bv version show`, the report of `bv dependencies`, or the summary of `bv self-update`. +The result is the report of `bv version show`, `bv info`, or `bv dependencies`, or the summary of `bv self-update`. A child `dotnet` process is streamed live, each of its streams to the same stream of `bv`. The output of `dotnet` is the result of a build pipeline command. diff --git a/docs/tool-commands/info.md b/docs/tool-commands/info.md new file mode 100644 index 00000000..e2305957 --- /dev/null +++ b/docs/tool-commands/info.md @@ -0,0 +1,80 @@ +# Info + +`bv info` reports what `bv` detected about the machine and the repository, and what `bv release` would use. +This page says what each line of the report means, and what the command does when a line cannot be resolved. + +--- + + +**Table of contents** + + +- [The report](#the-report) +- [Lines that cannot be resolved](#lines-that-cannot-be-resolved) +- [Exit codes](#exit-codes) + +--- + +## The report + +```text +bv info [OPTIONS] +``` + +The report has nine lines: + +```text +CI platform: GitHub Actions +Server URL: https://github.com/ +Bot identity: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> +ContinuousIntegrationBuild: true +Repository URL: https://github.com/Tenacom/Buildvana +Release remote: origin +Git host: GitHub (github.com) +Token variable: GITHUB_TOKEN (set) +Release identity: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>, from the bot identity of GitHub Actions +``` + +- The CI platform is the one `bv` detects from the environment, as [CI platforms](../introduction.md#ci-platforms) says. +- The server URL is the URL of the server that runs the CI platform, when the platform states one. +- The bot identity is the identity the CI platform commits with, when it has one. +- The `ContinuousIntegrationBuild` line holds the value the build pipeline passes to `dotnet`. + `bv release` passes `true` on every platform, as [The steps](release.md#the-steps) says. +- The repository URL is the `repository.url` setting of `buildvana.jsonc`. +- The release remote is the remote of the clone that names that repository, as [The release remote](release.md#the-release-remote) says. +- The Git host line names the host of `repository.url`, and its host name. + For an unknown host, the line says whether `release.allowUnknownHost` lets `bv release` proceed. +- The token variable is the environment variable that holds the token of the host, with whether it is set. + `bv info` never prints the value of the token. +- The release identity is the identity the commits of `bv release` would have, with where it comes from. + The source is the `git.identity` setting, the bot identity of the CI platform, or the Git configuration, as [Preconditions](release.md#preconditions) says. + +The report is the result of the command, so it goes to standard output at every verbosity, and the narration goes to standard error. +The command needs no network, changes nothing, and runs no SDK version check. + +--- + +## Lines that cannot be resolved + +A line whose value does not exist reads `(none)`. +When the value cannot be resolved, the release remote line and the Git host line add the reason after `(none)`: + +```text +Release remote: (none) No remote of the clone names repository.url 'https://github.com/Tenacom/Buildvana'. The remotes of the clone are 'origin' (https://github.com/someone/Buildvana). +``` + +The reason is the message `bv release` would fail with. +Without `repository.url`, both lines state that the setting is missing, and the token variable line reads `(none)`. +Outside a Git repository, the release remote line states that there is no Git repository. +The release identity line then ignores the Git configuration. + +--- + +## Exit codes + +`bv info` returns the [exit codes every `bv` command returns](../tool-diagnostics.md#exit-codes). + +Code 0 is a report, whatever its lines say. +Code 1 is an invalid `buildvana.jsonc`. +Code 2 is a refusal of the command line: an argument, an unknown option, or a `--` separator. +Code 130 is a run terminated with Ctrl-C. diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index 93e50ee1..0560bea6 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -60,6 +60,8 @@ Before building anything, `bv release` checks what a build cannot change: 9. The version spec change is computed from `--bump` and the public API files, as [`bv version advance`](version.md#bv-version-advance) computes it. 10. [`GITHUB_OUTPUT`](../environment-variables.md#github_output) is set, on GitHub Actions. +[`bv info`](info.md) reports the release remote, the Git host, the token, and the committer identity, without releasing. + `bv` reads the latest versions from the tags reachable from `HEAD`, so the clone needs the full history. With `actions/checkout`, that is `fetch-depth: 0`. diff --git a/src/Buildvana.Tool/Services/Release/ReleaseCommitterIdentity.cs b/src/Buildvana.Tool/Services/Release/ReleaseCommitterIdentity.cs new file mode 100644 index 00000000..fa1c954b --- /dev/null +++ b/src/Buildvana.Tool/Services/Release/ReleaseCommitterIdentity.cs @@ -0,0 +1,45 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Runtime; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Platforms; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Release; + +/// +/// Resolves the identity the commits of bv release are made with. +/// +internal static class ReleaseCommitterIdentity +{ + /// + /// Resolves the committer identity of a release: the git.identity setting, then the bot identity of + /// the CI platform, then the identity the Git configuration states. + /// + /// The Git configuration of buildvana.jsonc. + /// The CI platform bv runs on. + /// The identity the Git configuration states, or + /// if it states none. + /// The first identity of the three that exists, with a description of where it comes from; + /// or if none exists. + public static (GitIdentity Identity, string Source)? Resolve( + GitConfig config, + CIPlatform platform, + GitIdentity? gitConfigurationIdentity) + { + Guard.IsNotNull(config); + Guard.IsNotNull(platform); + if (config.Identity is { } configured) + { + return (new GitIdentity(configured.Name, configured.Email), "git.identity"); + } + + if (platform.BotIdentity is { } bot) + { + return (bot, $"the bot identity of {platform.Name}"); + } + + return gitConfigurationIdentity is not null ? (gitConfigurationIdentity, "the Git configuration") : null; + } +} diff --git a/src/Buildvana.Tool/Subcommands/InfoCommand.cs b/src/Buildvana.Tool/Subcommands/InfoCommand.cs new file mode 100644 index 00000000..5839804d --- /dev/null +++ b/src/Buildvana.Tool/Subcommands/InfoCommand.cs @@ -0,0 +1,90 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Buildvana.Core; +using Buildvana.Runtime; +using Buildvana.Tool.Infrastructure.Execution; +using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Hosts; +using Buildvana.Tool.Services.Platforms; +using Buildvana.Tool.Services.Release; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; + +namespace Buildvana.Tool.Subcommands; + +[ImplementsCommand("info")] +[Description("Show the CI platform, the Git host, and what a release would use.")] +internal sealed class InfoCommand( + IServiceProvider services, + BuildvanaConfig config, + CIPlatform platform, + IAnsiConsole console) : IBvCommand +{ + private const string None = "(none)"; + + public Task ExecuteAsync(CancellationToken cancellationToken) + { + // The report is the command's deliverable, as for `bv version show`: it goes to the console at every + // verbosity. A line that cannot be resolved states the reason, and the command still succeeds, because + // the reason is what the user runs the command to learn. + var remote = TryGet(() => services.GetRequiredService().Resolve(), out var remoteError); + var host = TryGet(services.GetRequiredService, out var hostError); + var git = TryGet(services.GetRequiredService, out _); + WriteLine("CI platform:", platform.Name); + WriteLine("Server URL:", platform.ServerUrl?.ToString() ?? None); + WriteLine("Bot identity:", platform.BotIdentity is { } bot ? Describe(bot) : None); + WriteLine("ContinuousIntegrationBuild:", platform.IsCI ? "true" : "false"); + WriteLine("Repository URL:", config.Repository.Url is { Length: > 0 } url ? url : None); + WriteLine("Release remote:", remote ?? $"{None} {remoteError}"); + WriteLine("Git host:", host is not null ? DescribeHost(host) : $"{None} {hostError}"); + WriteLine("Token variable:", host is not null ? DescribeToken(host) : None); + WriteLine("Release identity:", DescribeIdentity(git)); + return Task.FromResult(0); + } + + private static T? TryGet(Func get, out string? error) + where T : class + { + try + { + error = null; + return get(); + } + catch (BuildFailedException e) + { + error = e.Message; + return null; + } + } + + private static string Describe(GitIdentity identity) => $"{identity.Name} <{identity.Email}>"; + + private static string DescribeToken(GitHost host) + => host.TokenEnv is { } name ? $"{name} ({(host.GetToken() is null ? "not set" : "set")})" : None; + + // The writer of the console, not the console itself: the console wraps text at its width, 80 columns when + // the output is redirected, and a wrapped line is no longer one line per item. + private void WriteLine(string label, string value) => console.Profile.Out.Writer.WriteLine($"{label,-28}{value}"); + + private string DescribeHost(GitHost host) + { + if (host.Kind is not null) + { + return $"{host.Name} ({host.HostName})"; + } + + return config.Release.AllowUnknownHost + ? $"{host.Name}, allowed by release.allowUnknownHost" + : $"{host.Name}, refused by bv release unless release.allowUnknownHost is true"; + } + + private string DescribeIdentity(IGitService? git) + => ReleaseCommitterIdentity.Resolve(config.Git, platform, git?.CommitterIdentity) is (var identity, var source) + ? $"{Describe(identity)}, from {source}" + : None; +} diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index 7bbcd75b..1ee639d2 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -84,25 +84,13 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var hookRunner = services.GetRequiredService(); var hookArgsFactory = services.GetRequiredService(); - // Resolve the committer identity for release commits: the configured git.identity outranks the bot - // identity of the CI platform, which outranks whatever the repository's own Git configuration - // happens to state; with none of the three, the release cannot commit. - var configuredIdentity = config.Git.Identity; - var identity = configuredIdentity is not null - ? new GitIdentity(configuredIdentity.Name, configuredIdentity.Email) - : platform.BotIdentity; - if (identity is not null) - { - git.CommitterIdentity = identity; - } - else if (git.CommitterIdentity is null) - { - throw new BuildFailedException( + // With no committer identity, the release cannot commit. + var identity = ReleaseCommitterIdentity.Resolve(config.Git, platform, git.CommitterIdentity)?.Identity + ?? throw new BuildFailedException( "Cannot determine a committer identity for release commits. " + "Set git.identity in the configuration file, or configure git config user.name/user.email."); - } - - reporter.Notice($"Using committer identity: {git.CommitterIdentity.Name} <{git.CommitterIdentity.Email}>"); + git.CommitterIdentity = identity; + reporter.Notice($"Using committer identity: {identity.Name} <{identity.Email}>"); // Set fallback Git credentials if the host can provide them. var pushUsername = host.PushUsername; diff --git a/tests/Buildvana.Tool.Tests/CommandPagesTests.cs b/tests/Buildvana.Tool.Tests/CommandPagesTests.cs index 981dea15..8705fcc5 100644 --- a/tests/Buildvana.Tool.Tests/CommandPagesTests.cs +++ b/tests/Buildvana.Tool.Tests/CommandPagesTests.cs @@ -26,6 +26,7 @@ internal sealed class CommandPagesTests ["test"] = "build-pipeline.md", ["pack"] = "build-pipeline.md", ["dependencies"] = "dependencies.md", + ["info"] = "info.md", ["release"] = "release.md", ["self-update"] = "self-update.md", ["version"] = "version.md", diff --git a/tests/Buildvana.Tool.Tests/CommandRegistryTests.cs b/tests/Buildvana.Tool.Tests/CommandRegistryTests.cs index 30d240d1..5e266274 100644 --- a/tests/Buildvana.Tool.Tests/CommandRegistryTests.cs +++ b/tests/Buildvana.Tool.Tests/CommandRegistryTests.cs @@ -41,7 +41,7 @@ public async Task PipelineCommands_AppearInExecutionOrderBeforeOthers() { var names = string.Join(",", CommandRegistry.Commands.Select(c => c.Name)); await Assert.That(names).IsEqualTo( - "clean,restore,build,test,pack,dependencies prune,dependencies show,dependencies update,release,self-update," + "clean,restore,build,test,pack,dependencies prune,dependencies show,dependencies update,info,release,self-update," + "version advance,version show"); } @@ -49,7 +49,7 @@ await Assert.That(names).IsEqualTo( public async Task TopLevelNodes_ListEachGroupOnce() { var names = string.Join(",", CommandRegistry.TopLevelNodes.Select(n => n.Name)); - await Assert.That(names).IsEqualTo("clean,restore,build,test,pack,dependencies,deps,release,self-update,version"); + await Assert.That(names).IsEqualTo("clean,restore,build,test,pack,dependencies,deps,info,release,self-update,version"); } [Test] diff --git a/tests/Buildvana.Tool.Tests/InfoCommandTests.cs b/tests/Buildvana.Tool.Tests/InfoCommandTests.cs new file mode 100644 index 00000000..8251ef7a --- /dev/null +++ b/tests/Buildvana.Tool.Tests/InfoCommandTests.cs @@ -0,0 +1,168 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Core.ConsoleOutput; +using Buildvana.Core.HomeDirectory; +using Buildvana.Core.Testing; +using Buildvana.Tool.CommandLine; +using Buildvana.Tool.Infrastructure.DependencyInjection; +using Buildvana.Tool.Services.Platforms; +using Buildvana.Tool.Subcommands; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; +using Spectre.Console.Testing; + +// `bv info` over the real service graph, with the CI platform faked. +internal sealed class InfoCommandTests +{ + private const string RepositoryUrl = "https://github.com/Tenacom/Buildvana"; + private const string TokenEnvVar = "BV_TEST_INFO_TOKEN"; + + [Test] + [NotInParallel] + public async Task Info_OnAKnownHost_ReportsEveryLine() + { + using var repo = CreateRepo(("origin", "https://github.com/someone/Buildvana"), ("upstream", RepositoryUrl)); + repo.WriteFile("buildvana.json", $$""" + { + "repository": { "url": "{{RepositoryUrl}}" }, + "hosts": { "github": { "tokenEnv": "{{TokenEnvVar}}" } }, + "git": { "identity": { "name": "Release Bot", "email": "release@buildvana.invalid" } } + } + """); + var platform = new FakeCIPlatform { Server = new("https://github.com") }; + var originalToken = Environment.GetEnvironmentVariable(TokenEnvVar); + Environment.SetEnvironmentVariable(TokenEnvVar, "secret-token-value"); + try + { + var output = await RunAsync(repo.RootPath, platform).ConfigureAwait(false); + + await Assert.That(output).IsEqualTo(""" + CI platform: Test CI platform + Server URL: https://github.com/ + Bot identity: Buildvana Test Bot + ContinuousIntegrationBuild: true + Repository URL: https://github.com/Tenacom/Buildvana + Release remote: upstream + Git host: GitHub (github.com) + Token variable: BV_TEST_INFO_TOKEN (set) + Release identity: Release Bot , from git.identity + """); + await Assert.That(output).DoesNotContain("secret-token-value"); + } + finally + { + Environment.SetEnvironmentVariable(TokenEnvVar, originalToken); + } + } + + [Test] + public async Task Info_WithoutRepositoryUrl_ReportsTheReason() + { + using var repo = CreateRepo(("origin", RepositoryUrl)); + repo.WriteFile("buildvana.json", "{}"); + + var report = ParseReport(await RunAsync(repo.RootPath, new FakeCIPlatform()).ConfigureAwait(false)); + + await Assert.That(report["Repository URL"]).IsEqualTo("(none)"); + await Assert.That(report["Release remote"]).StartsWith("(none) The configuration file states no repository.url"); + await Assert.That(report["Git host"]).StartsWith("(none) The configuration file states no repository.url"); + await Assert.That(report["Token variable"]).IsEqualTo("(none)"); + } + + [Test] + [Arguments(true, "unknown host (git.example.com), allowed by release.allowUnknownHost")] + [Arguments(false, "unknown host (git.example.com), refused by bv release unless release.allowUnknownHost is true")] + public async Task Info_OnAnUnknownHost_SaysWhetherAReleaseProceeds(bool allowUnknownHost, string expected) + { + const string unknownUrl = "https://git.example.com/Tenacom/Buildvana"; + using var repo = CreateRepo(("origin", unknownUrl)); + repo.WriteFile("buildvana.json", $$""" + { + "repository": { "url": "{{unknownUrl}}" }, + "release": { "allowUnknownHost": {{(allowUnknownHost ? "true" : "false")}} } + } + """); + + var report = ParseReport(await RunAsync(repo.RootPath, new FakeCIPlatform()).ConfigureAwait(false)); + + await Assert.That(report["Release remote"]).IsEqualTo("origin"); + await Assert.That(report["Git host"]).IsEqualTo(expected); + await Assert.That(report["Token variable"]).IsEqualTo("(none)"); + } + + [Test] + public async Task Info_WithNeitherConfiguredNorBotIdentity_ReportsTheIdentityOfTheGitConfiguration() + { + using var repo = CreateRepo(("origin", RepositoryUrl)); + repo.SetCommitterIdentity("Local Dev", "dev@buildvana.invalid"); + repo.WriteFile("buildvana.json", $$""" + { + "repository": { "url": "{{RepositoryUrl}}" }, + "hosts": { "github": { "tokenEnv": "BV_TEST_INFO_UNSET_TOKEN" } } + } + """); + var platform = new FakeCIPlatform { RunsOnCI = false, Identity = null }; + + var report = ParseReport(await RunAsync(repo.RootPath, platform).ConfigureAwait(false)); + + await Assert.That(report["Bot identity"]).IsEqualTo("(none)"); + await Assert.That(report["ContinuousIntegrationBuild"]).IsEqualTo("false"); + await Assert.That(report["Token variable"]).IsEqualTo("BV_TEST_INFO_UNSET_TOKEN (not set)"); + await Assert.That(report["Release identity"]).IsEqualTo("Local Dev , from the Git configuration"); + } + + [Test] + public async Task Info_OutsideAGitRepository_ReportsTheReason() + { + using var home = new TempHome(); + home.WriteFile("buildvana.json", $$"""{ "repository": { "url": "{{RepositoryUrl}}" } }"""); + + var platform = new FakeCIPlatform { Identity = null }; + + var report = ParseReport(await RunAsync(home.RootPath, platform).ConfigureAwait(false)); + + await Assert.That(report["Release remote"]).StartsWith("(none) There is no Git repository at"); + await Assert.That(report["Git host"]).IsEqualTo("GitHub (github.com)"); + await Assert.That(report["Release identity"]).IsEqualTo("(none)"); + } + + private static TempGitRepo CreateRepo(params (string Name, string Url)[] remotes) + { + var repo = new TempGitRepo(); + repo.WriteFile("a.txt", "content"); + repo.CommitAll(); + foreach (var (name, url) in remotes) + { + repo.AddRemote(name, new Uri(url)); + } + + return repo; + } + + private static async Task RunAsync(string homePath, CIPlatform platform) + { + using var console = new TestConsole(); + using var services = new ServiceCollection() + .AddSingleton(console) + .AddSingleton(NullReporter.Instance) + .AddSingleton(new GlobalSettings(null, false, false, true, true, true, false)) + .AddSingleton(new CommandParameters([], [], [])) + .AddSingleton(new FixedHomeDirectoryProvider(homePath)) + .AddBvServices() + .AddSingleton(platform) + .BuildServiceProvider(); + + var exitCode = await services.GetRequiredService().ExecuteAsync(CancellationToken.None).ConfigureAwait(false); + + await Assert.That(exitCode).IsEqualTo(0); + return console.Output.ReplaceLineEndings("\n").TrimEnd('\n'); + } + + // The report, keyed by the label of each line, without the colon. + private static Dictionary ParseReport(string output) + => output + .Split('\n') + .Select(static line => line.Split(':', 2)) + .ToDictionary(static parts => parts[0], static parts => parts[1].Trim()); +} From 4d37dfa29eb2e46e72721dad8f1bb1c2d3b65280 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 03:08:02 +0200 Subject: [PATCH 06/12] Stop disposing the ServiceProvider of InfoCommandTests synchronously ReSharper reported UseAwaitUsing on the synchronous disposal of the ServiceProvider in InfoCommandTests.RunAsync. The test now disposes it with await using, in the form DotNetServiceResolutionTests and Program already use. Co-Authored-By: Claude Opus 5.5 --- tests/Buildvana.Tool.Tests/InfoCommandTests.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/Buildvana.Tool.Tests/InfoCommandTests.cs b/tests/Buildvana.Tool.Tests/InfoCommandTests.cs index 8251ef7a..76d34d78 100644 --- a/tests/Buildvana.Tool.Tests/InfoCommandTests.cs +++ b/tests/Buildvana.Tool.Tests/InfoCommandTests.cs @@ -143,7 +143,7 @@ private static TempGitRepo CreateRepo(params (string Name, string Url)[] remotes private static async Task RunAsync(string homePath, CIPlatform platform) { using var console = new TestConsole(); - using var services = new ServiceCollection() + var services = new ServiceCollection() .AddSingleton(console) .AddSingleton(NullReporter.Instance) .AddSingleton(new GlobalSettings(null, false, false, true, true, true, false)) @@ -152,11 +152,13 @@ private static async Task RunAsync(string homePath, CIPlatform platform) .AddBvServices() .AddSingleton(platform) .BuildServiceProvider(); + await using (services.ConfigureAwait(false)) + { + var exitCode = await services.GetRequiredService().ExecuteAsync(CancellationToken.None).ConfigureAwait(false); - var exitCode = await services.GetRequiredService().ExecuteAsync(CancellationToken.None).ConfigureAwait(false); - - await Assert.That(exitCode).IsEqualTo(0); - return console.Output.ReplaceLineEndings("\n").TrimEnd('\n'); + await Assert.That(exitCode).IsEqualTo(0); + return console.Output.ReplaceLineEndings("\n").TrimEnd('\n'); + } } // The report, keyed by the label of each line, without the colon. From ee768626ab900d91c7fc837dd0048eb888539a02 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 03:27:09 +0200 Subject: [PATCH 07/12] Remove the blank line inside the changes list of CHANGELOG.md A blank line separated the two breaking-change bullets about bv release from the other bullets of "Changes to existing features". GitHub then rendered the whole list with paragraph spacing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a063c9..af7233f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,6 @@ The patch number restarted from 1, below the 2.0.x versions already published. - **BREAKING CHANGE**: `bv release` no longer runs without [`repository.url`](docs/configuration-file.md#settings) in `buildvana.jsonc`. - **BREAKING CHANGE**: `bv release` no longer leaves the release tag to the host, and [creates and pushes it](docs/tool-commands/release.md#the-release-tag) before it publishes the release. - - **BREAKING CHANGE**: `BV_MinRoslynVersion` moves from 5.3 to 5.9, so Buildvana SDK raises BVSDK1101 on [Roslyn older than 5.9](docs/sdk-diagnostics.md#source-generators-1100-1199). - **BREAKING CHANGE**: `BV_MinMSBuildVersion` moves from 17.4 to 18.9, so Buildvana SDK raises BVSDK1004 on [MSBuild older than 18.9](docs/introduction.md#toolchain). - **BREAKING CHANGE**: The `AlternatePack` module no longer adds `Tools.InnoDownloadPlugin` to an Inno Setup project, and the built-in [`download` and `issigverify` flags](https://jrsoftware.org/ishelp/topic_filessection.htm) replace it. From b332f8c98cb3ce09073822805c37afca9f8fc21e Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 09:19:57 +0200 Subject: [PATCH 08/12] Stop linking the rejected push and repository.host to "Publishing" The changelog bullet on a rejected push linked "Publishing" in release.md, which describes the NuGet push alone. The repository.host row of configuration-file.md linked the same section, which does not describe the host detection. The links now point at "The release tag" and "Preconditions", where those facts are stated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/configuration-file.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af7233f5..033d7d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,7 +141,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - The [`AdditionalAssemblyInfo` module](docs/sdk-modules/additional-assembly-info.md#clscompliant-property) generates `CLSCompliant(true)` and `ComVisible(true)` for any value MSBuild reads as true, such as `yes`, where it generated `false`. - [`bv test`](docs/tool-commands/build-pipeline.md#bv-test) runs the tests of a project that sets `IsTestingPlatformApplication` to a value MSBuild reads as true, such as `yes`, where it skipped the project. - The [`ConvertToWinePaths` task](docs/sdk-modules/wine.md#converttowinepaths-task) converts an item when MSBuild reads the metadata named by `OnlyIfMetadata` as true, such as `yes`, where it skipped the item. -- `bv release` fails when the remote rejects the [push of its commits](docs/tool-commands/release.md#publishing), where it used to go on as if the push had succeeded. +- `bv release` fails when the remote rejects the [push of its commits](docs/tool-commands/release.md#the-release-tag), where it used to go on as if the push had succeeded. - When a `PackageVersion` item versions a package Buildvana SDK injects, the reference [is an ordinary `PackageReference`](docs/sdk-modules/standard-analyzers.md#package-references), where NuGet rejected the item with NU1009. ### Known problems introduced by this release diff --git a/docs/configuration-file.md b/docs/configuration-file.md index 779e5763..ca2e0ec2 100644 --- a/docs/configuration-file.md +++ b/docs/configuration-file.md @@ -86,7 +86,7 @@ A setting whose member names are data, such as `dependencies.policies` and `dotn | ------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `$schema` | none | The URL of the JSON schema, whose version segment [`bv self-update`](tool-commands/self-update.md#what-the-command-updates) moves with the other pins. | | `repository.url` | none | The URL of the repository that [`bv release`](tool-commands/release.md#preconditions) publishes to, and without it `bv release` fails. | -| `repository.host` | `auto` | The [kind of Git host](tool-commands/release.md#publishing) of the repository: `auto` or `github`. | +| `repository.host` | `auto` | The [kind of Git host](tool-commands/release.md#preconditions) of the repository: `auto` or `github`. | | `release.branches` | none | The regular expressions of the branches that produce [public releases](sdk-modules/versioning.md#releasebranches-setting), and by default no branch does. | | `release.configuration` | `dotnet.configuration` | The [build configuration of `bv release`](tool-commands/release.md#the-build-configuration). | | `release.checkPublicApi` | `true` | Whether the public API files take part in [the version spec change](tool-commands/release.md#the-version-spec-change) of `bv release`. | From 76a576e123cbab8788f304b69f89f30037f92dda Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 09:22:10 +0200 Subject: [PATCH 09/12] Stop calling the Git host a server in CHANGELOG.md and release.md Two CHANGELOG.md bullets and "The release commit" of release.md called a Git host a server. The other pages call it a Git host. terminology.md had no row for the Git host or the release remote, so nothing stated which name to use. The two rows now state both names. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/project/terminology.md | 2 ++ CHANGELOG.md | 4 ++-- docs/tool-commands/release.md | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.claude/rules/project/terminology.md b/.claude/rules/project/terminology.md index a3f8a5e6..ff5fab74 100644 --- a/.claude/rules/project/terminology.md +++ b/.claude/rules/project/terminology.md @@ -12,6 +12,8 @@ One name per thing, in every kind of prose: documentation, chat, commit messages | The shared library package | `Buildvana.Runtime` | the runtime | | The directory Buildvana works from | home directory | repository root, solution directory | | Where `bv` runs, as `CIPlatform` detects it: GitHub Actions, GitLab CI, Generic CI, or Local | CI platform | build platform, cloud build, CI provider | +| The service that hosts the repository `repository.url` names, such as GitHub | Git host | server, Git server | +| The remote of the clone whose URL names the repository `repository.url` names | release remote | upstream remote, `origin` | | The configuration file, in either spelling | `buildvana.jsonc` | `buildvana.json`, the configuration file | | A `.buildvana/hooks//.cs` file | hook | | | A program the .NET CLI runs from a single `.cs` file, a hook included | file-based app | file-based program, single-file app | diff --git a/CHANGELOG.md b/CHANGELOG.md index 033d7d5e..de88cc10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - `bv` recognizes a CI runner [as the .NET CLI does](docs/environment-variables.md#variables-that-mark-a-ci-runner), and passes `ContinuousIntegrationBuild=true` to the build pipeline on every one. - `bv release` runs [on any machine](docs/tool-commands/release.md#preconditions), with `repository.url` naming the repository the release goes to. - `bv release` fetches the tags of the [release remote](docs/tool-commands/release.md#the-release-remote) before it checks the versions. -- `release.allowUnknownHost` lets `bv release` [tag and push without a release page](docs/tool-commands/release.md#publishing) on a server `bv` does not know. +- `release.allowUnknownHost` lets `bv release` [tag and push without a release page](docs/tool-commands/release.md#publishing) on a Git host `bv` does not know. - [`bv info`](docs/tool-commands/info.md) prints the CI platform, the Git host, the release remote, and the identity a release would use. ### Changes to existing features @@ -146,7 +146,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. ### Known problems introduced by this release -- When no file changes before the build, [the release commit](docs/tool-commands/release.md#the-release-commit) is empty, which a `pre-receive` hook of a self-hosted Git server may reject. +- When no file changes before the build, [the release commit](docs/tool-commands/release.md#the-release-commit) is empty, which a `pre-receive` hook of a self-hosted Git host may reject. ## [1.1.10](https://github.com/Tenacom/Buildvana/releases/tag/1.1.10) (2026-04-27) diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index 0560bea6..f8c1fc19 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -128,10 +128,10 @@ A build of the tagged commit would compute another version, so the release stops When no file changes before the build, the release commit is empty. The major public Git hosts accept an empty commit. -A self-hosted server with a `pre-receive` hook of its own may reject it. +A self-hosted Git host with a `pre-receive` hook of its own may reject it. Two remedies exist: -- allow empty commits on the server; +- allow empty commits on the Git host; - set `release.changelogUpdates` to `all`, so that every release updates the changelog, and its commit holds at least that change. An empty "Unreleased changes" section then fails the release, so keep at least one entry between releases, or set `release.emptyChangelog`. From 0d56fdfa51859c73e4d833f381226656bf14b28c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 09:23:19 +0200 Subject: [PATCH 10/12] Stop tying the rollback of bv release to a draft release in release.md "Rollback" said that bv release undoes its steps when one fails after the draft release exists. On an unknown host there is no draft, yet ServerRelease.BeginAsync registers every rollback action all the same. A reader on such a host would expect a failed release to leave its commits and its tag in place. Co-Authored-By: Claude Opus 5.5 --- docs/tool-commands/release.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index f8c1fc19..1e90a699 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -235,12 +235,12 @@ The last line of a release is `notice: Published release with ## Rollback -When a step fails after the draft release exists, `bv release` undoes what it did, in reverse order: +When a step fails, `bv release` undoes what the earlier steps did, in reverse order: - it deletes the release tag from the release remote, when it was pushed, and from the clone; - it resets the branch to the commit before the release commit, post-release commit included; - when the commits were pushed, it force-pushes the previous tip; -- it deletes the release on the host, draft or published. +- on GitHub, it deletes the release, draft or published. A rollback action that fails is a warning, and the rollback goes on. Once the release is published and the step output is written, nothing is undone. From 4cb4c3a32a129d2f614249f86a28b1582e8d0617 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 10:14:01 +0200 Subject: [PATCH 11/12] Test GitService pushes that the remote refuses No test reached the OnPushStatusError handler of GitService.Push. PushBranch_WhenNotFastForward_Throws passes without the handler, because LibGit2Sharp refuses a non-fast-forward push before the remote sees it. TempGitRepo.LockRemoteReference creates the lock file of a reference in a remote that TempGitRepo.AddBareRemote created. The remote then fails to update the reference, and reports it through OnPushStatusError. Co-Authored-By: Claude Opus 5.5 --- src/Buildvana.Core.Testing/TempGitRepo.cs | 18 +++++++ tests/Buildvana.Tool.Tests/GitServiceTests.cs | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/Buildvana.Core.Testing/TempGitRepo.cs b/src/Buildvana.Core.Testing/TempGitRepo.cs index c0415457..5a467713 100644 --- a/src/Buildvana.Core.Testing/TempGitRepo.cs +++ b/src/Buildvana.Core.Testing/TempGitRepo.cs @@ -264,6 +264,24 @@ public void CreateRemoteTag(string tag, string name = "origin") _ = remote.Tags.Add(tag, remote.Branches[branchName].Tip); } + /// + /// Makes a remote added by refuse every update of a reference, its deletion included. + /// + /// The canonical name of the reference, such as refs/tags/1.0.0. + /// The name of the remote. + /// + /// The method creates the lock file of the reference, as a Git process does while it updates one. The remote + /// then fails to take the lock, and reports the reference as rejected at the end of a push, as a server reports a + /// reference its hooks or its protection rules refuse. + /// + public void LockRemoteReference(string canonicalName, string name = "origin") + { + ArgumentException.ThrowIfNullOrEmpty(canonicalName); + var lockPath = Path.Combine([_remotePaths[name], .. $"{canonicalName}.lock".Split('/')]); + _ = Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + File.WriteAllBytes(lockPath, []); + } + /// /// Gets a value from the repository's local configuration. /// diff --git a/tests/Buildvana.Tool.Tests/GitServiceTests.cs b/tests/Buildvana.Tool.Tests/GitServiceTests.cs index fbf0e6ad..2077213b 100644 --- a/tests/Buildvana.Tool.Tests/GitServiceTests.cs +++ b/tests/Buildvana.Tool.Tests/GitServiceTests.cs @@ -219,6 +219,24 @@ public async Task PushBranch_WithForce_ReplacesDivergedRemoteBranch() } } + [Test] + public async Task PushBranch_WhenRemoteRefusesUpdate_Throws_AndLeavesRemoteBranchAlone() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + git.PushBranch("origin"); + var remoteTip = repo.GetRemoteTipSha(); + git.CommitterIdentity = new("Release Bot", "bot@example.invalid"); + git.Commit("Refused commit", allowEmpty: true); + var branchRef = $"refs/heads/{repo.CurrentBranchName}"; + repo.LockRemoteReference(branchRef); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + var exception = await Assert.That(() => git.PushBranch("origin")).Throws(); + await Assert.That(exception!.Message).Contains(branchRef); + await Assert.That(repo.GetRemoteTipSha()).IsEqualTo(remoteTip); + } + [Test] public async Task PushBranch_WhenHeadIsDetached_Throws() { @@ -320,6 +338,21 @@ public async Task PushTag_WhenLocalTagDoesNotExist_Throws() await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsNull(); } + [Test] + public async Task PushTag_WhenRemoteRefusesUpdate_Throws() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + git.PushBranch("origin"); + git.CreateTag("1.0.0", repo.HeadSha); + repo.LockRemoteReference("refs/tags/1.0.0"); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + var exception = await Assert.That(() => git.PushTag("origin", "1.0.0")).Throws(); + await Assert.That(exception!.Message).Contains("refs/tags/1.0.0"); + await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsNull(); + } + [Test] public async Task DeleteRemoteTag_RemovesTagFromRemote_AndKeepsLocalTag() { @@ -335,6 +368,22 @@ public async Task DeleteRemoteTag_RemovesTagFromRemote_AndKeepsLocalTag() await Assert.That(repo.TagNames).IsEquivalentTo(["1.0.0"]); } + [Test] + public async Task DeleteRemoteTag_WhenRemoteRefusesUpdate_Throws_AndKeepsRemoteTag() + { + using var repo = CreateRepoWithBareRemote(); + using var git = CreateGitService(repo); + git.PushBranch("origin"); + git.CreateTag("1.0.0", repo.HeadSha); + git.PushTag("origin", "1.0.0"); + repo.LockRemoteReference("refs/tags/1.0.0"); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + var exception = await Assert.That(() => git.DeleteRemoteTag("origin", "1.0.0")).Throws(); + await Assert.That(exception!.Message).Contains("refs/tags/1.0.0"); + await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsEqualTo(repo.HeadSha); + } + [Test] public async Task DeleteTag_RemovesLocalTag() { From a9eb5772333f704dc3dfd94a6e321d247e3a4fbd Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 23 Oct 2026 11:05:46 +0200 Subject: [PATCH 12/12] Stop ending bv with an unhandled exception on a failed push or fetch When a push was not a fast-forward, LibGit2Sharp threw NonFastForwardException before the remote saw the push. Program catches only BuildFailedException, so bv release ended with a stack trace. Any other LibGit2SharpException of GitService.Push, ListRemoteReferences, or FetchTags did the same, such as a failed authentication or an unreachable remote. GitService wraps each one in a BuildFailedException that names the remote, and keeps the original as the inner exception. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/tool-commands/release.md | 2 + src/Buildvana.Tool/Services/Git/GitService.cs | 45 +++++++++++++--- .../Services/Git/IGitService.cs | 3 +- tests/Buildvana.Tool.Tests/GitServiceTests.cs | 51 ++++++++++++++++++- 5 files changed, 93 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de88cc10..450e0573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - [`bv test`](docs/tool-commands/build-pipeline.md#bv-test) runs the tests of a project that sets `IsTestingPlatformApplication` to a value MSBuild reads as true, such as `yes`, where it skipped the project. - The [`ConvertToWinePaths` task](docs/sdk-modules/wine.md#converttowinepaths-task) converts an item when MSBuild reads the metadata named by `OnlyIfMetadata` as true, such as `yes`, where it skipped the item. - `bv release` fails when the remote rejects the [push of its commits](docs/tool-commands/release.md#the-release-tag), where it used to go on as if the push had succeeded. +- `bv release` reports a failed fetch or [push](docs/tool-commands/release.md#the-release-tag) as one error line naming the release remote, not as an unhandled exception. - When a `PackageVersion` item versions a package Buildvana SDK injects, the reference [is an ordinary `PackageReference`](docs/sdk-modules/standard-analyzers.md#package-references), where NuGet rejected the item with NU1009. ### Known problems introduced by this release diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index 1e90a699..d49b4f59 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -149,6 +149,7 @@ The tag names the release commit, whatever commits follow it. A tag of the same name on the remote fails the release, unless it names the same commit. `bv` never moves a tag of the remote. When the remote rejects a push, the release fails. +So does a push of the branch when the branch of the remote has commits that the local branch lacks. The host publishes the release on the tag that is already there. A workflow that the push of a tag triggers therefore runs before the release page exists. @@ -277,6 +278,7 @@ The configuration selects the artifacts directory, `artifacts\\`, `bv release` returns the [exit codes every `bv` command returns](../tool-diagnostics.md#exit-codes). Code 1 is a precondition that fails, a versioning anomaly, a tag that exists, an empty changelog with no substitute, or a missing feed. +A fetch or a push that fails returns code 1 as well. Code 2 is a refusal of the command line: a `--` separator, an unknown option, or an unknown `--bump` value. Code 3 says that a program `bv` ran failed: a `dotnet` command, `dotnet nuget push`, or the hook. Code 130 is a run terminated with Ctrl-C. diff --git a/src/Buildvana.Tool/Services/Git/GitService.cs b/src/Buildvana.Tool/Services/Git/GitService.cs index 19f39e65..08b63bd9 100644 --- a/src/Buildvana.Tool/Services/Git/GitService.cs +++ b/src/Buildvana.Tool/Services/Git/GitService.cs @@ -296,7 +296,15 @@ public void FetchTags(string remote) using var gitRemote = GetRemote(remote); _reporter.Info($"Fetching tags from '{remote}'..."); var options = new FetchOptions { CredentialsProvider = CreateCredentialsProvider() }; - Commands.Fetch(_repository, gitRemote.Name, ["refs/tags/*:refs/tags/*"], options, logMessage: null); + try + { + Commands.Fetch(_repository, gitRemote.Name, ["refs/tags/*:refs/tags/*"], options, logMessage: null); + } + catch (LibGit2SharpException ex) + { + throw new BuildFailedException($"Git: cannot fetch tags from '{remote}': {ex.Message}", ex); + } + _reporter.Notice($"Fetched tags from '{remote}'."); } @@ -311,14 +319,30 @@ private void Push(string remote, string refSpec) using var gitRemote = GetRemote(remote); // LibGit2Sharp reports a reference the remote refused through OnPushStatusError, and nowhere else: - // without the handler, a push that the server rejects returns as if it had succeeded. + // without the handler, a push that the remote rejects returns as if it had succeeded. // The handler only collects, because an exception thrown from it would have to cross native code. var rejections = new List(); var options = new PushOptions { CredentialsProvider = CreateCredentialsProvider(), OnPushStatusError = rejections.Add, }; - _repository.Network.Push(gitRemote, refSpec, options); + try + { + _repository.Network.Push(gitRemote, refSpec, options); + } + catch (NonFastForwardException ex) + { + // LibGit2Sharp refuses a push that is not a fast-forward before the remote sees it. + var destination = refSpec[(refSpec.IndexOf(':', StringComparison.Ordinal) + 1)..]; + throw new BuildFailedException( + $"Git: cannot push '{destination}' to '{remote}', because the remote has commits that the local reference lacks.", + ex); + } + catch (LibGit2SharpException ex) + { + throw new BuildFailedException($"Git: cannot push to '{remote}': {ex.Message}", ex); + } + if (rejections.Count > 0) { var details = string.Join("; ", rejections.Select(x => $"{x.Reference}: {x.Message}")); @@ -330,10 +354,17 @@ private IReadOnlyList ListRemoteReferences(string remote) { using var gitRemote = GetRemote(remote); var credentialsProvider = CreateCredentialsProvider(); - var references = credentialsProvider is null - ? _repository.Network.ListReferences(gitRemote) - : _repository.Network.ListReferences(gitRemote, credentialsProvider); - return [.. references]; + try + { + var references = credentialsProvider is null + ? _repository.Network.ListReferences(gitRemote) + : _repository.Network.ListReferences(gitRemote, credentialsProvider); + return [.. references]; + } + catch (LibGit2SharpException ex) + { + throw new BuildFailedException($"Git: cannot list the references of '{remote}': {ex.Message}", ex); + } } private Remote GetRemote(string name) diff --git a/src/Buildvana.Tool/Services/Git/IGitService.cs b/src/Buildvana.Tool/Services/Git/IGitService.cs index 5d438910..3880b2df 100644 --- a/src/Buildvana.Tool/Services/Git/IGitService.cs +++ b/src/Buildvana.Tool/Services/Git/IGitService.cs @@ -125,7 +125,8 @@ internal interface IGitService /// The name of the remote. /// If , replaces the remote branch even if the push is not a fast-forward. /// - /// This method fails if HEAD is not on a branch, and if the remote rejects the push. + /// This method fails if HEAD is not on a branch, if the push is not a fast-forward and + /// is , and if the remote rejects the push. /// The tracking information of the branch plays no part in the push. /// void PushBranch(string remote, bool force = false); diff --git a/tests/Buildvana.Tool.Tests/GitServiceTests.cs b/tests/Buildvana.Tool.Tests/GitServiceTests.cs index 2077213b..74bd0342 100644 --- a/tests/Buildvana.Tool.Tests/GitServiceTests.cs +++ b/tests/Buildvana.Tool.Tests/GitServiceTests.cs @@ -202,7 +202,8 @@ public async Task PushBranch_WhenNotFastForward_Throws() var remoteTip = repo.GetRemoteTipSha(); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - _ = await Assert.That(() => git.PushBranch("origin")).ThrowsException(); + var exception = await Assert.That(() => git.PushBranch("origin")).Throws(); + await Assert.That(exception!.Message).Contains($"'refs/heads/{repo.CurrentBranchName}'"); await Assert.That(repo.GetRemoteTipSha()).IsEqualTo(remoteTip); } } @@ -237,6 +238,18 @@ public async Task PushBranch_WhenRemoteRefusesUpdate_Throws_AndLeavesRemoteBranc await Assert.That(repo.GetRemoteTipSha()).IsEqualTo(remoteTip); } + [Test] + public async Task PushBranch_WhenRemoteIsUnreachable_Throws() + { + using var repo = CreateRepoWithUnreachableRemote(); + using var git = CreateGitService(repo); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + var exception = await Assert.That(() => git.PushBranch("origin")).Throws(); + await Assert.That(exception!.Message).Contains("'origin'"); + await Assert.That(exception.InnerException).IsNotNull(); + } + [Test] public async Task PushBranch_WhenHeadIsDetached_Throws() { @@ -353,6 +366,19 @@ public async Task PushTag_WhenRemoteRefusesUpdate_Throws() await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsNull(); } + [Test] + public async Task PushTag_WhenRemoteIsUnreachable_Throws() + { + using var repo = CreateRepoWithUnreachableRemote(); + using var git = CreateGitService(repo); + git.CreateTag("1.0.0", repo.HeadSha); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + var exception = await Assert.That(() => git.PushTag("origin", "1.0.0")).Throws(); + await Assert.That(exception!.Message).Contains("'origin'"); + await Assert.That(exception.InnerException).IsNotNull(); + } + [Test] public async Task DeleteRemoteTag_RemovesTagFromRemote_AndKeepsLocalTag() { @@ -411,6 +437,18 @@ public async Task FetchTags_BringsTagsOfRemote_IntoLocalRepository() await Assert.That(repo.TagNames).IsEquivalentTo(["1.0.0"]); } + [Test] + public async Task FetchTags_WhenRemoteIsUnreachable_Throws() + { + using var repo = CreateRepoWithUnreachableRemote(); + using var git = CreateGitService(repo); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed + var exception = await Assert.That(() => git.FetchTags("origin")).Throws(); + await Assert.That(exception!.Message).Contains("'origin'"); + await Assert.That(exception.InnerException).IsNotNull(); + } + private static TempGitRepo CreateRepoWithCommit() { var repo = new TempGitRepo(); @@ -429,6 +467,17 @@ private static TempGitRepo CreateRepoWithBareRemote() return repo; } + // The URL of the remote names a directory that does not exist. A file URL keeps libgit2 from reading a Windows + // path as the host of an SSH URL, and from running ssh to reach it. + private static TempGitRepo CreateRepoWithUnreachableRemote() + { + var repo = new TempGitRepo(); + repo.WriteFile("a.txt", "content"); + repo.CommitAll(); + repo.AddRemote("origin", new Uri(Path.Combine(repo.RootPath, "missing-remote"))); + return repo; + } + // The remote ends up with a commit that the local branch does not have, and the local branch with one that the // remote does not have. private static TempGitRepo CreateRepoWithDivergedRemote(out GitService git)