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
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Custom xUnit attributes are sometimes used for conditional test execution:
#### CI/CD Pipeline
- **Build workflow**: `.github/workflows/build.yml` - runs on PR and push to main/rel/feature branches
- **Publish workflow**: Builds Native AOT archives for the six-RID matrix and attaches them (plus install.sh / install.ps1) to the GitHub Release. Does not publish to nuget.org or Sleet.
- **CI prerelease**: `.github/workflows/ci-release.yml` runs after a successful `build` on `main` and rewrites a rolling `ci` prerelease (`--latest=false`) so dogfood is `NDNX_VERSION=ci` / `ndnx --update ci`.
- **OS matrix**: Configured in `.github/workflows/os-matrix.json` (defaults to ubuntu-latest)

### Special Files and Tools
Expand Down
117 changes: 117 additions & 0 deletions .github/workflows/ci-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Rolling native CI prerelease for dogfooding before a GitHub Release.
# Runs after a successful `build` on main (not PRs). Tag `ci` is rewritten
# each time; `releases/latest` is left pointing at real releases.
# Native binaries only — not nuget.org / Sleet.

name: ci-release
on:
workflow_dispatch:
workflow_run:
workflows: [build]
types: [completed]

concurrency:
group: ci-release
cancel-in-progress: true

env:
DOTNET_NOLOGO: true
Configuration: Release
VersionPrefix: 42.42.${{ github.run_number }}
VersionLabel: ${{ github.event.workflow_run.head_sha || github.sha }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
MSBUILDTERMINALLOGGER: auto
NDNX_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}

jobs:
native-aot:
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main')
name: native-aot-${{ matrix.rid }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
rid: linux-x64
- os: windows-latest
rid: win-x64
steps:
- name: 🤘 checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}

- name: ⚙ dotnet
uses: devlooped/actions-dotnet-env@v1

- name: 🧊 publish
shell: pwsh
run: |
$out = "artifacts/${{ matrix.rid }}"
New-Item -ItemType Directory -Force -Path $out | Out-Null
dotnet publish src/ndnx/ndnx.csproj -c $env:Configuration -r ${{ matrix.rid }} --self-contained -p:PublishAot=true -o $out -bl:"publish-${{ matrix.rid }}.binlog"

- name: 📦 pack
shell: pwsh
run: |
dotnet run --project src/nativepack --no-launch-profile -c $env:Configuration -- artifacts/${{ matrix.rid }} ${{ matrix.rid }} artifacts ci

- name: 📤 artifact
uses: actions/upload-artifact@v4
with:
name: native-aot-${{ matrix.rid }}
path: |
artifacts/${{ matrix.rid }}/ndnx
artifacts/${{ matrix.rid }}/ndnx.exe
artifacts/ndnx-*.zip
artifacts/ndnx-*.tar.gz
artifacts/ndnx-*.sha256
retention-days: 7
if-no-files-found: error

- name: 🐛 logs
uses: actions/upload-artifact@v4
if: runner.debug && always()
with:
name: logs-${{ matrix.rid }}
path: '*.binlog'

publish:
needs: native-aot
runs-on: ${{ vars.PUBLISH_AGENT || 'ubuntu-latest' }}
permissions:
contents: write
steps:
- name: 🤘 checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}

- name: 📥 native
uses: actions/download-artifact@v4
with:
pattern: native-aot-*
path: native

