diff --git a/.claude/rules/project/terminology.md b/.claude/rules/project/terminology.md index ff5fab74..61677700 100644 --- a/.claude/rules/project/terminology.md +++ b/.claude/rules/project/terminology.md @@ -11,7 +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 | +| Where `bv` runs, as `CIPlatform` detects it, listed below the table | 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 | @@ -34,4 +34,6 @@ One name per thing, in every kind of prose: documentation, chat, commit messages | An `InternalAPI.Shipped.txt` or `InternalAPI.Unshipped.txt` file | internal API file | | | The public API files and the internal API files together | declared API files | API files | +The CI platforms are GitHub Actions, GitLab CI, Gitea Actions, Forgejo Actions, Generic CI, and Local. + `buildvana.jsonc` stands for either spelling because the name reminds the reader that comments and trailing commas are accepted. The page about the configuration file says that `buildvana.json` is accepted too. diff --git a/CHANGELOG.md b/CHANGELOG.md index 804fff43..40818522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ The patch number restarted from 1, below the 2.0.x versions already published. - `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-an-unknown-host) on a Git host `bv` does not know. - `bv release` [publishes on GitLab](docs/tool-commands/release.md#publishing-on-gitlab), with the assets in the generic package registry of the project, and `hosts.gitlab.tokenEnv` names the token variable. +- `bv release` [publishes on Gitea](docs/tool-commands/release.md#publishing-on-gitea), with the assets as attachments of the release, and `hosts.gitea.tokenEnv` names the token variable. +- `bv release` [publishes on Forgejo](docs/tool-commands/release.md#publishing-on-forgejo), with the assets as attachments of the release, and `hosts.forgejo.tokenEnv` names the token variable. +- `bv` recognizes [Gitea Actions](docs/environment-variables.md#gitea_actions) as a CI platform, with the `gitea-actions` bot identity and the `version` step output of `bv release`. +- `bv` recognizes [Forgejo Actions](docs/environment-variables.md#forgejo_actions) as a CI platform, with the `forgejo-actions` bot identity and the `version` step output of `bv release`. - `bv release` takes a host whose name ends in `.ghe.com` for [GitHub Enterprise Cloud with data residency](docs/tool-commands/release.md#publishing-on-github), whose API is at `https://api.`. - [`bv info`](docs/tool-commands/info.md) prints the CI platform, the Git host, the release remote, and the identity a release would use. diff --git a/buildvana.example.jsonc b/buildvana.example.jsonc index fb042192..e132c69d 100644 --- a/buildvana.example.jsonc +++ b/buildvana.example.jsonc @@ -152,6 +152,18 @@ // Name of the environment variable that holds the GitLab access token. "tokenEnv": "GITLAB_TOKEN", }, + + // Configuration for the Gitea host. + "gitea": { + // Name of the environment variable that holds the Gitea access token. + "tokenEnv": "GITEA_TOKEN", + }, + + // Configuration for the Forgejo host. + "forgejo": { + // Name of the environment variable that holds the Forgejo access token. + "tokenEnv": "FORGEJO_TOKEN", + }, }, // Configuration for Git-related behavior. diff --git a/docs/configuration-file.md b/docs/configuration-file.md index 51c3bc75..3e1b70f9 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#preconditions) of the repository: `auto`, `github`, or `gitlab`. | +| `repository.host` | `auto` | The [kind of Git host](tool-commands/release.md#preconditions) of the repository: `auto`, `github`, `gitlab`, `gitea`, or `forgejo`. | | `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`. | @@ -115,6 +115,8 @@ A setting whose member names are data, such as `dependencies.policies` and `dotn | `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. | | `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. | | `hosts.gitlab.tokenEnv` | `GITLAB_TOKEN` | The environment variable holding the [GitLab token](environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) of the release operations. | +| `hosts.gitea.tokenEnv` | `GITEA_TOKEN` | The environment variable holding the [Gitea token](environment-variables.md#secret-carrying-variables-named-by-the-configuration-file) of the release operations. | +| `hosts.forgejo.tokenEnv` | `FORGEJO_TOKEN` | The environment variable holding the [Forgejo 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 315aced7..1cf65f0c 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -17,6 +17,10 @@ The variables that the .NET SDK, MSBuild, and NuGet read are out of scope, and t - [`DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING`](#dotnet_cli_console_use_default_encoding) - [`DOTNET_CLI_HOME`](#dotnet_cli_home) - [`DOTNET_HOST_PATH`](#dotnet_host_path) + - [`FORGEJO_ACTIONS`](#forgejo_actions) + - [`FORGEJO_OUTPUT`](#forgejo_output) + - [`FORGEJO_SERVER_URL`](#forgejo_server_url) + - [`GITEA_ACTIONS`](#gitea_actions) - [`GITHUB_ACTIONS`](#github_actions) - [`GITHUB_OUTPUT`](#github_output) - [`GITHUB_SERVER_URL`](#github_server_url) @@ -96,6 +100,44 @@ The `dotnet` muxer sets it on every process it starts, with the full path of the The variable is unset when `bv` runs through the native shim of a global tool. `bv` then runs the `dotnet` found on the `PATH`. +### `FORGEJO_ACTIONS` + +The Forgejo runner sets it to `true` on every step, from Forgejo Runner 7.0.0. +`bv` reads it to recognize the Forgejo Actions [CI platform](introduction.md#ci-platforms). +The step output of `bv release` then goes to the file `FORGEJO_OUTPUT` names. +The `forgejo-actions` identity authors the commits of `bv release`, unless `buildvana.jsonc` states a `git.identity`. +The comparison ignores case. + +The Forgejo runner also sets `GITHUB_ACTIONS`, and Forgejo Runner 7 to 12 set `GITEA_ACTIONS`. +`bv` reads `FORGEJO_ACTIONS` before both, so such a run is a Forgejo Actions run. + +### `FORGEJO_OUTPUT` + +The Forgejo runner sets it to the path of the file that collects the outputs of a step, from Forgejo Runner 9.0.0. +On Forgejo Actions, `bv release` appends the released version to that file as the `version` step output. +When the variable is unset, the release fails before creating anything. +`bv release` therefore needs Forgejo Runner 9.0.0 or later. +`bv` never sets the variable. + +### `FORGEJO_SERVER_URL` + +The Forgejo runner sets it to the URL of the Forgejo instance that runs the job, from Forgejo Runner 9.0.0. +When `repository.url` names a repository on the host of that URL, `bv release` takes the host for Forgejo. +A self-hosted Forgejo instance is recognized this way on its own runners. +The variable matters only together with `FORGEJO_ACTIONS`. + +### `GITEA_ACTIONS` + +The Gitea runner sets it to `true` on every step. +`bv` reads it to recognize the Gitea Actions [CI platform](introduction.md#ci-platforms). +The step output of `bv release` then goes to the file `GITHUB_OUTPUT` names. +The `gitea-actions` identity authors the commits of `bv release`, unless `buildvana.jsonc` states a `git.identity`. +Its address is `gitea-actions@noreply.`, with the host name of `GITHUB_SERVER_URL`. +The comparison ignores case. + +The Gitea runner also sets `GITHUB_ACTIONS`, and `bv` reads `GITEA_ACTIONS` first. +Forgejo Runner 7 to 12 set `GITEA_ACTIONS` too, and `bv` reads `FORGEJO_ACTIONS` before it. + ### `GITHUB_ACTIONS` GitHub Actions sets it to `true` on every step. @@ -105,19 +147,26 @@ The `github-actions[bot]` identity authors the commits of `bv release`, unless ` The comparison ignores case, so `TRUE` and `True` count too. Any other value, or no value, means no GitHub Actions. +The Gitea runner and the Forgejo runner set it too. +`bv` reads `FORGEJO_ACTIONS` and `GITEA_ACTIONS` before it, so such a run is not a GitHub Actions run. + ### `GITHUB_OUTPUT` -GitHub Actions sets it to the path of the file that collects the outputs of a step. -`bv release` appends the released version to that file as the `version` step output, for the later steps of the job. +GitHub Actions and the Gitea runner set it to the path of the file that collects the outputs of a step. +On GitHub Actions and on Gitea Actions, `bv release` appends the released version to that file as the `version` step output. When the variable is unset, the release fails before creating anything. `bv` never sets the variable. +Recent Gitea runners also set `GITEA_OUTPUT` to the same path. +`bv` reads `GITHUB_OUTPUT`, because every Gitea runner sets it. + ### `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`. +GitHub Actions and the Gitea runner set it to the URL of the 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 on GitHub Actions. +On Gitea Actions, it takes the host for Gitea. +A GitHub Enterprise Server, or a self-hosted Gitea instance, is recognized this way on its own runners. +The variable matters only together with `GITHUB_ACTIONS` or `GITEA_ACTIONS`. ### `GITLAB_CI` @@ -143,7 +192,7 @@ 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: +When none of `GITLAB_CI`, `FORGEJO_ACTIONS`, `GITEA_ACTIONS`, and `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; @@ -168,6 +217,14 @@ The [configuration file](configuration-file.md) names the environment variable t The token is also the password of the fetch and the push of `bv release`. The default name is `GITLAB_TOKEN`. The name `CI_JOB_TOKEN` selects the job token of GitLab CI, as [Publishing on GitLab](tool-commands/release.md#publishing-on-gitlab) says. +- `hosts.gitea.tokenEnv` names the variable holding the Gitea token of the release operations. + The token is also the password of the fetch and the push of `bv release`. + The default name is `GITEA_TOKEN`. + On Gitea Actions, the workflow sets the variable, as [Publishing on Gitea](tool-commands/release.md#publishing-on-gitea) says. +- `hosts.forgejo.tokenEnv` names the variable holding the Forgejo token of the release operations. + The token is also the password of the fetch and the push of `bv release`. + The default name is `FORGEJO_TOKEN`. + On Forgejo Actions, the runner sets that variable to the token of the job, as [Publishing on Forgejo](tool-commands/release.md#publishing-on-forgejo) says. - `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 3c2517f3..1164f441 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -96,6 +96,8 @@ Each table below states a status for every item. | GitHub Enterprise Cloud with data residency | Supported (untested) | SourceLink needs a `SourceLinkGitHubHost` item, with `ContentUrl` set to `https://raw.`. | | GitLab | Supported (untested) | | | Self-managed GitLab | Supported (untested) | SourceLink needs a `SourceLinkGitLabHost` item. | +| Gitea | Supported (untested) | SourceLink needs a `Microsoft.SourceLink.Gitea` reference and a `SourceLinkGiteaHost` item. | +| Forgejo | Supported (untested) | SourceLink needs a `Microsoft.SourceLink.Gitea` reference and a `SourceLinkGiteaHost` item. | | Other Git hosts | 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-on-an-unknown-host). | ### CI platforms @@ -105,12 +107,14 @@ 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. Every CI platform runs every command, `bv release` included. -| CI platform | Status | -| ---------------------------------------------------------------------- | -------------------- | -| GitHub Actions | Supported | -| GitLab CI | Supported (untested) | -| [Generic CI](environment-variables.md#variables-that-mark-a-ci-runner) | Supported (untested) | -| Local | Supported (untested) | +| CI platform | Status | Limits | +| ---------------------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------- | +| GitHub Actions | Supported | | +| GitLab CI | Supported (untested) | | +| Gitea Actions | Supported (untested) | | +| Forgejo Actions | Supported (untested) | `bv release` needs [Forgejo Runner 9.0.0 or later](environment-variables.md#forgejo_output). | +| [Generic CI](environment-variables.md#variables-that-mark-a-ci-runner) | Supported (untested) | | +| Local | Supported (untested) | | ### Toolchain diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index b34f7b22..51e44a8a 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -19,6 +19,8 @@ This page says what the command checks, what it does step by step, and what it u - [Publishing](#publishing) - [Publishing on GitHub](#publishing-on-github) - [Publishing on GitLab](#publishing-on-gitlab) + - [Publishing on Gitea](#publishing-on-gitea) + - [Publishing on Forgejo](#publishing-on-forgejo) - [Publishing on an unknown host](#publishing-on-an-unknown-host) - [Rollback](#rollback) - [Options](#options) @@ -36,7 +38,7 @@ bv release [OPTIONS] `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, GitLab, or a host `bv` does not know. +The Git host decides where the release is published: GitHub, GitLab, Gitea, Forgejo, or a host `bv` does not know. Before building anything, `bv release` checks what a build cannot change: @@ -45,8 +47,11 @@ Before building anything, `bv release` checks what a build cannot change: 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` and any host whose name ends in `.ghe.com` are GitHub, and `gitlab.com` is GitLab. + Otherwise, `gitea.com` is Gitea, and `codeberg.org` is Forgejo. 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. Otherwise, a host whose name is the host name of [`CI_SERVER_URL`](../environment-variables.md#ci_server_url) is a self-managed GitLab instance, on GitLab CI. + Otherwise, a host whose name is the host name of `GITHUB_SERVER_URL` is a self-hosted Gitea instance, on Gitea Actions. + Otherwise, a host whose name is the host name of [`FORGEJO_SERVER_URL`](../environment-variables.md#forgejo_server_url) is a self-hosted Forgejo instance, on Forgejo 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. @@ -54,16 +59,22 @@ 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). + On Gitea Actions, it is `gitea-actions`, with the address `gitea-actions@noreply.` built from `GITHUB_SERVER_URL`. + On Forgejo Actions, it is `forgejo-actions`, with the address `noreply@forgejo.org`. `bv` commits with that identity, and writes nothing to the Git configuration. 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. On GitLab, they are a username and the token that `hosts.gitlab.tokenEnv` names, as [Publishing on GitLab](#publishing-on-gitlab) says. + On Gitea, they are `token` and the token that `hosts.gitea.tokenEnv` names. + On Forgejo, they are `token` and the token that `hosts.forgejo.tokenEnv` names. + Both hosts ignore the username when the password is a token. 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. +10. [`GITHUB_OUTPUT`](../environment-variables.md#github_output) is set, on GitHub Actions and on Gitea Actions. + [`FORGEJO_OUTPUT`](../environment-variables.md#forgejo_output) is set, on Forgejo Actions, which takes Forgejo Runner 9.0.0 or later. [`bv info`](info.md) reports the release remote, the Git host, the token, and the committer identity, without releasing. @@ -95,6 +106,7 @@ When no remote matches, the release fails, and the message names the remotes of 1. Check that the token can publish on the host, so that a token without permission fails the release early. On GitHub, create a provisional draft release, named ` [provisional]`. On GitLab, read the releases of the project, and check the role of the token, as [Publishing on GitLab](#publishing-on-gitlab) says. + On Gitea and Forgejo, read the repository, and check that its releases are enabled and that the token can push to it. 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. @@ -225,7 +237,8 @@ A line with the wrong shape, or one naming a file that does not exist, is a warn `bv` collects the assets before it pushes the commits, so that an asset the host refuses fails the release before anything leaves the clone. `bv` then publishes the release on the Git host, as the sections below say. -On GitHub Actions, `bv` appends `version=` to the file [`GITHUB_OUTPUT`](../environment-variables.md#github_output) names, for the later steps of the job. +On GitHub Actions and on Gitea Actions, `bv` appends `version=` to the file [`GITHUB_OUTPUT`](../environment-variables.md#github_output) names, for the later steps of the job. +On Forgejo Actions, the file is the one [`FORGEJO_OUTPUT`](../environment-variables.md#forgejo_output) names. The last line of a release is `notice: Published release with assets.` ### Publishing on GitHub @@ -288,6 +301,79 @@ GitLab accepts an asset only when its file name meets the rules of the generic p Two assets of a release also differ in file name and in description. An asset that breaks a rule fails the release before the push of the commits. +### Publishing on Gitea + +`bv` reaches the API of the host of `repository.url` at `https:///api/v1`, on `gitea.com` and on a self-hosted instance alike. +`bv` recognizes a self-hosted instance on its own Gitea Actions runners. +On any other CI platform, set `repository.host` to `gitea`. + +The token that `hosts.gitea.tokenEnv` names is an access token, or the token of a Gitea Actions job. +An access token needs the `write:repository` scope. +Its user needs write access to the code and to the releases of the repository. + +Before the release commit, `bv` reads the repository. +The release fails when the releases of the repository are disabled, or when the token cannot push to the repository. +Gitea does not state whether the token can write releases. +A token without that access fails the release after the push of the tag, and the rollback deletes the tag. + +The Gitea runner puts no token in the environment of a step. +The token of the job is the `GITEA_TOKEN` secret, so the step that runs `bv release` maps it to the variable: + +```yaml +- run: dotnet bv release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} +``` + +By default, the token of the job has write access to its repository. +When the settings of the repository or of its owner restrict the token, the job needs `contents: write` in its `permissions`. +A push with the token of the job starts no workflow, so no workflow runs on the release tag. + +After the push of the tag, `bv` creates a draft release on the release tag, uploads the assets, and publishes the draft. +The release is named after the version, and the prerelease flag follows the version. +Its description is a link to `CHANGELOG.md` on the release branch. + +Gitea processes the push of a tag in a queue, and publishes the draft on that tag when it does. +When the queue is slow, the release appears before all its assets do. + +Each asset is an attachment of the release, named after its file. +An attachment has no description, so the descriptions of the assets do not reach the release. +Two assets with the same file name fail the release before the push of the commits. + +### Publishing on Forgejo + +`bv` reaches the API of the host of `repository.url` at `https:///api/v1`, on `codeberg.org` and on a self-hosted instance alike. +`bv` recognizes a self-hosted instance on its own Forgejo Actions runners. +On any other CI platform, set `repository.host` to `forgejo`. + +The token that `hosts.forgejo.tokenEnv` names is an access token, or the token of a Forgejo Actions job. +An access token needs the `write:repository` scope. +Its user needs write access to the code and to the releases of the repository. +An access token limited to specific repositories must list this repository. + +Before the release commit, `bv` reads the repository. +The release fails when the releases of the repository are disabled, or when the token cannot push to the repository. +Forgejo does not state whether the token can write releases. +A token without that access fails the release after the push of the tag, and the rollback deletes the tag. + +The Forgejo runner sets `FORGEJO_TOKEN` to the token of the job in the environment of every step. +The default of `hosts.forgejo.tokenEnv` therefore needs no setup. +The token of the job has write access to its repository, and read access in a pull request from a fork. +No setting changes either. +A push with the token of the job starts no workflow, so no workflow runs on the release tag. + +After the push of the tag, `bv` creates a draft release on the release tag, uploads the assets, and publishes the draft. +The release is named after the version, and the prerelease flag follows the version. +Its description is a link to `CHANGELOG.md` on the release branch. + +Forgejo processes the push of a tag in a queue, and publishes the draft on that tag when it does. +When the queue is slow, the release appears before all its assets do. + +Each asset is an attachment of the release, named after its file. +An attachment has no description, so the descriptions of the assets do not reach the release. +Two assets with the same file name fail the release before the push of the commits. +An asset that exceeds the quota of the instance fails the release. + ### Publishing on an unknown host On a host `bv` does not know, there is no release page, and `bv` says so with a notice. @@ -304,7 +390,8 @@ When a step fails, `bv release` undoes what the earlier steps did, in reverse or - 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; - on GitHub, it deletes the release, draft or published; -- on GitLab, it deletes the release, then the generic package, each when it exists. +- on GitLab, it deletes the release, then the generic package, each when it exists; +- on Gitea and Forgejo, it deletes the release, then the record the host keeps of the tag, each when it exists. 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/schemas/buildvana.schema.json b/schemas/buildvana.schema.json index 416a4a91..783706ee 100644 --- a/schemas/buildvana.schema.json +++ b/schemas/buildvana.schema.json @@ -26,7 +26,9 @@ "enum": [ "auto", "github", - "gitlab" + "gitlab", + "gitea", + "forgejo" ], "default": "auto" } @@ -384,6 +386,30 @@ } }, "additionalProperties": false + }, + "gitea": { + "description": "Configuration for the Gitea host.", + "type": "object", + "properties": { + "tokenEnv": { + "description": "Name of the environment variable that holds the Gitea access token.", + "type": "string", + "default": "GITEA_TOKEN" + } + }, + "additionalProperties": false + }, + "forgejo": { + "description": "Configuration for the Forgejo host.", + "type": "object", + "properties": { + "tokenEnv": { + "description": "Name of the environment variable that holds the Forgejo access token.", + "type": "string", + "default": "FORGEJO_TOKEN" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/src/Buildvana.Core.Configuration/BuildvanaConfigFactory.cs b/src/Buildvana.Core.Configuration/BuildvanaConfigFactory.cs index eb3a8608..57bb37ad 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaConfigFactory.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaConfigFactory.cs @@ -170,6 +170,8 @@ private static HostsConfig ComposeHosts(HostsJsonConfig? json) => new() { GitHub = ComposeGitHub(json?.GitHub), GitLab = ComposeGitLab(json?.GitLab), + Gitea = ComposeGitea(json?.Gitea), + Forgejo = ComposeForgejo(json?.Forgejo), }; // A blank tokenEnv cannot name a variable, so it counts as not stated at all. @@ -190,6 +192,24 @@ private static GitLabConfig ComposeGitLab(GitLabJsonConfig? json) }; } + // A blank tokenEnv cannot name a variable, so it counts as not stated at all. + private static GiteaConfig ComposeGitea(GiteaJsonConfig? json) + { + var defaults = new GiteaConfig(); + return new GiteaConfig { + TokenEnv = NormalizeBlankToNull(json?.TokenEnv) ?? defaults.TokenEnv, + }; + } + + // A blank tokenEnv cannot name a variable, so it counts as not stated at all. + private static ForgejoConfig ComposeForgejo(ForgejoJsonConfig? json) + { + var defaults = new ForgejoConfig(); + return new ForgejoConfig { + TokenEnv = NormalizeBlankToNull(json?.TokenEnv) ?? defaults.TokenEnv, + }; + } + // The schema requires name and email whenever git.identity is stated, and forbids blank values for // them, so a wire identity that reaches the factory carries both. private static GitConfig ComposeGit(GitJsonConfig? json) diff --git a/src/Buildvana.Core.Configuration/ForgejoJsonConfig.cs b/src/Buildvana.Core.Configuration/ForgejoJsonConfig.cs new file mode 100644 index 00000000..698473a0 --- /dev/null +++ b/src/Buildvana.Core.Configuration/ForgejoJsonConfig.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 System.ComponentModel; +using JetBrains.Annotations; + +namespace Buildvana.Core.Configuration; + +/// +/// Models the hosts.forgejo section of a Buildvana configuration file. +/// +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public sealed record ForgejoJsonConfig +{ + /// Gets the name of the environment variable holding the Forgejo access token. + [Description("Name of the environment variable that holds the Forgejo access token.")] + public string? TokenEnv { get; init; } +} diff --git a/src/Buildvana.Core.Configuration/GiteaJsonConfig.cs b/src/Buildvana.Core.Configuration/GiteaJsonConfig.cs new file mode 100644 index 00000000..ef3c5519 --- /dev/null +++ b/src/Buildvana.Core.Configuration/GiteaJsonConfig.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 System.ComponentModel; +using JetBrains.Annotations; + +namespace Buildvana.Core.Configuration; + +/// +/// Models the hosts.gitea section of a Buildvana configuration file. +/// +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public sealed record GiteaJsonConfig +{ + /// Gets the name of the environment variable holding the Gitea access token. + [Description("Name of the environment variable that holds the Gitea access token.")] + public string? TokenEnv { get; init; } +} diff --git a/src/Buildvana.Core.Configuration/HostsJsonConfig.cs b/src/Buildvana.Core.Configuration/HostsJsonConfig.cs index 3e0ac89b..a0d5811a 100644 --- a/src/Buildvana.Core.Configuration/HostsJsonConfig.cs +++ b/src/Buildvana.Core.Configuration/HostsJsonConfig.cs @@ -22,4 +22,14 @@ public sealed record HostsJsonConfig [JsonPropertyName("gitlab")] [Description("Configuration for the GitLab host.")] public GitLabJsonConfig? GitLab { get; init; } + + /// Gets the hosts.gitea section. + [JsonPropertyName("gitea")] + [Description("Configuration for the Gitea host.")] + public GiteaJsonConfig? Gitea { get; init; } + + /// Gets the hosts.forgejo section. + [JsonPropertyName("forgejo")] + [Description("Configuration for the Forgejo host.")] + public ForgejoJsonConfig? Forgejo { get; init; } } diff --git a/src/Buildvana.Runtime/ForgejoConfig.cs b/src/Buildvana.Runtime/ForgejoConfig.cs new file mode 100644 index 00000000..8f5ebe94 --- /dev/null +++ b/src/Buildvana.Runtime/ForgejoConfig.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. + +using JetBrains.Annotations; + +namespace Buildvana.Runtime; + +/// +/// The resolved configuration of the Forgejo host, from the hosts.forgejo section. +/// +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public sealed record ForgejoConfig +{ + /// + /// Gets the name of the environment variable holding the Forgejo access token. + /// The token itself is read on demand through the GetToken extension method, never stored. + /// + public string TokenEnv { get; init; } = "FORGEJO_TOKEN"; +} diff --git a/src/Buildvana.Runtime/ForgejoConfigExtensions.cs b/src/Buildvana.Runtime/ForgejoConfigExtensions.cs new file mode 100644 index 00000000..dfbe67e6 --- /dev/null +++ b/src/Buildvana.Runtime/ForgejoConfigExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Runtime; + +/// +/// Provides extension methods for `ForgejoConfig` instances. +/// +#pragma warning disable CA1034 // Nested types should not be visible — false positive on C# 14 extension blocks; fixed in .NET 11, backport to .NET 10 requested in https://github.com/dotnet/sdk/issues/53984 +#pragma warning disable CA1708 // Identifiers should differ by more than case — false positive on classes with C# 14 extension blocks; fixed in .NET 11, https://github.com/dotnet/sdk/issues/51716 +public static class ForgejoConfigExtensions +{ + extension(ForgejoConfig @this) + { + /// + /// Gets the Forgejo access token: the value of the environment variable named by + /// , read at the moment of the call. + /// + /// The token. + /// The environment variable is missing or empty. + /// + /// The model stores the variable's name, never its value: the model is serialized into hook args + /// files, and a resolved secret must never be written to disk. This accessor is a method rather than a + /// member, so the guarantee is structural — nothing here can be serialized. + /// + public string GetToken() => EnvironmentVariables.GetRequired(@this.TokenEnv); + } +} diff --git a/src/Buildvana.Runtime/GiteaConfig.cs b/src/Buildvana.Runtime/GiteaConfig.cs new file mode 100644 index 00000000..07730c31 --- /dev/null +++ b/src/Buildvana.Runtime/GiteaConfig.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. + +using JetBrains.Annotations; + +namespace Buildvana.Runtime; + +/// +/// The resolved configuration of the Gitea host, from the hosts.gitea section. +/// +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public sealed record GiteaConfig +{ + /// + /// Gets the name of the environment variable holding the Gitea access token. + /// The token itself is read on demand through the GetToken extension method, never stored. + /// + public string TokenEnv { get; init; } = "GITEA_TOKEN"; +} diff --git a/src/Buildvana.Runtime/GiteaConfigExtensions.cs b/src/Buildvana.Runtime/GiteaConfigExtensions.cs new file mode 100644 index 00000000..73e05acc --- /dev/null +++ b/src/Buildvana.Runtime/GiteaConfigExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Runtime; + +/// +/// Provides extension methods for `GiteaConfig` instances. +/// +#pragma warning disable CA1034 // Nested types should not be visible — false positive on C# 14 extension blocks; fixed in .NET 11, backport to .NET 10 requested in https://github.com/dotnet/sdk/issues/53984 +#pragma warning disable CA1708 // Identifiers should differ by more than case — false positive on classes with C# 14 extension blocks; fixed in .NET 11, https://github.com/dotnet/sdk/issues/51716 +public static class GiteaConfigExtensions +{ + extension(GiteaConfig @this) + { + /// + /// Gets the Gitea access token: the value of the environment variable named by + /// , read at the moment of the call. + /// + /// The token. + /// The environment variable is missing or empty. + /// + /// The model stores the variable's name, never its value: the model is serialized into hook args + /// files, and a resolved secret must never be written to disk. This accessor is a method rather than a + /// member, so the guarantee is structural — nothing here can be serialized. + /// + public string GetToken() => EnvironmentVariables.GetRequired(@this.TokenEnv); + } +} diff --git a/src/Buildvana.Runtime/HostKind.cs b/src/Buildvana.Runtime/HostKind.cs index 4de90c42..f4d48fdd 100644 --- a/src/Buildvana.Runtime/HostKind.cs +++ b/src/Buildvana.Runtime/HostKind.cs @@ -23,4 +23,12 @@ public enum HostKind /// The host is GitLab: gitlab.com, or a self-managed GitLab instance. [JsonStringEnumMemberName("gitlab")] GitLab, + + /// The host is Gitea: gitea.com, or a self-hosted Gitea instance. + [JsonStringEnumMemberName("gitea")] + Gitea, + + /// The host is Forgejo: codeberg.org, or a self-hosted Forgejo instance. + [JsonStringEnumMemberName("forgejo")] + Forgejo, } diff --git a/src/Buildvana.Runtime/HostsConfig.cs b/src/Buildvana.Runtime/HostsConfig.cs index 97efa772..d3237d41 100644 --- a/src/Buildvana.Runtime/HostsConfig.cs +++ b/src/Buildvana.Runtime/HostsConfig.cs @@ -22,4 +22,12 @@ public sealed record HostsConfig /// Gets the resolved GitLab configuration. [JsonPropertyName("gitlab")] public GitLabConfig GitLab { get; init; } = new(); + + /// Gets the resolved Gitea configuration. + [JsonPropertyName("gitea")] + public GiteaConfig Gitea { get; init; } = new(); + + /// Gets the resolved Forgejo configuration. + [JsonPropertyName("forgejo")] + public ForgejoConfig Forgejo { get; init; } = new(); } diff --git a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt index fa46a5f6..7d8b26b1 100644 --- a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt +++ b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt @@ -131,6 +131,15 @@ Buildvana.Runtime.DotNetInvocationConfig.DotNetInvocationConfig() -> void Buildvana.Runtime.DotNetInvocationConfig.Env.get -> System.Collections.Generic.IReadOnlyDictionary! Buildvana.Runtime.DotNetInvocationConfig.Env.init -> void Buildvana.Runtime.DotNetInvocationConfig.Equals(Buildvana.Runtime.DotNetInvocationConfig? other) -> bool +Buildvana.Runtime.ForgejoConfig +Buildvana.Runtime.ForgejoConfig.$() -> Buildvana.Runtime.ForgejoConfig! +Buildvana.Runtime.ForgejoConfig.Equals(Buildvana.Runtime.ForgejoConfig? other) -> bool +Buildvana.Runtime.ForgejoConfig.ForgejoConfig() -> void +Buildvana.Runtime.ForgejoConfig.TokenEnv.get -> string! +Buildvana.Runtime.ForgejoConfig.TokenEnv.init -> void +Buildvana.Runtime.ForgejoConfigExtensions +Buildvana.Runtime.ForgejoConfigExtensions.extension(Buildvana.Runtime.ForgejoConfig!) +Buildvana.Runtime.ForgejoConfigExtensions.extension(Buildvana.Runtime.ForgejoConfig!).GetToken() -> string! Buildvana.Runtime.GitConfig Buildvana.Runtime.GitConfig.$() -> Buildvana.Runtime.GitConfig! Buildvana.Runtime.GitConfig.Equals(Buildvana.Runtime.GitConfig? other) -> bool @@ -163,6 +172,15 @@ Buildvana.Runtime.GitLabConfig.TokenEnv.init -> void Buildvana.Runtime.GitLabConfigExtensions Buildvana.Runtime.GitLabConfigExtensions.extension(Buildvana.Runtime.GitLabConfig!) Buildvana.Runtime.GitLabConfigExtensions.extension(Buildvana.Runtime.GitLabConfig!).GetToken() -> string! +Buildvana.Runtime.GiteaConfig +Buildvana.Runtime.GiteaConfig.$() -> Buildvana.Runtime.GiteaConfig! +Buildvana.Runtime.GiteaConfig.Equals(Buildvana.Runtime.GiteaConfig? other) -> bool +Buildvana.Runtime.GiteaConfig.GiteaConfig() -> void +Buildvana.Runtime.GiteaConfig.TokenEnv.get -> string! +Buildvana.Runtime.GiteaConfig.TokenEnv.init -> void +Buildvana.Runtime.GiteaConfigExtensions +Buildvana.Runtime.GiteaConfigExtensions.extension(Buildvana.Runtime.GiteaConfig!) +Buildvana.Runtime.GiteaConfigExtensions.extension(Buildvana.Runtime.GiteaConfig!).GetToken() -> string! Buildvana.Runtime.HookArgs Buildvana.Runtime.HookArgs.HookArgs() -> void Buildvana.Runtime.HookArgs.HookArgs(Buildvana.Runtime.HookArgs! original) -> void @@ -170,15 +188,21 @@ 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.Forgejo = 4 -> Buildvana.Runtime.HostKind Buildvana.Runtime.HostKind.GitHub = 1 -> Buildvana.Runtime.HostKind Buildvana.Runtime.HostKind.GitLab = 2 -> Buildvana.Runtime.HostKind +Buildvana.Runtime.HostKind.Gitea = 3 -> Buildvana.Runtime.HostKind Buildvana.Runtime.HostsConfig Buildvana.Runtime.HostsConfig.$() -> Buildvana.Runtime.HostsConfig! Buildvana.Runtime.HostsConfig.Equals(Buildvana.Runtime.HostsConfig? other) -> bool +Buildvana.Runtime.HostsConfig.Forgejo.get -> Buildvana.Runtime.ForgejoConfig! +Buildvana.Runtime.HostsConfig.Forgejo.init -> void Buildvana.Runtime.HostsConfig.GitHub.get -> Buildvana.Runtime.GitHubConfig! Buildvana.Runtime.HostsConfig.GitHub.init -> void Buildvana.Runtime.HostsConfig.GitLab.get -> Buildvana.Runtime.GitLabConfig! Buildvana.Runtime.HostsConfig.GitLab.init -> void +Buildvana.Runtime.HostsConfig.Gitea.get -> Buildvana.Runtime.GiteaConfig! +Buildvana.Runtime.HostsConfig.Gitea.init -> void Buildvana.Runtime.HostsConfig.HostsConfig() -> void Buildvana.Runtime.IHookEvent Buildvana.Runtime.IHookEvent.Context.get -> string! @@ -353,6 +377,9 @@ override Buildvana.Runtime.DotNetConfig.ToString() -> string! override Buildvana.Runtime.DotNetInvocationConfig.Equals(object? obj) -> bool override Buildvana.Runtime.DotNetInvocationConfig.GetHashCode() -> int override Buildvana.Runtime.DotNetInvocationConfig.ToString() -> string! +override Buildvana.Runtime.ForgejoConfig.Equals(object? obj) -> bool +override Buildvana.Runtime.ForgejoConfig.GetHashCode() -> int +override Buildvana.Runtime.ForgejoConfig.ToString() -> string! override Buildvana.Runtime.GitConfig.Equals(object? obj) -> bool override Buildvana.Runtime.GitConfig.GetHashCode() -> int override Buildvana.Runtime.GitConfig.ToString() -> string! @@ -365,6 +392,9 @@ override Buildvana.Runtime.GitIdentityConfig.ToString() -> string! override Buildvana.Runtime.GitLabConfig.Equals(object? obj) -> bool override Buildvana.Runtime.GitLabConfig.GetHashCode() -> int override Buildvana.Runtime.GitLabConfig.ToString() -> string! +override Buildvana.Runtime.GiteaConfig.Equals(object? obj) -> bool +override Buildvana.Runtime.GiteaConfig.GetHashCode() -> int +override Buildvana.Runtime.GiteaConfig.ToString() -> string! override Buildvana.Runtime.HookArgs.Equals(object? obj) -> bool override Buildvana.Runtime.HookArgs.GetHashCode() -> int override Buildvana.Runtime.HookArgs.ToString() -> string! @@ -428,6 +458,9 @@ static Buildvana.Runtime.DotNetConfig.operator !=(Buildvana.Runtime.DotNetConfig static Buildvana.Runtime.DotNetConfig.operator ==(Buildvana.Runtime.DotNetConfig? left, Buildvana.Runtime.DotNetConfig? right) -> bool static Buildvana.Runtime.DotNetInvocationConfig.operator !=(Buildvana.Runtime.DotNetInvocationConfig? left, Buildvana.Runtime.DotNetInvocationConfig? right) -> bool static Buildvana.Runtime.DotNetInvocationConfig.operator ==(Buildvana.Runtime.DotNetInvocationConfig? left, Buildvana.Runtime.DotNetInvocationConfig? right) -> bool +static Buildvana.Runtime.ForgejoConfig.operator !=(Buildvana.Runtime.ForgejoConfig? left, Buildvana.Runtime.ForgejoConfig? right) -> bool +static Buildvana.Runtime.ForgejoConfig.operator ==(Buildvana.Runtime.ForgejoConfig? left, Buildvana.Runtime.ForgejoConfig? right) -> bool +static Buildvana.Runtime.ForgejoConfigExtensions.GetToken(this Buildvana.Runtime.ForgejoConfig! this) -> string! static Buildvana.Runtime.GitConfig.operator !=(Buildvana.Runtime.GitConfig? left, Buildvana.Runtime.GitConfig? right) -> bool static Buildvana.Runtime.GitConfig.operator ==(Buildvana.Runtime.GitConfig? left, Buildvana.Runtime.GitConfig? right) -> bool static Buildvana.Runtime.GitHubConfig.operator !=(Buildvana.Runtime.GitHubConfig? left, Buildvana.Runtime.GitHubConfig? right) -> bool @@ -438,6 +471,9 @@ static Buildvana.Runtime.GitIdentityConfig.operator ==(Buildvana.Runtime.GitIden static Buildvana.Runtime.GitLabConfig.operator !=(Buildvana.Runtime.GitLabConfig? left, Buildvana.Runtime.GitLabConfig? right) -> bool static Buildvana.Runtime.GitLabConfig.operator ==(Buildvana.Runtime.GitLabConfig? left, Buildvana.Runtime.GitLabConfig? right) -> bool static Buildvana.Runtime.GitLabConfigExtensions.GetToken(this Buildvana.Runtime.GitLabConfig! this) -> string! +static Buildvana.Runtime.GiteaConfig.operator !=(Buildvana.Runtime.GiteaConfig? left, Buildvana.Runtime.GiteaConfig? right) -> bool +static Buildvana.Runtime.GiteaConfig.operator ==(Buildvana.Runtime.GiteaConfig? left, Buildvana.Runtime.GiteaConfig? right) -> bool +static Buildvana.Runtime.GiteaConfigExtensions.GetToken(this Buildvana.Runtime.GiteaConfig! this) -> string! 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 @@ -487,10 +523,12 @@ virtual Buildvana.Runtime.HookArgs.PrintMembers(System.Text.StringBuilder! build ~Buildvana.Runtime.BuildvanaJsonContext.DependencyScopesConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.DotNetConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~Buildvana.Runtime.BuildvanaJsonContext.DotNetInvocationConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo +~Buildvana.Runtime.BuildvanaJsonContext.ForgejoConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo ~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.GitLabConfig.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo +~Buildvana.Runtime.BuildvanaJsonContext.GiteaConfig.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> diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoApiClient.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoApiClient.cs new file mode 100644 index 00000000..dca2a8b9 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoApiClient.cs @@ -0,0 +1,264 @@ +// 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 System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Buildvana.Tool.Utilities; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Hosts.Forgejo; + +/// +/// Performs the operations of the Forgejo REST API that bv needs. +/// +/// +/// The API of a host is at https://<host>/api/v1. +/// An answer outside the 2xx range fails the build, as says. The exceptions +/// are , where a 404 means that the release does not exist, and +/// , where a 404 means that the tag does not exist. +/// +internal sealed class ForgejoApiClient : HostApiClient +{ + private const string AttachmentFieldName = "attachment"; + + private readonly AuthenticationHeaderValue _authorization; + + /// + /// Initializes a new instance of the class. + /// + /// The host name of the repository, such as codeberg.org. + /// The token that authenticates every request. + /// The handler that sends the requests. It is disposed together with this + /// instance. + public ForgejoApiClient(string hostName, string token, HttpMessageHandler handler) + : base("Forgejo", GetBaseUrl(hostName), handler) + { + Guard.IsNotNullOrEmpty(token); + + _authorization = new AuthenticationHeaderValue("token", token); + var headers = DefaultRequestHeaders; + headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + headers.UserAgent.Add(new ProductInfoHeaderValue("bv", OwnVersion.Value.ToNormalizedString())); + } + + /// + /// Asynchronously gets a repository. + /// + /// The owner of the repository. + /// The name of the repository. + /// A token that cancels the operation. + /// A whose result is the repository. + public Task GetRepositoryAsync( + string owner, + string name, + CancellationToken cancellationToken = default) + => SendAsync( + HttpMethod.Get, + ApiUrl(RepositoryPath(owner, name)), + null, + ForgejoJsonContext.Default.ForgejoRepository, + cancellationToken); + + /// + /// Asynchronously creates a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The members of the new release. + /// A token that cancels the operation. + /// A whose result is the created release. + /// + /// On a tag that has no release, Forgejo turns the tag into the new release. A draft creates no tag. + /// + public Task CreateReleaseAsync( + string owner, + string name, + ForgejoNewRelease release, + CancellationToken cancellationToken = default) + { + Guard.IsNotNull(release); + return SendAsync( + HttpMethod.Post, + ApiUrl(RepositoryPath(owner, name) + "/releases"), + JsonBody(release, ForgejoJsonContext.Default.ForgejoNewRelease), + ForgejoJsonContext.Default.ForgejoRelease, + cancellationToken); + } + + /// + /// Asynchronously edits a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The identifier of the release. + /// The new members of the release. + /// A token that cancels the operation. + /// A representing the ongoing operation. + public Task UpdateReleaseAsync( + string owner, + string name, + long releaseId, + ForgejoReleaseUpdate update, + CancellationToken cancellationToken = default) + { + Guard.IsNotNull(update); + return SendAsync( + HttpMethod.Patch, + ApiUrl(ReleasePath(owner, name, releaseId)), + JsonBody(update, ForgejoJsonContext.Default.ForgejoReleaseUpdate), + cancellationToken); + } + + /// + /// Asynchronously deletes a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The identifier of the release. + /// A token that cancels the operation. + /// A whose result is when the release was deleted, and + /// when the API answers 404. + /// + /// Forgejo deletes the assets of the release, and keeps its tag. can then + /// delete the tag. + /// + public Task DeleteReleaseAsync( + string owner, + string name, + long releaseId, + CancellationToken cancellationToken = default) + => DeleteAsync(ApiUrl(ReleasePath(owner, name, releaseId)), cancellationToken); + + /// + /// Asynchronously deletes a tag that has no release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The name of the tag. + /// A token that cancels the operation. + /// A whose result is when the tag was deleted, and + /// when the API answers 404. + /// + /// Forgejo refuses to delete a tag that has a release, with 409. + /// + public Task DeleteTagAsync( + string owner, + string name, + string tagName, + CancellationToken cancellationToken = default) + => DeleteAsync(ApiUrl(RepositoryPath(owner, name) + "/tags/" + EscapeRefName(tagName)), cancellationToken); + + /// + /// Asynchronously uploads an asset of a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The identifier of the release. + /// The file name of the asset. + /// The contents of the asset. The stream is disposed together with the request. + /// A token that cancels the operation. + /// A representing the ongoing operation. + /// + /// Forgejo accepts two assets of a release with the same file name. It answers 413 when the upload + /// exceeds the quota of the repository. + /// + public Task UploadReleaseAssetAsync( + string owner, + string name, + long releaseId, + string fileName, + Stream contents, + CancellationToken cancellationToken = default) + { + Guard.IsNotNullOrEmpty(fileName); + Guard.IsNotNull(contents); + + // The name query parameter takes precedence over the file name of the form field. + var url = ApiUrl(ReleasePath(owner, name, releaseId) + "/assets?name=" + Uri.EscapeDataString(fileName)); + var content = new MultipartFormDataContent(); +#pragma warning disable CA2000 // Dispose objects before losing scope — the multipart content owns and disposes its parts + var file = new StreamContent(contents); +#pragma warning restore CA2000 + content.Add(file, AttachmentFieldName, fileName); + file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + return SendAsync(HttpMethod.Post, url, content, cancellationToken); + } + + /// + protected override void Authenticate(HttpRequestMessage request) + { + Guard.IsNotNull(request); + request.Headers.Authorization = _authorization; + } + + /// + protected override async Task ReadErrorMessageAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + // A Forgejo error answer is JSON with a "message" member. A proxy in front of a server may answer with + // anything else, and then the status code is the whole report. + Guard.IsNotNull(response); + ForgejoErrorBody? body; + try + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + body = await JsonSerializer.DeserializeAsync(stream, ForgejoJsonContext.Default.ForgejoErrorBody, cancellationToken) + .ConfigureAwait(false); + } + } + catch (JsonException) + { + return null; + } + + return body?.Message is { Length: > 0 } message ? message : null; + } + + private static Uri GetBaseUrl(string hostName) + { + Guard.IsNotNullOrEmpty(hostName); + return new Uri($"https://{hostName}/api/v1/"); + } + + private static string ReleasePath(string owner, string name, long releaseId) + => RepositoryPath(owner, name) + "/releases/" + releaseId.ToString(CultureInfo.InvariantCulture); + + private static string RepositoryPath(string owner, string name) + { + Guard.IsNotNullOrEmpty(owner); + Guard.IsNotNullOrEmpty(name); + return $"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}"; + } + + // A tag name may hold slashes, and the API expects them as path separators. Every other character is + // escaped within its segment. + private static string EscapeRefName(string tagName) + { + Guard.IsNotNullOrEmpty(tagName); + return string.Join('/', tagName.Split('/').Select(Uri.EscapeDataString)); + } + + private async Task DeleteAsync(Uri url, CancellationToken cancellationToken) + { + using var request = CreateRequest(HttpMethod.Delete, url); + using var response = await SendCoreAsync(request, url, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + await EnsureSuccessAsync(HttpMethod.Delete, url, response, cancellationToken).ConfigureAwait(false); + return true; + } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoErrorBody.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoErrorBody.cs new file mode 100644 index 00000000..a2518fdd --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoErrorBody.cs @@ -0,0 +1,15 @@ +// 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.Forgejo; + +/// +/// The members of a Forgejo API error answer that bv reads. +/// +internal sealed record ForgejoErrorBody +{ + /// + /// Gets the error message, or when the answer states none. + /// + public string? Message { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoHost.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoHost.cs new file mode 100644 index 00000000..90f75903 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoHost.cs @@ -0,0 +1,120 @@ +// 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.Forgejo; + +/// +/// The Forgejo host: codeberg.org, or a self-hosted Forgejo instance. +/// +internal sealed class ForgejoHost : GitHost, IDisposable +{ + private readonly IServiceProvider _services; + private readonly ForgejoConfig _config; + private readonly ForgejoRepositoryUrls _urls; + private readonly HttpMessageHandler? _handler; + + private ForgejoApiClient? _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 ForgejoHost(IServiceProvider services, GitUrlInfo url, HttpMessageHandler? handler = null) + : base(url) + { + Guard.IsNotNull(services); + BuildFailedException.ThrowIfNot(url.PathSegments.Count == 2, $"'{url.Url}' is not a valid Forgejo repository URL."); + _services = services; + _config = services.GetRequiredService().Hosts.Forgejo; + _urls = new ForgejoRepositoryUrls(HostName, Owner, Repository); + _handler = handler; + } + + /// + public override string Name => "Forgejo"; + + /// + /// Always . + public override HostKind? Kind => HostKind.Forgejo; + + /// + public override Uri RepositoryUrl => _urls.Repository; + + /// + public override string TokenEnv => _config.TokenEnv; + + /// + /// Always token. + /// + /// Forgejo ignores the username when the password is an access token or an Actions token. + /// + public override string PushUsername => "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 ForgejoApiClient Client => _client ??= new ForgejoApiClient( + HostName, + RuntimeAccess.Translate(_config.GetToken), + _handler ?? new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }); + + /// + public override Uri GetReleaseUrl(string tag) => _urls.Release(tag); + + /// + public override Uri GetFileUrl(string path, GitRef gitRef) => _urls.File(path, gitRef); + + /// + /// + /// A repository whose owner has limited or private visibility counts as private, because + /// Forgejo hides it from anonymous users even when the repository itself is not private. + /// + public override async Task IsPrivateRepositoryAsync() + { + _services.GetRequiredService().Info("Fetching repository information..."); + var repository = await Client.GetRepositoryAsync(Owner, Repository).ConfigureAwait(false); + return repository.Private || repository.Owner.Visibility is not "public"; + } + + /// + public override ReleasePublisher CreateReleasePublisher() + => new ForgejoReleasePublisher( + 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/Hosts/Forgejo/ForgejoJsonContext.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoJsonContext.cs new file mode 100644 index 00000000..d125728e --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoJsonContext.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 System.Text.Json.Serialization; + +namespace Buildvana.Tool.Services.Hosts.Forgejo; + +/// +/// The source-generated serializer context for the requests and answers of the Forgejo REST API. +/// +/// +/// Reading ignores unknown members, because every Forgejo answer carries many more members than +/// bv reads. A required member that is missing fails the read. +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + RespectNullableAnnotations = true)] +[JsonSerializable(typeof(ForgejoRepository))] +[JsonSerializable(typeof(ForgejoRelease))] +[JsonSerializable(typeof(ForgejoNewRelease))] +[JsonSerializable(typeof(ForgejoReleaseUpdate))] +[JsonSerializable(typeof(ForgejoErrorBody))] +internal sealed partial class ForgejoJsonContext : JsonSerializerContext; diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoNewRelease.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoNewRelease.cs new file mode 100644 index 00000000..f03f6e07 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoNewRelease.cs @@ -0,0 +1,38 @@ +// 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.Forgejo; + +/// +/// The members bv sends to create a Forgejo release. +/// +internal sealed record ForgejoNewRelease +{ + /// + /// Gets the name of the tag of the release. + /// + public required string TagName { get; init; } + + /// + /// Gets the branch or commit SHA that the tag is created from, when the tag does not exist. + /// + /// + /// Forgejo does not fall back to the default branch when this member is empty. + /// + public required string TargetCommitish { get; init; } + + /// + /// Gets the name of the release. + /// + public required string Name { get; init; } + + /// + /// Gets a value indicating whether the release is a prerelease. + /// + public required bool Prerelease { get; init; } + + /// + /// Gets a value indicating whether the release is a draft. + /// + public required bool Draft { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoPermission.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoPermission.cs new file mode 100644 index 00000000..9b858841 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoPermission.cs @@ -0,0 +1,15 @@ +// 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.Forgejo; + +/// +/// The members of the permissions of a user on a Forgejo repository that bv reads. +/// +internal sealed record ForgejoPermission +{ + /// + /// Gets a value indicating whether the user can push to the repository. + /// + public required bool Push { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoRelease.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoRelease.cs new file mode 100644 index 00000000..ff033d52 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoRelease.cs @@ -0,0 +1,15 @@ +// 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.Forgejo; + +/// +/// The members of a Forgejo release that bv reads. +/// +internal sealed record ForgejoRelease +{ + /// + /// Gets the identifier of the release. + /// + public required long Id { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoReleasePublisher.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoReleasePublisher.cs new file mode 100644 index 00000000..4e1889e9 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoReleasePublisher.cs @@ -0,0 +1,186 @@ +// 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.Linq; +using System.Threading.Tasks; +using Buildvana.Core; +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.Forgejo; + +/// +/// The release publisher of a : a draft release on the pushed tag, its attachments, then +/// the publication of the draft. +/// +/// +/// Nothing exists on the host before . When Forgejo processes the push of a tag, it +/// publishes a draft on the same tag name, so a draft created before the push would be published without its +/// attachments. +/// Forgejo processes a tag push asynchronously, so a slow queue can still publish the draft before the +/// attachments are uploaded. The release ends complete either way. +/// +internal sealed class ForgejoReleasePublisher : ReleasePublisher +{ + private readonly ForgejoHost _host; + private readonly IReporter _reporter; + private readonly VersionService _version; + private readonly IGitService _git; + + private (long Id, string Tag)? _release; + private bool _published; + + /// + /// Initializes a new instance of the class. + /// + /// The host to publish on. + /// The reporter to report to. + /// The version service. + /// The Git service. + public ForgejoReleasePublisher(ForgejoHost 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; + } + + /// + /// Two assets have the same file name. + /// + /// Forgejo accepts two attachments of a release with the same name, but the download URL of an + /// attachment names it by its file name, and then leads to one of them only. + /// + public override void CheckAssets(IReadOnlyList assets) + { + Guard.IsNotNull(assets); + var problems = Duplicates(assets.Select(static x => Path.GetFileName(x.Path))) + .Select(static x => $"More than one asset has the file name '{x}'.") + .ToList(); + + BuildFailedException.ThrowIf( + problems.Count > 0, + "The release assets cannot go to a Forgejo release:" + string.Concat(problems.Select(static x => "\n- " + x))); + } + + /// + /// + /// This method creates nothing. It reads the repository, which fails for a token that cannot reach it, + /// and refuses a repository whose releases are disabled, or that the token cannot push to. + /// Forgejo does not state whether the token can write releases. A token without that access fails the + /// release when creates the draft, and the rollback then deletes the tag. + /// + public override async Task BeginAsync() + { + var repositoryPath = $"{_host.Owner}/{_host.Repository}"; + _reporter.Info($"Checking access to {repositoryPath}..."); + var repository = await _host.Client.GetRepositoryAsync(_host.Owner, _host.Repository).ConfigureAwait(false); + BuildFailedException.ThrowIfNot( + repository.HasReleases, + $"The releases of {repositoryPath} are disabled: enable them in the settings of the repository."); + BuildFailedException.ThrowIfNot( + repository.Permissions.Push, + $"The Forgejo token cannot push to {repositoryPath}. A release needs write access to the code of the repository."); + } + + /// + public override async Task PublishAsync(string tag, string sha, IReadOnlyList assets) + { + Guard.IsNotNullOrEmpty(tag); + Guard.IsNotNullOrEmpty(sha); + Guard.IsNotNull(assets); + _reporter.Info($"Creating a draft release on tag {tag}..."); + var newRelease = new ForgejoNewRelease { + TagName = tag, + TargetCommitish = sha, + Name = tag, + Prerelease = _version.IsPrerelease, + Draft = true, + }; + + var release = await _host.Client.CreateReleaseAsync(_host.Owner, _host.Repository, newRelease).ConfigureAwait(false); + _release = (release.Id, tag); + 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); + } + + var changelogUrl = _host.GetFileUrl("CHANGELOG.md", GitRef.Branch(_git.CurrentBranch)); + _reporter.Info($"Publishing the draft release as {tag}..."); + var update = new ForgejoReleaseUpdate { + Name = tag, + Body = $"We have a [human-curated changelog]({changelogUrl}).", + Prerelease = _version.IsPrerelease, + Draft = false, + }; + + await _host.Client.UpdateReleaseAsync(_host.Owner, _host.Repository, release.Id, update).ConfigureAwait(false); + _published = true; + } + + /// + /// + /// A rollback deletes the release tag before this method runs. Forgejo then keeps the release as a draft, + /// and keeps a record of the tag once the release is deleted. This method deletes both. + /// A release or a tag record that no longer exists is no failure, because there is nothing left to + /// undo. + /// + public override async Task UndoAsync() + { + if (_release is not (var releaseId, var tag)) + { + return; + } + + _reporter.Info("Deleting the previously created release..."); + var deleted = await _host.Client.DeleteReleaseAsync(_host.Owner, _host.Repository, releaseId).ConfigureAwait(false); + if (deleted) + { + _reporter.Notice(_published ? $"Deleted release {tag}." : $"Deleted the draft release on tag {tag}."); + } + else + { + _reporter.Info(_published ? $"Release {tag} not found." : $"Draft release on tag {tag} not found."); + } + + _reporter.Info($"Deleting the record of tag {tag} on Forgejo..."); + _ = await _host.Client.DeleteTagAsync(_host.Owner, _host.Repository, tag).ConfigureAwait(false); + _release = null; + } + + private async Task UploadAssetAsync(ForgejoRelease release, ReleaseAsset asset) + { + _reporter.Detail($"Uploading asset {asset.Path}..."); + var contents = UserFile.OpenRead(asset.Path); + await using (contents.ConfigureAwait(false)) + { + await _host.Client.UploadReleaseAssetAsync( + _host.Owner, + _host.Repository, + release.Id, + Path.GetFileName(asset.Path), + contents).ConfigureAwait(false); + } + } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoReleaseUpdate.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoReleaseUpdate.cs new file mode 100644 index 00000000..e3a2bfa6 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoReleaseUpdate.cs @@ -0,0 +1,33 @@ +// 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.Forgejo; + +/// +/// The members bv sends to edit a Forgejo release. +/// +/// +/// Forgejo keeps the current value of a string member that a request sends empty. +/// +internal sealed record ForgejoReleaseUpdate +{ + /// + /// Gets the name of the release. + /// + public required string Name { get; init; } + + /// + /// Gets the description of the release, in Markdown. + /// + public required string Body { get; init; } + + /// + /// Gets a value indicating whether the release is a prerelease. + /// + public required bool Prerelease { get; init; } + + /// + /// Gets a value indicating whether the release is a draft. + /// + public required bool Draft { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoRepository.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoRepository.cs new file mode 100644 index 00000000..ea368c74 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoRepository.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. + +namespace Buildvana.Tool.Services.Hosts.Forgejo; + +/// +/// The members of a Forgejo repository that bv reads. +/// +internal sealed record ForgejoRepository +{ + /// + /// Gets the owner of the repository. + /// + public required ForgejoUser Owner { get; init; } + + /// + /// Gets a value indicating whether the repository is private. + /// + public required bool Private { get; init; } + + /// + /// Gets a value indicating whether the releases unit of the repository is enabled. + /// + public required bool HasReleases { get; init; } + + /// + /// Gets the permissions of the authenticated user on the repository. + /// + public required ForgejoPermission Permissions { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoRepositoryUrls.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoRepositoryUrls.cs new file mode 100644 index 00000000..ae123f6f --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoRepositoryUrls.cs @@ -0,0 +1,76 @@ +// 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 CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Hosts.Forgejo; + +/// +/// Builds the URLs of a Forgejo repository and of the resources it contains. +/// +/// +/// The repository URL carries no trailing slash, as says of +/// a GitHub repository URL. +/// +internal sealed class ForgejoRepositoryUrls +{ + private readonly string _repository; + + /// + /// Initializes a new instance of the class. + /// + /// The host name of the Forgejo instance, such as codeberg.org. + /// The owner of the repository. + /// The name of the repository. + public ForgejoRepositoryUrls(string hostName, string repositoryOwner, string repositoryName) + { + Guard.IsNotNullOrEmpty(hostName); + Guard.IsNotNullOrEmpty(repositoryOwner); + Guard.IsNotNullOrEmpty(repositoryName); + + _repository = $"https://{hostName}/{repositoryOwner}/{repositoryName}"; + Repository = new Uri(_repository); + } + + /// + /// Gets the URL of the repository, without a trailing slash. + /// + public Uri Repository { get; } + + /// + /// Builds the URL of the release of a tag. + /// + /// The name of the tag. + /// The URL of the release. + public Uri Release(string tag) + { + Guard.IsNotNullOrEmpty(tag); + return new Uri($"{_repository}/releases/tag/{tag}"); + } + + /// + /// Builds the URL of a file of the repository, as of a tag, a branch, or a commit. + /// + /// 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. + /// is rooted, or leaves the repository, as + /// says. + /// + /// A Forgejo file URL names the kind of the reference before its name, so that a tag and a branch of + /// the same name lead to different files. + /// + public Uri File(string path, GitRef gitRef) + { + Guard.IsNotNull(gitRef); + var kind = gitRef.Kind switch { + GitRefKind.Tag => "tag", + GitRefKind.Branch => "branch", + GitRefKind.Commit => "commit", + _ => ThrowHelper.ThrowArgumentOutOfRangeException(nameof(gitRef), gitRef.Kind, "Unknown kind of reference."), + }; + + return new Uri($"{_repository}/src/{kind}/{gitRef.Name}/{RepositoryFilePath.ToUrlPath(path)}"); + } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoUser.cs b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoUser.cs new file mode 100644 index 00000000..629fe1d8 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Forgejo/ForgejoUser.cs @@ -0,0 +1,15 @@ +// 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.Forgejo; + +/// +/// The members of a Forgejo user or organization that bv reads. +/// +internal sealed record ForgejoUser +{ + /// + /// Gets the visibility of the user or organization: public, limited, or private. + /// + public required string Visibility { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/GitHost.cs b/src/Buildvana.Tool/Services/Hosts/GitHost.cs index 7549bcf3..4ac1a7cf 100644 --- a/src/Buildvana.Tool/Services/Hosts/GitHost.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHost.cs @@ -8,6 +8,8 @@ using Buildvana.Core.ConsoleOutput; using Buildvana.Runtime; using Buildvana.Tool.Services.Git; +using Buildvana.Tool.Services.Hosts.Forgejo; +using Buildvana.Tool.Services.Hosts.Gitea; using Buildvana.Tool.Services.Hosts.GitHub; using Buildvana.Tool.Services.Hosts.GitLab; using Buildvana.Tool.Services.Platforms; @@ -92,6 +94,8 @@ public static GitHost Create(IServiceProvider services) return DetectKind(config.Repository, url, services.GetRequiredService()) switch { HostKind.GitHub => new GitHubHost(services, url), HostKind.GitLab => new GitLabHost(services, url), + HostKind.Gitea => new GiteaHost(services, url), + HostKind.Forgejo => new ForgejoHost(services, url), _ => new UnknownHost(url, services.GetRequiredService()), }; } @@ -121,8 +125,8 @@ public static GitUrlInfo ParseRepositoryUrl(RepositoryConfig repository) /// 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 host that GitHub or GitLab runs, such as github.com, when - /// names it; the + /// ; the kind of a host that GitHub, GitLab, Gitea, or Forgejo runs, such as + /// github.com, when names it; the /// of a platform whose has the host /// name of ; otherwise, . public static HostKind? DetectKind(RepositoryConfig repository, GitUrlInfo url, CIPlatform platform) @@ -148,6 +152,16 @@ public static GitUrlInfo ParseRepositoryUrl(RepositoryConfig repository) return HostKind.GitLab; } + if (string.Equals(url.Host, "gitea.com", StringComparison.OrdinalIgnoreCase)) + { + return HostKind.Gitea; + } + + if (string.Equals(url.Host, "codeberg.org", StringComparison.OrdinalIgnoreCase)) + { + return HostKind.Forgejo; + } + // A self-hosted server, such as a GitHub Enterprise Server or a self-managed GitLab instance, is recognized // on its own runners. var isPlatformServer = string.Equals(platform.ServerUrl?.Host, url.Host, StringComparison.OrdinalIgnoreCase); diff --git a/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubApiClient.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubApiClient.cs index ba820290..bcc1687a 100644 --- a/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubApiClient.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubApiClient.cs @@ -27,8 +27,9 @@ namespace Buildvana.Tool.Services.Hosts.GitHub; /// https://<host>/api/v3. /// An asset upload goes to the address that the release states in its upload_url member, so this /// type derives no upload host. -/// An answer outside the 2xx range fails the build, as says. The one exception -/// is a tag lookup, where a 404 means that the tag does not exist. +/// An answer outside the 2xx range fails the build, as says. The exceptions +/// are , where a 404 means that the release does not exist, and +/// , where a 404 means that the tag does not exist. /// internal sealed class GitHubApiClient : HostApiClient { @@ -163,13 +164,25 @@ public Task UpdateReleaseAsync( /// The name of the repository. /// The identifier of the release. /// A token that cancels the operation. - /// A representing the ongoing operation. - public Task DeleteReleaseAsync( + /// A whose result is when the release was deleted, and + /// when the API answers 404. + public async Task DeleteReleaseAsync( string owner, string name, long releaseId, CancellationToken cancellationToken = default) - => SendAsync(HttpMethod.Delete, ApiUrl(ReleasePath(owner, name, releaseId)), null, cancellationToken); + { + var url = ApiUrl(ReleasePath(owner, name, releaseId)); + using var request = CreateRequest(HttpMethod.Delete, url); + using var response = await SendCoreAsync(request, url, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + await EnsureSuccessAsync(HttpMethod.Delete, url, response, cancellationToken).ConfigureAwait(false); + return true; + } /// /// Asynchronously gets a value indicating whether a tag exists. diff --git a/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleasePublisher.cs b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleasePublisher.cs index 3d195841..9158d7fd 100644 --- a/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleasePublisher.cs +++ b/src/Buildvana.Tool/Services/Hosts/GitHub/GitHubReleasePublisher.cs @@ -132,6 +132,7 @@ public override async Task PublishAsync(string tag, string sha, IReadOnlyList /// 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. + /// A release that no longer exists is no failure, because there is nothing left to undo. /// public override async Task UndoAsync() { @@ -141,8 +142,16 @@ public override async Task UndoAsync() } _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}."); + var deleted = await _host.Client.DeleteReleaseAsync(_host.Owner, _host.Repository, _release.Id).ConfigureAwait(false); + if (deleted) + { + _reporter.Notice(_publishedTag is null ? "Deleted the provisional draft release." : $"Deleted release {_publishedTag}."); + } + else + { + _reporter.Info(_publishedTag is null ? "Provisional draft release not found." : $"Release {_publishedTag} not found."); + } + _release = null; } diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaApiClient.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaApiClient.cs new file mode 100644 index 00000000..a2b6aafd --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaApiClient.cs @@ -0,0 +1,263 @@ +// 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 System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Buildvana.Tool.Utilities; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Hosts.Gitea; + +/// +/// Performs the operations of the Gitea REST API that bv needs. +/// +/// +/// The API of a host is at https://<host>/api/v1. +/// An answer outside the 2xx range fails the build, as says. The exceptions +/// are , where a 404 means that the release does not exist, and +/// , where a 404 means that the tag does not exist. +/// +internal sealed class GiteaApiClient : HostApiClient +{ + private const string AttachmentFieldName = "attachment"; + + private readonly AuthenticationHeaderValue _authorization; + + /// + /// Initializes a new instance of the class. + /// + /// The host name of the repository, such as gitea.com. + /// The token that authenticates every request. + /// The handler that sends the requests. It is disposed together with this + /// instance. + public GiteaApiClient(string hostName, string token, HttpMessageHandler handler) + : base("Gitea", GetBaseUrl(hostName), handler) + { + Guard.IsNotNullOrEmpty(token); + + _authorization = new AuthenticationHeaderValue("token", token); + var headers = DefaultRequestHeaders; + headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + headers.UserAgent.Add(new ProductInfoHeaderValue("bv", OwnVersion.Value.ToNormalizedString())); + } + + /// + /// Asynchronously gets a repository. + /// + /// The owner of the repository. + /// The name of the repository. + /// A token that cancels the operation. + /// A whose result is the repository. + public Task GetRepositoryAsync( + string owner, + string name, + CancellationToken cancellationToken = default) + => SendAsync( + HttpMethod.Get, + ApiUrl(RepositoryPath(owner, name)), + null, + GiteaJsonContext.Default.GiteaRepository, + cancellationToken); + + /// + /// Asynchronously creates a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The members of the new release. + /// A token that cancels the operation. + /// A whose result is the created release. + /// + /// On a tag that has no release, Gitea turns the tag into the new release. A draft creates no tag. + /// + public Task CreateReleaseAsync( + string owner, + string name, + GiteaNewRelease release, + CancellationToken cancellationToken = default) + { + Guard.IsNotNull(release); + return SendAsync( + HttpMethod.Post, + ApiUrl(RepositoryPath(owner, name) + "/releases"), + JsonBody(release, GiteaJsonContext.Default.GiteaNewRelease), + GiteaJsonContext.Default.GiteaRelease, + cancellationToken); + } + + /// + /// Asynchronously edits a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The identifier of the release. + /// The new members of the release. + /// A token that cancels the operation. + /// A representing the ongoing operation. + public Task UpdateReleaseAsync( + string owner, + string name, + long releaseId, + GiteaReleaseUpdate update, + CancellationToken cancellationToken = default) + { + Guard.IsNotNull(update); + return SendAsync( + HttpMethod.Patch, + ApiUrl(ReleasePath(owner, name, releaseId)), + JsonBody(update, GiteaJsonContext.Default.GiteaReleaseUpdate), + cancellationToken); + } + + /// + /// Asynchronously deletes a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The identifier of the release. + /// A token that cancels the operation. + /// A whose result is when the release was deleted, and + /// when the API answers 404. + /// + /// Gitea deletes the assets of the release, and keeps its tag. can then + /// delete the tag. + /// + public Task DeleteReleaseAsync( + string owner, + string name, + long releaseId, + CancellationToken cancellationToken = default) + => DeleteAsync(ApiUrl(ReleasePath(owner, name, releaseId)), cancellationToken); + + /// + /// Asynchronously deletes a tag that has no release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The name of the tag. + /// A token that cancels the operation. + /// A whose result is when the tag was deleted, and + /// when the API answers 404. + /// + /// Gitea refuses to delete a tag that has a release, with 409. + /// + public Task DeleteTagAsync( + string owner, + string name, + string tagName, + CancellationToken cancellationToken = default) + => DeleteAsync(ApiUrl(RepositoryPath(owner, name) + "/tags/" + EscapeRefName(tagName)), cancellationToken); + + /// + /// Asynchronously uploads an asset of a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The identifier of the release. + /// The file name of the asset. + /// The contents of the asset. The stream is disposed together with the request. + /// A token that cancels the operation. + /// A representing the ongoing operation. + /// + /// Gitea accepts two assets of a release with the same file name. + /// + public Task UploadReleaseAssetAsync( + string owner, + string name, + long releaseId, + string fileName, + Stream contents, + CancellationToken cancellationToken = default) + { + Guard.IsNotNullOrEmpty(fileName); + Guard.IsNotNull(contents); + + // The name query parameter takes precedence over the file name of the form field. + var url = ApiUrl(ReleasePath(owner, name, releaseId) + "/assets?name=" + Uri.EscapeDataString(fileName)); + var content = new MultipartFormDataContent(); +#pragma warning disable CA2000 // Dispose objects before losing scope — the multipart content owns and disposes its parts + var file = new StreamContent(contents); +#pragma warning restore CA2000 + content.Add(file, AttachmentFieldName, fileName); + file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + return SendAsync(HttpMethod.Post, url, content, cancellationToken); + } + + /// + protected override void Authenticate(HttpRequestMessage request) + { + Guard.IsNotNull(request); + request.Headers.Authorization = _authorization; + } + + /// + protected override async Task ReadErrorMessageAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + // A Gitea error answer is JSON with a "message" member. A proxy in front of a server may answer with + // anything else, and then the status code is the whole report. + Guard.IsNotNull(response); + GiteaErrorBody? body; + try + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + body = await JsonSerializer.DeserializeAsync(stream, GiteaJsonContext.Default.GiteaErrorBody, cancellationToken) + .ConfigureAwait(false); + } + } + catch (JsonException) + { + return null; + } + + return body?.Message is { Length: > 0 } message ? message : null; + } + + private static Uri GetBaseUrl(string hostName) + { + Guard.IsNotNullOrEmpty(hostName); + return new Uri($"https://{hostName}/api/v1/"); + } + + private static string ReleasePath(string owner, string name, long releaseId) + => RepositoryPath(owner, name) + "/releases/" + releaseId.ToString(CultureInfo.InvariantCulture); + + private static string RepositoryPath(string owner, string name) + { + Guard.IsNotNullOrEmpty(owner); + Guard.IsNotNullOrEmpty(name); + return $"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}"; + } + + // A tag name may hold slashes, and the API expects them as path separators. Every other character is + // escaped within its segment. + private static string EscapeRefName(string tagName) + { + Guard.IsNotNullOrEmpty(tagName); + return string.Join('/', tagName.Split('/').Select(Uri.EscapeDataString)); + } + + private async Task DeleteAsync(Uri url, CancellationToken cancellationToken) + { + using var request = CreateRequest(HttpMethod.Delete, url); + using var response = await SendCoreAsync(request, url, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + await EnsureSuccessAsync(HttpMethod.Delete, url, response, cancellationToken).ConfigureAwait(false); + return true; + } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaErrorBody.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaErrorBody.cs new file mode 100644 index 00000000..856d18c5 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaErrorBody.cs @@ -0,0 +1,15 @@ +// 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.Gitea; + +/// +/// The members of a Gitea API error answer that bv reads. +/// +internal sealed record GiteaErrorBody +{ + /// + /// Gets the error message, or when the answer states none. + /// + public string? Message { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaHost.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaHost.cs new file mode 100644 index 00000000..a1c49536 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaHost.cs @@ -0,0 +1,120 @@ +// 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.Gitea; + +/// +/// The Gitea host: gitea.com, or a self-hosted Gitea instance. +/// +internal sealed class GiteaHost : GitHost, IDisposable +{ + private readonly IServiceProvider _services; + private readonly GiteaConfig _config; + private readonly GiteaRepositoryUrls _urls; + private readonly HttpMessageHandler? _handler; + + private GiteaApiClient? _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 GiteaHost(IServiceProvider services, GitUrlInfo url, HttpMessageHandler? handler = null) + : base(url) + { + Guard.IsNotNull(services); + BuildFailedException.ThrowIfNot(url.PathSegments.Count == 2, $"'{url.Url}' is not a valid Gitea repository URL."); + _services = services; + _config = services.GetRequiredService().Hosts.Gitea; + _urls = new GiteaRepositoryUrls(HostName, Owner, Repository); + _handler = handler; + } + + /// + public override string Name => "Gitea"; + + /// + /// Always . + public override HostKind? Kind => HostKind.Gitea; + + /// + public override Uri RepositoryUrl => _urls.Repository; + + /// + public override string TokenEnv => _config.TokenEnv; + + /// + /// Always token. + /// + /// Gitea ignores the username when the password is an access token or an Actions token. + /// + public override string PushUsername => "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 GiteaApiClient Client => _client ??= new GiteaApiClient( + HostName, + RuntimeAccess.Translate(_config.GetToken), + _handler ?? new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }); + + /// + public override Uri GetReleaseUrl(string tag) => _urls.Release(tag); + + /// + public override Uri GetFileUrl(string path, GitRef gitRef) => _urls.File(path, gitRef); + + /// + /// + /// A repository whose owner has limited or private visibility counts as private, because + /// Gitea hides it from anonymous users even when the repository itself is not private. + /// + public override async Task IsPrivateRepositoryAsync() + { + _services.GetRequiredService().Info("Fetching repository information..."); + var repository = await Client.GetRepositoryAsync(Owner, Repository).ConfigureAwait(false); + return repository.Private || repository.Owner.Visibility is not "public"; + } + + /// + public override ReleasePublisher CreateReleasePublisher() + => new GiteaReleasePublisher( + 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/Hosts/Gitea/GiteaJsonContext.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaJsonContext.cs new file mode 100644 index 00000000..c4899271 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaJsonContext.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 System.Text.Json.Serialization; + +namespace Buildvana.Tool.Services.Hosts.Gitea; + +/// +/// The source-generated serializer context for the requests and answers of the Gitea REST API. +/// +/// +/// Reading ignores unknown members, because every Gitea answer carries many more members than +/// bv reads. A required member that is missing fails the read. +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + RespectNullableAnnotations = true)] +[JsonSerializable(typeof(GiteaRepository))] +[JsonSerializable(typeof(GiteaRelease))] +[JsonSerializable(typeof(GiteaNewRelease))] +[JsonSerializable(typeof(GiteaReleaseUpdate))] +[JsonSerializable(typeof(GiteaErrorBody))] +internal sealed partial class GiteaJsonContext : JsonSerializerContext; diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaNewRelease.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaNewRelease.cs new file mode 100644 index 00000000..41a7869c --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaNewRelease.cs @@ -0,0 +1,35 @@ +// 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.Gitea; + +/// +/// The members bv sends to create a Gitea release. +/// +internal sealed record GiteaNewRelease +{ + /// + /// Gets the name of the tag of the release. + /// + public required string TagName { get; init; } + + /// + /// Gets the branch or commit SHA that the tag is created from, when the tag does not exist. + /// + public required string TargetCommitish { get; init; } + + /// + /// Gets the name of the release. + /// + public required string Name { get; init; } + + /// + /// Gets a value indicating whether the release is a prerelease. + /// + public required bool Prerelease { get; init; } + + /// + /// Gets a value indicating whether the release is a draft. + /// + public required bool Draft { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaPermission.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaPermission.cs new file mode 100644 index 00000000..0e65eb38 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaPermission.cs @@ -0,0 +1,15 @@ +// 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.Gitea; + +/// +/// The members of the permissions of a user on a Gitea repository that bv reads. +/// +internal sealed record GiteaPermission +{ + /// + /// Gets a value indicating whether the user can push to the repository. + /// + public required bool Push { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaRelease.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaRelease.cs new file mode 100644 index 00000000..0cef67d3 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaRelease.cs @@ -0,0 +1,15 @@ +// 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.Gitea; + +/// +/// The members of a Gitea release that bv reads. +/// +internal sealed record GiteaRelease +{ + /// + /// Gets the identifier of the release. + /// + public required long Id { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaReleasePublisher.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaReleasePublisher.cs new file mode 100644 index 00000000..208a3e4f --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaReleasePublisher.cs @@ -0,0 +1,186 @@ +// 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.Linq; +using System.Threading.Tasks; +using Buildvana.Core; +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.Gitea; + +/// +/// The release publisher of a : a draft release on the pushed tag, its attachments, then +/// the publication of the draft. +/// +/// +/// Nothing exists on the host before . When Gitea processes the push of a tag, it +/// publishes a draft on the same tag name, so a draft created before the push would be published without its +/// attachments. +/// Gitea processes a tag push asynchronously, so a slow queue can still publish the draft before the +/// attachments are uploaded. The release ends complete either way. +/// +internal sealed class GiteaReleasePublisher : ReleasePublisher +{ + private readonly GiteaHost _host; + private readonly IReporter _reporter; + private readonly VersionService _version; + private readonly IGitService _git; + + private (long Id, string Tag)? _release; + private bool _published; + + /// + /// Initializes a new instance of the class. + /// + /// The host to publish on. + /// The reporter to report to. + /// The version service. + /// The Git service. + public GiteaReleasePublisher(GiteaHost 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; + } + + /// + /// Two assets have the same file name. + /// + /// Gitea accepts two attachments of a release with the same name, but the download URL of an + /// attachment names it by its file name, and then leads to one of them only. + /// + public override void CheckAssets(IReadOnlyList assets) + { + Guard.IsNotNull(assets); + var problems = Duplicates(assets.Select(static x => Path.GetFileName(x.Path))) + .Select(static x => $"More than one asset has the file name '{x}'.") + .ToList(); + + BuildFailedException.ThrowIf( + problems.Count > 0, + "The release assets cannot go to a Gitea release:" + string.Concat(problems.Select(static x => "\n- " + x))); + } + + /// + /// + /// This method creates nothing. It reads the repository, which fails for a token that cannot reach it, + /// and refuses a repository whose releases are disabled, or that the token cannot push to. + /// Gitea does not state whether the token can write releases. A token without that access fails the + /// release when creates the draft, and the rollback then deletes the tag. + /// + public override async Task BeginAsync() + { + var repositoryPath = $"{_host.Owner}/{_host.Repository}"; + _reporter.Info($"Checking access to {repositoryPath}..."); + var repository = await _host.Client.GetRepositoryAsync(_host.Owner, _host.Repository).ConfigureAwait(false); + BuildFailedException.ThrowIfNot( + repository.HasReleases, + $"The releases of {repositoryPath} are disabled: enable them in the settings of the repository."); + BuildFailedException.ThrowIfNot( + repository.Permissions.Push, + $"The Gitea token cannot push to {repositoryPath}. A release needs write access to the code of the repository."); + } + + /// + public override async Task PublishAsync(string tag, string sha, IReadOnlyList assets) + { + Guard.IsNotNullOrEmpty(tag); + Guard.IsNotNullOrEmpty(sha); + Guard.IsNotNull(assets); + _reporter.Info($"Creating a draft release on tag {tag}..."); + var newRelease = new GiteaNewRelease { + TagName = tag, + TargetCommitish = sha, + Name = tag, + Prerelease = _version.IsPrerelease, + Draft = true, + }; + + var release = await _host.Client.CreateReleaseAsync(_host.Owner, _host.Repository, newRelease).ConfigureAwait(false); + _release = (release.Id, tag); + 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); + } + + var changelogUrl = _host.GetFileUrl("CHANGELOG.md", GitRef.Branch(_git.CurrentBranch)); + _reporter.Info($"Publishing the draft release as {tag}..."); + var update = new GiteaReleaseUpdate { + Name = tag, + Body = $"We have a [human-curated changelog]({changelogUrl}).", + Prerelease = _version.IsPrerelease, + Draft = false, + }; + + await _host.Client.UpdateReleaseAsync(_host.Owner, _host.Repository, release.Id, update).ConfigureAwait(false); + _published = true; + } + + /// + /// + /// A rollback deletes the release tag before this method runs. Gitea then keeps the release as a draft, + /// and keeps a record of the tag once the release is deleted. This method deletes both. + /// A release or a tag record that no longer exists is no failure, because there is nothing left to + /// undo. + /// + public override async Task UndoAsync() + { + if (_release is not (var releaseId, var tag)) + { + return; + } + + _reporter.Info("Deleting the previously created release..."); + var deleted = await _host.Client.DeleteReleaseAsync(_host.Owner, _host.Repository, releaseId).ConfigureAwait(false); + if (deleted) + { + _reporter.Notice(_published ? $"Deleted release {tag}." : $"Deleted the draft release on tag {tag}."); + } + else + { + _reporter.Info(_published ? $"Release {tag} not found." : $"Draft release on tag {tag} not found."); + } + + _reporter.Info($"Deleting the record of tag {tag} on Gitea..."); + _ = await _host.Client.DeleteTagAsync(_host.Owner, _host.Repository, tag).ConfigureAwait(false); + _release = null; + } + + private async Task UploadAssetAsync(GiteaRelease release, ReleaseAsset asset) + { + _reporter.Detail($"Uploading asset {asset.Path}..."); + var contents = UserFile.OpenRead(asset.Path); + await using (contents.ConfigureAwait(false)) + { + await _host.Client.UploadReleaseAssetAsync( + _host.Owner, + _host.Repository, + release.Id, + Path.GetFileName(asset.Path), + contents).ConfigureAwait(false); + } + } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaReleaseUpdate.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaReleaseUpdate.cs new file mode 100644 index 00000000..1770f3ae --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaReleaseUpdate.cs @@ -0,0 +1,33 @@ +// 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.Gitea; + +/// +/// The members bv sends to edit a Gitea release. +/// +/// +/// Gitea keeps the current value of a string member that a request sends empty. +/// +internal sealed record GiteaReleaseUpdate +{ + /// + /// Gets the name of the release. + /// + public required string Name { get; init; } + + /// + /// Gets the description of the release, in Markdown. + /// + public required string Body { get; init; } + + /// + /// Gets a value indicating whether the release is a prerelease. + /// + public required bool Prerelease { get; init; } + + /// + /// Gets a value indicating whether the release is a draft. + /// + public required bool Draft { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaRepository.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaRepository.cs new file mode 100644 index 00000000..a6305e9b --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaRepository.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. + +namespace Buildvana.Tool.Services.Hosts.Gitea; + +/// +/// The members of a Gitea repository that bv reads. +/// +internal sealed record GiteaRepository +{ + /// + /// Gets the owner of the repository. + /// + public required GiteaUser Owner { get; init; } + + /// + /// Gets a value indicating whether the repository is private. + /// + public required bool Private { get; init; } + + /// + /// Gets a value indicating whether the releases unit of the repository is enabled. + /// + public required bool HasReleases { get; init; } + + /// + /// Gets the permissions of the authenticated user on the repository. + /// + public required GiteaPermission Permissions { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaRepositoryUrls.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaRepositoryUrls.cs new file mode 100644 index 00000000..940e9d48 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaRepositoryUrls.cs @@ -0,0 +1,76 @@ +// 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 CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Hosts.Gitea; + +/// +/// Builds the URLs of a Gitea repository and of the resources it contains. +/// +/// +/// The repository URL carries no trailing slash, as says of +/// a GitHub repository URL. +/// +internal sealed class GiteaRepositoryUrls +{ + private readonly string _repository; + + /// + /// Initializes a new instance of the class. + /// + /// The host name of the Gitea instance, such as gitea.com. + /// The owner of the repository. + /// The name of the repository. + public GiteaRepositoryUrls(string hostName, string repositoryOwner, string repositoryName) + { + Guard.IsNotNullOrEmpty(hostName); + Guard.IsNotNullOrEmpty(repositoryOwner); + Guard.IsNotNullOrEmpty(repositoryName); + + _repository = $"https://{hostName}/{repositoryOwner}/{repositoryName}"; + Repository = new Uri(_repository); + } + + /// + /// Gets the URL of the repository, without a trailing slash. + /// + public Uri Repository { get; } + + /// + /// Builds the URL of the release of a tag. + /// + /// The name of the tag. + /// The URL of the release. + public Uri Release(string tag) + { + Guard.IsNotNullOrEmpty(tag); + return new Uri($"{_repository}/releases/tag/{tag}"); + } + + /// + /// Builds the URL of a file of the repository, as of a tag, a branch, or a commit. + /// + /// 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. + /// is rooted, or leaves the repository, as + /// says. + /// + /// A Gitea file URL names the kind of the reference before its name, so that a tag and a branch of + /// the same name lead to different files. + /// + public Uri File(string path, GitRef gitRef) + { + Guard.IsNotNull(gitRef); + var kind = gitRef.Kind switch { + GitRefKind.Tag => "tag", + GitRefKind.Branch => "branch", + GitRefKind.Commit => "commit", + _ => ThrowHelper.ThrowArgumentOutOfRangeException(nameof(gitRef), gitRef.Kind, "Unknown kind of reference."), + }; + + return new Uri($"{_repository}/src/{kind}/{gitRef.Name}/{RepositoryFilePath.ToUrlPath(path)}"); + } +} diff --git a/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaUser.cs b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaUser.cs new file mode 100644 index 00000000..a62ebaa1 --- /dev/null +++ b/src/Buildvana.Tool/Services/Hosts/Gitea/GiteaUser.cs @@ -0,0 +1,15 @@ +// 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.Gitea; + +/// +/// The members of a Gitea user or organization that bv reads. +/// +internal sealed record GiteaUser +{ + /// + /// Gets the visibility of the user or organization: public, limited, or private. + /// + public required string Visibility { get; init; } +} diff --git a/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs b/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs index 53706a5f..339fe4ed 100644 --- a/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs +++ b/src/Buildvana.Tool/Services/Platforms/CIPlatform.cs @@ -17,10 +17,13 @@ namespace Buildvana.Tool.Services.Platforms; /// 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. + // The order matters: each detector runs only when the ones above it found nothing. Forgejo runners v7 to v12 + // set GITEA_ACTIONS, and Gitea and Forgejo runners set GITHUB_ACTIONS. GenericCIPlatform recognizes every + // runner the .NET CLI recognizes, the more specific ones included. private static readonly Func, CIPlatform?>[] Detectors = [ GitLabCIPlatform.DetectIfApplicable, + ForgejoActionsPlatform.DetectIfApplicable, + GiteaActionsPlatform.DetectIfApplicable, GitHubActionsPlatform.DetectIfApplicable, GenericCIPlatform.DetectIfApplicable, ]; diff --git a/src/Buildvana.Tool/Services/Platforms/ForgejoActionsPlatform.cs b/src/Buildvana.Tool/Services/Platforms/ForgejoActionsPlatform.cs new file mode 100644 index 00000000..187a4930 --- /dev/null +++ b/src/Buildvana.Tool/Services/Platforms/ForgejoActionsPlatform.cs @@ -0,0 +1,75 @@ +// 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.Runtime; +using Buildvana.Tool.Services.Git; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Platforms; + +/// +/// The Forgejo Actions platform. +/// +/// +/// Forgejo runners set FORGEJO_SERVER_URL and FORGEJO_OUTPUT since v9.0.0. An older runner +/// fails at , with a message that names FORGEJO_OUTPUT. +/// +internal sealed class ForgejoActionsPlatform : CIPlatform +{ + private const string StepOutputsVariable = "FORGEJO_OUTPUT"; + + private readonly string? _stepOutputsPath; + + private ForgejoActionsPlatform(string? stepOutputsPath, Uri? serverUrl) + { + _stepOutputsPath = stepOutputsPath; + ServerUrl = serverUrl; + } + + /// + public override string Name => "Forgejo Actions"; + + /// + /// Always . + public override bool IsCI => true; + + /// + public override GitIdentity? BotIdentity { get; } = new("forgejo-actions", "noreply@forgejo.org"); + + /// + /// The value of FORGEJO_SERVER_URL. + public override Uri? ServerUrl { get; } + + /// + /// Always . + public override HostKind? ServerKind => HostKind.Forgejo; + + /// + /// Always . + public override bool HasStepOutputs => true; + + /// + /// Creates and returns an instance of if bv runs in a Forgejo Actions runner. + /// + /// A function that returns the value of an environment variable, + /// or when the variable is not set. + /// If FORGEJO_ACTIONS is true, in any case, a newly-created ; + /// otherwise, . + public static CIPlatform? DetectIfApplicable(Func getEnvironmentVariable) + { + Guard.IsNotNull(getEnvironmentVariable); + return string.Equals(getEnvironmentVariable("FORGEJO_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) + ? new ForgejoActionsPlatform( + getEnvironmentVariable(StepOutputsVariable), + ParseServerUrl(getEnvironmentVariable("FORGEJO_SERVER_URL"))) + : 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 index 0eb8e7bf..b6d4dbba 100644 --- a/src/Buildvana.Tool/Services/Platforms/GitHubActionsStepOutputWriter.cs +++ b/src/Buildvana.Tool/Services/Platforms/GitHubActionsStepOutputWriter.cs @@ -15,6 +15,7 @@ namespace Buildvana.Tool.Services.Platforms; /// 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. +/// Gitea and Forgejo runners read the same format, from GITHUB_OUTPUT and FORGEJO_OUTPUT. /// internal sealed class GitHubActionsStepOutputWriter : StepOutputWriter { diff --git a/src/Buildvana.Tool/Services/Platforms/GiteaActionsPlatform.cs b/src/Buildvana.Tool/Services/Platforms/GiteaActionsPlatform.cs new file mode 100644 index 00000000..01bfe804 --- /dev/null +++ b/src/Buildvana.Tool/Services/Platforms/GiteaActionsPlatform.cs @@ -0,0 +1,76 @@ +// 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.Runtime; +using Buildvana.Tool.Services.Git; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.Platforms; + +/// +/// The Gitea Actions platform. +/// +/// +/// A Gitea runner sets no GITEA_SERVER_URL, so the server comes from GITHUB_SERVER_URL. +/// The step outputs go to GITHUB_OUTPUT, which every Gitea runner sets. +/// +internal sealed class GiteaActionsPlatform : CIPlatform +{ + private const string StepOutputsVariable = "GITHUB_OUTPUT"; + + private readonly string? _stepOutputsPath; + + private GiteaActionsPlatform(string? stepOutputsPath, Uri? serverUrl) + { + _stepOutputsPath = stepOutputsPath; + BotIdentity = new("gitea-actions", $"gitea-actions@noreply.{serverUrl?.Host}"); + ServerUrl = serverUrl; + } + + /// + public override string Name => "Gitea Actions"; + + /// + /// Always . + public override bool IsCI => true; + + /// + public override GitIdentity? BotIdentity { get; } + + /// + /// The value of GITHUB_SERVER_URL. + public override Uri? ServerUrl { get; } + + /// + /// Always . + public override HostKind? ServerKind => HostKind.Gitea; + + /// + /// Always . + public override bool HasStepOutputs => true; + + /// + /// Creates and returns an instance of if bv runs in a Gitea Actions runner. + /// + /// A function that returns the value of an environment variable, + /// or when the variable is not set. + /// If GITEA_ACTIONS is true, in any case, a newly-created ; + /// otherwise, . + public static CIPlatform? DetectIfApplicable(Func getEnvironmentVariable) + { + Guard.IsNotNull(getEnvironmentVariable); + return string.Equals(getEnvironmentVariable("GITEA_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) + ? new GiteaActionsPlatform( + getEnvironmentVariable(StepOutputsVariable), + ParseServerUrl(getEnvironmentVariable("GITHUB_SERVER_URL"))) + : 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/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs index f0495809..65b16f5b 100644 --- a/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs @@ -31,6 +31,8 @@ public async Task Create_NoSources_YieldsDomainDefaults() await Assert.That(config.NuGet.Feeds.Release).IsNull(); await Assert.That(config.Hosts.GitHub.TokenEnv).IsEqualTo("GITHUB_TOKEN"); await Assert.That(config.Hosts.GitLab.TokenEnv).IsEqualTo("GITLAB_TOKEN"); + await Assert.That(config.Hosts.Gitea.TokenEnv).IsEqualTo("GITEA_TOKEN"); + await Assert.That(config.Hosts.Forgejo.TokenEnv).IsEqualTo("FORGEJO_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"); @@ -62,6 +64,8 @@ public async Task Create_FileValues_OverrideDefaults() Hosts = new() { GitHub = new() { TokenEnv = "MY_TOKEN" }, GitLab = new() { TokenEnv = "CI_JOB_TOKEN" }, + Gitea = new() { TokenEnv = "MY_GITEA_TOKEN" }, + Forgejo = new() { TokenEnv = "MY_FORGEJO_TOKEN" }, }, }; @@ -81,6 +85,8 @@ public async Task Create_FileValues_OverrideDefaults() await Assert.That(config.DotNet.Configuration).IsEqualTo("Debug"); await Assert.That(config.Hosts.GitHub.TokenEnv).IsEqualTo("MY_TOKEN"); await Assert.That(config.Hosts.GitLab.TokenEnv).IsEqualTo("CI_JOB_TOKEN"); + await Assert.That(config.Hosts.Gitea.TokenEnv).IsEqualTo("MY_GITEA_TOKEN"); + await Assert.That(config.Hosts.Forgejo.TokenEnv).IsEqualTo("MY_FORGEJO_TOKEN"); } [Test] @@ -167,6 +173,12 @@ public async Task Create_TokenEnv_BlankFallsBackToDefault() var gitLab = new BuildvanaJsonConfig { Hosts = new() { GitLab = new() { TokenEnv = " " } } }; await Assert.That(BuildvanaConfigFactory.Create(gitLab, null).Hosts.GitLab.TokenEnv).IsEqualTo("GITLAB_TOKEN"); + + var gitea = new BuildvanaJsonConfig { Hosts = new() { Gitea = new() { TokenEnv = " " } } }; + await Assert.That(BuildvanaConfigFactory.Create(gitea, null).Hosts.Gitea.TokenEnv).IsEqualTo("GITEA_TOKEN"); + + var forgejo = new BuildvanaJsonConfig { Hosts = new() { Forgejo = new() { TokenEnv = " " } } }; + await Assert.That(BuildvanaConfigFactory.Create(forgejo, null).Hosts.Forgejo.TokenEnv).IsEqualTo("FORGEJO_TOKEN"); } // A blank URL names no repository, so it counts as not stated, and bv release refuses to run. diff --git a/tests/Buildvana.Runtime.Tests/HookArgsSecretsTests.cs b/tests/Buildvana.Runtime.Tests/HookArgsSecretsTests.cs index 5b22dd24..aa1bd005 100644 --- a/tests/Buildvana.Runtime.Tests/HookArgsSecretsTests.cs +++ b/tests/Buildvana.Runtime.Tests/HookArgsSecretsTests.cs @@ -42,6 +42,8 @@ public async Task Serialize_WritesCredentialVariableNames_NeverTheirValues() Hosts = new() { GitHub = new() { TokenEnv = credentialEnvVar }, GitLab = new() { TokenEnv = credentialEnvVar }, + Gitea = new() { TokenEnv = credentialEnvVar }, + Forgejo = new() { TokenEnv = credentialEnvVar }, }, NuGet = new() { Feeds = new() { diff --git a/tests/Buildvana.Tool.Tests/CIPlatformTests.cs b/tests/Buildvana.Tool.Tests/CIPlatformTests.cs index 85b9953f..330ee7a3 100644 --- a/tests/Buildvana.Tool.Tests/CIPlatformTests.cs +++ b/tests/Buildvana.Tool.Tests/CIPlatformTests.cs @@ -32,6 +32,40 @@ public async Task Detect_WithGitHubActionsTrue_ReturnsGitHubActionsPlatform(stri await Assert.That(platform.HasStepOutputs).IsTrue(); } + // A Gitea runner sets GITHUB_ACTIONS to true, which the GitHub Actions detector would match too. + [Test] + [Arguments("true")] + [Arguments("TRUE")] + public async Task Detect_WithGiteaActionsTrue_ReturnsGiteaActionsPlatform(string value) + { + var platform = Detect( + ("GITEA_ACTIONS", value), + ("GITHUB_ACTIONS", "true"), + ("CI", "true"), + ("GITHUB_SERVER_URL", "https://gitea.example.invalid")); + + await Assert.That(platform).IsTypeOf(); + await Assert.That(platform.IsCI).IsTrue(); + await Assert.That(platform.BotIdentity!.Name).IsEqualTo("gitea-actions"); + await Assert.That(platform.BotIdentity!.Email).IsEqualTo("gitea-actions@noreply.gitea.example.invalid"); + await Assert.That(platform.HasStepOutputs).IsTrue(); + } + + // Forgejo runners v7 to v12 set GITEA_ACTIONS to true, which the Gitea Actions detector would match too. + [Test] + [Arguments("true")] + [Arguments("TRUE")] + public async Task Detect_WithForgejoActionsTrue_ReturnsForgejoActionsPlatform(string value) + { + var platform = Detect(("FORGEJO_ACTIONS", value), ("GITEA_ACTIONS", "true"), ("GITHUB_ACTIONS", "true"), ("CI", "true")); + + await Assert.That(platform).IsTypeOf(); + await Assert.That(platform.IsCI).IsTrue(); + await Assert.That(platform.BotIdentity!.Name).IsEqualTo("forgejo-actions"); + await Assert.That(platform.BotIdentity!.Email).IsEqualTo("noreply@forgejo.org"); + await Assert.That(platform.HasStepOutputs).IsTrue(); + } + // GitLab CI sets CI to true, which the generic detector would match too. [Test] [Arguments("true")] @@ -106,11 +140,14 @@ public async Task Detect_WithEmptyTeamCityVersion_ReturnsLocalPlatform() } [Test] - public async Task OpenStepOutputs_OnGitHubActionsWithOutputFile_WritesToTheFile() + [Arguments("GITHUB_ACTIONS", "GITHUB_OUTPUT")] + [Arguments("GITEA_ACTIONS", "GITHUB_OUTPUT")] + [Arguments("FORGEJO_ACTIONS", "FORGEJO_OUTPUT")] + public async Task OpenStepOutputs_WithOutputFile_WritesToTheFile(string marker, string outputVariable) { using var home = new TempHome(); var path = home.GetFullPath("output.txt"); - var platform = Detect(("GITHUB_ACTIONS", "true"), ("GITHUB_OUTPUT", path)); + var platform = Detect((marker, "true"), (outputVariable, path)); platform.OpenStepOutputs().Write("version", "1.2.3"); @@ -118,40 +155,53 @@ public async Task OpenStepOutputs_OnGitHubActionsWithOutputFile_WritesToTheFile( } [Test] - [Arguments(null)] - [Arguments("")] - public async Task OpenStepOutputs_OnGitHubActionsWithoutOutputFile_Fails(string? path) + [Arguments("GITHUB_ACTIONS", "GITHUB_OUTPUT", null)] + [Arguments("GITHUB_ACTIONS", "GITHUB_OUTPUT", "")] + [Arguments("GITEA_ACTIONS", "GITHUB_OUTPUT", null)] + [Arguments("GITEA_ACTIONS", "GITHUB_OUTPUT", "")] + [Arguments("FORGEJO_ACTIONS", "FORGEJO_OUTPUT", null)] + [Arguments("FORGEJO_ACTIONS", "FORGEJO_OUTPUT", "")] + public async Task OpenStepOutputs_WithoutOutputFile_Fails(string marker, string outputVariable, string? path) { - var platform = Detect(("GITHUB_ACTIONS", "true"), ("GITHUB_OUTPUT", path)); + var platform = Detect((marker, "true"), (outputVariable, path)); var exception = await Assert.That(platform.OpenStepOutputs).Throws(); - await Assert.That(exception!.Message).Contains("GITHUB_OUTPUT"); + await Assert.That(exception!.Message).Contains(outputVariable); } + // Forgejo runners before v9.0.0 set GITHUB_OUTPUT and no FORGEJO_OUTPUT. [Test] - public async Task OpenStepOutputs_OnPlatformWithoutStepOutputs_Throws() + public async Task OpenStepOutputs_OnForgejoActionsWithGitHubOutputOnly_Fails() { - var platform = Detect(("CI", "true")); + using var home = new TempHome(); + var platform = Detect(("FORGEJO_ACTIONS", "true"), ("GITHUB_OUTPUT", home.GetFullPath("output.txt"))); - await Assert.That(platform.OpenStepOutputs).Throws(); + var exception = await Assert.That(platform.OpenStepOutputs).Throws(); + await Assert.That(exception!.Message).Contains("FORGEJO_OUTPUT"); } [Test] - public async Task Detect_OnGitHubActions_TakesTheServerFromGitHubServerUrl() + public async Task OpenStepOutputs_OnPlatformWithoutStepOutputs_Throws() { - var platform = Detect(("GITHUB_ACTIONS", "true"), ("GITHUB_SERVER_URL", "https://github.example.invalid")); + var platform = Detect(("CI", "true")); - await Assert.That(platform.ServerUrl!.Host).IsEqualTo("github.example.invalid"); - await Assert.That(platform.ServerKind).IsEqualTo(HostKind.GitHub); + await Assert.That(platform.OpenStepOutputs).Throws(); } [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).IsEqualTo(HostKind.GitLab); + [Arguments("GITHUB_ACTIONS", "GITHUB_SERVER_URL", HostKind.GitHub)] + [Arguments("GITLAB_CI", "CI_SERVER_URL", HostKind.GitLab)] + [Arguments("GITEA_ACTIONS", "GITHUB_SERVER_URL", HostKind.Gitea)] + [Arguments("FORGEJO_ACTIONS", "FORGEJO_SERVER_URL", HostKind.Forgejo)] + public async Task Detect_OnAPlatformWithAServer_TakesTheServerFromItsVariable( + string marker, + string serverVariable, + HostKind kind) + { + var platform = Detect((marker, "true"), (serverVariable, "https://git.example.invalid")); + + await Assert.That(platform.ServerUrl!.Host).IsEqualTo("git.example.invalid"); + await Assert.That(platform.ServerKind).IsEqualTo(kind); } // A value that is not an absolute URL names no server, and host detection then has no hint to go on. diff --git a/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs b/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs index 84858c5f..a86b06bf 100644 --- a/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs +++ b/tests/Buildvana.Tool.Tests/DotNetServiceResolutionTests.cs @@ -22,7 +22,7 @@ // repository, so VersionService cannot be constructed, and the build pipeline resolves all the same. internal sealed class DotNetServiceResolutionTests { - private static readonly string[] PlatformVariables = ["GITHUB_ACTIONS", "GITLAB_CI"]; + private static readonly string[] PlatformVariables = ["FORGEJO_ACTIONS", "GITEA_ACTIONS", "GITHUB_ACTIONS", "GITLAB_CI"]; [Test] public async Task GetRequiredService_OnLocalPlatform_ResolvesBuildPipelineWithoutGitRepository() @@ -48,8 +48,12 @@ public async Task GetRequiredService_OnLocalPlatform_ResolvesBuildPipelineWithou public async Task GetRequiredService_OnGitHubActions_ResolvesBuildPipelineWithoutGitRepository() { var originals = PlatformVariables.ToDictionary(x => x, Environment.GetEnvironmentVariable); + foreach (var name in PlatformVariables) + { + Environment.SetEnvironmentVariable(name, null); + } + Environment.SetEnvironmentVariable("GITHUB_ACTIONS", "true"); - Environment.SetEnvironmentVariable("GITLAB_CI", null); try { using var home = CreateHome(); diff --git a/tests/Buildvana.Tool.Tests/ForgejoApiClientTests.cs b/tests/Buildvana.Tool.Tests/ForgejoApiClientTests.cs new file mode 100644 index 00000000..88941739 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/ForgejoApiClientTests.cs @@ -0,0 +1,269 @@ +// 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; +using Buildvana.Tool.Services.Hosts.Forgejo; +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 +// Forgejo documents, not whether the network is up. +internal sealed class ForgejoApiClientTests +{ + private const string Owner = "Tenacom"; + private const string Repo = "Buildvana"; + private const string Token = "secret-token"; + private const string ApiRepositoryUrl = "https://forgejo.example.com/api/v1/repos/Tenacom/Buildvana"; + + private const string RepositoryJson = """ + { + "owner": { "login": "Tenacom", "visibility": "limited" }, + "private": false, + "has_releases": true, + "permissions": { "admin": false, "push": true, "pull": true } + } + """; + + private static readonly ForgejoNewRelease NewRelease = new() { + TagName = "1.0.0", + TargetCommitish = "abc123", + Name = "1.0.0", + Prerelease = true, + Draft = true, + }; + + private static readonly ForgejoReleaseUpdate ReleaseUpdate = new() { + Name = "1.0.0", + Body = "Text", + Prerelease = true, + Draft = false, + }; + + [Test] + public async Task BaseUrl_IsApiV1OfTheHost() + { + using var client = NewClient(Answering()); + await Assert.That(client.BaseUrl.AbsoluteUri).IsEqualTo("https://forgejo.example.com/api/v1/"); + } + + [Test] + [Arguments("", Token)] + [Arguments("codeberg.org", "")] + public async Task Constructor_RejectsEmptyArguments(string hostName, string token) + { + await Assert.That(() => new ForgejoApiClient(hostName, token, Answering())).Throws(); + } + + [Test] + public async Task EveryRequest_CarriesTheApiHeaders() + { + var handler = Answering((HttpMethod.Get, ApiRepositoryUrl), RepositoryJson); + using var client = NewClient(handler); + _ = await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false); + var headers = handler.Requests[0].Headers; + await Assert.That(headers["Accept"]).IsEqualTo("application/json"); + await Assert.That(headers["Authorization"]).IsEqualTo("token secret-token"); + await Assert.That(headers["User-Agent"]).IsEqualTo($"bv/{OwnVersion.Value.ToNormalizedString()}"); + } + + [Test] + public async Task GetRepositoryAsync_ReadsTheRepository() + { + var handler = Answering((HttpMethod.Get, ApiRepositoryUrl), RepositoryJson); + using var client = NewClient(handler); + var repository = await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false); + await Assert.That(repository.Owner.Visibility).IsEqualTo("limited"); + await Assert.That(repository.Private).IsFalse(); + await Assert.That(repository.HasReleases).IsTrue(); + await Assert.That(repository.Permissions.Push).IsTrue(); + await Assert.That(handler.Requests[0].Body).IsNull(); + } + + [Test] + public async Task GetRepositoryAsync_EscapesTheOwnerAndTheName() + { + var handler = Answering(); + using var client = NewClient(handler); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await client.GetRepositoryAsync("Ten acom", "Build#vana").ConfigureAwait(false)) + .Throws(); + await Assert.That(handler.Requests[0].Url).IsEqualTo("https://forgejo.example.com/api/v1/repos/Ten%20acom/Build%23vana"); + } + + [Test] + public async Task GetRepositoryAsync_WhenTheAnswerLacksThePermissions_Fails() + { + const string body = """{"owner":{"visibility":"public"},"private":false,"has_releases":true}"""; + using var client = NewClient(Answering((HttpMethod.Get, ApiRepositoryUrl), body)); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws(); + } + + [Test] + public async Task CreateReleaseAsync_PostsTheNewRelease_AndReadsTheRelease() + { + var handler = Answering( + (HttpMethod.Post, ApiRepositoryUrl + "/releases"), + """{"id":42,"tag_name":"1.0.0","draft":true}""", + HttpStatusCode.Created); + using var client = NewClient(handler); + var release = await client.CreateReleaseAsync(Owner, Repo, NewRelease).ConfigureAwait(false); + await Assert.That(release.Id).IsEqualTo(42); + var request = handler.Requests[0]; + await Assert.That(request.Headers["Content-Type"]).IsEqualTo("application/json; charset=utf-8"); + await Assert.That(request.Body) + .IsEqualTo("""{"tag_name":"1.0.0","target_commitish":"abc123","name":"1.0.0","prerelease":true,"draft":true}"""); + } + + [Test] + public async Task UpdateReleaseAsync_PatchesTheRelease() + { + var handler = Answering((HttpMethod.Patch, ApiRepositoryUrl + "/releases/42"), """{"id":42}"""); + using var client = NewClient(handler); + await client.UpdateReleaseAsync(Owner, Repo, 42, ReleaseUpdate).ConfigureAwait(false); + await Assert.That(handler.Requests[0].Body) + .IsEqualTo("""{"name":"1.0.0","body":"Text","prerelease":true,"draft":false}"""); + } + + [Test] + public async Task DeleteReleaseAsync_WhenTheReleaseIsDeleted_ReturnsTrue() + { + var handler = Answering((HttpMethod.Delete, ApiRepositoryUrl + "/releases/42"), null, HttpStatusCode.NoContent); + using var client = NewClient(handler); + await Assert.That(await client.DeleteReleaseAsync(Owner, Repo, 42).ConfigureAwait(false)).IsTrue(); + await Assert.That(handler.Requests).Count().IsEqualTo(1); + } + + [Test] + public async Task DeleteReleaseAsync_WhenTheApiAnswersNotFound_ReturnsFalse() + { + var handler = Answering(); + using var client = NewClient(handler); + await Assert.That(await client.DeleteReleaseAsync(Owner, Repo, 42).ConfigureAwait(false)).IsFalse(); + await Assert.That(handler.Requests[0].Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(handler.Requests[0].Url).IsEqualTo(ApiRepositoryUrl + "/releases/42"); + } + + [Test] + public async Task DeleteTagAsync_WhenTheTagIsDeleted_ReturnsTrue() + { + var handler = Answering((HttpMethod.Delete, ApiRepositoryUrl + "/tags/1.0.0"), null, HttpStatusCode.NoContent); + using var client = NewClient(handler); + await Assert.That(await client.DeleteTagAsync(Owner, Repo, "1.0.0").ConfigureAwait(false)).IsTrue(); + } + + [Test] + public async Task DeleteTagAsync_WhenTheApiAnswersNotFound_ReturnsFalse() + { + using var client = NewClient(Answering()); + await Assert.That(await client.DeleteTagAsync(Owner, Repo, "1.0.0").ConfigureAwait(false)).IsFalse(); + } + + [Test] + public async Task DeleteTagAsync_KeepsTheSlashesOfATagName() + { + var handler = Answering(); + using var client = NewClient(handler); + _ = await client.DeleteTagAsync(Owner, Repo, "release/1.0 beta").ConfigureAwait(false); + await Assert.That(handler.Requests[0].Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(handler.Requests[0].Url).IsEqualTo(ApiRepositoryUrl + "/tags/release/1.0%20beta"); + } + + [Test] + public async Task UploadReleaseAssetAsync_PostsTheContentsAsTheAttachmentField() + { + const string url = ApiRepositoryUrl + "/releases/42/assets?name=Foo%2B1.0.0.nupkg"; + const string answer = """{"id":7,"name":"Foo+1.0.0.nupkg","type":"attachment"}"""; + var handler = Answering((HttpMethod.Post, url), answer, HttpStatusCode.Created); + using var client = NewClient(handler); + await UploadAsync(client, "Foo+1.0.0.nupkg").ConfigureAwait(false); + var request = handler.Requests[0]; + await Assert.That(request.Headers["Content-Type"]).StartsWith("multipart/form-data; boundary="); + await Assert.That(request.Body).Contains("name=attachment"); + await Assert.That(request.Body).Contains("filename=Foo+1.0.0.nupkg"); + await Assert.That(request.Body).Contains("Content-Type: application/octet-stream"); + await Assert.That(request.Body).Contains("asset contents"); + } + + [Test] + [Arguments("GetRepository")] + [Arguments("CreateRelease")] + [Arguments("UpdateRelease")] + [Arguments("DeleteRelease")] + [Arguments("DeleteTag")] + [Arguments("UploadReleaseAsset")] + public async Task Operation_WhenTheApiRefuses_FailsWithTheStatusAndTheMessage(string operation) + { + const string body = """{"message":"release 1.0.0 already exists","url":"https://forgejo.example.com/api/swagger"}"""; + using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.Conflict, body))); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + var exception = await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) + .Throws(); + await Assert.That(exception!.Message).Contains("409 Conflict: release 1.0.0 already exists"); + } + + [Test] + public async Task Operation_WhenTheApiAnswersNotFoundWithErrors_FailsWithTheMessage() + { + // A Forgejo 404 holds an "errors" member next to the message. + const string body = """ + { + "message": "The target couldn't be found.", + "url": "https://forgejo.example.com/api/swagger", + "errors": ["user redirect does not exist"] + } + """; + using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.NotFound, body))); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + var exception = await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws(); + await Assert.That(exception!.Message).Contains("404 Not Found: The target couldn't be found."); + } + + [Test] + [Arguments("Bad gateway")] + [Arguments("{}")] + [Arguments("""{"message":"","url":"https://forgejo.example.com/api/swagger"}""")] + [Arguments("null")] + public async Task Operation_WhenTheErrorAnswerStatesNoMessage_FailsWithTheStatus(string body) + { + using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.BadGateway, body))); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + var exception = await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws(); + await Assert.That(exception!.Message).EndsWith("with 502 Bad Gateway."); + } + + private static ForgejoApiClient NewClient(StubHttpMessageHandler handler) => new("forgejo.example.com", Token, handler); + + private static StubHttpMessageHandler Answering() => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); + + private static StubHttpMessageHandler AnsweringEverything(StubHttpAnswer answer) + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>(), answer); + + private static StubHttpMessageHandler Answering( + (HttpMethod Method, string Url) request, + string? body, + HttpStatusCode status = HttpStatusCode.OK) + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer> { [request] = new(status, body) }); + + private static Task InvokeAsync(ForgejoApiClient client, string operation) + => operation switch { + "GetRepository" => client.GetRepositoryAsync(Owner, Repo), + "CreateRelease" => client.CreateReleaseAsync(Owner, Repo, NewRelease), + "UpdateRelease" => client.UpdateReleaseAsync(Owner, Repo, 42, ReleaseUpdate), + "DeleteRelease" => client.DeleteReleaseAsync(Owner, Repo, 42), + "DeleteTag" => client.DeleteTagAsync(Owner, Repo, "1.0.0"), + "UploadReleaseAsset" => UploadAsync(client, "Foo.1.0.0.nupkg"), + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, "Unknown operation."), + }; + + private static Task UploadAsync(ForgejoApiClient client, string fileName) + => client.UploadReleaseAssetAsync(Owner, Repo, 42, fileName, new MemoryStream("asset contents"u8.ToArray())); +} diff --git a/tests/Buildvana.Tool.Tests/ForgejoHostTests.cs b/tests/Buildvana.Tool.Tests/ForgejoHostTests.cs new file mode 100644 index 00000000..e5a124ee --- /dev/null +++ b/tests/Buildvana.Tool.Tests/ForgejoHostTests.cs @@ -0,0 +1,154 @@ +// 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.Forgejo; +using Microsoft.Extensions.DependencyInjection; + +internal sealed class ForgejoHostTests +{ + private const string TokenVariable = "BV_TEST_FORGEJO_HOST_TOKEN"; + + [Test] + public async Task Members_DescribeTheRepository() + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + using var host = new ForgejoHost(services, Parse("git@codeberg.org:tenacom/buildvana.git")); + + await Assert.That(host.Name).IsEqualTo("Forgejo"); + await Assert.That(host.Kind).IsEqualTo(HostKind.Forgejo); + await Assert.That(host.RepositoryUrl.AbsoluteUri).IsEqualTo("https://codeberg.org/tenacom/buildvana"); + await Assert.That(host.TokenEnv).IsEqualTo(TokenVariable); + await Assert.That(host.PushUsername).IsEqualTo("token"); + await Assert.That(host.GetReleaseUrl("1.0.0").AbsoluteUri) + .IsEqualTo("https://codeberg.org/tenacom/buildvana/releases/tag/1.0.0"); + } + } + + // Forgejo names the kind of the reference in a file URL, so a tag and a branch of the same name differ. + [Test] + [Arguments(GitRefKind.Tag, "tag")] + [Arguments(GitRefKind.Branch, "branch")] + [Arguments(GitRefKind.Commit, "commit")] + public async Task GetFileUrl_NamesTheKindOfTheReference(GitRefKind kind, string expectedSegment) + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + using var host = new ForgejoHost(services, Parse("https://codeberg.org/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://codeberg.org/tenacom/buildvana/src/{expectedSegment}/v1/docs/README.md"); + } + } + + [Test] + [Arguments("../outside.md")] + [Arguments("/docs/README.md")] + public async Task GetFileUrl_ForAPathOutsideTheRepository_Throws(string path) + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + using var host = new ForgejoHost(services, Parse("https://codeberg.org/tenacom/buildvana")); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before host is disposed + await Assert.That(() => host.GetFileUrl(path, GitRef.Branch("main"))).Throws(); + } + } + + [Test] + public async Task Constructor_ForAPathOfThreeSegments_Throws() + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion runs the lambda before services is disposed + var exception = await Assert.That(() => new ForgejoHost(services, Parse("https://codeberg.org/a/b/c"))) + .Throws(); + await Assert.That(exception!.Message).Contains("not a valid Forgejo repository URL"); + } + } + + // Anonymous users cannot see the repositories of an owner of limited or private visibility. + [Test] + [NotInParallel] + [Arguments(true, "public", true)] + [Arguments(false, "limited", true)] + [Arguments(false, "private", true)] + [Arguments(false, "public", false)] + public async Task IsPrivateRepositoryAsync_ReadsThePrivacyOfTheRepository_AndTheVisibilityOfItsOwner( + bool isPrivate, + string ownerVisibility, + bool expected) + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + var api = new StubHttpMessageHandler(new Dictionary { + ["https://codeberg.org/api/v1/repos/tenacom/buildvana"] = Repository(isPrivate, ownerVisibility), + }); + using var host = new ForgejoHost(services, Parse("https://codeberg.org/tenacom/buildvana"), api); + Environment.SetEnvironmentVariable(TokenVariable, "secret"); + try + { + await Assert.That(await host.IsPrivateRepositoryAsync().ConfigureAwait(false)).IsEqualTo(expected); + await Assert.That(api.Requests[0].Headers["Authorization"]).IsEqualTo("token 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 ForgejoHost(services, Parse("https://codeberg.org/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 string Repository(bool isPrivate, string ownerVisibility) + => $$""" + { + "owner": { "login": "tenacom", "visibility": "{{ownerVisibility}}" }, + "private": {{(isPrivate ? "true" : "false")}}, + "has_releases": true, + "permissions": { "push": true } + } + """; + + 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() { Forgejo = new() { TokenEnv = TokenVariable } } }) + .AddSingleton(NullReporter.Instance) + .BuildServiceProvider(); +} diff --git a/tests/Buildvana.Tool.Tests/GitHostTests.cs b/tests/Buildvana.Tool.Tests/GitHostTests.cs index 7a5d28ec..62609f91 100644 --- a/tests/Buildvana.Tool.Tests/GitHostTests.cs +++ b/tests/Buildvana.Tool.Tests/GitHostTests.cs @@ -7,6 +7,8 @@ using Buildvana.Runtime; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Hosts; +using Buildvana.Tool.Services.Hosts.Forgejo; +using Buildvana.Tool.Services.Hosts.Gitea; using Buildvana.Tool.Services.Hosts.GitHub; using Buildvana.Tool.Services.Hosts.GitLab; using Buildvana.Tool.Services.Platforms; @@ -42,6 +44,24 @@ public async Task DetectKind_ForGitLabCom_IsGitLab(string url) await Assert.That(GitHost.DetectKind(Repository(url), Parse(url), new FakeCIPlatform())).IsEqualTo(HostKind.GitLab); } + [Test] + [Arguments("https://gitea.com/tenacom/buildvana")] + [Arguments("https://Gitea.com/tenacom/buildvana.git")] + [Arguments("git@gitea.com:tenacom/buildvana.git")] + public async Task DetectKind_ForGiteaCom_IsGitea(string url) + { + await Assert.That(GitHost.DetectKind(Repository(url), Parse(url), new FakeCIPlatform())).IsEqualTo(HostKind.Gitea); + } + + [Test] + [Arguments("https://codeberg.org/tenacom/buildvana")] + [Arguments("https://Codeberg.org/tenacom/buildvana.git")] + [Arguments("git@codeberg.org:tenacom/buildvana.git")] + public async Task DetectKind_ForCodebergOrg_IsForgejo(string url) + { + await Assert.That(GitHost.DetectKind(Repository(url), Parse(url), new FakeCIPlatform())).IsEqualTo(HostKind.Forgejo); + } + [Test] public async Task DetectKind_WhenTheConfigurationStatesTheHost_IsThatHost() { @@ -113,6 +133,26 @@ public async Task Create_ForGitLabCom_CreatesAGitLabHost() } } + [Test] + public async Task Create_ForGiteaCom_CreatesAGiteaHost() + { + var services = Services("https://gitea.com/tenacom/buildvana"); + await using (services.ConfigureAwait(false)) + { + await Assert.That(GitHost.Create(services)).IsTypeOf(); + } + } + + [Test] + public async Task Create_ForCodebergOrg_CreatesAForgejoHost() + { + var services = Services("https://codeberg.org/tenacom/buildvana"); + await using (services.ConfigureAwait(false)) + { + await Assert.That(GitHost.Create(services)).IsTypeOf(); + } + } + [Test] public async Task Create_ForAnotherHost_CreatesAnUnknownHost() { @@ -135,7 +175,7 @@ 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 + // ReSharper disable once AccessToDisposedClosure // False positive: the lambda runs before services is disposed var exception = await Assert.That(() => GitHost.Create(services)).Throws(); await Assert.That(exception!.Message).Contains("names no repository"); } @@ -161,7 +201,8 @@ public async Task UnknownHost_HasNoUrls_NoToken_AndAPublisherThatPublishesNothin 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)."); + 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] @@ -194,7 +235,8 @@ public async Task GitRef_RejectsAnEmptyName() 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 GitUrlInfo Parse(string url) + => GitUrlInfo.TryCreate(url, out var info) ? info : throw new InvalidOperationException(url); private static ServiceProvider Services(string url) => new ServiceCollection() diff --git a/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs index e1a044dc..993f8fac 100644 --- a/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs +++ b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs @@ -149,14 +149,24 @@ await Assert.That(handler.Requests[0].Body) } [Test] - public async Task DeleteReleaseAsync_DeletesTheRelease() + public async Task DeleteReleaseAsync_WhenTheReleaseIsDeleted_ReturnsTrue() { var handler = Answering((HttpMethod.Delete, ApiRepositoryUrl + "/releases/42"), null, HttpStatusCode.NoContent); using var client = NewClient(handler); - await client.DeleteReleaseAsync(Owner, Repo, 42).ConfigureAwait(false); + await Assert.That(await client.DeleteReleaseAsync(Owner, Repo, 42).ConfigureAwait(false)).IsTrue(); await Assert.That(handler.Requests).Count().IsEqualTo(1); } + [Test] + public async Task DeleteReleaseAsync_WhenTheApiAnswersNotFound_ReturnsFalse() + { + var handler = Answering(); + using var client = NewClient(handler); + await Assert.That(await client.DeleteReleaseAsync(Owner, Repo, 42).ConfigureAwait(false)).IsFalse(); + await Assert.That(handler.Requests[0].Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(handler.Requests[0].Url).IsEqualTo(ApiRepositoryUrl + "/releases/42"); + } + [Test] public async Task TagExistsAsync_WhenTheTagExists_ReturnsTrue() { diff --git a/tests/Buildvana.Tool.Tests/GiteaApiClientTests.cs b/tests/Buildvana.Tool.Tests/GiteaApiClientTests.cs new file mode 100644 index 00000000..0c0aab58 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/GiteaApiClientTests.cs @@ -0,0 +1,249 @@ +// 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; +using Buildvana.Tool.Services.Hosts.Gitea; +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 +// Gitea documents, not whether the network is up. +internal sealed class GiteaApiClientTests +{ + private const string Owner = "Tenacom"; + private const string Repo = "Buildvana"; + private const string Token = "secret-token"; + private const string ApiRepositoryUrl = "https://gitea.example.com/api/v1/repos/Tenacom/Buildvana"; + + private const string RepositoryJson = """ + { + "owner": { "login": "Tenacom", "visibility": "limited" }, + "private": false, + "has_releases": true, + "permissions": { "admin": false, "push": true, "pull": true } + } + """; + + private static readonly GiteaNewRelease NewRelease = new() { + TagName = "1.0.0", + TargetCommitish = "abc123", + Name = "1.0.0", + Prerelease = true, + Draft = true, + }; + + private static readonly GiteaReleaseUpdate ReleaseUpdate = new() { + Name = "1.0.0", + Body = "Text", + Prerelease = true, + Draft = false, + }; + + [Test] + public async Task BaseUrl_IsApiV1OfTheHost() + { + using var client = NewClient(Answering()); + await Assert.That(client.BaseUrl.AbsoluteUri).IsEqualTo("https://gitea.example.com/api/v1/"); + } + + [Test] + [Arguments("", Token)] + [Arguments("gitea.com", "")] + public async Task Constructor_RejectsEmptyArguments(string hostName, string token) + { + await Assert.That(() => new GiteaApiClient(hostName, token, Answering())).Throws(); + } + + [Test] + public async Task EveryRequest_CarriesTheApiHeaders() + { + var handler = Answering((HttpMethod.Get, ApiRepositoryUrl), RepositoryJson); + using var client = NewClient(handler); + _ = await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false); + var headers = handler.Requests[0].Headers; + await Assert.That(headers["Accept"]).IsEqualTo("application/json"); + await Assert.That(headers["Authorization"]).IsEqualTo("token secret-token"); + await Assert.That(headers["User-Agent"]).IsEqualTo($"bv/{OwnVersion.Value.ToNormalizedString()}"); + } + + [Test] + public async Task GetRepositoryAsync_ReadsTheRepository() + { + var handler = Answering((HttpMethod.Get, ApiRepositoryUrl), RepositoryJson); + using var client = NewClient(handler); + var repository = await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false); + await Assert.That(repository.Owner.Visibility).IsEqualTo("limited"); + await Assert.That(repository.Private).IsFalse(); + await Assert.That(repository.HasReleases).IsTrue(); + await Assert.That(repository.Permissions.Push).IsTrue(); + await Assert.That(handler.Requests[0].Body).IsNull(); + } + + [Test] + public async Task GetRepositoryAsync_EscapesTheOwnerAndTheName() + { + var handler = Answering(); + using var client = NewClient(handler); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await client.GetRepositoryAsync("Ten acom", "Build#vana").ConfigureAwait(false)) + .Throws(); + await Assert.That(handler.Requests[0].Url).IsEqualTo("https://gitea.example.com/api/v1/repos/Ten%20acom/Build%23vana"); + } + + [Test] + public async Task GetRepositoryAsync_WhenTheAnswerLacksThePermissions_Fails() + { + const string body = """{"owner":{"visibility":"public"},"private":false,"has_releases":true}"""; + using var client = NewClient(Answering((HttpMethod.Get, ApiRepositoryUrl), body)); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws(); + } + + [Test] + public async Task CreateReleaseAsync_PostsTheNewRelease_AndReadsTheRelease() + { + var handler = Answering( + (HttpMethod.Post, ApiRepositoryUrl + "/releases"), + """{"id":42,"tag_name":"1.0.0","draft":true}""", + HttpStatusCode.Created); + using var client = NewClient(handler); + var release = await client.CreateReleaseAsync(Owner, Repo, NewRelease).ConfigureAwait(false); + await Assert.That(release.Id).IsEqualTo(42); + var request = handler.Requests[0]; + await Assert.That(request.Headers["Content-Type"]).IsEqualTo("application/json; charset=utf-8"); + await Assert.That(request.Body) + .IsEqualTo("""{"tag_name":"1.0.0","target_commitish":"abc123","name":"1.0.0","prerelease":true,"draft":true}"""); + } + + [Test] + public async Task UpdateReleaseAsync_PatchesTheRelease() + { + var handler = Answering((HttpMethod.Patch, ApiRepositoryUrl + "/releases/42"), """{"id":42}"""); + using var client = NewClient(handler); + await client.UpdateReleaseAsync(Owner, Repo, 42, ReleaseUpdate).ConfigureAwait(false); + await Assert.That(handler.Requests[0].Body) + .IsEqualTo("""{"name":"1.0.0","body":"Text","prerelease":true,"draft":false}"""); + } + + [Test] + public async Task DeleteReleaseAsync_WhenTheReleaseIsDeleted_ReturnsTrue() + { + var handler = Answering((HttpMethod.Delete, ApiRepositoryUrl + "/releases/42"), null, HttpStatusCode.NoContent); + using var client = NewClient(handler); + await Assert.That(await client.DeleteReleaseAsync(Owner, Repo, 42).ConfigureAwait(false)).IsTrue(); + await Assert.That(handler.Requests).Count().IsEqualTo(1); + } + + [Test] + public async Task DeleteReleaseAsync_WhenTheApiAnswersNotFound_ReturnsFalse() + { + var handler = Answering(); + using var client = NewClient(handler); + await Assert.That(await client.DeleteReleaseAsync(Owner, Repo, 42).ConfigureAwait(false)).IsFalse(); + await Assert.That(handler.Requests[0].Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(handler.Requests[0].Url).IsEqualTo(ApiRepositoryUrl + "/releases/42"); + } + + [Test] + public async Task DeleteTagAsync_WhenTheTagIsDeleted_ReturnsTrue() + { + var handler = Answering((HttpMethod.Delete, ApiRepositoryUrl + "/tags/1.0.0"), null, HttpStatusCode.NoContent); + using var client = NewClient(handler); + await Assert.That(await client.DeleteTagAsync(Owner, Repo, "1.0.0").ConfigureAwait(false)).IsTrue(); + } + + [Test] + public async Task DeleteTagAsync_WhenTheApiAnswersNotFound_ReturnsFalse() + { + using var client = NewClient(Answering()); + await Assert.That(await client.DeleteTagAsync(Owner, Repo, "1.0.0").ConfigureAwait(false)).IsFalse(); + } + + [Test] + public async Task DeleteTagAsync_KeepsTheSlashesOfATagName() + { + var handler = Answering(); + using var client = NewClient(handler); + _ = await client.DeleteTagAsync(Owner, Repo, "release/1.0 beta").ConfigureAwait(false); + await Assert.That(handler.Requests[0].Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(handler.Requests[0].Url).IsEqualTo(ApiRepositoryUrl + "/tags/release/1.0%20beta"); + } + + [Test] + public async Task UploadReleaseAssetAsync_PostsTheContentsAsTheAttachmentField() + { + const string url = ApiRepositoryUrl + "/releases/42/assets?name=Foo%2B1.0.0.nupkg"; + var handler = Answering((HttpMethod.Post, url), """{"id":7,"name":"Foo+1.0.0.nupkg"}""", HttpStatusCode.Created); + using var client = NewClient(handler); + await UploadAsync(client, "Foo+1.0.0.nupkg").ConfigureAwait(false); + var request = handler.Requests[0]; + await Assert.That(request.Headers["Content-Type"]).StartsWith("multipart/form-data; boundary="); + await Assert.That(request.Body).Contains("name=attachment"); + await Assert.That(request.Body).Contains("filename=Foo+1.0.0.nupkg"); + await Assert.That(request.Body).Contains("Content-Type: application/octet-stream"); + await Assert.That(request.Body).Contains("asset contents"); + } + + [Test] + [Arguments("GetRepository")] + [Arguments("CreateRelease")] + [Arguments("UpdateRelease")] + [Arguments("DeleteRelease")] + [Arguments("DeleteTag")] + [Arguments("UploadReleaseAsset")] + public async Task Operation_WhenTheApiRefuses_FailsWithTheStatusAndTheMessage(string operation) + { + const string body = """{"message":"release 1.0.0 already exists","url":"https://gitea.example.com/api/swagger"}"""; + using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.Conflict, body))); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + var exception = await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) + .Throws(); + await Assert.That(exception!.Message).Contains("409 Conflict: release 1.0.0 already exists"); + } + + [Test] + [Arguments("Bad gateway")] + [Arguments("{}")] + [Arguments("""{"message":"","url":"https://gitea.example.com/api/swagger"}""")] + [Arguments("null")] + public async Task Operation_WhenTheErrorAnswerStatesNoMessage_FailsWithTheStatus(string body) + { + using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.BadGateway, body))); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + var exception = await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws(); + await Assert.That(exception!.Message).EndsWith("with 502 Bad Gateway."); + } + + private static GiteaApiClient NewClient(StubHttpMessageHandler handler) => new("gitea.example.com", Token, handler); + + private static StubHttpMessageHandler Answering() => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); + + private static StubHttpMessageHandler AnsweringEverything(StubHttpAnswer answer) + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>(), answer); + + private static StubHttpMessageHandler Answering( + (HttpMethod Method, string Url) request, + string? body, + HttpStatusCode status = HttpStatusCode.OK) + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer> { [request] = new(status, body) }); + + private static Task InvokeAsync(GiteaApiClient client, string operation) + => operation switch { + "GetRepository" => client.GetRepositoryAsync(Owner, Repo), + "CreateRelease" => client.CreateReleaseAsync(Owner, Repo, NewRelease), + "UpdateRelease" => client.UpdateReleaseAsync(Owner, Repo, 42, ReleaseUpdate), + "DeleteRelease" => client.DeleteReleaseAsync(Owner, Repo, 42), + "DeleteTag" => client.DeleteTagAsync(Owner, Repo, "1.0.0"), + "UploadReleaseAsset" => UploadAsync(client, "Foo.1.0.0.nupkg"), + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, "Unknown operation."), + }; + + private static Task UploadAsync(GiteaApiClient client, string fileName) + => client.UploadReleaseAssetAsync(Owner, Repo, 42, fileName, new MemoryStream("asset contents"u8.ToArray())); +} diff --git a/tests/Buildvana.Tool.Tests/GiteaHostTests.cs b/tests/Buildvana.Tool.Tests/GiteaHostTests.cs new file mode 100644 index 00000000..3f82fd51 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/GiteaHostTests.cs @@ -0,0 +1,153 @@ +// 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.Gitea; +using Microsoft.Extensions.DependencyInjection; + +internal sealed class GiteaHostTests +{ + private const string TokenVariable = "BV_TEST_GITEA_HOST_TOKEN"; + + [Test] + public async Task Members_DescribeTheRepository() + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + using var host = new GiteaHost(services, Parse("git@gitea.com:tenacom/buildvana.git")); + + await Assert.That(host.Name).IsEqualTo("Gitea"); + await Assert.That(host.Kind).IsEqualTo(HostKind.Gitea); + await Assert.That(host.RepositoryUrl.AbsoluteUri).IsEqualTo("https://gitea.com/tenacom/buildvana"); + await Assert.That(host.TokenEnv).IsEqualTo(TokenVariable); + await Assert.That(host.PushUsername).IsEqualTo("token"); + await Assert.That(host.GetReleaseUrl("1.0.0").AbsoluteUri).IsEqualTo("https://gitea.com/tenacom/buildvana/releases/tag/1.0.0"); + } + } + + // Gitea names the kind of the reference in a file URL, so a tag and a branch of the same name differ. + [Test] + [Arguments(GitRefKind.Tag, "tag")] + [Arguments(GitRefKind.Branch, "branch")] + [Arguments(GitRefKind.Commit, "commit")] + public async Task GetFileUrl_NamesTheKindOfTheReference(GitRefKind kind, string expectedSegment) + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + using var host = new GiteaHost(services, Parse("https://gitea.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://gitea.com/tenacom/buildvana/src/{expectedSegment}/v1/docs/README.md"); + } + } + + [Test] + [Arguments("../outside.md")] + [Arguments("/docs/README.md")] + public async Task GetFileUrl_ForAPathOutsideTheRepository_Throws(string path) + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + using var host = new GiteaHost(services, Parse("https://gitea.com/tenacom/buildvana")); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before host is disposed + await Assert.That(() => host.GetFileUrl(path, GitRef.Branch("main"))).Throws(); + } + } + + [Test] + public async Task Constructor_ForAPathOfThreeSegments_Throws() + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion runs the lambda before services is disposed + var exception = await Assert.That(() => new GiteaHost(services, Parse("https://gitea.com/a/b/c"))) + .Throws(); + await Assert.That(exception!.Message).Contains("not a valid Gitea repository URL"); + } + } + + // Anonymous users cannot see the repositories of an owner of limited or private visibility. + [Test] + [NotInParallel] + [Arguments(true, "public", true)] + [Arguments(false, "limited", true)] + [Arguments(false, "private", true)] + [Arguments(false, "public", false)] + public async Task IsPrivateRepositoryAsync_ReadsThePrivacyOfTheRepository_AndTheVisibilityOfItsOwner( + bool isPrivate, + string ownerVisibility, + bool expected) + { + var services = Services(); + await using (services.ConfigureAwait(false)) + { + var api = new StubHttpMessageHandler(new Dictionary { + ["https://gitea.com/api/v1/repos/tenacom/buildvana"] = Repository(isPrivate, ownerVisibility), + }); + using var host = new GiteaHost(services, Parse("https://gitea.com/tenacom/buildvana"), api); + Environment.SetEnvironmentVariable(TokenVariable, "secret"); + try + { + await Assert.That(await host.IsPrivateRepositoryAsync().ConfigureAwait(false)).IsEqualTo(expected); + await Assert.That(api.Requests[0].Headers["Authorization"]).IsEqualTo("token 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 GiteaHost(services, Parse("https://gitea.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 string Repository(bool isPrivate, string ownerVisibility) + => $$""" + { + "owner": { "login": "tenacom", "visibility": "{{ownerVisibility}}" }, + "private": {{(isPrivate ? "true" : "false")}}, + "has_releases": true, + "permissions": { "push": true } + } + """; + + 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() { Gitea = new() { TokenEnv = TokenVariable } } }) + .AddSingleton(NullReporter.Instance) + .BuildServiceProvider(); +} diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandForgejoTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandForgejoTests.cs new file mode 100644 index 00000000..f4d0be76 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandForgejoTests.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.Net; +using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; +using TUnit.Assertions.Enums; + +/// +/// End-to-end tests of the release command on a real Forgejo host, whose API answers from a table +/// (see ). +/// +/// +/// The repository URL of the harness is not codeberg.org, so the host stands for a self-hosted +/// instance, with its API under /api/v1. +/// +[NotInParallel] +internal sealed class ReleaseCommandForgejoTests +{ + private const string ReleasedVersion = "2.3.2-preview"; + private const string ApiRepositoryUrl = "https://git.example.invalid/api/v1/repos/tenacom/test-repo"; + private const string ReleaseUrl = ApiRepositoryUrl + "/releases/42"; + + [Test] + public async Task Release_ChecksTheRepository_CreatesADraftOnTheTag_UploadsTheAssets_AndPublishesIt() + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.Created, """{"id":1}""")); + using var harness = new ReleaseHarness(new() { ForgejoApi = 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.Get); + await Assert.That(requests[0].Url).IsEqualTo(ApiRepositoryUrl); + + // The draft goes on the pushed tag, then one upload per asset, then the update that publishes the draft. + var releaseCommitSha = harness.Repo.GetCommits(2)[1].Sha; + var draft = requests[1]; + await Assert.That(draft.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(draft.Url).IsEqualTo(ApiRepositoryUrl + "/releases"); + await Assert.That(draft.Body).Contains($"\"tag_name\":\"{ReleasedVersion}\",\"target_commitish\":\"{releaseCommitSha}\""); + await Assert.That(draft.Body).Contains("\"draft\":true"); + var uploads = requests.Where(static x => x.Url.StartsWith(ReleaseUrl + "/assets?name=", StringComparison.Ordinal)).ToArray(); + await Assert.That(uploads.Length).IsEqualTo(5); + await Assert.That(uploads.Select(static x => x.Url)).Contains(ReleaseUrl + "/assets?name=setup.exe"); + var update = requests[^1]; + await Assert.That(update.Method).IsEqualTo(HttpMethod.Patch); + await Assert.That(update.Url).IsEqualTo(ReleaseUrl); + await Assert.That(update.Body).Contains("\"draft\":false"); + await Assert.That(update.Body).Contains("https://git.example.invalid/tenacom/test-repo/src/branch/release/CHANGELOG.md"); + await Assert.That(requests.All(static x => x.Headers["Authorization"] == "token test-token")).IsTrue(); + await Assert.That(harness.Reporter.Messages.Select(static x => x.Message)) + .Contains("Fallback Git credentials provided by Forgejo (protocol username: 'token')."); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsEqualTo(releaseCommitSha); + } + + // The checks run before the release commit, so a refusal leaves nothing to roll back. + [Test] + [Arguments(false, true, "cannot push to tenacom/test-repo")] + [Arguments(true, false, "The releases of tenacom/test-repo are disabled")] + public async Task Release_WithoutPushAccess_OrWithReleasesDisabled_FailsBeforeTheReleaseCommit( + bool canPush, + bool hasReleases, + string expectedMessage) + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.Created, """{"id":1}"""), canPush: canPush, hasReleases: hasReleases); + using var harness = new ReleaseHarness(new() { ForgejoApi = api }); + var remoteTip = harness.Repo.GetRemoteTipSha(); + + // 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(expectedMessage); + await Assert.That(api.Requests.Count).IsEqualTo(1); + await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // The assets are checked before the push, so a refused asset reaches neither the remote nor the feed. + [Test] + public async Task Release_WithTwoAssetsOfTheSameFileName_FailsBeforeThePush() + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.Created, """{"id":1}""")); + using var harness = new ReleaseHarness(new() { ForgejoApi = api, WithHook = true }); + var remoteTip = harness.Repo.GetRemoteTipSha(); + var artifactsPath = harness.ArtifactsPath; + + harness.HookBehavior = () => { + var directory = Directory.CreateDirectory(Path.Combine(artifactsPath, "other")).FullName; + var path = Path.Combine(directory, "setup.exe"); + File.WriteAllText(path, "fake setup"); + File.WriteAllText( + Path.Combine(artifactsPath, "Other.assets.txt"), + $"{path}\tapplication/vnd.microsoft.portable-executable\tOther setup program\n"); + }; + + // 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).IsEqualTo( + "The release assets cannot go to a Forgejo release:\n- More than one asset has the file name 'setup.exe'."); + await Assert.That(harness.Events.Select(static x => x.Name)).DoesNotContain("nuget-push"); + await Assert.That(api.Requests.Count).IsEqualTo(1); + await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // 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_ThenTheRecordOfTheTag() + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.RequestEntityTooLarge, """{"message":"quota exceeded"}""")); + using var harness = new ReleaseHarness(new() { ForgejoApi = 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("quota exceeded"); + var deletions = api.Requests.Where(static x => x.Method == HttpMethod.Delete).Select(static x => x.Url); + string[] expected = [ReleaseUrl, ApiRepositoryUrl + "/tags/" + ReleasedVersion]; + await Assert.That(deletions).IsEquivalentTo(expected, CollectionOrdering.Matching); + await Assert.That(harness.Notices).Contains($"Deleted the draft release on tag {ReleasedVersion}."); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // A release or a tag record deleted by someone else leaves nothing to undo, so the rollback reports it and goes on. + [Test] + public async Task Release_WhenAnUploadFails_AndTheDraftIsGone_ReportsIt_AndDeletesTheRecordOfTheTag() + { + var notFound = new StubHttpAnswer(HttpStatusCode.NotFound, """{"message":"Not Found"}"""); + var api = Answering( + new StubHttpAnswer(HttpStatusCode.RequestEntityTooLarge, """{"message":"quota exceeded"}"""), + releaseDeletion: notFound, + tagDeletion: notFound); + using var harness = new ReleaseHarness(new() { ForgejoApi = api }); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed + Task Act() => harness.RunAsync(); + + _ = await Assert.That(Act).Throws(); + await Assert.That(api.Requests[^1].Url).IsEqualTo(ApiRepositoryUrl + "/tags/" + ReleasedVersion); + var messages = harness.Reporter.Messages; + await Assert.That(messages.Select(static x => x.Message)).Contains($"Draft release on tag {ReleasedVersion} not found."); + await Assert.That(harness.Notices).DoesNotContain($"Deleted the draft release on tag {ReleasedVersion}."); + var warnings = messages.Where(static x => x.Level == MessageLevel.Warning).Select(static x => x.Message); + await Assert.That(warnings.Any(static x => x.Contains("rollback", StringComparison.Ordinal))).IsFalse(); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // The repository, the draft, the update, and the deletions have answers. The deletions succeed unless + // releaseDeletion or tagDeletion names another answer. Every other request, the uploads included, gets the + // fallback answer. + private static StubHttpMessageHandler Answering( + StubHttpAnswer fallback, + bool canPush = true, + bool hasReleases = true, + StubHttpAnswer? releaseDeletion = null, + StubHttpAnswer? tagDeletion = null) + { + var repository = $$""" + { + "owner": { "login": "tenacom", "visibility": "public" }, + "private": false, + "has_releases": {{(hasReleases ? "true" : "false")}}, + "permissions": { "push": {{(canPush ? "true" : "false")}} } + } + """; + return new( + new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer> { + [(HttpMethod.Get, ApiRepositoryUrl)] = new(HttpStatusCode.OK, repository), + [(HttpMethod.Post, ApiRepositoryUrl + "/releases")] = new(HttpStatusCode.Created, """{"id":42,"draft":true}"""), + [(HttpMethod.Patch, ReleaseUrl)] = new(HttpStatusCode.OK, """{"id":42}"""), + [(HttpMethod.Delete, ReleaseUrl)] = releaseDeletion ?? new(HttpStatusCode.NoContent), + [(HttpMethod.Delete, ApiRepositoryUrl + "/tags/" + ReleasedVersion)] = tagDeletion ?? new(HttpStatusCode.NoContent), + }, + fallback); + } +} diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs index 8a38e5da..3f60f2aa 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs @@ -3,6 +3,7 @@ using System.Net; using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; /// /// End-to-end tests of the release command on a real GitHub host, whose API answers from a table @@ -98,9 +99,31 @@ public async Task Release_WhenAnUploadFails_DeletesTheDraft_AndTheTag() 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) + // A draft deleted by someone else leaves nothing to undo, so the rollback reports it and goes on. + [Test] + public async Task Release_WhenAnUploadFails_AndTheDraftIsGone_ReportsIt_AndDeletesTheTag() + { + var api = Answering( + new StubHttpAnswer(HttpStatusCode.UnprocessableEntity, """{"message":"Validation Failed"}"""), + new StubHttpAnswer(HttpStatusCode.NotFound, """{"message":"Not Found"}""")); + 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(); + + _ = await Assert.That(Act).Throws(); + await Assert.That(api.Requests[^1].Method).IsEqualTo(HttpMethod.Delete); + var messages = harness.Reporter.Messages; + await Assert.That(messages.Select(static x => x.Message)).Contains("Provisional draft release not found."); + await Assert.That(harness.Notices).DoesNotContain("Deleted the provisional draft release."); + var warnings = messages.Where(static x => x.Level == MessageLevel.Warning).Select(static x => x.Message); + await Assert.That(warnings.Any(static x => x.Contains("rollback", StringComparison.Ordinal))).IsFalse(); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // The draft, the notes, the update, and the deletion have answers. The deletion succeeds unless + // deletion names another answer. Every other request, the uploads included, gets the fallback answer. + private static StubHttpMessageHandler Answering(StubHttpAnswer fallback, StubHttpAnswer? deletion = null) => new( new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer> { [(HttpMethod.Post, ApiRepositoryUrl + "/releases")] = new( @@ -110,7 +133,7 @@ private static StubHttpMessageHandler Answering(StubHttpAnswer fallback) 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), + [(HttpMethod.Delete, ApiRepositoryUrl + "/releases/42")] = deletion ?? new(HttpStatusCode.NoContent), }, fallback); } diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandGiteaTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandGiteaTests.cs new file mode 100644 index 00000000..0dcb24b0 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandGiteaTests.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.Net; +using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; +using TUnit.Assertions.Enums; + +/// +/// End-to-end tests of the release command on a real Gitea host, whose API answers from a table +/// (see ). +/// +/// +/// The repository URL of the harness is not gitea.com, so the host stands for a self-hosted +/// instance, with its API under /api/v1. +/// +[NotInParallel] +internal sealed class ReleaseCommandGiteaTests +{ + private const string ReleasedVersion = "2.3.2-preview"; + private const string ApiRepositoryUrl = "https://git.example.invalid/api/v1/repos/tenacom/test-repo"; + private const string ReleaseUrl = ApiRepositoryUrl + "/releases/42"; + + [Test] + public async Task Release_ChecksTheRepository_CreatesADraftOnTheTag_UploadsTheAssets_AndPublishesIt() + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.Created, """{"id":1}""")); + using var harness = new ReleaseHarness(new() { GiteaApi = 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.Get); + await Assert.That(requests[0].Url).IsEqualTo(ApiRepositoryUrl); + + // The draft goes on the pushed tag, then one upload per asset, then the update that publishes the draft. + var releaseCommitSha = harness.Repo.GetCommits(2)[1].Sha; + var draft = requests[1]; + await Assert.That(draft.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(draft.Url).IsEqualTo(ApiRepositoryUrl + "/releases"); + await Assert.That(draft.Body).Contains($"\"tag_name\":\"{ReleasedVersion}\",\"target_commitish\":\"{releaseCommitSha}\""); + await Assert.That(draft.Body).Contains("\"draft\":true"); + var uploads = requests.Where(static x => x.Url.StartsWith(ReleaseUrl + "/assets?name=", StringComparison.Ordinal)).ToArray(); + await Assert.That(uploads.Length).IsEqualTo(5); + await Assert.That(uploads.Select(static x => x.Url)).Contains(ReleaseUrl + "/assets?name=setup.exe"); + var update = requests[^1]; + await Assert.That(update.Method).IsEqualTo(HttpMethod.Patch); + await Assert.That(update.Url).IsEqualTo(ReleaseUrl); + await Assert.That(update.Body).Contains("\"draft\":false"); + await Assert.That(update.Body).Contains("https://git.example.invalid/tenacom/test-repo/src/branch/release/CHANGELOG.md"); + await Assert.That(requests.All(static x => x.Headers["Authorization"] == "token test-token")).IsTrue(); + await Assert.That(harness.Reporter.Messages.Select(static x => x.Message)) + .Contains("Fallback Git credentials provided by Gitea (protocol username: 'token')."); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsEqualTo(releaseCommitSha); + } + + // The checks run before the release commit, so a refusal leaves nothing to roll back. + [Test] + [Arguments(false, true, "cannot push to tenacom/test-repo")] + [Arguments(true, false, "The releases of tenacom/test-repo are disabled")] + public async Task Release_WithoutPushAccess_OrWithReleasesDisabled_FailsBeforeTheReleaseCommit( + bool canPush, + bool hasReleases, + string expectedMessage) + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.Created, """{"id":1}"""), canPush: canPush, hasReleases: hasReleases); + using var harness = new ReleaseHarness(new() { GiteaApi = api }); + var remoteTip = harness.Repo.GetRemoteTipSha(); + + // 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(expectedMessage); + await Assert.That(api.Requests.Count).IsEqualTo(1); + await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // The assets are checked before the push, so a refused asset reaches neither the remote nor the feed. + [Test] + public async Task Release_WithTwoAssetsOfTheSameFileName_FailsBeforeThePush() + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.Created, """{"id":1}""")); + using var harness = new ReleaseHarness(new() { GiteaApi = api, WithHook = true }); + var remoteTip = harness.Repo.GetRemoteTipSha(); + var artifactsPath = harness.ArtifactsPath; + + harness.HookBehavior = () => { + var directory = Directory.CreateDirectory(Path.Combine(artifactsPath, "other")).FullName; + var path = Path.Combine(directory, "setup.exe"); + File.WriteAllText(path, "fake setup"); + File.WriteAllText( + Path.Combine(artifactsPath, "Other.assets.txt"), + $"{path}\tapplication/vnd.microsoft.portable-executable\tOther setup program\n"); + }; + + // 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).IsEqualTo( + "The release assets cannot go to a Gitea release:\n- More than one asset has the file name 'setup.exe'."); + await Assert.That(harness.Events.Select(static x => x.Name)).DoesNotContain("nuget-push"); + await Assert.That(api.Requests.Count).IsEqualTo(1); + await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // 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_ThenTheRecordOfTheTag() + { + var api = Answering(new StubHttpAnswer(HttpStatusCode.RequestEntityTooLarge, """{"message":"quota exceeded"}""")); + using var harness = new ReleaseHarness(new() { GiteaApi = 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("quota exceeded"); + var deletions = api.Requests.Where(static x => x.Method == HttpMethod.Delete).Select(static x => x.Url); + string[] expected = [ReleaseUrl, ApiRepositoryUrl + "/tags/" + ReleasedVersion]; + await Assert.That(deletions).IsEquivalentTo(expected, CollectionOrdering.Matching); + await Assert.That(harness.Notices).Contains($"Deleted the draft release on tag {ReleasedVersion}."); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // A release or a tag record deleted by someone else leaves nothing to undo, so the rollback reports it and goes on. + [Test] + public async Task Release_WhenAnUploadFails_AndTheDraftIsGone_ReportsIt_AndDeletesTheRecordOfTheTag() + { + var notFound = new StubHttpAnswer(HttpStatusCode.NotFound, """{"message":"Not Found"}"""); + var api = Answering( + new StubHttpAnswer(HttpStatusCode.RequestEntityTooLarge, """{"message":"quota exceeded"}"""), + releaseDeletion: notFound, + tagDeletion: notFound); + using var harness = new ReleaseHarness(new() { GiteaApi = api }); + + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed + Task Act() => harness.RunAsync(); + + _ = await Assert.That(Act).Throws(); + await Assert.That(api.Requests[^1].Url).IsEqualTo(ApiRepositoryUrl + "/tags/" + ReleasedVersion); + var messages = harness.Reporter.Messages; + await Assert.That(messages.Select(static x => x.Message)).Contains($"Draft release on tag {ReleasedVersion} not found."); + await Assert.That(harness.Notices).DoesNotContain($"Deleted the draft release on tag {ReleasedVersion}."); + var warnings = messages.Where(static x => x.Level == MessageLevel.Warning).Select(static x => x.Message); + await Assert.That(warnings.Any(static x => x.Contains("rollback", StringComparison.Ordinal))).IsFalse(); + await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); + } + + // The repository, the draft, the update, and the deletions have answers. The deletions succeed unless + // releaseDeletion or tagDeletion names another answer. Every other request, the uploads included, gets the + // fallback answer. + private static StubHttpMessageHandler Answering( + StubHttpAnswer fallback, + bool canPush = true, + bool hasReleases = true, + StubHttpAnswer? releaseDeletion = null, + StubHttpAnswer? tagDeletion = null) + { + var repository = $$""" + { + "owner": { "login": "tenacom", "visibility": "public" }, + "private": false, + "has_releases": {{(hasReleases ? "true" : "false")}}, + "permissions": { "push": {{(canPush ? "true" : "false")}} } + } + """; + return new( + new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer> { + [(HttpMethod.Get, ApiRepositoryUrl)] = new(HttpStatusCode.OK, repository), + [(HttpMethod.Post, ApiRepositoryUrl + "/releases")] = new(HttpStatusCode.Created, """{"id":42,"draft":true}"""), + [(HttpMethod.Patch, ReleaseUrl)] = new(HttpStatusCode.OK, """{"id":42}"""), + [(HttpMethod.Delete, ReleaseUrl)] = releaseDeletion ?? new(HttpStatusCode.NoContent), + [(HttpMethod.Delete, ApiRepositoryUrl + "/tags/" + ReleasedVersion)] = tagDeletion ?? new(HttpStatusCode.NoContent), + }, + fallback); + } +} diff --git a/tests/Buildvana.Tool.Tests/ReleaseHarness.cs b/tests/Buildvana.Tool.Tests/ReleaseHarness.cs index 7faecf11..84d6d45a 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseHarness.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseHarness.cs @@ -16,6 +16,8 @@ using Buildvana.Tool.Services; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Hosts; +using Buildvana.Tool.Services.Hosts.Forgejo; +using Buildvana.Tool.Services.Hosts.Gitea; using Buildvana.Tool.Services.Hosts.GitHub; using Buildvana.Tool.Services.Hosts.GitLab; using Buildvana.Tool.Services.Platforms; @@ -160,7 +162,9 @@ public IReadOnlyList PipelineSteps /// /// Gets the Git host the release drives. /// - /// The harness runs on an unknown host (). + /// + /// The harness runs on an unknown host (). + /// public RecordingHost Host => (RecordingHost)_services.GetRequiredService(); /// @@ -291,6 +295,16 @@ private GitHost CreateHost(IServiceProvider services) return new GitLabHost(services, ParseRepositoryUrl(services), gitLabApi); } + if (_options.GiteaApi is { } giteaApi) + { + return new GiteaHost(services, ParseRepositoryUrl(services), giteaApi); + } + + if (_options.ForgejoApi is { } forgejoApi) + { + return new ForgejoHost(services, ParseRepositoryUrl(services), forgejoApi); + } + return new RecordingHost { TokenVariable = _options.HostTokenEnv, PushUser = _options.PushUser, @@ -469,6 +483,8 @@ private string BuildConfiguration() ["hosts"] = new JsonObject { ["github"] = new JsonObject { ["tokenEnv"] = _options.HostTokenEnv }, ["gitlab"] = new JsonObject { ["tokenEnv"] = _options.HostTokenEnv }, + ["gitea"] = new JsonObject { ["tokenEnv"] = _options.HostTokenEnv }, + ["forgejo"] = new JsonObject { ["tokenEnv"] = _options.HostTokenEnv }, }, }; if (_options.RepositoryUrl is { } repositoryUrl) diff --git a/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs b/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs index de8462d6..d32810b9 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseHarnessOptions.cs @@ -114,6 +114,21 @@ internal sealed record ReleaseHarnessOptions /// public StubHttpMessageHandler? GitLabApi { get; init; } + /// + /// Gets the handler that answers the requests of a real , + /// or for a . Ignored when is + /// , or or is not . + /// + public StubHttpMessageHandler? GiteaApi { get; init; } + + /// + /// Gets the handler that answers the requests of a real , + /// or for a . Ignored when is + /// , or , , or is + /// not . + /// + public StubHttpMessageHandler? ForgejoApi { get; init; } + /// /// Gets the name of the variable that holds the token of the host: the tokenEnv of every section /// under hosts. The harness sets it to for the duration of the test.