Skip to content

Publish releases on GitLab - #410

Merged
rdeago merged 18 commits into
Tenacom:mainfrom
rdeago:405-gitlab
Oct 25, 2026
Merged

rdeago merged 18 commits into
Tenacom:mainfrom
rdeago:405-gitlab

Conversation

@rdeago

@rdeago rdeago commented Oct 25, 2026

Copy link
Copy Markdown
Member

Checklist of related issues / discussions

Proposed changes

GitHost.Create made an UnknownHost for every GitLab repository, so bv release created no release page on GitLab. This PR adds GitLab as a Git host, on gitlab.com and on a self-managed instance.

  • GitLabApiClient reaches the REST API at https://<host>/api/v4, and addresses the project by its URL-encoded path.
  • GitLabHost builds the release and file URLs, and decides the token kind and the push username.
  • GitLabReleasePublisher checks the token, uploads the assets to the generic package registry, creates the release, and undoes both on a rollback.
  • buildvana.jsonc accepts gitlab in repository.host, and hosts.gitlab.tokenEnv, GITLAB_TOKEN by default.
  • GitHost.DetectKind takes gitlab.com for GitLab on every CI platform. On GitLab CI, it also takes the host of CI_SERVER_URL for GitLab, through GitLabCIPlatform.ServerKind.

The token works as the issue says:

  • When tokenEnv is CI_JOB_TOKEN, the token is the job token of GitLab CI. The API requests carry JOB-TOKEN, and the push username is gitlab-ci-token. The comparison is case-sensitive.
  • Otherwise, the token is a personal, project, or group access token, and the push username is oauth2.

The release:

  • GitLabReleasePublisher.BeginAsync reads GET /projects/:id/releases?per_page=1 as the permission probe.
  • PublishAsync uploads each asset with PUT /projects/:id/packages/generic/<package>/<tag>/<file name>?select=package_file. The package is named after the repository, the last segment of repository.url. Its version is the release tag.
  • PublishAsync then creates the release, with one link of type package per asset, and direct_asset_path set to /<file name>. The name of a link is the description of the asset.
  • The description is "We have a human-curated changelog.", with the link to CHANGELOG.md on the release branch.
  • UndoAsync deletes the release, then the package, each when it exists.

release.md has a "Publishing on GitLab" section. It states the token kinds, the Maintainer role, the push setting a job token needs, the generic package, and the file name rules. environment-variables.md describes CI_SERVER_URL and hosts.gitlab.tokenEnv. The hosts table of introduction.md has a row for GitLab and one for a self-managed GitLab.

Where the code differs from the issue

  • The access token header. An access token goes in PRIVATE-TOKEN, where the issue says Authorization: Bearer. The documentation of the generic package registry lists PRIVATE-TOKEN, JOB-TOKEN, DEPLOY-TOKEN, and Basic, and warns against any other method.
  • The permission probe. BeginAsync reads the releases of the project, where the issue says it reads the project. A job token reaches the Releases, Release links, and Packages APIs, and does not reach the Projects API.
  • The role check. A rollback deletes the generic package, which needs the Maintainer role. With an access token, BeginAsync reads GET /user, then GET /projects/:id/members/all/:user_id. It refuses an access_level below 40 before the release commit.
    • On 404, it goes on. A user with no membership is an administrator or an organization owner, who can delete the package. Otherwise, the user has no push access, and the push of the release commit fails first.
    • permissions of GET /projects/:id misses access through a shared group. members/all reports the effective access level.
    • GitLab states no role for a job token, so bv checks none.
  • IsPrivateRepositoryAsync with a job token. A job token cannot read the project, so GitLabHost reads it without credentials. GitLab answers 200 for a public project and 404 otherwise, and 404 counts as private. No production code calls the member.
  • The asset check. GitLab refuses some file names, and two links of a release with the same name or direct_asset_path. GitLabReleasePublisher.CheckAssets refuses such assets before the first commit leaves the clone. ServerRelease.PushUpdates calls the virtual ReleasePublisher.CheckAssets before the push, and ReleaseCommand collects the assets before it. The asset collection moves before the push on every host, as step 15 of release.md says.
  • Tests. ReleaseCommandGitLabTests runs a release through GitLabHost over StubHttpMessageHandler, as ReleaseCommandGitHubTests does for GitHub. GitLabReleasePublisherTests covers the role check, the asset check, and the rollback.