- name: 🚀 ci prerelease
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
set -euo pipefail
mapfile -t assets < <(find native -type f \( -name '*.zip' -o -name '*.tar.gz' -o -name '*.sha256' \))
assets+=(install.sh install.ps1)
if [ ${#assets[@]} -eq 0 ]; then
echo "No native archives, checksums, or install scripts found to attach." >&2
exit 1
fi
if gh release view ci >/dev/null 2>&1; then
gh release delete ci --yes --cleanup-tag
fi
gh release create ci --prerelease --latest=false --title "CI" \
--notes "Rolling native build from \`${NDNX_SHA}\`. Not a release.

Install: \`curl -fsSL https://github.com/${GITHUB_REPOSITORY}/releases/download/ci/install.sh | NDNX_VERSION=ci sh\`

Update: \`ndnx --update ci\`" \
"${assets[@]}"
12 changes: 10 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,16 @@ New-Item -ItemType Directory -Path $tmp | Out-Null
try {
if (-not $Archive) {
if ($Version) {
$tag = if ($Version.StartsWith('v')) { $Version } else { "v$Version" }
$resolved = $tag.TrimStart('v')
if ($Version -eq 'ci') {
$tag = 'ci'
$resolved = 'ci'
} elseif ($Version.StartsWith('v')) {
$tag = $Version
$resolved = $tag.TrimStart('v')
} else {
$tag = "v$Version"
$resolved = $Version
}
} else {
$release = Invoke-RestMethod -Headers @{ Accept = 'application/vnd.github+json' } `
-Uri "https://api.github.com/repos/$Repo/releases/latest"
Expand Down
18 changes: 13 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,20 @@ trap 'rm -rf "$tmp"' EXIT INT TERM

if [ -z "$ARCHIVE" ]; then
if [ -n "$VERSION" ]; then
tag=$VERSION
case "$tag" in
v*) ;;
*) tag="v${tag}" ;;
case "$(printf '%s' "$VERSION" | tr '[:upper:]' '[:lower:]')" in
ci)
tag=ci
version=ci
;;
v*)
tag=$VERSION
version=${tag#v}
;;
*)
tag="v${VERSION}"
version=$VERSION
;;
esac
version=${tag#v}
else
json=$(github_json "https://api.github.com/repos/${REPO}/releases/latest")
tag=$(printf '%s' "$json" | json_string tag_name)
Expand Down
15 changes: 15 additions & 0 deletions src/Tests/ArgParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,21 @@ public void Update_accepts_an_optional_version(params string[] args)
Assert.Equal("1.2.3", parsed.Version);
}

[Theory]
[InlineData("--update", "ci")]
[InlineData("--update", "CI")]
[InlineData("--update=ci")]
[InlineData("--update=vci")]
public void Update_accepts_the_ci_channel(params string[] args)
{
var parsed = ArgParser.Parse(args);

Assert.True(parsed.Success);
Assert.True(parsed.Update);
Assert.Null(parsed.PackageId);
Assert.Equal(SelfUpdate.CiChannel, parsed.Version);
}

[Fact]
public void Update_rejects_a_package_identity()
{
Expand Down
17 changes: 17 additions & 0 deletions src/Tests/InstallScriptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,33 @@ public void Shell_installer_skip_path_does_not_write_rc()
Assert.True(File.Exists(Path.Combine(dir.Prefix, "ndnx")));
}

[Fact]
public void Install_scripts_resolve_ci_channel_without_a_v_prefix()
{
var root = FindRepoRoot();
var sh = File.ReadAllText(Path.Combine(root, "install.sh"));
var ps = File.ReadAllText(Path.Combine(root, "install.ps1"));

Assert.Contains("ci)", sh);
Assert.Contains("tag=ci", sh);
Assert.Contains("version=ci", sh);
Assert.Contains("$Version -eq 'ci'", ps);
Assert.Contains("$tag = 'ci'", ps);
Assert.Contains("$resolved = 'ci'", ps);
}

[Fact]
public void Workflow_does_not_publish_nuget_or_sleet()
{
var yml = File.ReadAllText(Path.Combine(FindRepoRoot(), ".github", "workflows", "publish.yml"));
var build = File.ReadAllText(Path.Combine(FindRepoRoot(), ".github", "workflows", "build.yml"));
var ci = File.ReadAllText(Path.Combine(FindRepoRoot(), ".github", "workflows", "ci-release.yml"));
Assert.DoesNotContain("dotnet nuget push", yml);
Assert.DoesNotContain("sleet push", yml);
Assert.DoesNotContain("NUGET_API_KEY", yml);
Assert.DoesNotContain("SLEET_CONNECTION", yml);
Assert.DoesNotContain("sleet push", build);
Assert.DoesNotContain("sleet push", ci);
Assert.Contains("install.sh", yml);
Assert.Contains("install.ps1", yml);
}
Expand Down
1 change: 1 addition & 0 deletions src/Tests/NativePackerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public void Windows_rid_emits_zip_of_ndnx_exe_and_matching_sha256(string rid)
var result = NativePacker.Pack(dir.Publish, rid, dir.Output, "1.2.3");

