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 .agents/skills/powerforge-homeassistant-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Use PowerForge as the release owner. A receiver repository should contain only t
- Published releases use GitHub-generated change and contributor notes. PowerForge
prepends only hidden provenance metadata needed for safe retry and recovery.
- Treat three-part versions as the public contract. Do not introduce four-part consumer versions.
- When a repository has only prereleases, use its highest published three-part version as the baseline for the first stable release. Preserve the historical prerelease tags and assets; the normal increment creates a new stable tag.

## Recover a failed run

Expand Down
2 changes: 2 additions & 0 deletions Docs/PowerForge.HomeAssistantRelease.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Use at most one release label. Conflicting labels fail the run rather than guess

If the repository has no GitHub release yet, PowerForge publishes the synchronized metadata version as the initial baseline instead of incrementing past an unreleased version.

For a repository with only prereleases, PowerForge uses the highest published three-part version as the baseline and applies the normal increment. For example, metadata `0.1.5` and a prerelease tagged `v0.1.5` produce stable `v0.1.6` for a product change. Drafts and tags outside the supported three-part version format are excluded. Existing tags and prerelease assets retain their original provenance. Once a stable release exists, GitHub's latest stable release remains the baseline.

Before changing anything, PowerForge confirms that the pull request is merged, its merge SHA matches the event, the checked-out default branch contains that merge, and the PR head has completed checks with accepted conclusions (`success`, `neutral`, or `skipped`). PowerForge excludes the current release run from settling itself. During recovery it excludes a prior failed release check only after the Actions API proves that it came from the same trusted receiver workflow path and release event; other pending or failed checks remain blocking.

The reusable workflow uses GitHub's durable `queue: max` concurrency mode. Every merged-PR trigger waits for the repository release lock instead of replacing an older pending run, so an intermediate `release:minor` or `release:major` decision is not discarded during a merge burst. Each queued trigger applies its own policy to the then-current default branch.
Expand Down
116 changes: 116 additions & 0 deletions PowerForge.Tests/HomeAssistantReleaseBaselineTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Net;
using System.Text;

namespace PowerForge.Tests;

public sealed class HomeAssistantReleaseBaselineTests {
[Fact]
public void GetLatestRelease_PreservesGitHubsStableBaselineWithoutListingPrereleases() {
using var http = CreateHttpClient(uri => uri.PathAndQuery switch {
"/repos/EvotecIT/example/releases/latest" => Json(HttpStatusCode.OK, """
{ "tag_name": "v0.1.5", "draft": false, "prerelease": false }
"""),
_ => throw new InvalidOperationException($"Unexpected request: {uri.PathAndQuery}")
});

var release = CreateClient(http).GetLatestRelease();

Assert.NotNull(release);
Assert.Equal("v0.1.5", release.TagName);
Assert.False(release.IsPrerelease);
}

[Fact]
public void GetLatestRelease_UsesTheHighestPublishedThreePartVersionWhenOnlyPrereleasesExist() {
using var http = CreateHttpClient(uri => uri.PathAndQuery switch {
"/repos/EvotecIT/example/releases/latest" => Json(HttpStatusCode.NotFound, "{}"),
"/repos/EvotecIT/example/releases?per_page=100&page=1" => Json(HttpStatusCode.OK, """
[
{ "tag_name": "v9.0.0", "draft": true, "prerelease": true },
{ "tag_name": "v0.1.9", "draft": false, "prerelease": true },
{ "tag_name": "v0.1.10", "draft": false, "prerelease": true },
{ "tag_name": "v0.1.11-beta.1", "draft": false, "prerelease": true },
{ "tag_name": "legacy-build", "draft": false, "prerelease": true }
]
"""),
_ => throw new InvalidOperationException($"Unexpected request: {uri.PathAndQuery}")
});

var release = CreateClient(http).GetLatestRelease();

Assert.NotNull(release);
Assert.Equal("v0.1.10", release.TagName);
Assert.True(release.IsPrerelease);
}

[Fact]
public void GetLatestRelease_ScansLaterPagesBeforeChoosingTheBaseline() {
var firstPage = "[" + string.Join(",", Enumerable.Repeat(
"{\"tag_name\":\"v0.1.5\",\"draft\":false,\"prerelease\":true}", 100)) + "]";
using var http = CreateHttpClient(uri => uri.PathAndQuery switch {
"/repos/EvotecIT/example/releases/latest" => Json(HttpStatusCode.NotFound, "{}"),
"/repos/EvotecIT/example/releases?per_page=100&page=1" => Json(HttpStatusCode.OK, firstPage),
"/repos/EvotecIT/example/releases?per_page=100&page=2" => Json(HttpStatusCode.OK, """
[{ "tag_name": "v0.2.0", "draft": false, "prerelease": true }]
"""),
_ => throw new InvalidOperationException($"Unexpected request: {uri.PathAndQuery}")
});

Assert.Equal("v0.2.0", CreateClient(http).GetLatestRelease()!.TagName);
}

[Fact]
public void GetLatestRelease_LeavesANewRepositoryWithoutABaseline() {
using var http = CreateHttpClient(uri => uri.PathAndQuery switch {
"/repos/EvotecIT/example/releases/latest" => Json(HttpStatusCode.NotFound, "{}"),
"/repos/EvotecIT/example/releases?per_page=100&page=1" => Json(HttpStatusCode.OK, "[]"),
_ => throw new InvalidOperationException($"Unexpected request: {uri.PathAndQuery}")
});

Assert.Null(CreateClient(http).GetLatestRelease());
}

[Fact]
public void GetLatestRelease_DoesNotTreatAnApiFailureAsAMissingStableRelease() {
using var http = CreateHttpClient(uri => uri.PathAndQuery switch {
"/repos/EvotecIT/example/releases/latest" => Json(HttpStatusCode.Forbidden, "{}"),
_ => throw new InvalidOperationException($"Unexpected request: {uri.PathAndQuery}")
});

var exception = Assert.Throws<InvalidOperationException>(() => CreateClient(http).GetLatestRelease());

Assert.Contains("403", exception.Message, StringComparison.Ordinal);
}

[Fact]
public void GetLatestRelease_RejectsAnIncompleteBaselineSearch() {
var fullPage = "[" + string.Join(",", Enumerable.Repeat(
"{\"tag_name\":\"v0.1.5\",\"draft\":false,\"prerelease\":true}", 100)) + "]";
var pages = 0;
using var http = CreateHttpClient(uri => {
if (uri.AbsolutePath.EndsWith("/latest", StringComparison.Ordinal))
return Json(HttpStatusCode.NotFound, "{}");
pages++;
return Json(HttpStatusCode.OK, fullPage);
});

var exception = Assert.Throws<InvalidOperationException>(() => CreateClient(http).GetLatestRelease());

Assert.Equal(10, pages);
Assert.Contains("baseline search exceeded", exception.Message, StringComparison.OrdinalIgnoreCase);
}

private static HomeAssistantGitHubClient CreateClient(HttpClient http)
=> new("EvotecIT", "example", "fixture-token", "https://api.github.test", http);

private static HttpClient CreateHttpClient(Func<Uri, HttpResponseMessage> response)
=> new(new ResponseHandler(response));

private static HttpResponseMessage Json(HttpStatusCode status, string body)
=> new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };

