diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f336faa..8f5b809 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,17 @@ jobs: # The test project isn't in the slnx (app-only solution), so it restores/builds itself here. - name: Test run: dotnet test tests/Aperture.Core.Tests/Aperture.Core.Tests.csproj -c Release + + # net10.0 (not windows) helper used by tag-version.yml — compile + a + # greater-than-latest decision against a fake tag list. + - name: Release-tag helper + shell: pwsh + run: | + $tagsFile = Join-Path $env:RUNNER_TEMP 'v-tags.txt' + Set-Content -Path $tagsFile -Value "v0.8.0-beta1" -Encoding utf8NoBOM + $out = dotnet run --project scripts/DecideReleaseTag -- --version 0.8.1-beta1 --tags-file $tagsFile | Out-String + $out + if ($LASTEXITCODE -ne 0) { throw "DecideReleaseTag exited $LASTEXITCODE`n$out" } + if ($out -notmatch 'should_tag=true' -or $out -notmatch 'tag=v0.8.1-beta1') { + throw "DecideReleaseTag did not request v0.8.1-beta1. Output:`n$out" + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e980d0..95f2ceb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,14 @@ on: description: 'Tag to release (must already exist)' required: true type: string + # Invoked by tag-version.yml after it creates a tag. A GITHUB_TOKEN tag push + # does not start this workflow on its own (Actions will not re-enter). + workflow_call: + inputs: + tag: + description: 'Tag to release (must already exist)' + required: true + type: string permissions: contents: write @@ -22,11 +30,7 @@ jobs: id: tag shell: pwsh run: | - $tag = if ('${{ github.event_name }}' -eq 'workflow_dispatch') { - '${{ inputs.tag }}' - } else { - $env:GITHUB_REF_NAME - } + $tag = '${{ inputs.tag || github.ref_name }}' if (-not $tag) { throw "No tag provided." } $version = $tag -replace '^v','' # Numeric base for assembly/file version (strip any -prerelease suffix). diff --git a/.github/workflows/tag-version.yml b/.github/workflows/tag-version.yml new file mode 100644 index 0000000..9f2990b --- /dev/null +++ b/.github/workflows/tag-version.yml @@ -0,0 +1,85 @@ +# Create v{Version} on main when the landed Version is SemVer-greater than +# every existing v* tag. This job only writes the tag. Signing stays in +# release.yml (same Funcular Labs Azure Trusted Signing identity Markdown +# Midget uses). A GITHUB_TOKEN tag push does not start other workflows, so +# we call release.yml with secrets: inherit — do not sign here, do not +# invent another cert/SP, do not skip the fail-fast secret check. +name: Tag version + +on: + push: + branches: [main] + +concurrency: + group: tag-version-main + cancel-in-progress: false + +permissions: {} + +jobs: + tag: + runs-on: windows-latest + permissions: + contents: write + outputs: + tag: ${{ steps.push.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-tags: true + + - name: Fetch v* tags + shell: pwsh + run: git fetch --tags --force origin + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Decide whether to tag + id: decide + shell: pwsh + run: | + $tagsFile = Join-Path $env:RUNNER_TEMP 'v-tags.txt' + git tag -l 'v*' | Set-Content -Path $tagsFile -Encoding utf8NoBOM + dotnet run --project scripts/DecideReleaseTag -- ` + --repo . --tags-file $tagsFile --github-output + + - name: Create and push tag + id: push + if: steps.decide.outputs.should_tag == 'true' + shell: pwsh + run: | + $tag = '${{ steps.decide.outputs.tag }}' + if ($tag -notmatch '^v') { throw "Refusing to create non-v* tag '$tag'" } + + $remote = git ls-remote --tags origin "refs/tags/$tag" + if ($remote) { + Write-Host "Tag $tag already exists on origin; not force-pushing." + return + } + if (git show-ref --verify --quiet "refs/tags/$tag") { + Write-Host "Tag $tag already exists locally; not force-pushing." + return + } + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git tag -a $tag -m "Aperture Image Viewer $tag" + git push origin "refs/tags/$tag" + "tag=$tag" >> $env:GITHUB_OUTPUT + Write-Host "Pushed $tag" + + release: + needs: tag + if: needs.tag.outputs.tag != '' + uses: ./.github/workflows/release.yml + with: + tag: ${{ needs.tag.outputs.tag }} + # Passes AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET through + # to the existing signed-release job (org secrets; values never in the tree). + secrets: inherit + # Same write the standalone Release run needs (create the GitHub release). + permissions: + contents: write diff --git a/docs/GITHUB-SETUP.md b/docs/GITHUB-SETUP.md index 880b0a3..b3a553e 100644 --- a/docs/GITHUB-SETUP.md +++ b/docs/GITHUB-SETUP.md @@ -1,7 +1,7 @@ # Publishing to GitHub Repo: **`github.com/FuncularLabs/Aperture`** — **private** for now (flip to public when ready). -CI (`.github/workflows/ci.yml`) and a signed release pipeline (`release.yml`) are in the tree. +CI (`.github/workflows/ci.yml`), auto-tag on a greater Version landing on `main` (`tag-version.yml`), and a signed release pipeline (`release.yml`) are in the tree. ## Pre-flight (already done ✓) - `LICENSE` — MIT, **© 2026 Funcular Labs**. @@ -44,10 +44,22 @@ and signs any number of that publisher's products. Nothing new needs to be provi "Aperture Image Viewer"`, and attaches it to a GitHub Release built from `CHANGELOG.md`. ## Cutting a release -1. Ensure `CHANGELOG.md` has a `## []` section for the version. -2. Tag and push: `git tag v0.7.0-beta1 && cd C:\code\Reel && git push origin v0.7.0-beta1`. -3. The `Release` workflow builds → tests → publishes → **signs** → creates the (pre)release with the exe. - (`-beta`/`-rc` tags are marked as prereleases automatically.) + +Happy path — **no manual `git tag`**: + +1. Bump `` in `src/Aperture.App/Aperture.App.csproj` (the shipped assembly version; `Directory.Build.props` is only a fallback if the csproj has no Version) and add a `## []` section in `CHANGELOG.md`. +2. Merge that to `main`. +3. On push to `main` only (not pull requests), the **Tag version** workflow (`.github/workflows/tag-version.yml`) reads that Version and compares it to existing `v*` tags with the same SemVer rules as the in-app updater (`src/Aperture.App/Updates/UpdateVersion.cs` — prerelease suffixes matter: `0.8.1-beta1` > `0.8.0-beta1`, and `0.8.0-beta1` < `0.8.0`). If the landed version is **greater** and `v{version}` does not already exist, it creates and pushes that tag (never force-pushes). Equal or lower versions are left untagged. +4. The existing **Release** workflow then builds → tests → publishes → **signs** → creates the (pre)release with the exe. (`-beta`/`-rc` tags are marked as prereleases automatically.) The tag job does not sign and does not bypass this job: it calls `release.yml` with `secrets: inherit` so the same Funcular Labs Azure Trusted Signing identity Markdown Midget uses (`AZURE_CLIENT_ID` / `AZURE_TENANT_ID` / `AZURE_CLIENT_SECRET`, account `func-az-artifact-signing`, profile `funcular-labs-public-trust`, endpoint `https://cus.codesigning.azure.net/`, `--description "Aperture Image Viewer"`) still runs, including the fail-fast check that those secrets are present. + +Fallback — tag by hand if the automatic job cannot run (workflow not yet on `main`, or you need to re-cut an already-tagged version after fixing the tag job): + +```powershell +git tag v0.8.1-beta1 +git push origin v0.8.1-beta1 +``` + +Or **Actions → Release → Run workflow** and pass a tag that already exists. Notes: - Local git history is intact (the Reel→Aperture rebrand preserved it), so the full commit trail comes along. diff --git a/scripts/DecideReleaseTag/DecideReleaseTag.csproj b/scripts/DecideReleaseTag/DecideReleaseTag.csproj new file mode 100644 index 0000000..e8ca609 --- /dev/null +++ b/scripts/DecideReleaseTag/DecideReleaseTag.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + false + DecideReleaseTag + + + + + + + + + diff --git a/scripts/DecideReleaseTag/Program.cs b/scripts/DecideReleaseTag/Program.cs new file mode 100644 index 0000000..12026ef --- /dev/null +++ b/scripts/DecideReleaseTag/Program.cs @@ -0,0 +1,83 @@ +using Aperture.App.Updates; + +var repo = "."; +string? versionOverride = null; +string? tagsFile = null; +var writeGithub = false; + +for (var i = 0; i < args.Length; i++) +{ + var arg = args[i]; + string Next() => i + 1 < args.Length + ? args[++i] + : throw new ArgumentException($"Missing value for {arg}"); + + switch (arg) + { + case "--repo": + repo = Next(); + break; + case "--version": + versionOverride = Next(); + break; + case "--tags-file": + tagsFile = Next(); + break; + case "--github-output": + writeGithub = true; + break; + default: + throw new ArgumentException($"Unknown argument: {arg}"); + } +} + +var version = versionOverride ?? ReleaseTag.ReadLandedVersion(repo); +if (string.IsNullOrWhiteSpace(version) || UpdateVersion.Parse(version) is null) +{ + Console.Error.WriteLine( + "No parseable in src/Aperture.App/Aperture.App.csproj or Directory.Build.props."); + return 1; +} + +IEnumerable tags = tagsFile is null + ? Array.Empty() + : File.ReadAllLines(tagsFile); + +var latest = ReleaseTag.LatestTag(tags); +var tag = ReleaseTag.TagToCreate(version, tags); +var shouldTag = tag is not null; + +var reason = tag is not null + ? latest is null + ? $"{version} is the first v* tag" + : $"{version} is greater than {latest}" + : latest is not null && string.Equals(ReleaseTag.TagNameFor(version), latest, StringComparison.Ordinal) + ? $"{version} already has tag {latest}" + : latest is not null && tags.Any(t => string.Equals(t.Trim(), ReleaseTag.TagNameFor(version), StringComparison.Ordinal)) + ? $"tag {ReleaseTag.TagNameFor(version)} already exists" + : latest is null + ? $"{version} is not taggable" + : $"{version} is not greater than {latest}"; + +Console.WriteLine($"version={version}"); +Console.WriteLine($"latest_tag={latest ?? ""}"); +Console.WriteLine($"should_tag={shouldTag.ToString().ToLowerInvariant()}"); +Console.WriteLine($"tag={tag ?? ""}"); +Console.WriteLine($"reason={reason}"); + +if (writeGithub) +{ + var output = Environment.GetEnvironmentVariable("GITHUB_OUTPUT"); + if (string.IsNullOrWhiteSpace(output)) + throw new InvalidOperationException("--github-output set but GITHUB_OUTPUT is empty."); + File.AppendAllLines(output, new[] + { + $"version={version}", + $"latest_tag={latest ?? ""}", + $"should_tag={shouldTag.ToString().ToLowerInvariant()}", + $"tag={tag ?? ""}", + $"reason={reason}", + }); +} + +return 0; diff --git a/src/Aperture.App/Updates/ReleaseTag.cs b/src/Aperture.App/Updates/ReleaseTag.cs new file mode 100644 index 0000000..08a51c2 --- /dev/null +++ b/src/Aperture.App/Updates/ReleaseTag.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace Aperture.App.Updates; + +/// +/// Whether a Version that just landed on main should become a v* +/// git tag. Comparison is — the same SemVer rules +/// as the in-app updater (numeric first, prerelease below its own stable). +/// +internal static class ReleaseTag +{ + public const string AppCsprojRelativePath = "src/Aperture.App/Aperture.App.csproj"; + public const string DirectoryBuildPropsRelativePath = "Directory.Build.props"; + + /// + /// Reads <Version> from the app csproj (source of truth for + /// the shipped assembly). Falls back to Directory.Build.props if + /// the csproj has no Version — MSBuild would inherit it from there. + /// + public static string? ReadLandedVersion(string repoRoot) + { + var csproj = ReadVersionElement(Path.Combine(repoRoot, AppCsprojRelativePath)); + if (!string.IsNullOrWhiteSpace(csproj)) return csproj; + var props = ReadVersionElement(Path.Combine(repoRoot, DirectoryBuildPropsRelativePath)); + return string.IsNullOrWhiteSpace(props) ? null : props; + } + + public static string? ReadVersionElement(string path) + { + if (!File.Exists(path)) return null; + var doc = XDocument.Load(path); + return doc.Descendants() + .Where(e => e.Name.LocalName == "Version") + .Select(e => e.Value.Trim()) + .FirstOrDefault(v => v.Length > 0); + } + + /// + /// Among existing v* tags, the one that compares greatest under + /// , or null if none parse. + /// + public static string? LatestTag(IEnumerable existingTags) + { + string? bestName = null; + UpdateVersion? best = null; + foreach (var raw in existingTags) + { + if (!TryVTag(raw, out var name, out var parsed)) continue; + if (best is null || parsed.CompareTo(best) > 0) + { + best = parsed; + bestName = name; + } + } + return bestName; + } + + /// + /// Returns v{version} when is + /// strictly greater than every existing v* tag and that tag name + /// is not already present. Equal, lower, unparseable, or already-tagged + /// versions return null. Never force-updates a tag. + /// + public static string? TagToCreate(string? landedVersion, IEnumerable existingTags) + { + var raw = landedVersion?.Trim(); + if (string.IsNullOrWhiteSpace(raw)) return null; + + var landed = UpdateVersion.Parse(raw); + if (landed is null) return null; + + var wanted = TagNameFor(raw); + if (wanted is null) return null; + + var tags = existingTags + .Select(t => t.Trim()) + .Where(t => t.Length > 0) + .ToList(); + + if (tags.Any(t => string.Equals(t, wanted, StringComparison.Ordinal))) + return null; + + var latestName = LatestTag(tags); + if (latestName is not null) + { + var latest = UpdateVersion.Parse(latestName); + if (latest is not null && landed.CompareTo(latest) <= 0) + return null; + } + + return wanted; + } + + /// + /// Tag name from a Version string: leading v is normalized, build + /// metadata is dropped so it matches . + /// The prerelease suffix is kept as written (e.g. 0.8.1-beta1 → + /// v0.8.1-beta1), not rewritten to the 3-component ToString form. + /// + public static string? TagNameFor(string versionText) + { + var s = versionText.Trim(); + if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..]; + var plus = s.IndexOf('+'); + if (plus >= 0) s = s[..plus]; + s = s.Trim(); + return s.Length == 0 ? null : $"v{s}"; + } + + private static bool TryVTag(string? raw, out string name, out UpdateVersion parsed) + { + name = raw?.Trim() ?? ""; + parsed = null!; + if (name.Length == 0) return false; + if (!(name.StartsWith('v') || name.StartsWith('V'))) return false; + var v = UpdateVersion.Parse(name); + if (v is null) return false; + parsed = v; + return true; + } +} diff --git a/tests/Aperture.Core.Tests/UpdateTests.cs b/tests/Aperture.Core.Tests/UpdateTests.cs index ae3b4c9..cabef6f 100644 --- a/tests/Aperture.Core.Tests/UpdateTests.cs +++ b/tests/Aperture.Core.Tests/UpdateTests.cs @@ -41,6 +41,89 @@ public void CompareTo_Orders(string a, string b, int sign) } } +public class ReleaseTagTests +{ + // Same ordering as UpdateVersionTests.CompareTo_Orders — the tag job must + // use those rules, not git's string sort or GitHub's "latest release" date. + [Theory] + [InlineData("0.8.1-beta1", new[] { "v0.8.0-beta1" }, "v0.8.1-beta1")] + [InlineData("0.8.0", new[] { "v0.8.0-beta1" }, "v0.8.0")] + [InlineData("0.8.0-beta2", new[] { "v0.8.0-beta1" }, "v0.8.0-beta2")] + [InlineData("0.8.0-rc1", new[] { "v0.8.0-beta9" }, "v0.8.0-rc1")] + [InlineData("0.8.1-beta1", new string[] { }, "v0.8.1-beta1")] + [InlineData("v0.8.1-beta1", new[] { "v0.8.0-beta1" }, "v0.8.1-beta1")] + public void TagToCreate_WhenGreater(string landed, string[] existing, string expected) => + Assert.Equal(expected, ReleaseTag.TagToCreate(landed, existing)); + + [Theory] + [InlineData("0.8.0-beta1", new[] { "v0.8.0-beta1" })] + [InlineData("0.8.0-beta1", new[] { "v0.8.1-beta1" })] + [InlineData("0.8.0-beta1", new[] { "v0.8.0" })] + [InlineData("0.7.1-beta1", new[] { "v0.8.0-beta1", "v0.7.1-beta1" })] + [InlineData("0.8.1-beta1", new[] { "v0.8.1-beta1", "v0.8.0-beta1" })] + [InlineData("garbage", new[] { "v0.8.0-beta1" })] + [InlineData("", new[] { "v0.8.0-beta1" })] + [InlineData(null, new[] { "v0.8.0-beta1" })] + public void TagToCreate_WhenEqualLowerOrPresent(string? landed, string[] existing) => + Assert.Null(ReleaseTag.TagToCreate(landed, existing)); + + [Fact] + public void LatestTag_UsesSemVerNotStringSort() + { + // String-max would pick v0.9.0-beta1 over v0.10.0; SemVer must not. + var latest = ReleaseTag.LatestTag(new[] { "v0.9.0-beta1", "v0.10.0-beta1", "v0.8.0" }); + Assert.Equal("v0.10.0-beta1", latest); + } + + [Fact] + public void LatestTag_StableOutranksItsPrerelease() + { + Assert.Equal("v0.8.0", ReleaseTag.LatestTag(new[] { "v0.8.0-beta1", "v0.8.0", "v0.7.1-beta1" })); + } + + [Fact] + public void ReadLandedVersion_PrefersAppCsprojOverProps() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + Directory.CreateDirectory(Path.Combine(dir.FullName, "src", "Aperture.App")); + File.WriteAllText( + Path.Combine(dir.FullName, "Directory.Build.props"), + "1.0.0"); + File.WriteAllText( + Path.Combine(dir.FullName, "src", "Aperture.App", "Aperture.App.csproj"), + "0.8.1-beta1"); + Assert.Equal("0.8.1-beta1", ReleaseTag.ReadLandedVersion(dir.FullName)); + } + finally + { + dir.Delete(true); + } + } + + [Fact] + public void ReadLandedVersion_FallsBackToDirectoryBuildProps() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + Directory.CreateDirectory(Path.Combine(dir.FullName, "src", "Aperture.App")); + File.WriteAllText( + Path.Combine(dir.FullName, "Directory.Build.props"), + "0.9.0-rc1"); + File.WriteAllText( + Path.Combine(dir.FullName, "src", "Aperture.App", "Aperture.App.csproj"), + "net10.0-windows"); + Assert.Equal("0.9.0-rc1", ReleaseTag.ReadLandedVersion(dir.FullName)); + } + finally + { + dir.Delete(true); + } + } +} + public class UpdateOfferTests { private static ReleaseInfo Rel(string tag, bool prerelease) =>