Assert.Equal($"ndnx-1.2.3-{rid}.zip", Path.GetFileName(result.ArchivePath));
Assert.Equal($"ndnx-ci-{rid}.zip", NativePacker.ArchiveFileName(rid, "ci"));
Assert.True(File.Exists(result.ArchivePath));
Assert.True(File.Exists(result.Sha256Path));

Expand Down
30 changes: 30 additions & 0 deletions src/Tests/PublishWorkflowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,36 @@ static readonly (string Os, string Rid)[] ExpectedMatrix =
("macos-latest", "osx-arm64"),
];

[Fact]
public void Ci_release_workflow_publishes_a_rolling_prerelease()
{
var yml = File.ReadAllText(Path.Combine(FindRepoRoot(), ".github", "workflows", "ci-release.yml"));

Assert.Contains("workflow_run", yml);
Assert.Contains("workflows: [build]", yml);
Assert.Contains("head_branch == 'main'", yml);
Assert.Contains("--prerelease", yml);
Assert.Contains("--latest=false", yml);
Assert.Contains("release create ci", yml);
Assert.Contains("release delete ci", yml);
Assert.Contains("--cleanup-tag", yml);
Assert.Contains("name: native-aot-${{ matrix.rid }}", yml);
Assert.Contains("dotnet publish", yml);
Assert.Contains("PublishAot", yml);
Assert.Contains("src/nativepack", yml);
Assert.DoesNotContain("dotnet nuget push", yml);
Assert.DoesNotContain("sleet push", yml);
Assert.DoesNotContain("osx-", yml);
Assert.DoesNotContain("macos-", yml);
Assert.DoesNotContain("arm64", yml);

foreach (var (os, rid) in ExpectedMatrix.Where(entry => entry.Rid is "linux-x64" or "win-x64"))
{
Assert.Contains($"os: {os}", yml);
Assert.Contains($"rid: {rid}", yml);
}
}

[Fact]
public void Release_workflow_builds_the_six_rid_matrix_and_attaches_archives()
{
Expand Down
46 changes: 43 additions & 3 deletions src/Tests/SelfUpdateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,45 @@ public async Task Update_skips_download_when_already_on_latest()
Assert.DoesNotContain(handler.Hits, u => u.Contains("/releases/download/", StringComparison.Ordinal));
}

[Fact]
public async Task Update_to_ci_downloads_the_rolling_prerelease_tag()
{
using var dir = new TempDir();
var current = Path.Combine(dir.Prefix, "ndnx.exe");
File.WriteAllBytes(current, "old-binary"u8.ToArray());

using var handler = new MapHandler();
AddRelease(handler, dir, SelfUpdate.CiChannel, "ci-binary"u8.ToArray(), tag: SelfUpdate.CiChannel);
var host = NewHost(dir, current, "0.1.0", handler);

var code = await App.RunAsync(["--update", "ci"], host);

Assert.Equal(0, code);
Assert.Equal("ci-binary"u8.ToArray(), File.ReadAllBytes(current));
Assert.Contains("Updating to ci", host.Out.ToString());
Assert.Contains(SelfUpdate.AssetUrl(Repo, SelfUpdate.CiChannel, SelfUpdate.ArchiveFileName(Rid, SelfUpdate.CiChannel)), handler.Hits);
Assert.DoesNotContain(handler.Hits, u => u.Contains("/releases/latest", StringComparison.Ordinal));
Assert.DoesNotContain(handler.Hits, u => u.Contains("/vci/", StringComparison.Ordinal));
}

[Fact]
public async Task Update_to_ci_redownloads_even_when_already_labeled_ci()
{
using var dir = new TempDir();
var current = Path.Combine(dir.Prefix, "ndnx.exe");
File.WriteAllBytes(current, "stale-ci"u8.ToArray());

using var handler = new MapHandler();
AddRelease(handler, dir, SelfUpdate.CiChannel, "fresh-ci"u8.ToArray(), tag: SelfUpdate.CiChannel);
var host = NewHost(dir, current, SelfUpdate.CiChannel, handler);

var code = await App.RunAsync(["--update", "ci"], host);

Assert.Equal(0, code);
Assert.Equal("fresh-ci"u8.ToArray(), File.ReadAllBytes(current));
Assert.Contains(handler.Hits, u => u.Contains("/releases/download/ci/", StringComparison.Ordinal));
}