private sealed class ResponseHandler(Func<Uri, HttpResponseMessage> response) : HttpMessageHandler {
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(response(request.RequestUri ?? throw new InvalidOperationException("Request URI was missing.")));
}
}
14 changes: 10 additions & 4 deletions PowerForge.Tests/HomeAssistantReleaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,11 @@ public void ReleaseService_QueuedPullRequestPreservesItsIncrementAfterANewerMerg
Assert.Equal(29561117925L, github.ExcludedWorkflowRunId);
}

[Fact]
public void PrepareStage_DoesNotExecuteRepositoryBuildCommands() {
[Theory]
[InlineData(null, "0.1.10")]
[InlineData(false, "0.1.11")]
[InlineData(true, "0.1.11")]
public void PrepareStage_PlansFromThePublishedBaselineWithoutExecutingBuildCommands(bool? isPrerelease, string expectedVersion) {
using var fixture = HomeAssistantFixture.CreatePlugin("0.1.10");
RunGit(fixture.Root, "init", "-b", "main");
RunGit(fixture.Root, "config", "user.name", "PowerForge Tests");
Expand All @@ -277,7 +280,9 @@ public void PrepareStage_DoesNotExecuteRepositoryBuildCommands() {
HeadSha = mergeSha,
MergeCommitSha = mergeSha
},
LatestRelease = new HomeAssistantGitHubRelease { TagName = "v0.1.10" }
LatestRelease = isPrerelease.HasValue
? new HomeAssistantGitHubRelease { TagName = "v0.1.10", IsPrerelease = isPrerelease.Value }
: null
};
github.PullRequest.ChangedFiles.Add("src/example.ts");
var runner = new RecordingProcessRunner(mutateTrackedFile: false);
Expand All @@ -297,7 +302,8 @@ public void PrepareStage_DoesNotExecuteRepositoryBuildCommands() {
MergeCommitSha = mergeSha
});

Assert.Equal(HomeAssistantReleaseAction.Planned, result.Action);
Assert.Equal(HomeAssistantReleaseAction.Planned, result.Action);
Assert.Equal(expectedVersion, result.ReleaseVersion);
Assert.Empty(runner.Requests);
}

Expand Down
36 changes: 34 additions & 2 deletions PowerForge/Services/HomeAssistantGitHubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,40 @@ public HomeAssistantCheckSummary GetCheckSummary(string commitSha, long? exclude
return result;
}

public HomeAssistantGitHubRelease? GetLatestRelease()
=> TryGetRelease($"/repos/{_owner}/{_repository}/releases/latest");
public HomeAssistantGitHubRelease? GetLatestRelease() {
var stableRelease = TryGetRelease($"/repos/{_owner}/{_repository}/releases/latest");
if (stableRelease is not null) return stableRelease;

// GitHub excludes prereleases from /latest. They still reserve public version tags,
// so a repository's first stable release must increment from its published baseline.
HomeAssistantGitHubRelease? baseline = null;
HomeAssistantSemanticVersion baselineVersion = default;
for (var page = 1; page <= 10; page++) {
var releases = GetArray($"/repos/{_owner}/{_repository}/releases?per_page=100&page={page}");
foreach (var value in releases.OfType<JsonObject>()) {
var release = ParseRelease(value);
if (release.IsDraft) continue;

HomeAssistantSemanticVersion version;
try {
version = HomeAssistantSemanticVersion.Parse(release.TagName);
} catch (InvalidOperationException) {
// Tags outside the supported three-part version contract do not reserve
// one of the stable version tags generated by this release engine.
continue;
}

if (baseline is null || version.CompareTo(baselineVersion) > 0) {
baseline = release;
baselineVersion = version;
}
}

if (releases.Count < 100) return baseline;
}

throw new InvalidOperationException("Release baseline search exceeded 1,000 GitHub releases.");
}

public HomeAssistantGitHubRelease? FindReleaseByMarker(string marker) {
if (string.IsNullOrWhiteSpace(marker)) throw new ArgumentException("Release marker is required.", nameof(marker));
Expand Down
Loading