Risks

  • The generic package is one more object a release creates, and one more object a rollback removes.
  • A job token copied into a variable of another name counts as an access token, and GitLab refuses it. release.md says so.
  • The GitLab release flow is tested against a stubbed API. No release has run on a GitLab project, so the hosts table says "Supported (untested)" for GitLab.

Additional changes

  • HostApiClient. GitHubApiClient held the code that sends a request, reads a JSON answer, and turns an error answer into a BuildFailedException. HostApiClient holds it now, for GitLabApiClient and for the Gitea client of Publish releases on Gitea and Forgejo, and run on Gitea Actions #406. The requests and the diagnostics of GitHubApiClient do not change. A request carries the credentials only when the client asks for them, so that GitLabHost can read a project without them.
  • RepositoryFilePath. GitHubRepositoryUrls.File held the check that keeps a file URL inside the repository. RepositoryFilePath.ToUrlPath holds it for every host.
  • Duplicate GitHub assets. GitHub names an asset after its file, and refuses a second upload of the same name. GitHubReleasePublisher found out at that upload, after the push of the tag and the packages. GitHubReleasePublisher.CheckAssets refuses the list before the push. The changelog has a bullet under "Changes to existing features".
  • GitHub Enterprise Cloud with data residency. bv release took a host ending in .ghe.com for a GitHub Enterprise Server, with the API at https://<host>/api/v3. GitHubApiClient.GetBaseUrl returns https://api.<host>/ for such a host, and GitHost.DetectKind takes it for GitHub on every CI platform. release.md describes it, and the changelog has a bullet under "New features".
  • The hosts table of introduction.md. The table has one row per Git host, so that the rows of github.com and gitlab.com show no limit. The rows of GitHub Enterprise Server, GHE.com, and self-managed GitLab state the SourceLink item each needs. Microsoft.SourceLink.GitHub.props and Microsoft.SourceLink.GitLab.props declare github.com and gitlab.com alone. A GHE.com host serves raw content from https://raw.<host>, where SourceLink defaults to https://<host>/raw.
  • Status names in introduction.md. "Untested" becomes "Supported (untested)" for a Git host or a CI platform bv supports, and "??? (untested)" elsewhere.
  • "Git server". The heading and column of introduction.md, and one sentence of getting-started.md, say "Git host", as terminology.md names it.
  • GitHubApiClient.GetRepositoryAsync. Its arguments are wrapped, because the line was 143 characters.

Types of changes

This pull request introduces the following types of changes:

  • Bug fix
  • New feature
  • Test addition / update (no changes to non-test code)
  • Refactor (no changes in public API syntax or semantics)
  • Performance improvement (no changes in public API syntax or semantics)
  • Documentation update (not including changelog)
  • Dependency addition / update
  • Changes to toolchain configuration
  • Changes to CI (workflows, bot / app configurations)
  • Changes to repository files (.gitattributes, .gitignore)
  • Other

Breaking changes

This pull request introduces breaking changes:

  • Yes
  • No

Checklist

  • For all types of changes:
  • For code changes only:
    • The project builds on my machine, via the provided build script, with zero warnings
    • I have added tests that prove my feature works / my fix is effective
    • I have added / modified XML documentation according to changes in code
    • I have checked that all the links I added or modified in XML documentation point to their intended destination
  • For documentation changes only:
    • I have checked and proofread documentation locally
    • I have checked that all the links I added or modified point to their intended destination

🤖 Generated with Claude Code

