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
15 changes: 6 additions & 9 deletions .github/workflows/publish-verified-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ jobs:
Path('verified/release-notes.md').write_text('\n'.join(lines), encoding='utf-8')
PY

- name: Publish or update stable GitHub release
- name: Publish immutable stable GitHub release
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -161,15 +161,12 @@ jobs:
)

if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" \
--title "ARSAS $VERSION" --notes-file verified/release-notes.md \
--draft=false --prerelease=false --latest
else
gh release create "$TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" --title "ARSAS $VERSION" \
--notes-file verified/release-notes.md --latest
echo "Refusing to overwrite existing stable release $TAG. Use a new version/tag." >&2
exit 1
fi
gh release create "$TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" --title "ARSAS $VERSION" \
--notes-file verified/release-notes.md --latest

- name: Verify published release assets
if: github.event_name != 'pull_request'
Expand Down
45 changes: 39 additions & 6 deletions .github/workflows/release-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ jobs:
ArIED61850Tester/dist/ARSAS-Windows-x64-PROVENANCE.json

- name: Create or update GitHub Release
if: github.ref_type == 'tag' || github.ref == 'refs/heads/main' || inputs.publish_release == true
if: (github.event_name == 'push' && (github.ref_type == 'tag' || github.ref == 'refs/heads/main')) || (github.event_name == 'workflow_dispatch' && inputs.publish_release == true)
shell: powershell
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -464,10 +464,43 @@ jobs:

$null = cmd /c "gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY >NUL 2>NUL"
if ($LASTEXITCODE -eq 0) {
gh release upload $env:RELEASE_TAG @assets --repo $env:GITHUB_REPOSITORY --clobber
if ($LASTEXITCODE -ne 0) { throw "Failed to update GitHub Release assets." }
gh release edit $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --title "ARSAS $env:RELEASE_VERSION" --latest
if ($LASTEXITCODE -ne 0) { throw "Failed to mark the GitHub Release as latest." }
# Existing stable tags/assets are immutable. A rerun with the same source and
# identical bytes is a no-op; a different source or binary is rejected.
$tagCommit = git -C .\ArIED61850Tester rev-list -n 1 $env:RELEASE_TAG
if ($LASTEXITCODE -ne 0 -or "$tagCommit".Trim() -ne $env:GITHUB_SHA) {
throw "Existing release $env:RELEASE_TAG belongs to a different source commit; use a new version/tag."
}

$published = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json assets | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) { throw "Cannot inspect existing release assets." }
Comment on lines +474 to +475

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate release state before declaring an immutable no-op

When an existing release has matching assets and source but is still a draft or prerelease, this query requests only assets, so the workflow declares success without producing the stable/latest release promised by a main or stable-tag run. This can occur after an interrupted gh release create, whose help documents a draft → asset upload → publish sequence, or after a manual prerelease; gh release view --help exposes isDraft, isPrerelease, and publishedAt, so inspect those fields and reject or explicitly handle non-stable state before setting RELEASE_ALREADY_PUBLISHED.

AGENTS.md reference: AGENTS.md:L58-L66

Useful? React with 👍 / 👎.

$expectedNames = @($assets | ForEach-Object { [IO.Path]::GetFileName($_) })
$publishedNames = @($published.assets | ForEach-Object { $_.name })
if ($publishedNames.Count -ne $expectedNames.Count) {
throw "Existing release asset count differs; refusing to mutate published binaries."
}
foreach ($name in $expectedNames) {
if ($publishedNames -notcontains $name) {
throw "Existing release is missing $name; refusing in-place repair of a published tag."
}
}

