Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions .claude/skills/release-libgit2-natives/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
---
name: release-libgit2-natives
description: >-
Runbook for cutting a new LibGit2Sharp.UiPath native release: bump the OpenSSL/libssh2 submodules,
rebuild the prebuilt deps, rebuild the multi-RID native archive, and roll the new SHA256 pins
through the two lockfiles up to the final NuGet publish. Use when asked to bump OpenSSL or libssh2,
refresh the native binaries, produce a new natives archive, or ship a new LibGit2Sharp.UiPath
version. Driven interactively — pause for human review at each hash-pin gate.
---

# Releasing LibGit2Sharp.UiPath native binaries

This skill drives the cross-repo release chain by hand, **interactively**. There is deliberately no
CI auto-PR: every hop that moves a SHA256 pin ends at a **human review gate** — you show the diff,
confirm with the user, then commit. Hash-pinning is a security boundary; a freshly-built SHA must be
reviewed before the next stage is allowed to consume it. Never auto-merge, never skip a gate.

## The two repos

| Role | Path | GitHub | Default branch |
|------|------|--------|----------------|
| Managed library (this repo) | `.` | `UiPath/libgit2sharp` | `develop` |
| Native binaries (sibling) | `../libgit2sharp.nativebinaries` | `UiPath/libgit2sharp.nativebinaries` | `develop` |

The native-binaries repo is a **sibling directory** next to this one. All paths below are relative to
this repo's root (the managed `libgit2sharp`). The atom script is at
`./.claude/skills/release-libgit2-natives/Update-Lockfile.ps1`.

> **Branch note:** `develop` is the default/integration branch for **both** repos. Dispatch workflows
> against `develop` (`--ref develop`), or against a feature branch (`--ref <branch>`) to validate the
> whole chain before merging.

## The chain (what produces what)

```
bump openssl/libssh2 submodule + deps/vcpkg.json (sibling repo)
▼ build-deps.yml (manual)
Release deps-openssl-<X>_libssh2-<Y> → 6× deps-<platform>.{zip,tar.gz} + .sha256
▼ GATE A: Update-Lockfile.ps1 → deps.lock.json (sibling repo) — review + branch + commit + PR
▼ build.yml (publish=true)
Release natives-<version> → natives-<version>.zip + .sha256
▼ GATE B: Update-Lockfile.ps1 → natives.lock.json (this repo) — review + branch + commit + PR
▼ fetch.natives.ps1 + CI (ci.yml) build & test all 3 platforms
▼ (the very last step) publish LibGit2Sharp.UiPath nupkg to the uipath-internal feed
```

`build-deps.yml` runs **rarely** — only when the submodule versions change. Most releases that are
*just* a libgit2 rebuild start at `build.yml` and reuse the existing deps release.

---

## Step 0 — Bump the deps (only when changing OpenSSL/libssh2)

Skip this whole step if you are not changing the dep versions. The usual trigger is a new **OpenSSL**
patch (occasionally **libssh2**), often a CVE fix. Three things move in **lockstep**, or
`build.deps.ps1` throws on its version assertion:

1. **The submodule tag** (the authoritative pin), in `../libgit2sharp.nativebinaries`:
```bash
cd ../libgit2sharp.nativebinaries
git -C openssl fetch --tags --quiet
git -C openssl checkout openssl-<X> # e.g. openssl-3.6.4 (libssh2 uses tag libssh2-<Y>)
git add openssl
```
2. **The `overrides` version** in `deps/vcpkg.json` — must equal the submodule version exactly.
3. **`builtin-baseline`** in `deps/vcpkg.json` — a vcpkg commit whose versions database actually
contains that OpenSSL/libssh2 version. **This is the real friction, not the SHA copying:** if
vcpkg's registry doesn't yet ship the exact patch at the current baseline, bump `builtin-baseline`
to a newer vcpkg commit that does (check `microsoft/vcpkg` history / `versions/o-/openssl.json`).
`build.deps.ps1` asserts `submodule == override == what-vcpkg-built` and fails loudly otherwise.

