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 @@ + + + + + + + + + + + + +