[Fact]
public async Task Update_to_an_older_version_is_allowed()
{
Expand Down Expand Up @@ -209,7 +248,7 @@ static MapHandler Feed(
return handler;
}

static void AddRelease(MapHandler handler, TempDir dir, string version, byte[] payload)
static void AddRelease(MapHandler handler, TempDir dir, string version, byte[] payload, string? tag = null)
{
var publish = Path.Combine(dir.Root, "publish-" + version);
Directory.CreateDirectory(publish);
Expand All @@ -218,8 +257,9 @@ static void AddRelease(MapHandler handler, TempDir dir, string version, byte[] p
var archive = File.ReadAllBytes(packed.ArchivePath);
var sha = File.ReadAllBytes(packed.Sha256Path);
var name = Path.GetFileName(packed.ArchivePath);
handler.Map[SelfUpdate.AssetUrl(Repo, "v" + version, name)] = (HttpStatusCode.OK, archive, "application/octet-stream");
handler.Map[SelfUpdate.AssetUrl(Repo, "v" + version, name) + ".sha256"] = (HttpStatusCode.OK, sha, "text/plain");
var releaseTag = tag ?? SelfUpdate.ReleaseTag(version);
handler.Map[SelfUpdate.AssetUrl(Repo, releaseTag, name)] = (HttpStatusCode.OK, archive, "application/octet-stream");
handler.Map[SelfUpdate.AssetUrl(Repo, releaseTag, name) + ".sha256"] = (HttpStatusCode.OK, sha, "text/plain");
}

sealed class MapHandler : HttpMessageHandler
Expand Down
4 changes: 2 additions & 2 deletions src/ndnx/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static class App
{
const string Usage = """
Usage: ndnx <PACKAGE_NAME[@VERSION]> [options] [--] [tool arguments]
ndnx --update [VERSION]
ndnx --update [VERSION|ci]
ndnx --version

A floating version (unspecified, @*, @*-*, or a range) stays current:
Expand All @@ -56,7 +56,7 @@ ndnx watches the feed and restarts the tool when a newer match appears.
--ignore-failed-sources Treat source failures as warnings
--no-http-cache Do not use an HTTP cache
--interactive Allow interactive restore prompts
--update [VERSION] Self-update ndnx to the latest or given version
--update [VERSION] Self-update to latest, a version, or ci (rolling prerelease)
--version Print the ndnx version
""";

Expand Down
9 changes: 6 additions & 3 deletions src/ndnx/ArgParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ static Invocation FinishUpdate(
if (packageId is not null)
{
var fromOperand = NormalizeUpdateVersion(packageId);
if (!PackageVersion.TryParse(fromOperand, out _))
if (!IsUpdateTarget(fromOperand))
{
return Invocation.Failed(
$"--update cannot be combined with a package identity. Unexpected '{packageId}'.");
Expand All @@ -271,7 +271,7 @@ static Invocation FinishUpdate(
if (version is { Length: 0 })
return Invocation.Failed("Missing value for --update.");

if (version is not null && !PackageVersion.TryParse(version, out _))
if (version is not null && !IsUpdateTarget(version))
return Invocation.Failed($"Invalid version '{version}'.");

return new Invocation
Expand All @@ -288,9 +288,12 @@ static string NormalizeUpdateVersion(string value)
var text = value.Trim();
if (text.Length > 0 && (text[0] is 'v' or 'V'))
text = text[1..];
return text;
return SelfUpdate.IsCiChannel(text) ? SelfUpdate.CiChannel : text;
}

static bool IsUpdateTarget(string version)
=> SelfUpdate.IsCiChannel(version) || PackageVersion.TryParse(version, out _);

static bool TryParseIdentity(string token, out string? packageId, out string? version, out string? error)
{
packageId = null;
Expand Down
Loading
Loading