Commit these on a branch in the sibling repo. **Gate:** show the user the submodule + vcpkg.json diff
and confirm before pushing.

## Step 1 — Rebuild the deps (`build-deps.yml`)

```bash
gh workflow run build-deps.yml --repo UiPath/libgit2sharp.nativebinaries --ref <branch>
# grab the run id, then:
gh run list --repo UiPath/libgit2sharp.nativebinaries --workflow build-deps.yml --limit 1 --json databaseId
gh run watch <run-id> --repo UiPath/libgit2sharp.nativebinaries --exit-status
```

It fans out over 6 platforms and publishes Release **`deps-openssl-<X>_libssh2-<Y>`** (the tag is
derived from the built versions, so it's predictable from `deps/vcpkg.json`). Each archive gets a
bare-hash `.sha256` sidecar.

## Gate A — Roll the deps pins into `deps.lock.json`

```bash
TAG=deps-openssl-<X>_libssh2-<Y>
gh release download "$TAG" --repo UiPath/libgit2sharp.nativebinaries --pattern '*.sha256' --dir "$TMPDIR/depssha"
pwsh ./.claude/skills/release-libgit2-natives/Update-Lockfile.ps1 \
-LockfilePath ../libgit2sharp.nativebinaries/deps.lock.json -Tag "$TAG" -ShaDir "$TMPDIR/depssha"
```

**Human gate:** `git -C ../libgit2sharp.nativebinaries diff deps.lock.json`, show it, confirm, then in
the sibling repo **create a new branch, commit, push, and open a PR** (`gh pr create`) against
`develop`. Do **not** merge yourself and do **not** proceed until the user has reviewed and merged it —
the libgit2 build is about to trust these hashes.

## Step 2 — Rebuild the native archive (`build.yml`, `publish=true`)

```bash
gh workflow run build.yml --repo UiPath/libgit2sharp.nativebinaries --ref <branch> -f publish=true
gh run list --repo UiPath/libgit2sharp.nativebinaries --workflow build.yml --limit 1 --json databaseId
gh run watch <run-id> --repo UiPath/libgit2sharp.nativebinaries --exit-status
```

It fetches + SHA256-verifies the deps (no OpenSSL/libssh2 compile), builds libgit2 for all 6 RIDs,
assembles `natives-<version>.zip`, and — because `publish=true` (or on `develop`) — publishes Release
**`natives-<version>`**. `<version>` comes from **MinVer** on the nativebinaries repo, reformatted to a
single auto number `X.Y.Z-v<epoch+height>` (base tag `1.9.1-v5` + height 1 → `1.9.1-v6`; see Reference).
Find the exact tag:

```bash
gh release list --repo UiPath/libgit2sharp.nativebinaries --limit 5 # newest natives-* is yours
```

## Gate B — Roll the natives pin into `natives.lock.json` (this repo)

```bash
NTAG=natives-<version>
gh release download "$NTAG" --repo UiPath/libgit2sharp.nativebinaries --pattern '*.sha256' --dir "$TMPDIR/natsha"
pwsh ./.claude/skills/release-libgit2-natives/Update-Lockfile.ps1 \
-LockfilePath ./natives.lock.json -Tag "$NTAG" -ShaDir "$TMPDIR/natsha"
# prove it fetches + verifies before trusting the pin:
pwsh ./fetch.natives.ps1 -Force
```

`fetch.natives.ps1` fails hard on a SHA mismatch, so a green fetch confirms the pin. **Human gate:**
show the `natives.lock.json` diff, confirm, then **create a new branch, commit, push, and open a PR**
(`gh pr create`) against `develop`. Do **not** merge yourself — the PR's `ci.yml` run exercises
windows/ubuntu/macos, and the user reviews and merges.

## Step 3 — Ship the managed package (the very last step)

**Versioning — nothing to do.** The package version is `X.Y.Z-v<N>` (e.g. `1.9.1-v6`), a single auto
number where **`N = the base tag's epoch + the MinVer height`**. The base tag `1.9.1-v5` sets the epoch
(5) and each commit past it increments N (v6, v7, …), computed by the managed repo's `AdjustVersions`
target and the nativebinaries `build.yml` "Resolve version" step. Every develop commit yields the next
`v<N>` — no manual version tagging.

**Re-tagging is seamless** (unlike a height-only scheme). Because `N = epoch + height`, on the tag
commit itself height is 0, so `v<N>` equals the tag — cutting a new tag at the **current** `v<N>` (e.g.
`git tag 1.9.1-v20` when the version already reads `v20`) just re-bases the epoch with no jump, and the
number keeps climbing. Only pitfall: never tag **below** the current `v<N>`, or the version goes
backwards (NuGet forbids). New upstream base → `git tag X.Y.Z-v0`. Same scheme in the nativebinaries repo.

**Publishing to the uipath-internal feed — done interactively from here** (like the gates: there is
no CI publish job). Once the Gate B PR is merged to `develop` and CI is green, get the
`LibGit2Sharp.UiPath` nupkg — download the managed CI's **NuGet packages** artifact, or build locally
(`dotnet build -c Release` emits it under `bin/Packages/`, via `GeneratePackageOnBuild`).

**Recommended — mint a short-lived Azure DevOps token via the Azure CLI** (no stored PAT, reuses your
`az login` SSO):

```bash
FEED=https://pkgs.dev.azure.com/uipath/Public.Feeds/_packaging/UiPath-Internal/nuget/v3/index.json
# 499b84ac-... is the well-known Azure DevOps resource id
TOKEN=$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)
dotnet nuget add source "$FEED" --name uipath-internal 2>/dev/null || true
NuGetPackageSourceCredentials_uipath-internal="Username=az;Password=$TOKEN" \
dotnet nuget push "<path-to>.nupkg" --source uipath-internal --api-key az --skip-duplicate
```

**Fallback** — if the Azure Artifacts credential provider is already configured on the machine, just:
`nuget push <pkg> -src "$FEED" -ApiKey AzureDevops -SkipDuplicate`.

`--api-key`/`-ApiKey` is a required-but-ignored dummy (auth is the token / credential provider, not the
key). `--skip-duplicate` keeps it idempotent — and matters here: the feed may already hold a
manually-published `1.9.1-v5`, so the emitted `X.Y.Z-v<N>` must **exceed** the highest version
already on the feed (see the version-collision caveat). Human-run step — confirm the exact version with
the user before pushing. Only if the upstream base changed: `git tag X.Y.Z-v0` once, first.

---

## Reference

**Lockfile shapes** (both carry a `repo` field the atom reads to rebuild URLs):
- `deps.lock.json` (sibling repo): `{ repo, tag, platforms: { <rid>: { filename, url, sha256 } } }`,
6 RIDs. Windows = `.zip` (dynamic DLLs); posix = `.tar.gz` (static `.a`, symlinks preserved).
- `natives.lock.json` (this repo): `{ repo, tag, filename, url, sha256 }`, one archive; `filename` is
always `<tag>.zip`.

**MinVer** — both repos use `X.Y.Z-vN` base tags with `--default-pre-release-identifiers preview.0`.
MinVer emits `X.Y.Z-v<epoch>.<height>` (e.g. `1.9.1-v5.1`); both repos reformat to
**`X.Y.Z-v<epoch+height>`** (e.g. `1.9.1-v6`) — a single auto number that continues from the tag.
MinVer's height is the *shortest graph distance* to the tag, so it stays small after a merge (the base
tag often lands on the merge commit's first parent — that's why a naive height-only scheme wrongly
produced `v1`); epoch+height keeps climbing regardless. New upstream base → `git tag X.Y.Z-v0`.