$downloadDir = Join-Path $env:RUNNER_TEMP "arsas-existing-release-verification"
New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null
gh release download $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --dir $downloadDir
if ($LASTEXITCODE -ne 0) { throw "Cannot download existing release assets for integrity verification." }
foreach ($asset in $assets) {
$existingAsset = Join-Path $downloadDir ([IO.Path]::GetFileName($asset))
if (-not (Test-Path -LiteralPath $existingAsset -PathType Leaf)) {
throw "Missing existing release asset $existingAsset."
}
$rebuiltHash = (Get-FileHash -LiteralPath $asset -Algorithm SHA256).Hash
$publishedHash = (Get-FileHash -LiteralPath $existingAsset -Algorithm SHA256).Hash
if ($rebuiltHash -ne $publishedHash) {
throw "Existing release asset differs: $([IO.Path]::GetFileName($asset)). Publish a new version/tag instead."
}
}
"RELEASE_ALREADY_PUBLISHED=true" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "Existing release matches the exact source and all published asset hashes; no mutation performed."
exit 0
}

Expand All @@ -488,7 +521,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw "Failed to create GitHub Release." }

- name: Record verified release publication
if: github.ref_type == 'tag' || github.ref == 'refs/heads/main' || inputs.publish_release == true
if: ((github.event_name == 'push' && (github.ref_type == 'tag' || github.ref == 'refs/heads/main')) || (github.event_name == 'workflow_dispatch' && inputs.publish_release == true)) && env.RELEASE_ALREADY_PUBLISHED != 'true'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recover missing publication evidence after a release no-op

If gh release create succeeds but the subsequent publication-record API request fails, rerunning the workflow takes the exact-match path, sets RELEASE_ALREADY_PUBLISHED, and this condition permanently suppresses the only step that can repair .release/published.json. Because .github/workflows/sync-release-evidence.yml:3-7 updates the public updater/site only when that file changes, the rerun can report success while public metadata remains stale or absent. Skip this step only after verifying that the existing record matches the release; otherwise recreate the missing evidence.

AGENTS.md reference: AGENTS.md:L58-L66

Useful? React with 👍 / 👎.

shell: powershell
env:
GH_TOKEN: ${{ github.token }}
Expand Down
47 changes: 47 additions & 0 deletions tests/ARSAS.Tests/StableReleaseImmutabilityRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace ARSAS.Tests;

public sealed class StableReleaseImmutabilityRegressionTests
{
[Fact]
public void CanonicalRelease_RespectsManualPublishOptOut_AndDoesNotClobberExistingAssets()
{
var workflow = File.ReadAllText(FindRepoFile(".github/workflows/release-windows.yml"));

Assert.Contains("github.event_name == 'workflow_dispatch' && inputs.publish_release == true", workflow, StringComparison.Ordinal);
Assert.Contains("github.event_name == 'push'", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("gh release upload", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("--clobber", workflow, StringComparison.Ordinal);
Assert.Contains("rev-list -n 1 $env:RELEASE_TAG", workflow, StringComparison.Ordinal);
Assert.Contains("gh release download $env:RELEASE_TAG", workflow, StringComparison.Ordinal);
Assert.Contains("Get-FileHash -LiteralPath $existingAsset -Algorithm SHA256", workflow, StringComparison.Ordinal);
Assert.Contains("RELEASE_ALREADY_PUBLISHED=true", workflow, StringComparison.Ordinal);
Assert.Contains("env.RELEASE_ALREADY_PUBLISHED != 'true'", workflow, StringComparison.Ordinal);
Assert.Contains("use a new version/tag", workflow, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void AlternateVerifiedPublisher_RefusesToReplaceAnExistingStableTag()
{
var workflow = File.ReadAllText(FindRepoFile(".github/workflows/publish-verified-release.yml"));

Assert.DoesNotContain("--clobber", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("gh release upload", workflow, StringComparison.Ordinal);
Assert.Contains("Refusing to overwrite existing stable release", workflow, StringComparison.Ordinal);
Assert.Contains("gh release create", workflow, StringComparison.Ordinal);
}

private static string FindRepoFile(string relativePath)
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
return candidate;

directory = directory.Parent;
}

throw new FileNotFoundException($"Cannot locate repository file: {relativePath}");
}
}
Loading