From 4be13661dae2f0262d67eb9a77e6a12e5dd611e8 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 24 Aug 2026 21:46:54 +1200 Subject: [PATCH] Give the DevOps stack its own build, so its PRs are actually checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap in #415: this repo had no `.github` directory at all, so a PR against it merged with zero checks. Its shapes were only ever validated because someone happened to run the superproject's build by hand from a working tree that had the submodule checked out. A malformed shape merged clean and surfaced one repo away from the change that caused it. Ported from SmartHome, which is the current reference — note that #415's table is out of date. It describes an opt-in `validate.yml` calling the superproject's reusable `_validate-shapes.yml`; SmartHome has since replaced that with a Fallout-driven `build.yml` that runs the same portable validator through this repo's own build, so `./build.sh` locally and CI run the identical target. It also proves the release bundle builds on every PR rather than at release time. `.config`, `.husky`, `CLAUDE.md` and `README.md` were already here and already identical to SmartHome's, so this is the missing half rather than a fresh start. RUNNING IT CAUGHT WHAT READING IT DID NOT. A case-sensitive grep for "SmartHome" missed four lowercase references, so the first bundle came out as `smarthome-0.1.0.tar.gz` from a repo called DevOps — wrong archive name, wrong release asset, wrong CI artifact. Fixed and re-run; the bundle is now `devops-0.1.0.tar.gz` with all six members and the right repo in its manifest. BundlePaths stays hand-maintained, ported as-is rather than made clever, but it now carries a warning: a new asset directory that is not listed there is silently left out of the bundle instead of failing the build. It is the one part of this harness that rots, and `shell-assets` is currently the only entry that matters. ⚠ Unverified: whether this repo is inside the `SCHEMA_RO_PAT` org secret's visibility scope. Reading that needs admin:org, which I do not have — so the first CI run is the test. If `build` fails at the validator download, the fix is in the superproject's CLAUDE.md: grant the secret to this repo. Refs #415. --- .fallout/.gitignore | 1 + .fallout/build.schema.json | 129 +++++++++++++ .fallout/parameters.json | 3 + .github/workflows/build.yml | 45 +++++ .github/workflows/release.yml | 67 +++++++ .gitignore | 9 + build.ps1 | 13 ++ build.sh | 12 ++ build/Build.cs | 332 ++++++++++++++++++++++++++++++++++ build/_build.csproj | 38 ++++ global.json | 6 + nuget.config | 17 ++ 12 files changed, 672 insertions(+) create mode 100644 .fallout/.gitignore create mode 100644 .fallout/build.schema.json create mode 100644 .fallout/parameters.json create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 build.ps1 create mode 100755 build.sh create mode 100644 build/Build.cs create mode 100644 build/_build.csproj create mode 100644 global.json create mode 100644 nuget.config diff --git a/.fallout/.gitignore b/.fallout/.gitignore new file mode 100644 index 0000000..36445e5 --- /dev/null +++ b/.fallout/.gitignore @@ -0,0 +1 @@ +/temp diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json new file mode 100644 index 0000000..aa10da8 --- /dev/null +++ b/.fallout/build.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": { + "Host": { + "type": "string", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "ExecutableTarget": { + "type": "string", + "enum": [ + "Bundle", + "Release", + "RestoreValidator", + "ValidateShapes" + ] + }, + "Verbosity": { + "type": "string", + "description": "", + "enum": [ + "Verbose", + "Normal", + "Minimal", + "Quiet" + ] + }, + "FalloutBuild": { + "properties": { + "Continue": { + "type": "boolean", + "description": "Indicates to continue a previously failed build attempt" + }, + "Help": { + "type": "boolean", + "description": "Shows the help text for this build assembly" + }, + "Host": { + "description": "Host for execution. Default is 'automatic'", + "$ref": "#/definitions/Host" + }, + "NoLogo": { + "type": "boolean", + "description": "Disables displaying the NUKE logo" + }, + "Partition": { + "type": "string", + "description": "Partition to use on CI" + }, + "Plan": { + "type": "boolean", + "description": "Shows the execution plan (HTML)" + }, + "Profile": { + "type": "array", + "description": "Defines the profiles to load", + "items": { + "type": "string" + } + }, + "Root": { + "type": "string", + "description": "Root directory during build execution" + }, + "Skip": { + "type": "array", + "description": "List of targets to be skipped. Empty list skips all dependencies", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "Target": { + "type": "array", + "description": "List of targets to be invoked. Default is '{default_target}'", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "Verbosity": { + "description": "Logging verbosity during build execution. Default is 'Normal'", + "$ref": "#/definitions/Verbosity" + }, + "BuildProjectFile": { + "type": [ + "null", + "string" + ], + "description": "Path to the build project (.csproj) relative to the repository root. Defaults to 'build/_build.csproj' when unset. Read by the Fallout global tool's in-tool runner." + } + } + } + }, + "allOf": [ + { + "properties": { + "DryRun": { + "type": "boolean", + "description": "Compute, validate and bundle, but do not create the GitHub Release" + }, + "ReleaseVersion": { + "type": "string", + "description": "Explicit release version, without the leading 'v' (e.g. 1.4.0). Default: computed from the labels on PRs merged since the last tag" + }, + "SchemaRef": { + "type": "string", + "description": "Homelab release tag to pin the portable validator to (default: the moving schema-v1 channel)" + } + } + }, + { + "$ref": "#/definitions/FalloutBuild" + } + ] +} diff --git a/.fallout/parameters.json b/.fallout/parameters.json new file mode 100644 index 0000000..2a134ba --- /dev/null +++ b/.fallout/parameters.json @@ -0,0 +1,3 @@ +{ + "$schema": "build.schema.json" +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9ad6e5f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: build + +# The stack's own PR gate (ADR-0008). Replaces the previous validate.yml, which called the +# superproject's reusable _validate-shapes.yml: the same portable validator runs here, but +# it is now driven by this repo's Fallout build, so `./build.sh` locally and CI run the +# identical target. Also proves the release bundle builds on every PR, rather than finding +# out at release time. +# +# Fallout 10.4 is public on nuget.org, so this needs no package feed credentials. +# SCHEMA_RO_PAT is still required — it downloads the validator from the private +# superproject's schema-v1 release. + +on: + pull_request: {} + push: + branches: [main] + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # version resolution walks tags + commits back to the last release + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Validate shapes + build the release bundle + env: + SCHEMA_RO_PAT: ${{ secrets.SCHEMA_RO_PAT }} # validator download (private superproject) + GH_TOKEN: ${{ github.token }} # PR-label lookup (this repo) + run: ./build.sh Bundle + + - name: Upload the bundle for inspection + uses: actions/upload-artifact@v4 + with: + name: devops-bundle + path: dist/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9155d24 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: release + +# Cuts the immutable artifact a deploy consumes. On a merge to main that actually changes +# the deployable stack, the build validates the shapes, bundles them with a manifest, works +# out the next SemVer from the merged PRs' labels, and publishes a GitHub Release. +# +# Why `paths:` and not every main push — the bundle contains shapes and service assets only, +# so a docs-only merge would produce a byte-identical artifact under a new version. Anything +# that changes what gets deployed is listed below; anything else is not a release. +# +# Environment `Production` gates this. Today it records the deployment and is the place to +# hang an approval; a `Test` environment slots in beside it later. + +on: + push: + branches: [main] + paths: + - '*.lxc.yaml' + - '*.vm.yaml' + - 'stack.yaml' + - 'aircast/**' + - 'esl2-bridge/**' + - 'leapmotor-mate/**' + - 'matter-server/**' + - 'podman-host/**' + - 'build/**' + - '.github/workflows/release.yml' + workflow_dispatch: + inputs: + version: + description: 'Explicit version without the leading v (e.g. 1.4.0). Blank = compute from PR labels.' + required: false + type: string + dry-run: + description: 'Validate and bundle, but do not create the release.' + required: false + type: boolean + default: false + +permissions: + contents: write # create the tag + GitHub Release + +concurrency: + group: release + cancel-in-progress: false # never cancel a half-published release + +jobs: + release: + runs-on: ubuntu-latest + environment: Production + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # version resolution walks tags + commits back to the last release + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Validate, bundle and release + env: + SCHEMA_RO_PAT: ${{ secrets.SCHEMA_RO_PAT }} # validator download (private superproject) + GH_TOKEN: ${{ github.token }} # PR labels + release creation (this repo) + run: | + ./build.sh Release \ + ${{ inputs.version && format('--release-version {0}', inputs.version) || '' }} \ + ${{ inputs.dry-run && '--dry-run' || '' }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f083d62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Fallout build outputs +build/bin/ +build/obj/ + +# Release bundle + manifest, rebuilt by ./build.sh Bundle +dist/ + +# Portable validator, downloaded from the superproject's schema-v1 release +.validator/ diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..32bd2f5 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,13 @@ +#!/usr/bin/env pwsh +# DevOps stack pipeline entrypoint (Fallout build). Requires the .NET 10 SDK on +# PATH (see global.json) and, for the validator download, a `gh` with read access to +# the private Chrison-Homelab/Homelab repo. +# +# ./build.ps1 # default target: ValidateShapes +# ./build.ps1 Bundle # validate + produce dist/ +# ./build.ps1 Release --dry-run # everything except cutting the release +# ./build.ps1 Bundle --skip ValidateShapes # on macOS/Windows (validator is linux-x64) +$ErrorActionPreference = 'Stop' +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +dotnet run --project "$ScriptDir/build/_build.csproj" -- @args +exit $LASTEXITCODE diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..62683a7 --- /dev/null +++ b/build.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# DevOps stack pipeline entrypoint (Fallout build). Requires the .NET 10 SDK on +# PATH (see global.json) and, for the validator download, a `gh` with read access to +# the private Chrison-Homelab/Homelab repo. +# +# ./build.sh # default target: ValidateShapes +# ./build.sh Bundle # validate + produce dist/ +# ./build.sh Release --dry-run # everything except cutting the release +# ./build.sh Bundle --skip ValidateShapes # on macOS/Windows (validator is linux-x64) +set -eo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec dotnet run --project "$SCRIPT_DIR/build/_build.csproj" -- "$@" diff --git a/build/Build.cs b/build/Build.cs new file mode 100644 index 0000000..6fe4f0d --- /dev/null +++ b/build/Build.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Tooling; +using Serilog; + +// DevOps stack pipeline — Fallout build. +// +// This stack lives in its own repo (ADR-0008 meta-repo model), so it owns its own +// pipeline rather than borrowing the superproject's. Two things it does today: +// +// ValidateShapes → the SAME engine `validate` the superproject runs, via the portable +// validator published to the Homelab `schema-v1` release. No .NET +// engine source, no private feeds, no self-hosted runner. +// Release → bundle the stack at a commit into an immutable artifact and cut the +// GitHub Release a deploy consumes by tag. +// +// Preview/Deploy are deliberately NOT here yet. Converge needs the engine plus cluster +// reach (Proxmox API + SSH to nodes), which stays in the superproject on the self-hosted +// runner. The seam for adding them later is `Engine(...)` below — it already runs the +// portable binary, so a Deploy target is a new target, not a restructure. +// +// ./build.sh # default target: ValidateShapes +// ./build.sh Bundle # validate + produce dist/ artifact +// ./build.sh Release # bundle + cut the GitHub Release +// ./build.sh Release --dry-run # everything except creating the release +class Build : FalloutBuild +{ + public static int Main() => Execute(x => x.ValidateShapes); + + [Parameter("Homelab release tag to pin the portable validator to (default: the moving schema-v1 channel).")] + readonly string SchemaRef = "schema-v1"; + + [Parameter("Explicit release version, without the leading 'v' (e.g. 1.4.0). Default: computed from the labels on PRs merged since the last tag.")] + readonly string ReleaseVersion; + + [Parameter("Compute, validate and bundle, but do not create the GitHub Release.")] + readonly bool DryRun; + + const string SuperprojectRepo = "Chrison-Homelab/Homelab"; + const string StackRepo = "Chrison-Homelab/Homelab.Stacks.DevOps"; + + AbsolutePath DistDirectory => RootDirectory / "dist"; + AbsolutePath ValidatorDirectory => RootDirectory / ".validator"; + AbsolutePath ValidatorBinary => ValidatorDirectory / "homelab-infra"; + + // ---------------------------------------------------------------- validate + + Target RestoreValidator => _ => _ + .Description("Download the pinned portable validator from the Homelab schema-v1 release.") + .OnlyWhenDynamic(() => !ValidatorBinary.FileExists()) + .Executes(() => + { + // The published validator is linux-x64 only, so on a dev Mac/Windows box this + // would otherwise fail with a bare "exec format error". Say what's wrong and how + // to get past it — the superproject publishes no other RID today. + if (!EnvironmentInfo.IsLinux) + throw new Exception( + $"The portable validator is linux-x64 only and this is {EnvironmentInfo.Platform}. " + + "Run `./build.sh --skip ValidateShapes` locally, or let CI validate — " + + $"the full-fidelity gate is the superproject's `./build.sh ValidateShapes`."); + + ValidatorDirectory.CreateDirectory(); + // Two `gh` calls in this build need DIFFERENT tokens: this one reads a release + // asset from the private SUPERPROJECT (SCHEMA_RO_PAT), while the version + // computation reads PR labels from THIS repo (the ambient Actions token). `gh` + // takes its token from the ambient GH_TOKEN, so the schema token is scoped to + // this one process rather than exported over the whole run. + Gh($"release download {SchemaRef} --repo {SuperprojectRepo} " + + $"--pattern homelab-validate-linux-x64.tar.gz --dir {ValidatorDirectory} --clobber", + token: SchemaToken); + Run("tar", $"-C {ValidatorDirectory} -xzf {ValidatorDirectory / "homelab-validate-linux-x64.tar.gz"}"); + Run("chmod", $"+x {ValidatorBinary}"); + + // The engine resolves the schema from AppContext.BaseDirectory, so it must sit + // beside the binary. A silently-missing schema would fail every shape instead. + var schema = ValidatorDirectory / "schema" / "shape.schema.json"; + if (!schema.FileExists()) + throw new Exception($"validator unpacked without its schema at {schema}"); + }); + + Target ValidateShapes => _ => _ + .Description("Validate every shape in this stack against shape.schema.json.") + .DependsOn(RestoreValidator) + .Executes(() => + { + // The validator skips any YAML that isn't an `apiVersion: homelab/v1` shape, so + // pointing it at the repo root is safe — compose files and CI aren't shapes. + Engine($"validate {RootDirectory}"); + }); + + // ---------------------------------------------------------------- bundle + + Target Bundle => _ => _ + .Description("Bundle the stack's shapes and assets into dist/ with a manifest.") + .DependsOn(ValidateShapes) + .Executes(() => + { + var version = ResolveVersion(); + DistDirectory.CreateOrCleanDirectory(); + + var manifest = DistDirectory / "MANIFEST.md"; + manifest.WriteAllText(BuildManifest(version)); + + // Ship the shapes and the assets they reference, and nothing else. Explicitly not + // a `git archive` of everything: the docs/ tree carries ~10 MB of firmware images + // and PDFs that a deploy has no use for. + var archive = DistDirectory / $"devops-{version}.tar.gz"; + var payload = string.Join(" ", BundlePaths().Select(p => p.ToString())); + Run("tar", $"-czf {archive} -C {RootDirectory} {payload} -C {DistDirectory} MANIFEST.md"); + + Log.Information("bundled {Archive} ({Size} bytes)", archive, archive.ToFileInfo().Length); + }); + + // Paths (relative to the repo root) that make up a deployable stack. + IEnumerable BundlePaths() + { + // ⚠ HAND-MAINTAINED, and a new asset directory that is not added here is silently + // left out of the bundle rather than failing the build. Ported as-is from SmartHome + // rather than made clever, but it is the one part of this harness that rots. + // Cross-check against `assets:` in the shapes when adding a member. + var candidates = new[] + { + "stack.yaml", "shell-assets", + }; + foreach (var yaml in RootDirectory.GlobFiles("*.lxc.yaml", "*.vm.yaml").OrderBy(p => p.Name)) + yield return yaml.Name; + foreach (var c in candidates) + { + var path = RootDirectory / c; + if (path.DirectoryExists() || path.FileExists()) + yield return c; + } + } + + string BuildManifest(string version) + { + var sha = Git("rev-parse HEAD").Trim(); + var members = RootDirectory + .GlobFiles("*.lxc.yaml", "*.vm.yaml") + .OrderBy(p => p.Name) + .Select(p => + { + var text = p.ReadAllText(); + var ctid = Regex.Match(text, @"^\s*(?:ctid|vmid):\s*(\d+)", RegexOptions.Multiline); + var manage = Regex.Match(text, @"^\s*manage:\s*([a-z-]+)", RegexOptions.Multiline); + return $"| {p.Name} | {(ctid.Success ? ctid.Groups[1].Value : "—")} " + + $"| {(manage.Success ? manage.Groups[1].Value : "managed")} |"; + }); + + return $""" + # DevOps stack — {version} + + - Commit: `{sha}` + - Repo: `{StackRepo}` + - Built: {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ} + + This artifact is the deployable stack at that commit. A deploy resolves the tag to + this bundle, so what was validated is what ships. + + ## Members + + | Shape | ID | Lifecycle | + |---|---|---| + {string.Join("\n", members)} + """; + } + + // ---------------------------------------------------------------- release + + Target Release => _ => _ + .Description("Cut the GitHub Release for this stack, with notes generated from PR labels.") + .DependsOn(Bundle) + .Executes(() => + { + var version = ResolveVersion(); + var tag = $"v{version}"; + + if (DryRun) + { + Log.Information("dry run — would create release {Tag} from {Sha}", tag, Git("rev-parse HEAD").Trim()); + return; + } + + if (!string.IsNullOrWhiteSpace(GhOrEmpty($"release view {tag} --repo {StackRepo} --json tagName"))) + { + Log.Warning("release {Tag} already exists — nothing to do", tag); + return; + } + + // --generate-notes groups merged PRs by label using .github/release.yml, which is + // why every PR must carry exactly one category label at creation time. + Gh($"release create {tag} {DistDirectory / $"devops-{version}.tar.gz"} {DistDirectory / "MANIFEST.md"} " + + $"--repo {StackRepo} --title \"DevOps {tag}\" --generate-notes --target {Git("rev-parse HEAD").Trim()}"); + + Log.Information("released {Tag}", tag); + }); + + // ---------------------------------------------------------------- versioning + + string _resolvedVersion; + + // SemVer, derived from the labels on PRs merged since the last tag — the same taxonomy + // the repo already enforces at PR-creation time, so the version says something true + // about whether an upgrade is safe: + // breaking-change → major (a ctid or contract change: recreating a guest) + // enhancement → minor + // anything else → patch + string ResolveVersion() + { + if (_resolvedVersion is not null) return _resolvedVersion; + if (!string.IsNullOrWhiteSpace(ReleaseVersion)) return _resolvedVersion = ReleaseVersion.TrimStart('v'); + + var last = GitOrEmpty("describe --tags --abbrev=0 --match v*").Trim(); + if (string.IsNullOrWhiteSpace(last)) + { + Log.Information("no previous tag — starting at 0.1.0"); + return _resolvedVersion = "0.1.0"; + } + + var parsed = Regex.Match(last, @"^v(\d+)\.(\d+)\.(\d+)$"); + if (!parsed.Success) + throw new Exception($"last tag '{last}' is not vMAJOR.MINOR.PATCH — pass --release-version to override"); + + var (major, minor, patch) = (int.Parse(parsed.Groups[1].Value), + int.Parse(parsed.Groups[2].Value), + int.Parse(parsed.Groups[3].Value)); + + var labels = MergedPrLabelsSince(last); + if (labels.Contains("breaking-change")) { major++; minor = 0; patch = 0; } + else if (labels.Contains("enhancement")) { minor++; patch = 0; } + else patch++; + + _resolvedVersion = $"{major}.{minor}.{patch}"; + Log.Information("{Last} + [{Labels}] → v{Next}", last, string.Join(", ", labels.OrderBy(x => x)), _resolvedVersion); + return _resolvedVersion; + } + + // Labels across every PR merged since `sinceTag`. Uses the commit list rather than a date + // window: merged-at timestamps overlap when two PRs land close together, which would + // silently fold one PR's labels into the previous release. + HashSet MergedPrLabelsSince(string sinceTag) + { + var labels = new HashSet(StringComparer.OrdinalIgnoreCase); + var shas = GitOrEmpty($"rev-list {sinceTag}..HEAD") + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .ToList(); + + foreach (var sha in shas) + { + var json = GhOrEmpty($"api repos/{StackRepo}/commits/{sha}/pulls --jq [.[].labels[].name]"); + if (string.IsNullOrWhiteSpace(json)) continue; + try + { + foreach (var name in JsonSerializer.Deserialize(json) ?? Array.Empty()) + labels.Add(name); + } + catch (JsonException) { /* no associated PR — direct commit */ } + } + + if (labels.Count == 0) + Log.Warning("no PR labels found since {Tag} — defaulting to a patch bump", sinceTag); + return labels; + } + + // ---------------------------------------------------------------- process helpers + + // Pre-built strings take StartProcess's plain overload. Passing an interpolated string + // DIRECTLY binds Fallout's ArgumentStringHandler, which quotes each interpolation hole — + // collapsing a multi-token argument list into one quoted argument. + void Engine(string arguments) + { + string command = arguments; + ProcessTasks.StartProcess(ValidatorBinary, command, workingDirectory: RootDirectory).AssertZeroExitCode(); + } + + void Run(string tool, string arguments) + { + string command = arguments; + ProcessTasks.StartProcess(tool, command, workingDirectory: RootDirectory).AssertZeroExitCode(); + } + + // Token with contents:read on the private superproject, needed only to download the + // validator. Falls back to the ambient gh auth locally, where a dev already has access. + string SchemaToken => Environment.GetEnvironmentVariable("SCHEMA_RO_PAT"); + + void Gh(string arguments, string token = null) + { + if (string.IsNullOrWhiteSpace(token)) { Run("gh", arguments); return; } + + string command = arguments; + var env = Environment.GetEnvironmentVariables() + .Cast() + .ToDictionary(e => (string)e.Key, e => (string)e.Value, StringComparer.OrdinalIgnoreCase); + env["GH_TOKEN"] = token; + + ProcessTasks.StartProcess("gh", command, workingDirectory: RootDirectory, environmentVariables: env) + .AssertZeroExitCode(); + } + + string Git(string arguments) + { + string command = arguments; + var proc = ProcessTasks.StartProcess("git", command, workingDirectory: RootDirectory, logOutput: false).AssertZeroExitCode(); + return string.Join("\n", proc.Output.Select(o => o.Text)); + } + + string GitOrEmpty(string arguments) => TryCapture("git", arguments); + string GhOrEmpty(string arguments) => TryCapture("gh", arguments); + + string TryCapture(string tool, string arguments) + { + string command = arguments; + try + { + var proc = ProcessTasks.StartProcess(tool, command, workingDirectory: RootDirectory, logOutput: false); + proc.WaitForExit(); + return proc.ExitCode == 0 ? string.Join("\n", proc.Output.Select(o => o.Text)) : ""; + } + catch (Exception ex) + { + Log.Debug(ex, "{Tool} {Args} failed", tool, arguments); + return ""; + } + } +} diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 0000000..f299308 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,38 @@ + + + + + + Exe + net10.0 + disable + disable + + false + $(NoWarn);CS0649;CS0169 + false + + + + + + + + + + + + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..1e7fdfa --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMinor" + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..b6521d0 --- /dev/null +++ b/nuget.config @@ -0,0 +1,17 @@ + + + + + + + + + + + + +