**Release gating** — `build.yml`'s publish step is gated on `inputs.publish || github.ref ==
refs/heads/develop`. PR/branch runs without `publish=true` produce only a workflow artifact (no stray
release). Always pass `-f publish=true` when you actually want the durable natives Release.

**`build-deps.yml`** is manual (`workflow_dispatch`) only — never on push — so a rebuild can't
silently republish archives with fresh, unpinned SHA256s.

**Sidecar formats differ** (the atom handles both): deps sidecars are a bare hash; the natives
sidecar is `sha filename` (sha256sum format). `Update-Lockfile.ps1` takes the first token either way.

**Testing off a branch** — `gh workflow run … --ref <branch>` dispatches the workflow as it exists on
that branch, so you can validate the whole chain before merging to `develop`.

**Gotchas**
- The deps rebuild is gated on the version existing in vcpkg's registry at the pinned baseline —
budget time for a `builtin-baseline` bump, not just a submodule checkout.
- The libgit2 submodule is private; `build.yml` clones it via the `LIBGIT2_DEPLOY_KEY` secret
(read-only deploy key). Nothing to do locally unless you're building libgit2 yourself.
- `gh run watch` needs the run id; logs are only fully available once the whole run completes.
130 changes: 130 additions & 0 deletions .claude/skills/release-libgit2-natives/Update-Lockfile.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<#
.SYNOPSIS
Rewrites a LibGit2Sharp dependency lockfile (deps.lock.json or natives.lock.json) to point at a
new GitHub Release: sets the tag, recomputes each asset URL, and writes the SHA256(s).

This is the single shared "atom" of the release chain. The release-libgit2-natives skill calls it
at both hand-off points (deps -> deps.lock.json, natives -> natives.lock.json). It never talks to
the network or to git; you hand it a tag and the SHA256 sidecar(s) the workflow already produced,
and it edits one JSON file. Review the diff and commit yourself.

.DESCRIPTION
Two lockfile shapes are handled, auto-detected from the JSON:

* multi-platform (deps.lock.json, in the sibling libgit2sharp.nativebinaries repo): has a
top-level "platforms" object. Each platform keeps its existing "filename"
(deps-<platform>.zip / .tar.gz), and its "url" + "sha256" are refreshed. A SHA256 must be
supplied for every platform present in the file (via -ShaDir), or it throws.

* flat (natives.lock.json, in this repo): has a top-level "filename". The filename is derived
as "<tag>.zip" (the natives archive is always named after its tag), and "url" + "sha256" are
refreshed.

The base repo ("UiPath/...") is read from the lockfile's own "repo" field, so URLs stay correct
without being passed in. SHA256 values come either from -Sha256 (single, flat lockfiles only) or
from -ShaDir, a directory of "<filename>.sha256" sidecars as published on the Release / uploaded
as workflow artifacts. Sidecars may be a bare hash (deps) or "sha filename" sha256sum format
(natives); the first whitespace-delimited token is taken as the hash.

.PARAMETER LockfilePath
Path to the lockfile to rewrite (deps.lock.json or natives.lock.json).

.PARAMETER Tag
The GitHub Release tag the assets live under (e.g. 'deps-openssl-3.6.3_libssh2-1.11.1' or
'natives-1.9.1-v5.21').

.PARAMETER ShaDir
Directory containing '<filename>.sha256' sidecar files. Get them with:
gh release download <tag> --repo <repo> --pattern '*.sha256' --dir <ShaDir>
Required for multi-platform lockfiles; optional for flat ones (use -Sha256 instead).

.PARAMETER Sha256
A single SHA256 hash, for flat (natives) lockfiles only. Convenient when you already have the hash
from the workflow output. Ignored for multi-platform lockfiles.

.EXAMPLE
# Deps hand-off (6 platforms): download the sidecars, then rewrite the sibling repo's lockfile.
gh release download deps-openssl-3.6.3_libssh2-1.11.1 --repo UiPath/libgit2sharp.nativebinaries `
--pattern '*.sha256' --dir $env:TEMP/depssha
./.claude/skills/release-libgit2-natives/Update-Lockfile.ps1 `
-LockfilePath ../libgit2sharp.nativebinaries/deps.lock.json `
-Tag deps-openssl-3.6.3_libssh2-1.11.1 -ShaDir $env:TEMP/depssha

