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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
14 changes: 9 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
85 changes: 85 additions & 0 deletions .github/workflows/tag-version.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 17 additions & 5 deletions docs/GITHUB-SETUP.md
Original file line number Diff line number Diff line change
@@ -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**.
Expand Down Expand Up @@ -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 `## [<version>]` 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 `<Version>` 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 `## [<version>]` 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` &gt; `0.8.0-beta1`, and `0.8.0-beta1` &lt; `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.
Expand Down
18 changes: 18 additions & 0 deletions scripts/DecideReleaseTag/DecideReleaseTag.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>DecideReleaseTag</RootNamespace>
</PropertyGroup>

<!-- Same source the app and UpdateVersionTests compile — do not fork the comparer. -->
<ItemGroup>
<Compile Include="..\..\src\Aperture.App\Updates\UpdateVersion.cs" Link="UpdateVersion.cs" />
<Compile Include="..\..\src\Aperture.App\Updates\ReleaseTag.cs" Link="ReleaseTag.cs" />
</ItemGroup>

</Project>
83 changes: 83 additions & 0 deletions scripts/DecideReleaseTag/Program.cs
Original file line number Diff line number Diff line change
@@ -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 <Version> in src/Aperture.App/Aperture.App.csproj or Directory.Build.props.");
return 1;
}

IEnumerable<string> tags = tagsFile is null
? Array.Empty<string>()
: 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;
125 changes: 125 additions & 0 deletions src/Aperture.App/Updates/ReleaseTag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml.Linq;

namespace Aperture.App.Updates;

/// <summary>
/// Whether a Version that just landed on <c>main</c> should become a <c>v*</c>
/// git tag. Comparison is <see cref="UpdateVersion"/> — the same SemVer rules
/// as the in-app updater (numeric first, prerelease below its own stable).
/// </summary>
internal static class ReleaseTag
{
public const string AppCsprojRelativePath = "src/Aperture.App/Aperture.App.csproj";
public const string DirectoryBuildPropsRelativePath = "Directory.Build.props";

/// <summary>
/// Reads <c>&lt;Version&gt;</c> from the app csproj (source of truth for
/// the shipped assembly). Falls back to <c>Directory.Build.props</c> if
/// the csproj has no Version — MSBuild would inherit it from there.
/// </summary>
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);
}

/// <summary>
/// Among existing <c>v*</c> tags, the one that compares greatest under
/// <see cref="UpdateVersion"/>, or <c>null</c> if none parse.
/// </summary>
public static string? LatestTag(IEnumerable<string> 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;
}

/// <summary>
/// Returns <c>v{version}</c> when <paramref name="landedVersion"/> is
/// strictly greater than every existing <c>v*</c> tag and that tag name
/// is not already present. Equal, lower, unparseable, or already-tagged
/// versions return <c>null</c>. Never force-updates a tag.
/// </summary>
public static string? TagToCreate(string? landedVersion, IEnumerable<string> 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;
}

/// <summary>
/// Tag name from a Version string: leading <c>v</c> is normalized, build
/// metadata is dropped so it matches <see cref="UpdateVersion.Parse"/>.
/// The prerelease suffix is kept as written (e.g. <c>0.8.1-beta1</c> →
/// <c>v0.8.1-beta1</c>), not rewritten to the 3-component ToString form.
/// </summary>
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;
}
}
Loading
Loading