rdeago and others added 17 commits October 23, 2026 12:49
GitHubApiClient held the code that sends a request, turns an error answer into a BuildFailedException, and reads a JSON answer. A GitLab client and a Gitea client need the same code. HostApiClient holds it, and each client supplies its base URL, its headers, and the reader of its error answers. The requests and the diagnostics of GitHubApiClient do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The line was 143 characters, over the limit of 140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HostApiClient put the credentials in the default headers of its HttpClient, so no request could go without them. A GitLab job token cannot read a project, so bv must read it without credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bv had no client of the GitLab REST API. An access token goes in PRIVATE-TOKEN, because the generic package registry warns against Authorization: Bearer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildvana.jsonc could not name GitLab as the host of a repository, nor the variable that holds a GitLab token. GitLabConfig mirrors GitHubConfig, with GITLAB_TOKEN as the default of TokenEnv. Nothing reads hosts.gitlab yet, and GitHost.Create still treats HostKind.GitLab as an unknown host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitLabApiClient could not read the access level of a token on a project. The permissions member of GET /projects/:id misses a membership through a shared group, so GetProjectMemberAsync reads members/all, which states the level GitLab enforces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHubRepositoryUrls.File held the only check that keeps a file URL inside the repository, so a second host would have copied it. RepositoryFilePath.ToUrlPath holds it for every host that builds file URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHost.Create made an UnknownHost for every GitLab repository, so bv release created no release page there. GitLab has no draft releases, so GitLabReleasePublisher uploads every ReleaseAsset to a generic package named after the repository, then creates a release that links them. A rollback deletes that package, which needs the Maintainer role, so BeginAsync refuses an access token below it before the release commit. GitLab refuses some file names and duplicate link names, so PublishAsync checks every ReleaseAsset with CheckAssets before the first upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitLabReleasePublisher.CheckAssets ran in PublishAsync, after the NuGet push, so a refused asset left its packages on the feed. ServerRelease.PushUpdates calls ReleasePublisher.CheckAssets instead, because nothing has left the clone before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The documentation said that bv knows no GitLab host. Publishing in release.md is split by Git host, because GitLab adds rules on tokens, roles, and file names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub names an asset after its file, and refuses a second upload of the same name. `GitHubReleasePublisher` found out at that upload, after the tag and the packages were pushed, so the release rolled back. `GitHubReleasePublisher.CheckAssets` now refuses the list before the push, and `ReleasePublisher.Duplicates` serves both hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
terminology.md names it "Git host".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hosts table of `docs/introduction.md` listed GitHub Enterprise as supported with no limit. `Microsoft.SourceLink.GitHub.props` declares `github.com` alone, so SourceLink on a GitHub Enterprise Server needs a `SourceLinkGitHubHost` item. The table gives each self-hosted server its own row, so that the limit stays off the rows of `github.com` and `gitlab.com`. No release has run on a GitHub Enterprise Server, so its row says "Untested", as the self-managed GitLab row does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub Enterprise Cloud with data residency has different URL patterns from GitHub Enterprise Server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since 37457ab, bv release takes a .ghe.com host for GitHub Enterprise Cloud with data residency. release.md still gave every host but github.com the API path of a GitHub Enterprise Server. The hosts table in introduction.md names the ContentUrl of the SourceLinkGitHubHost item. SourceLink reads raw content from https://<host>/raw by default, and GHE.com serves it from https://raw.<host>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Supported, but untested" is different from "untested, and we don't even try to tell it apart".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReSharper reported AccessToDisposedClosure on harness.ArtifactsPath in HookBehavior lambdas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rdeago rdeago added the enhancement [issue/PR] requests / implements new or improved functionality. label Oct 25, 2026
@github-actions github-actions Bot added area:docs [issue/PR] affects documentation (excluding XML documentation that is part of source code). area:code [issue/PR] affects project code (excluding tests). labels Oct 25, 2026
@codecov

codecov Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.95833% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.82%. Comparing base (23c4b84) to head (8063164).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...ol/Services/Hosts/GitLab/GitLabReleasePublisher.cs 98.03% 1 Missing and 1 partial ⚠️
...vana.Tool/Services/Hosts/GitLab/GitLabApiClient.cs 99.07% 0 Missing and 1 partial ⚠️
...Buildvana.Tool/Services/Hosts/GitLab/GitLabHost.cs 97.22% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #410      +/-   ##
==========================================
+ Coverage   86.41%   86.82%   +0.41%     
==========================================
  Files         299      307       +8     
  Lines        8744     9055     +311     
  Branches     1707     1749      +42     
==========================================
+ Hits         7556     7862     +306     
- Misses        904      905       +1     
- Partials      284      288       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rdeago rdeago left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review by Claude. Self-review.

⚠️ Changes requested.


One blocking finding, in the rollback of a GitLab release. The comment on GitLabReleasePublisher.UndoAsync describes it.

A failed release deletion stopped GitLabReleasePublisher.UndoAsync before DeletePackageAsync, so the package stayed. The rollback deletes the release tag before UndoAsync runs, and GitLab deletes the release with its tag, so DeleteReleaseAsync can get a 404. It now returns false on a 404, and any other failure is a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rdeago rdeago left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review by Claude. Self-review.

👍 Approved.


No blocking findings.

@rdeago
rdeago merged commit 4ff155e into Tenacom:main Oct 25, 2026
7 checks passed
@rdeago
rdeago deleted the 405-gitlab branch October 25, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:code [issue/PR] affects project code (excluding tests). area:docs [issue/PR] affects documentation (excluding XML documentation that is part of source code). enhancement [issue/PR] requests / implements new or improved functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish releases on GitLab

1 participant