.EXAMPLE
# Natives hand-off (single archive): pass the hash straight through to this repo's lockfile.
./.claude/skills/release-libgit2-natives/Update-Lockfile.ps1 -LockfilePath ./natives.lock.json `
-Tag natives-1.9.1-v5.22 -Sha256 1a40ac67ac14b1e099b4d3a0823019e164fcde9211e3c40c95c6b355267f7440
#>

[CmdletBinding()]
Param(
[Parameter(Mandatory)][string]$LockfilePath,
[Parameter(Mandatory)][string]$Tag,
[string]$ShaDir = '',
[string]$Sha256 = ''
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if (-not (Test-Path $LockfilePath)) { throw "Lockfile not found: $LockfilePath" }

$lock = Get-Content $LockfilePath -Raw | ConvertFrom-Json
$repo = "$($lock.repo)".Trim()
if (-not $repo) { throw "Lockfile '$LockfilePath' has no 'repo' field; cannot build asset URLs." }

function Get-ShaFor($filename) {
# A single explicit hash always wins (flat lockfiles). Otherwise read the sidecar.
if ($Sha256) { return $Sha256.Trim().ToLower() }
if (-not $ShaDir) {
throw "No -Sha256 and no -ShaDir given; cannot resolve the SHA256 for '$filename'."
}
$sidecar = Join-Path $ShaDir "$filename.sha256"
if (-not (Test-Path $sidecar)) {
throw "SHA256 sidecar not found for '$filename' (looked for '$sidecar'). " +
"Did 'gh release download --pattern *.sha256' fetch it?"
}
# Sidecars are either a bare hash (deps) or 'sha filename' (natives); take the first token.
$raw = (Get-Content $sidecar -Raw).Trim()
$hash = ($raw -split '\s+')[0].ToLower()
if ($hash -notmatch '^[0-9a-f]{64}$') {
throw "Sidecar '$sidecar' did not contain a 64-char SHA256 (got '$hash')."
}
return $hash
}

function New-AssetUrl($filename) {
return "https://github.com/$repo/releases/download/$Tag/$filename"
}

$lock.tag = $Tag

if ($lock.PSObject.Properties.Name -contains 'platforms') {
# --- multi-platform (deps.lock.json) ------------------------------------------------------
if (-not $ShaDir) { throw "Multi-platform lockfile needs -ShaDir (one <filename>.sha256 per platform)." }
foreach ($p in $lock.platforms.PSObject.Properties) {
$entry = $p.Value
$filename = "$($entry.filename)".Trim()
if (-not $filename) { throw "Platform '$($p.Name)' has no 'filename' in the lockfile." }
$entry.url = New-AssetUrl $filename
$entry.sha256 = Get-ShaFor $filename
Write-Host " $($p.Name): $($entry.sha256)"
}
}
elseif ($lock.PSObject.Properties.Name -contains 'filename') {
# --- flat (natives.lock.json) -------------------------------------------------------------
# The natives archive is always named after its tag.
$filename = "$Tag.zip"
$lock.filename = $filename
$lock.url = New-AssetUrl $filename
$lock.sha256 = Get-ShaFor $filename
Write-Host " ${filename}: $($lock.sha256)"
}
else {
throw "Unrecognised lockfile shape (no 'platforms' and no 'filename'): $LockfilePath"
}

# Depth covers the nested platforms object; pwsh 7 pretty-prints and does not escape '/'. The first
# rewrite may reformat the file once; subsequent runs touch only tag/url/sha256 lines.
$lock | ConvertTo-Json -Depth 10 | Set-Content -Path $LockfilePath -Encoding utf8
Write-Host "==> Updated $LockfilePath -> tag $Tag"
Loading
Loading