Compare commits

..

12 Commits

Author SHA1 Message Date
3ba2b71f4a Hold to debug
All checks were successful
Build on push / prepare (push) Successful in 8s
Build on push / build (push) Successful in 21s
2026-02-15 12:08:45 +01:00
17a4e57fa4 Added test output
All checks were successful
Build on push / prepare (push) Successful in 6s
Build on push / build (push) Successful in 19s
2026-02-15 12:07:19 +01:00
79902f323d Better output
All checks were successful
Build on push / prepare (push) Successful in 7s
Build on push / build (push) Successful in 22s
2026-02-15 12:03:52 +01:00
213fc4685d Build as single file
All checks were successful
Build on push / prepare (push) Successful in 6s
Build on push / build (push) Successful in 18s
2026-02-15 11:57:46 +01:00
c6fe9f7397 Added output manually
All checks were successful
Build on push / prepare (push) Successful in 6s
Build on push / build (push) Successful in 14s
2026-02-15 10:37:47 +01:00
4ff5680310 Changed logging
All checks were successful
Build on push / prepare (push) Successful in 7s
Build on push / build (push) Successful in 15s
2026-02-15 10:29:52 +01:00
6d3a5b1930 Fixe build
All checks were successful
Build on push / prepare (push) Successful in 5s
Build on push / build (push) Successful in 15s
2026-02-15 10:19:44 +01:00
5dca7e693b Fixed file structure
Some checks failed
Build on push / prepare (push) Successful in 8s
Build on push / build (push) Failing after 10s
2026-02-15 10:18:56 +01:00
a2fe4cb960 Added debug info [skip ci] 2026-02-15 10:17:48 +01:00
8cdd6a6e0e Should fix output problem
Some checks failed
Build on push / prepare (push) Successful in 7s
Build on push / build (push) Failing after 4s
2026-02-15 10:11:29 +01:00
ca51f738c0 Some improvements
Some checks failed
Build on push / prepare (push) Successful in 6s
Build on push / build (push) Failing after 4s
2026-02-15 03:20:03 +01:00
8586a09cd0 Added package cleanup 2026-02-15 03:02:20 +01:00
16 changed files with 523 additions and 1838 deletions

View File

@@ -33,15 +33,15 @@ jobs:
- name: Build single file executables - name: Build single file executables
run: | run: |
cd sh.actions/sh.actions.package-cleanup cd sh.actions.package-cleanup
# Build for Linux x64 # Build for Linux x64
dotnet publish -c Release -r linux-x64 -p:Version=$(cat ../../version.txt) -o publish/linux-x64 dotnet publish -c Release -r linux-x64 -p:Version=$(cat ../version.txt) -o publish/linux-x64
- name: Upload to Gitea Generic Package Registry - name: Upload to Gitea Generic Package Registry
run: | run: |
cd sh.actions/sh.actions.package-cleanup cd sh.actions.package-cleanup
curl -X PUT \ curl -X PUT \
-H "Authorization: token ${{ secrets.CI_ACCESS_TOKEN }}" \ -H "Authorization: token ${{ secrets.CI_ACCESS_TOKEN }}" \
-T publish/linux-x64/sh.actions.package-cleanup \ -T publish/linux-x64/sh.actions.package-cleanup \
"https://git.sh-edraft.de/api/packages/sh-edraft.de/generic/package-cleanup/$(cat ../../version.txt)/package-cleanup-linux-x64" "https://git.sh-edraft.de/api/packages/sh-edraft.de/generic/package-cleanup/$(cat ../version.txt)/package-cleanup-linux-x64"

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
bin/ bin/
obj/ obj/
publish/
# Environment Variables - DO NOT COMMIT # Environment Variables - DO NOT COMMIT
.env .env

View File

@@ -1,19 +1,52 @@
name: "package cleanup" name: "package cleanup"
description: "Cleans up old packages and versions" description: "Cleans up old packages and versions"
inputs:
url:
description: "Server URL"
required: true
names:
description: "Names of packages"
required: true
owner:
description: "Owner of the package"
required: true
types:
description: "Types of packages (e.g. Container, PyPi, NuGet)"
required: false
default: "Container,PyPi,NuGet"
api_token:
description: "API token for authentication"
required: true
dry_run:
description: "Execute without deleting packages"
required: false
default: "false"
runs: runs:
using: "composite" using: "composite"
steps: steps:
- name: Install set-version tool - name: Download and test package-cleanup tool
shell: bash shell: bash
run: | run: |
dotnet tool install \ curl -OJ https://git.sh-edraft.de/api/packages/sh-edraft.de/generic/package-cleanup/package-cleanup-linux-x64
--tool-path .tools \
ShEdraft.SetVersion \
--version 1.0.0 \
--add-source https://git.sh-edraft.de/api/packages/sh-edraft.de/nuget/index.json
- name: Run set-version # Make executable
chmod +x package-cleanup-linux-x64
- name: Run package-cleanup
id: cleanup
shell: bash shell: bash
env:
URL: ${{ inputs.url }}
OWNER: ${{ inputs.owner }}
TYPES: ${{ inputs.types }}
NAMES: ${{ inputs.names }}
API_TOKEN: ${{ inputs.api_token }}
DRY_RUN: ${{ inputs.dry_run }}
GITHUB_OUTPUT: ${{ env.GITHUB_OUTPUT }}
run: | run: |
./.tools/set-version --suffix dev echo "Starting cleanup..."
./package-cleanup-linux-x64 > output.txt
echo "Cleanup completed."
sleep 100000000

View File

@@ -1,617 +0,0 @@
using Microsoft.Extensions.Configuration;
using Moq;
using sh.actions.package_cleanup.Models;
using sh.actions.package_cleanup.Service;
using Xunit;
namespace sh.actions.package_cleanup.Tests.Service;
public class PackageServiceDateVersionTests
{
private Mock<IConfiguration> _configurationMock;
private Mock<IGiteaPackageService> _giteaPackageServiceMock;
private IPackageService _packageService;
private void SetupMocks()
{
_configurationMock = new Mock<IConfiguration>();
_giteaPackageServiceMock = new Mock<IGiteaPackageService>();
_packageService = new PackageService(_configurationMock.Object, _giteaPackageServiceMock.Object);
}
// ...existing code...
#region Version Detection Tests
[Theory]
[InlineData("2024.12.25.1")]
[InlineData("2025.2.14.1")]
[InlineData("2026.1.1.100")]
[InlineData("2023.12.31.999")]
public void FilterPackagesToDelete_WithDateVersions_ShouldIdentifyCorrectly(string version)
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Single prod date version should not be deleted
Assert.NotNull(result);
Assert.Empty(result);
}
[Theory]
[InlineData("2024.12.25.1-dev")]
[InlineData("2025.2.14.1-alpha")]
[InlineData("2026.1.1.100-beta")]
[InlineData("2023.12.31.999-rc")]
public void FilterPackagesToDelete_WithDateVersionsAndSuffixes_ShouldIdentifyCorrectly(string version)
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Single prerelease/dev date version should be kept
Assert.NotNull(result);
Assert.Empty(result);
}
#endregion
#region Prod Release Tests - Date Versioning
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithOnlyProdReleases_ShouldReturnEmpty()
{
// Arrange - CLEAN_PROD_VERSIONS not set, defaults to false
SetupMocks();
var packages = new List<GiteaPackage>
{
new()
{
Id = 1, Name = "test", Version = "2025.12.20.1", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-30)
},
new()
{
Id = 2, Name = "test", Version = "2025.12.25.1", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-20)
},
new()
{
Id = 3, Name = "test", Version = "2026.1.5.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10)
}
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Prod releases should be protected by default (CLEAN_PROD_VERSIONS=false)
Assert.Empty(result);
}
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithProdReleasesAndCleaningEnabled_ShouldDeleteOldest()
{
// Arrange
SetupMocks();
_configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("true");
_configurationMock.Setup(c => c["KEEP_PROD_VERSIONS"]).Returns("2");
var packages = new List<GiteaPackage>
{
new()
{
Id = 1, Name = "test", Version = "2025.1.1.1", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-365)
},
new()
{
Id = 2, Name = "test", Version = "2025.6.15.1", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-200)
},
new()
{
Id = 3, Name = "test", Version = "2026.1.1.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-45)
},
new() { Id = 4, Name = "test", Version = "2026.2.14.1", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should delete oldest 2 (2025.1.1 and 2025.6.15), keep newest 2
Assert.Equal(2, result.Count);
Assert.Contains(result, p => p.Id == 1);
Assert.Contains(result, p => p.Id == 2);
Assert.DoesNotContain(result, p => p.Id == 3 || p.Id == 4);
}
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithMultipleProdVersions_ShouldKeepNewest()
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new()
{
Id = 1, Name = "test", Version = "2025.1.1.1", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-365)
},
new()
{
Id = 2, Name = "test", Version = "2025.6.15.1", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-200)
},
new()
{
Id = 3, Name = "test", Version = "2026.1.1.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-45)
},
new() { Id = 4, Name = "test", Version = "2026.2.14.1", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Default is CLEAN_PROD_VERSIONS=false, so nothing should be deleted
Assert.Empty(result);
}
#endregion
#region Prerelease Tests - Date Versioning
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithPrereleaseVersions_ShouldKeepLatest()
{
// Arrange - Default KEEP_PRERELEASE_VERSIONS=3
SetupMocks();
var packages = new List<GiteaPackage>
{
new()
{
Id = 1, Name = "test", Version = "2026.2.10.1-alpha", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-5)
},
new()
{
Id = 2, Name = "test", Version = "2026.2.11.1-beta", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-4)
},
new()
{
Id = 3, Name = "test", Version = "2026.2.12.1-rc", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-3)
},
new()
{
Id = 4, Name = "test", Version = "2026.2.13.1-preview", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-2)
}
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should delete oldest 1 (alpha), keep newest 3
Assert.Single(result);
Assert.Contains(result, p => p.Id == 1); // alpha is oldest
Assert.DoesNotContain(result, p => p.Id == 2 || p.Id == 3 || p.Id == 4);
}
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithManyPrereleases_ShouldEnforceLimit()
{
// Arrange - Custom KEEP_PRERELEASE_VERSIONS=2
SetupMocks();
_configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("2");
var packages = new List<GiteaPackage>();
for (int i = 0; i < 10; i++)
{
var date = DateTime.UtcNow.AddDays(-i);
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"{date.Year}.{date.Month}.{date.Day}.{i}-beta",
Type = "docker",
CreatedAt = date
});
}
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should delete 8 oldest, keep only 2 newest (Id=0,1)
Assert.Equal(8, result.Count);
Assert.DoesNotContain(result, p => p.Id < 2);
Assert.Contains(result, p => p.Id >= 2);
}
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithMixedPrereleases_ShouldOrderByDate()
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new()
{
Id = 1, Name = "test", Version = "2025.12.1.1-beta", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-60)
},
new()
{
Id = 2, Name = "test", Version = "2026.1.15.1-beta", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-30)
},
new()
{
Id = 3, Name = "test", Version = "2026.2.1.1-alpha", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-14)
}
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Default keep 3, so all should be kept
Assert.Empty(result);
}
#endregion
#region Dev Release Tests - Date Versioning
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithDevVersions_ShouldKeepLatest()
{
// Arrange - Default KEEP_DEV_VERSIONS=5
SetupMocks();
var packages = new List<GiteaPackage>
{
new()
{
Id = 1, Name = "test", Version = "2026.2.10.1-dev", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-5)
},
new()
{
Id = 2, Name = "test", Version = "2026.2.11.2-dev", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-4)
},
new()
{
Id = 3, Name = "test", Version = "2026.2.12.3-dev", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-3)
}
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Default keep 5, so all 3 should be kept
Assert.Empty(result);
}
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithManyDailyDevReleases_ShouldEnforceLimit()
{
// Arrange - Custom KEEP_DEV_VERSIONS=3
SetupMocks();
_configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("3");
var packages = new List<GiteaPackage>();
for (int i = 0; i < 30; i++)
{
var date = DateTime.UtcNow.AddDays(-i);
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"{date.Year}.{date.Month}.{date.Day}.{i}-dev",
Type = "docker",
CreatedAt = date
});
}
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should delete 27 oldest, keep only 3 newest (Id=0,1,2)
Assert.Equal(27, result.Count);
Assert.DoesNotContain(result, p => p.Id < 3);
Assert.Contains(result, p => p.Id >= 3);
}
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithManyDailyDevReleasesDefaultLimit_ShouldIdentifyAll()
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>();
for (int i = 0; i < 30; i++)
{
var date = DateTime.UtcNow.AddDays(-i);
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"{date.Year}.{date.Month}.{date.Day}.{i}-dev",
Type = "docker",
CreatedAt = date
});
}
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Default keep 5, so 25 should be deleted
Assert.Equal(25, result.Count);
Assert.DoesNotContain(result, p => p.Id < 5); // Keep newest 5 (Id 0-4)
Assert.Contains(result, p => p.Id >= 5);
}
#endregion
#region Mixed Release Types - Date Versioning
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithMixedReleaseTypes_ShouldSeparateAndCleanCorrectly()
{
// Arrange
SetupMocks();
_configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("2");
_configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("2");
var packages = new List<GiteaPackage>
{
// Prod releases (should not be deleted - CLEAN_PROD_VERSIONS=false)
new()
{
Id = 1, Name = "test", Version = "2025.12.1.1", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-80)
},
new()
{
Id = 2, Name = "test", Version = "2026.1.1.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-45)
},
// Prerelease (keep 2 newest)
new()
{
Id = 3, Name = "test", Version = "2026.1.25.1-alpha", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-20)
},
new()
{
Id = 4, Name = "test", Version = "2026.2.1.1-beta", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-14)
},
new()
{
Id = 5, Name = "test", Version = "2026.2.5.1-rc", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-10)
},
// Dev (keep 2 newest)
new()
{
Id = 6, Name = "test", Version = "2026.2.10.1-dev", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-5)
},
new() { Id = 7, Name = "test", Version = "2026.2.14.1-dev", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert
Assert.Equal(1, result.Count); // Should delete 1 prerelease (alpha), 0 dev (keep both)
Assert.DoesNotContain(result, p => p.Id <= 2); // Prod not deleted
Assert.DoesNotContain(result, p => p.Id >= 6); // Keep 2 latest dev
}
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithLargeSet_ShouldHandleCorrectly()
{
// Arrange
SetupMocks();
_configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("false");
_configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("3");
_configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("5");
var packages = new List<GiteaPackage>();
// Add prod releases
for (int i = 0; i < 5; i++)
{
var date = DateTime.UtcNow.AddMonths(-(5 - i));
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"{date.Year}.{date.Month}.1.1",
Type = "docker",
CreatedAt = date
});
}
// Add prerelease
for (int i = 5; i < 15; i++)
{
var date = DateTime.UtcNow.AddDays(-(15 - i) * 7);
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"{date.Year}.{date.Month}.{date.Day}.1-beta",
Type = "docker",
CreatedAt = date
});
}
// Add dev
for (int i = 15; i < 30; i++)
{
var date = DateTime.UtcNow.AddDays(-(30 - i));
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"{date.Year}.{date.Month}.{date.Day}.{(30 - i)}-dev",
Type = "docker",
CreatedAt = date
});
}
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert
Assert.NotNull(result);
// Prod releases should never be deleted
Assert.DoesNotContain(result, p => p.Id < 5);
// Should delete 7 oldest prerelease (keep 3 newest)
var prereleaseDeleted = result.Where(p => p.Id >= 5 && p.Id < 15).ToList();
Assert.Equal(7, prereleaseDeleted.Count);
// Should delete 10 oldest dev (keep 5 newest)
var devDeleted = result.Where(p => p.Id >= 15).ToList();
Assert.Equal(10, devDeleted.Count);
}
#endregion
#region Edge Cases - Date Versioning
[Theory]
[InlineData("1000.1.1.1")] // Minimum valid year
[InlineData("2999.12.31.999")] // Maximum valid year
[InlineData("2025.2.14.0")] // Build number 0
public void FilterPackagesToDeleteByDateVersion_WithBoundaryVersions_ShouldHandleCorrectly(string version)
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert
Assert.NotNull(result);
}
[Theory]
[InlineData("999.12.31.1")] // Year too small
[InlineData("3000.1.1.1")] // Year too large
[InlineData("2025.13.1.1")] // Month out of range
[InlineData("2025.0.1.1")] // Month zero
[InlineData("2025.2.32.1")] // Day out of range
[InlineData("2025.2.0.1")] // Day zero
public void FilterPackagesToDelete_WithInvalidDateVersions_ShouldNotProcess(string version)
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow },
new() { Id = 2, Name = "test", Version = "1.0.0.1", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Invalid date version should be skipped, only semantic version processed
Assert.NotNull(result);
}
[Fact]
public void FilterPackagesToDeleteByDateVersion_WithConsecutiveDates_ShouldOrderCorrectly()
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new()
{
Id = 1, Name = "test", Version = "2025.2.10.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5)
},
new()
{
Id = 2, Name = "test", Version = "2025.2.11.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-4)
},
new()
{
Id = 3, Name = "test", Version = "2025.2.12.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3)
},
new()
{
Id = 4, Name = "test", Version = "2025.2.13.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-2)
},
new() { Id = 5, Name = "test", Version = "2025.2.14.1", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert
Assert.NotNull(result);
}
#endregion
#region Mixed Versioning Variants
[Fact]
public void FilterPackagesToDelete_WithMixedVersioningVariants_ShouldProcessBothTypes()
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
// Semantic versioning
new()
{
Id = 1, Name = "test", Version = "1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30)
},
new()
{
Id = 2, Name = "test", Version = "1.1.0-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10)
},
// Date versioning
new()
{
Id = 3, Name = "test", Version = "2025.1.1.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-45)
},
new()
{
Id = 4, Name = "test", Version = "2025.2.10.1-dev", Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-5)
}
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert
Assert.NotNull(result);
// Prod releases should not be in delete list
Assert.DoesNotContain(result, p => p.Id == 1 || p.Id == 3);
}
#endregion
}

View File

@@ -1,498 +0,0 @@
using Microsoft.Extensions.Configuration;
using Moq;
using sh.actions.package_cleanup.Models;
using sh.actions.package_cleanup.Service;
using Xunit;
namespace sh.actions.package_cleanup.Tests.Service;
public class PackageServiceSemanticVersionTests
{
private Mock<IConfiguration> _configurationMock;
private Mock<IGiteaPackageService> _giteaPackageServiceMock;
private IPackageService _packageService;
private void SetupMocks()
{
_configurationMock = new Mock<IConfiguration>();
_giteaPackageServiceMock = new Mock<IGiteaPackageService>();
_packageService = new PackageService(_configurationMock.Object, _giteaPackageServiceMock.Object);
}
#region Version Detection Tests
[Theory]
[InlineData("1.0.0.0")]
[InlineData("2.5.3.1")]
[InlineData("10.20.30.100")]
[InlineData("1.0.0")]
[InlineData("2.5")]
public void FilterPackagesToDelete_WithSemanticVersions_ShouldIdentifyCorrectly(string version)
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Single prod version should not be deleted (CLEAN_PROD_VERSIONS=false by default)
Assert.NotNull(result);
Assert.Empty(result);
}
[Theory]
[InlineData("1.0.0.0-dev")]
[InlineData("2.5.3.1-alpha")]
[InlineData("10.20.30.100-beta")]
[InlineData("1.0.0-rc1")]
[InlineData("2.5-preview")]
public void FilterPackagesToDelete_WithSemanticVersionsAndSuffixes_ShouldIdentifyCorrectly(string version)
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Single prerelease/dev should be kept
Assert.NotNull(result);
Assert.Empty(result);
}
#endregion
#region Prod Release Tests - Semantic Versioning
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithOnlyProdReleases_ShouldReturnEmpty()
{
// Arrange - CLEAN_PROD_VERSIONS not set, defaults to false
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = "1.0.0.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30) },
new() { Id = 2, Name = "test", Version = "1.1.0.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-20) },
new() { Id = 3, Name = "test", Version = "1.2.0.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Prod releases should be protected by default (CLEAN_PROD_VERSIONS=false)
Assert.Empty(result);
Assert.DoesNotContain(result, p => p.Id == 1 || p.Id == 2 || p.Id == 3);
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithProdReleasesAndCleaningEnabled_ShouldDeleteOldest()
{
// Arrange
SetupMocks();
_configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("true");
_configurationMock.Setup(c => c["KEEP_PROD_VERSIONS"]).Returns("2");
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = "1.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30) },
new() { Id = 2, Name = "test", Version = "1.1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-20) },
new() { Id = 3, Name = "test", Version = "2.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) },
new() { Id = 4, Name = "test", Version = "2.1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should delete oldest 2 (1.0.0.0 and 1.1.0.0), keep newest 2 (2.0.0.0 and 2.1.0.0)
Assert.Equal(2, result.Count);
Assert.Contains(result, p => p.Id == 1);
Assert.Contains(result, p => p.Id == 2);
Assert.DoesNotContain(result, p => p.Id == 3 || p.Id == 4);
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithMultipleProdVersions_ShouldKeepNewest()
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = "1.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30) },
new() { Id = 2, Name = "test", Version = "1.0.1.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-20) },
new() { Id = 3, Name = "test", Version = "2.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) },
new() { Id = 4, Name = "test", Version = "2.1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Default is CLEAN_PROD_VERSIONS=false, so nothing should be deleted
Assert.Empty(result);
}
#endregion
#region Prerelease Tests - Semantic Versioning
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithPrereleaseVersions_ShouldKeepLatest()
{
// Arrange - Default KEEP_PRERELEASE_VERSIONS=3
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = "1.0.0-alpha", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) },
new() { Id = 2, Name = "test", Version = "1.0.0-beta", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-4) },
new() { Id = 3, Name = "test", Version = "1.0.0-rc1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3) },
new() { Id = 4, Name = "test", Version = "1.0.0-preview", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-2) }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should delete oldest 1 (alpha, id=1), keep newest 3 (beta, rc1, preview)
Assert.Single(result);
Assert.Contains(result, p => p.Id == 1); // alpha is oldest, should be deleted
Assert.DoesNotContain(result, p => p.Id == 2 || p.Id == 3 || p.Id == 4); // Keep newest 3
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithManyPrereleases_ShouldEnforceLimit()
{
// Arrange - Custom KEEP_PRERELEASE_VERSIONS=2
SetupMocks();
_configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("2");
var packages = new List<GiteaPackage>();
for (int i = 0; i < 10; i++)
{
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"1.0.0-beta{i}",
Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-(10 - i))
});
}
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should delete 8 oldest, keep only 2 newest (Id=8,9)
Assert.Equal(8, result.Count);
Assert.DoesNotContain(result, p => p.Id >= 8);
Assert.Contains(result, p => p.Id < 8);
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithMixedPrereleases_ShouldOrderByVersion()
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = "1.0.0-beta", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) },
new() { Id = 2, Name = "test", Version = "2.0.0-beta", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) },
new() { Id = 3, Name = "test", Version = "1.5.0-alpha", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3) }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Default keep 3, so all should be kept
Assert.Empty(result);
}
#endregion
#region Dev Release Tests - Semantic Versioning
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithDevVersions_ShouldKeepLatest()
{
// Arrange - Default KEEP_DEV_VERSIONS=5
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = "1.0.0-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) },
new() { Id = 2, Name = "test", Version = "1.0.1-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-4) },
new() { Id = 3, Name = "test", Version = "2.0.0-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3) }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Default keep 5, so all 3 should be kept
Assert.Empty(result);
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithManyDevReleases_ShouldEnforceLimit()
{
// Arrange - Custom KEEP_DEV_VERSIONS=3
SetupMocks();
_configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("3");
var packages = new List<GiteaPackage>();
for (int i = 0; i < 10; i++)
{
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"1.0.{i}-dev",
Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-i)
});
}
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should delete 7 oldest, keep only 3 newest (Id=7,8,9 are newest by CreatedAt)
Assert.Equal(7, result.Count);
Assert.DoesNotContain(result, p => p.Id >= 7); // Keep newest 3
Assert.Contains(result, p => p.Id < 7); // Delete oldest 7
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithManyDevReleases_ShouldIdentifyAll()
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>();
for (int i = 0; i < 20; i++)
{
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"1.0.{i}-dev",
Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-i)
});
}
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Default keep 5, so 15 should be deleted
Assert.Equal(15, result.Count);
Assert.DoesNotContain(result, p => p.Id >= 15); // Keep newest 5 (Id 15-19)
Assert.Contains(result, p => p.Id < 15);
}
#endregion
#region Mixed Release Types - Semantic Versioning
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithMixedReleaseTypes_ShouldSeparateAndCleanCorrectly()
{
// Arrange - CLEAN_PROD_VERSIONS=false, KEEP_PRERELEASE_VERSIONS=2, KEEP_DEV_VERSIONS=2
SetupMocks();
_configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("2");
_configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("2");
var packages = new List<GiteaPackage>
{
// Prod releases (should not be deleted - CLEAN_PROD_VERSIONS=false)
new() { Id = 1, Name = "test", Version = "1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30) },
new() { Id = 2, Name = "test", Version = "1.1.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-25) },
// Prerelease (keep 2 newest)
new() { Id = 3, Name = "test", Version = "1.2.0-alpha", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-20) },
new() { Id = 4, Name = "test", Version = "1.2.0-beta", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-15) },
new() { Id = 5, Name = "test", Version = "1.2.1-rc", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) },
// Dev (keep 2 newest)
new() { Id = 6, Name = "test", Version = "1.2.1-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) },
new() { Id = 7, Name = "test", Version = "1.2.2-dev", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert
Assert.Equal(1, result.Count); // Should delete 1 prerelease (alpha), 0 dev (keep both)
Assert.DoesNotContain(result, p => p.Id <= 2); // Prod not deleted
Assert.DoesNotContain(result, p => p.Id >= 6); // Keep 2 latest dev
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithLargeSet_ShouldHandleCorrectly()
{
// Arrange
SetupMocks();
_configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("false");
_configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("3");
_configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("5");
var packages = new List<GiteaPackage>();
// Add prod releases
for (int i = 0; i < 5; i++)
{
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"1.{i}.0",
Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-(5 - i))
});
}
// Add prerelease
for (int i = 5; i < 15; i++)
{
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"2.0.{i - 5}-beta",
Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-(15 - i))
});
}
// Add dev
for (int i = 15; i < 30; i++)
{
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"2.1.{i - 15}-dev",
Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-(30 - i))
});
}
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert
Assert.NotNull(result);
// Prod releases should never be deleted
Assert.DoesNotContain(result, p => p.Id < 5);
// Should delete 7 oldest prerelease (keep 3 newest)
var prereleaseDeleted = result.Where(p => p.Id >= 5 && p.Id < 15).ToList();
Assert.Equal(7, prereleaseDeleted.Count);
// Should delete 10 oldest dev (keep 5 newest)
var devDeleted = result.Where(p => p.Id >= 15).ToList();
Assert.Equal(10, devDeleted.Count);
}
#endregion
#region Edge Cases - Semantic Versioning
[Theory]
[InlineData("0.0.0.0")]
[InlineData("99.99.99.99")]
[InlineData("100.0.0.0")] // Boundary case - might be detected as date version
public void FilterPackagesToDeleteBySemanticVersion_WithBoundaryVersions_ShouldHandleCorrectly(string version)
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should not throw, and boundary versions should be handled properly
Assert.NotNull(result);
// Versions 0.0.0.0 and 99.99.99.99 are semantic, 100.0.0.0 is likely treated as date (will fail date validation)
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithVersionsHavingDifferentLengths_ShouldHandleCorrectly()
{
// Arrange
SetupMocks();
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = "1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) },
new() { Id = 2, Name = "test", Version = "1.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-4) },
new() { Id = 3, Name = "test", Version = "1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3) },
new() { Id = 4, Name = "test", Version = "1.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-2) }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should not throw and should not delete any prod versions
Assert.NotNull(result);
// CLEAN_PROD_VERSIONS=false by default, so all versions should be kept
// Id=1 is invalid (only 1 component), ids 2-4 are valid but prod
var validVersions = new[] { 2, 3, 4 };
foreach (var id in validVersions)
{
Assert.DoesNotContain(result, p => p.Id == id);
}
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithZeroKeepCount_ShouldKeepAll()
{
// Arrange - KEEP_DEV_VERSIONS=0 means keep all
SetupMocks();
_configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("0");
var packages = new List<GiteaPackage>();
for (int i = 0; i < 10; i++)
{
packages.Add(new GiteaPackage
{
Id = i,
Name = "test",
Version = $"1.0.{i}-dev",
Type = "docker",
CreatedAt = DateTime.UtcNow.AddDays(-i)
});
}
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should keep all (0 means infinite/keep all)
Assert.Empty(result);
Assert.DoesNotContain(result, p => p.Id >= 0); // No packages should be deleted
}
[Fact]
public void FilterPackagesToDeleteBySemanticVersion_WithInvalidConfigValues_ShouldUseDefaults()
{
// Arrange - Set invalid config values
SetupMocks();
_configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("invalid");
_configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("maybe");
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = "1.0.0-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) },
new() { Id = 2, Name = "test", Version = "1.0.1-dev", Type = "docker", CreatedAt = DateTime.UtcNow },
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should use defaults (KEEP_DEV_VERSIONS=5, CLEAN_PROD_VERSIONS=false)
// With 2 packages and keep 5, no deletion should happen
Assert.Empty(result);
Assert.DoesNotContain(result, p => p.Id == 1 || p.Id == 2);
}
#endregion
}

View File

@@ -0,0 +1,146 @@
using Moq;
using sh.actions.package_cleanup.Models;
using sh.actions.package_cleanup.Service;
using Xunit;
namespace sh.actions.package_cleanup.Tests.Service;
public class PackageFilterTests
{
private readonly IPackageService _packageService;
private readonly Mock<IGiteaPackageService> _giteaPackageServiceMock = new Mock<IGiteaPackageService>();
private readonly List<GiteaPackage> _packages;
public PackageFilterTests()
{
_packageService = new PackageService(_giteaPackageServiceMock.Object);
_packages = _versions.Select(v => new GiteaPackage
{
Version = v,
Name = $"test-{v}"
}).ToList();
}
[Fact]
public void TestFilterPackagesToDelete()
{
var toDelete = _packageService.FilterPackagesToDelete(_packages);
var toDeleteVersions = toDelete
.Select(x => x.Version)
.OrderByDescending(x => x)
.ToList();
var expected = _expectDeleted.OrderByDescending(x => x).ToList();
Assert.Equal(expected.Count, toDeleteVersions.Count);
Assert.Equal(expected, toDeleteVersions);
}
private readonly List<string> _versions =
[
"0.1.0",
"0.1.1",
"0.1.2",
"1.0",
"1.1",
"1.2",
"2024.8.6.0",
"2024.8.7.0",
"2024.8.8.0",
"2024.8.9.0",
"2024.8.10.0",
"2024.8.11.0",
"latest",
// dev
"0-dev",
"1-dev",
"2-dev",
"1.0-dev",
"1.1-dev",
"1.2-dev",
"0.1.0-dev",
"0.1.1-dev",
"0.1.2-dev",
"2024.8.6.0-dev",
"2024.8.6.1-dev",
"2024.8.7.0-dev",
"2024.8.7.1-dev",
"2024.8.8.0-dev",
"2024.8.8.1-dev",
"2024.8.9.0-dev",
"2024.8.9.1-dev",
"2024.8.10.0-dev",
"2024.8.10.1-dev",
"2024.8.11.0-dev",
"2024.8.11.1-dev",
// test
"0.1.0-test",
"0.1.1-test",
"0.1.2-test",
"2024.8.6.0-test",
"2024.8.6.1-test",
"2024.8.7.0-test",
"2024.8.7.1-test",
"2024.8.8.0-test",
"2024.8.8.1-test",
"2024.8.9.0-test",
"2024.8.9.1-test",
"2024.8.10.0-test",
"2024.8.10.1-test",
"2024.8.11.0-test",
// exp
"0.1.0-exp",
"0.1.1-exp",
"0.1.2-exp",
"2024.8.6.0-exp",
"2024.8.6.1-exp",
"2024.8.7.0-exp",
"2024.8.7.1-exp",
"2024.8.8.0-exp",
"2024.8.8.1-exp",
"2024.8.9.0-exp",
"2024.8.9.1-exp",
"2024.8.10.0-exp",
"2024.8.10.1-exp",
"2024.8.11.0-exp",
"2024.8.11.2-exp"
];
private readonly List<string> _versionsToHold =
[
// prod (no suffix)
"0.1.0",
"0.1.1",
"0.1.2",
"1.0",
"1.1",
"1.2",
"2024.8.6.0",
"2024.8.7.0",
"2024.8.8.0",
"2024.8.9.0",
"2024.8.10.0",
"2024.8.11.0",
"latest",
// dev
"1-dev",
"2-dev",
"1.1-dev",
"1.2-dev",
"0.1.1-dev",
"0.1.2-dev",
"2024.8.11.0-dev",
"2024.8.11.1-dev",
// test
"0.1.1-test",
"0.1.2-test",
"2024.8.10.1-test",
"2024.8.11.0-test",
// exp
"0.1.1-exp",
"0.1.2-exp",
"2024.8.11.0-exp",
"2024.8.11.2-exp"
];
private List<string> _expectDeleted => _versions.Except(_versionsToHold).ToList();
}

View File

@@ -1,349 +0,0 @@
using Microsoft.Extensions.Configuration;
using Moq;
using sh.actions.package_cleanup.Models;
using sh.actions.package_cleanup.Service;
using Xunit;
namespace sh.actions.package_cleanup.Tests.Service;
public class PackageServiceVersionDetectionTests
{
private readonly IPackageService _packageService;
private readonly Mock<IConfiguration> _configurationMock;
private readonly Mock<IGiteaPackageService> _giteaPackageServiceMock;
public PackageServiceVersionDetectionTests()
{
_configurationMock = new Mock<IConfiguration>();
_giteaPackageServiceMock = new Mock<IGiteaPackageService>();
_packageService = new PackageService(_configurationMock.Object, _giteaPackageServiceMock.Object);
}
#region Release Type Detection Tests
[Theory]
[InlineData("1.0.0")]
[InlineData("2.5.3")]
[InlineData("10.20.30")]
[InlineData("2025.2.14.1")]
public void FilterPackagesToDelete_WithProdReleases_ShouldIdentifyAsNonDeletable(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Prod releases should not be in delete list (CLEAN_PROD_VERSIONS=false by default)
Assert.DoesNotContain(result, p => p.Id == 1);
Assert.Empty(result); // No packages should be deleted without CLEAN_PROD_VERSIONS=true
}
[Theory]
[InlineData("1.0.0-alpha")]
[InlineData("1.0.0-ALPHA")]
[InlineData("2.5.3-alpha123")]
[InlineData("1.0.0-beta")]
[InlineData("1.0.0-BETA")]
[InlineData("1.0.0-rc")]
[InlineData("1.0.0-RC1")]
[InlineData("1.0.0-preview")]
[InlineData("1.0.0-PREVIEW")]
public void FilterPackagesToDelete_WithPrereleaseVersions_ShouldIdentifyCorrectly(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Prerelease should be kept (default KEEP_PRERELEASE_VERSIONS=3, so 1 package is kept)
Assert.NotNull(result);
Assert.Empty(result); // Single prerelease should be kept as it's within the limit
}
[Theory]
[InlineData("1.0.0-dev")]
[InlineData("1.0.0-DEV")]
[InlineData("2.5.3-dev123")]
[InlineData("1.0.0-development")]
[InlineData("1.0.0-DEV")]
public void FilterPackagesToDelete_WithDevVersions_ShouldIdentifyCorrectly(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Dev version should be kept (default KEEP_DEV_VERSIONS=5, so 1 package is kept)
Assert.NotNull(result);
Assert.Empty(result); // Single dev version should be kept as it's within the limit
}
#endregion
#region Semantic Version Detection Tests
[Theory]
[InlineData("1.0")]
[InlineData("1.0.0")]
[InlineData("1.0.0.0")]
[InlineData("2.5")]
[InlineData("2.5.3")]
[InlineData("2.5.3.100")]
[InlineData("0.0.0.0")]
[InlineData("99.99.99.99")]
public void FilterPackagesToDelete_WithValidSemanticVersions_ShouldProcess(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should process without throwing and not delete prod releases
Assert.NotNull(result);
Assert.DoesNotContain(result, p => p.Id == 1); // Prod version should not be deleted
}
[Theory]
[InlineData("1")] // Only 1 component
[InlineData("1.0.0.0.0")] // 5 components
[InlineData("1.a.0.0")] // Non-numeric
[InlineData("a.b.c.d")] // All non-numeric
public void FilterPackagesToDelete_WithInvalidSemanticVersions_ShouldSkip(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow },
new() { Id = 2, Name = "test", Version = "2.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Invalid version should be skipped, only valid semantic version (id=2) is processed
Assert.NotNull(result);
// Should not crash and should only consider the valid version
Assert.DoesNotContain(result, p => p.Id == 2); // Valid prod should not be deleted
}
[Theory]
[InlineData("101.0.0.0")]
[InlineData("200.5.3.100")]
[InlineData("1000.0.0.0")]
public void FilterPackagesToDelete_WithLargeMajorVersion_ShouldTreatAsDateVersion(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Large major versions (>100) are detected as date versions, not semantic
// These will fail date validation (invalid month/day) and be skipped
Assert.NotNull(result);
}
#endregion
#region Date Version Detection Tests
[Theory]
[InlineData("2024.1.1.1")]
[InlineData("2025.2.14.1")]
[InlineData("2026.12.31.999")]
[InlineData("2000.6.15.100")]
[InlineData("1999.1.1.1")]
public void FilterPackagesToDelete_WithValidDateVersions_ShouldProcess(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should process without throwing and not delete prod date versions
Assert.NotNull(result);
Assert.DoesNotContain(result, p => p.Id == 1); // Prod date version should not be deleted
}
[Theory]
[InlineData("2025.1.1")] // 3 components (missing build)
[InlineData("2025.1.1.1.1")] // 5 components
[InlineData("2025.13.1.1")] // Month out of range
[InlineData("2025.0.1.1")] // Month zero
[InlineData("2025.2.32.1")] // Day out of range
[InlineData("2025.2.0.1")] // Day zero
[InlineData("999.2.14.1")] // Year too small
[InlineData("3000.2.14.1")] // Year too large
[InlineData("2025.a.14.1")] // Non-numeric month
public void FilterPackagesToDelete_WithInvalidDateVersions_ShouldSkip(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow },
new() { Id = 2, Name = "test", Version = "1.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Invalid date version should be skipped, valid semantic version (id=2) is processed as prod
Assert.NotNull(result);
Assert.DoesNotContain(result, p => p.Id == 2); // Valid semantic prod should not be deleted
}
[Theory]
[InlineData("1000.1.1.1")] // Minimum valid year
[InlineData("2999.12.31.999")] // Maximum valid year
[InlineData("2025.1.1.1")] // January 1
[InlineData("2025.12.31.1")] // December 31
public void FilterPackagesToDelete_WithDateVersionBoundaries_ShouldProcess(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Boundary date versions should be recognized as valid
Assert.NotNull(result);
Assert.DoesNotContain(result, p => p.Id == 1); // Prod date version should not be deleted
}
#endregion
#region Case Sensitivity Tests
[Theory]
[InlineData("1.0.0-alpha")]
[InlineData("1.0.0-ALPHA")]
[InlineData("1.0.0-Alpha")]
[InlineData("1.0.0-AlPhA")]
public void FilterPackagesToDelete_WithDifferentCasePrerelease_ShouldHandleCorrectly(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - All case variations should be recognized as prerelease
Assert.NotNull(result);
Assert.Empty(result); // Single prerelease should be kept
}
[Theory]
[InlineData("1.0.0-dev")]
[InlineData("1.0.0-DEV")]
[InlineData("1.0.0-Dev")]
[InlineData("1.0.0-DeV")]
public void FilterPackagesToDelete_WithDifferentCaseDev_ShouldHandleCorrectly(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - All case variations should be recognized as dev version
Assert.NotNull(result);
Assert.Empty(result); // Single dev version should be kept
}
#endregion
#region Suffix/Identifier Tests
[Theory]
[InlineData("1.0.0-alpha1")]
[InlineData("1.0.0-alpha.1")]
[InlineData("1.0.0-beta2")]
[InlineData("1.0.0-rc.1")]
[InlineData("2025.2.14.1-dev.1")]
[InlineData("2025.2.14.1-dev123")]
public void FilterPackagesToDelete_WithCompoundSuffixes_ShouldIdentifyCorrectly(string version)
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Compound suffixes should be recognized and not cause errors
Assert.NotNull(result);
Assert.Empty(result); // Single prerelease/dev should be kept
}
#endregion
#region Empty and Null Tests
[Fact]
public void FilterPackagesToDelete_WithEmptyList_ShouldReturnEmpty()
{
// Arrange
var packages = new List<GiteaPackage>();
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Empty input should return empty result
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void FilterPackagesToDelete_WithNullableInputs_ShouldHandleGracefully()
{
// Arrange
var packages = new List<GiteaPackage>
{
new() { Id = 1, Name = "test", Version = "1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow }
};
// Act
var result = _packageService.FilterPackagesToDelete(packages);
// Assert - Should handle without throwing and not delete prod
Assert.NotNull(result);
Assert.Empty(result); // Prod should not be deleted
}
#endregion
}

View File

@@ -2,24 +2,25 @@ namespace sh.actions.package_cleanup;
public static class ConfigurationExtension public static class ConfigurationExtension
{ {
private static void EnsureVariable(IConfiguration configuration, string key) private const string MissingConfigurationError = "Configuration variable '{0}' is required but was not found.";
private static void ValidateConfigurationVariable(IConfiguration configuration, string key)
{ {
var value = configuration[key]; if (string.IsNullOrWhiteSpace(configuration[key]))
if (string.IsNullOrEmpty(value))
{ {
throw new InvalidOperationException($"Configuration variable '{key}' is required but was not found."); throw new InvalidOperationException(string.Format(MissingConfigurationError, key));
} }
} }
public static IConfigurationBuilder EnsureGiteaConfig(this IConfigurationBuilder builder) public static IConfigurationBuilder EnsureGiteaConfig(this IConfigurationBuilder builder)
{ {
var configuration = builder.Build(); var configuration = builder.Build();
var requiredKeys = new[] { "URL", "OWNER", "TYPES", "NAMES", "API_TOKEN" };
EnsureVariable(configuration, "URL"); foreach (var key in requiredKeys)
EnsureVariable(configuration, "OWNER"); {
EnsureVariable(configuration, "TYPE"); ValidateConfigurationVariable(configuration, key);
EnsureVariable(configuration, "NAME"); }
EnsureVariable(configuration, "API_TOKEN");
return builder; return builder;
} }

View File

@@ -0,0 +1,97 @@
using System.Text.RegularExpressions;
namespace sh.actions.package_cleanup.Models;
public class SoftwareVersion(
int major,
int minor,
int patch = 0,
int build = 0,
string? suffix = null,
string? input = null,
bool isLatest = false)
{
public int Major { get; set; } = major;
public int Minor { get; set; } = minor;
public int Patch { get; set; } = patch;
public int Build { get; set; } = build;
public string? Suffix { get; set; } = suffix;
public string? Input { get; set; } = input;
public bool IsLatest { get; set; } = isLatest;
public override string ToString()
{
var versionStr = $"{Major}.{Minor}.{Patch}.{Build}";
if (!string.IsNullOrEmpty(Suffix))
{
versionStr += $"-{Suffix}";
}
return versionStr;
}
public override bool Equals(object? obj)
{
return obj is SoftwareVersion version && ToString() == version.ToString();
}
public override int GetHashCode()
{
return ToString().GetHashCode();
}
public static SoftwareVersion Parse(string versionString)
{
// Try matching format: X.X.X.X or X.X.X.X-suffix
var match = Regex.Match(versionString, @"(\d+)\.(\d+)\.(\d+)\.(\d+)(?:-(.*))?");
if (match.Success)
{
var major = int.Parse(match.Groups[1].Value);
var minor = int.Parse(match.Groups[2].Value);
var patch = int.Parse(match.Groups[3].Value);
var build = int.Parse(match.Groups[4].Value);
var suffix = match.Groups[5].Value;
return new SoftwareVersion(major, minor, patch, build, string.IsNullOrEmpty(suffix) ? null : suffix,
versionString);
}
// Try matching format: X.X.X or X.X.X-suffix
match = Regex.Match(versionString, @"(\d+)\.(\d+)\.(\d+)(?:-(.*))?");
if (match.Success)
{
var major = int.Parse(match.Groups[1].Value);
var minor = int.Parse(match.Groups[2].Value);
var patch = int.Parse(match.Groups[3].Value);
var suffix = match.Groups[4].Value;
return new SoftwareVersion(major, minor, patch, 0, string.IsNullOrEmpty(suffix) ? null : suffix,
versionString);
}
// Try matching format: X.X or X.X-suffix
match = Regex.Match(versionString, @"(\d+)\.(\d+)(?:-(.*))?");
if (match.Success)
{
var major = int.Parse(match.Groups[1].Value);
var minor = int.Parse(match.Groups[2].Value);
var suffix = match.Groups[3].Value;
return new SoftwareVersion(major, minor, 0, 0, string.IsNullOrEmpty(suffix) ? null : suffix, versionString);
}
// Try matching format: X or X-suffix
match = Regex.Match(versionString, @"(\d+)(?:-(.*))?");
if (match.Success)
{
var major = int.Parse(match.Groups[1].Value);
var suffix = match.Groups[2].Value;
return new SoftwareVersion(major, 0, 0, 0, string.IsNullOrEmpty(suffix) ? null : suffix, versionString);
}
// Special case for "latest"
if (versionString == "latest")
{
return new SoftwareVersion(1, 0, 0, 0, null, versionString, isLatest: true);
}
throw new ArgumentException($"Invalid version string: {versionString}");
}
}

View File

@@ -15,7 +15,10 @@ builder.Services
.AddHostedService<Worker>() .AddHostedService<Worker>()
.AddHttpClient<IGiteaPackageService, GiteaPackageService>(); .AddHttpClient<IGiteaPackageService, GiteaPackageService>();
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.SetMinimumLevel(LogLevel.Debug);
var host = builder.Build(); var host = builder.Build();
Console.WriteLine("STARTING");
host.Run(); await host.RunAsync();

View File

@@ -10,43 +10,103 @@ public class GiteaPackageService(
HttpClient httpClient) HttpClient httpClient)
: IGiteaPackageService : IGiteaPackageService
{ {
private string GetBaseUrl() =>
$"{configuration["URL"]?.TrimEnd('/')}/packages/{configuration["OWNER"]}";
private void AddAuthorizationHeader(HttpRequestMessage request)
{
var token = configuration["API_TOKEN"];
if (!string.IsNullOrEmpty(token))
{
request.Headers.Add("Authorization", $"token {token}");
}
}
public async Task<IEnumerable<GiteaPackage>> GetPackagesByNameAsync(string name, CancellationToken cancellationToken = default)
{
try
{
var baseUrl = GetBaseUrl();
var packages = new List<GiteaPackage>();
// Parse comma-separated types
var types = (configuration["TYPES"] ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (types.Length == 0)
{
logger.LogWarning("No package types configured");
return packages;
}
foreach (var type in types)
{
var page = 1;
while (true)
{
var url = $"{baseUrl}/{type}/{name}?page={page}";
logger.LogInformation("Fetching packages from Gitea: {Url}", url);
var request = new HttpRequestMessage(HttpMethod.Get, url);
AddAuthorizationHeader(request);
var response = await httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
var fetchedPackages =
await response.Content.ReadFromJsonAsync<List<GiteaPackage>>(cancellationToken: cancellationToken)
?? [];
if (fetchedPackages.Count == 0)
{
break;
}
packages.AddRange(fetchedPackages);
logger.LogInformation("Fetched {Count} packages from page {Page}", fetchedPackages.Count, page);
page++;
}
}
logger.LogInformation("Successfully fetched {Count} packages for name '{Name}'", packages.Count, name);
return packages;
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "Error fetching packages from Gitea for name '{Name}'", name);
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error fetching packages for name '{Name}'", name);
throw;
}
}
public async Task<IEnumerable<GiteaPackage>> GetPackagesByOwnerAsync(CancellationToken cancellationToken = default) public async Task<IEnumerable<GiteaPackage>> GetPackagesByOwnerAsync(CancellationToken cancellationToken = default)
{ {
try try
{ {
var baseUrl =
$"{configuration["URL"]?.TrimEnd('/')}/packages/{configuration["OWNER"]}/{configuration["TYPE"]}/{configuration["NAME"]}";
var allPackages = new List<GiteaPackage>(); var allPackages = new List<GiteaPackage>();
var page = 1;
var names = (configuration["NAMES"] ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
while (true) if (names.Length == 0)
{ {
var url = $"{baseUrl}?page={page}"; logger.LogWarning("No package names configured");
return allPackages;
logger.LogInformation("Fetching packages from Gitea: {Url}", url); }
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Authorization", $"token {configuration["API_TOKEN"]}");
var response = await httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
var packages =
await response.Content.ReadFromJsonAsync<List<GiteaPackage>>(cancellationToken: cancellationToken)
?? [];
if (packages.Count == 0)
{
break;
}
foreach (var name in names)
{
var packages = await GetPackagesByNameAsync(name, cancellationToken);
allPackages.AddRange(packages); allPackages.AddRange(packages);
logger.LogInformation("Fetched {Count} packages from page {Page}", packages.Count, page);
page++;
} }
logger.LogInformation("Successfully fetched {Count} packages in total", allPackages.Count); logger.LogInformation("Successfully fetched {Count} packages in total", allPackages.Count);
return allPackages; return allPackages;
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
@@ -65,30 +125,30 @@ public class GiteaPackageService(
{ {
try try
{ {
var url = var baseUrl = GetBaseUrl();
$"{configuration["URL"]?.TrimEnd('/')}/packages/{configuration["OWNER"]}/{package.Type}/{package.Name}/{package.Version}"; var url = $"{baseUrl}/{package.Type}/{package.Name}/{package.Version}";
logger.LogInformation("Deleting package {PackageName} (ID: {PackageId}) from Gitea: {Url}", package.Name, logger.LogInformation("Deleting package {PackageName} (ID: {PackageId}) from Gitea",
package.Id, url); package.Name, package.Id);
var request = new HttpRequestMessage(HttpMethod.Delete, url); var request = new HttpRequestMessage(HttpMethod.Delete, url);
request.Headers.Add("Authorization", $"token {configuration["API_TOKEN"]}"); AddAuthorizationHeader(request);
var response = await httpClient.SendAsync(request, cancellationToken); var response = await httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
logger.LogInformation("Successfully deleted package {PackageName} (ID: {PackageId})", package.Name, logger.LogInformation("Successfully deleted package {PackageName} (ID: {PackageId})",
package.Id); package.Name, package.Id);
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
logger.LogError(ex, "Error deleting package {PackageName} (ID: {PackageId}) from Gitea", package.Name, logger.LogError(ex, "Error deleting package {PackageName} (ID: {PackageId}) from Gitea",
package.Id); package.Name, package.Id);
throw; throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Unexpected error deleting package {PackageName} (ID: {PackageId})", package.Name, logger.LogError(ex, "Unexpected error deleting package {PackageName} (ID: {PackageId})",
package.Id); package.Name, package.Id);
throw; throw;
} }
} }

View File

@@ -4,6 +4,7 @@ using sh.actions.package_cleanup.Models;
public interface IGiteaPackageService public interface IGiteaPackageService
{ {
Task<IEnumerable<GiteaPackage>> GetPackagesByNameAsync(string name, CancellationToken cancellationToken = default);
Task<IEnumerable<GiteaPackage>> GetPackagesByOwnerAsync(CancellationToken cancellationToken = default); Task<IEnumerable<GiteaPackage>> GetPackagesByOwnerAsync(CancellationToken cancellationToken = default);
Task DeletePackage(GiteaPackage package, CancellationToken cancellationToken = default); Task DeletePackage(GiteaPackage package, CancellationToken cancellationToken = default);
} }

View File

@@ -2,311 +2,49 @@ using sh.actions.package_cleanup.Models;
namespace sh.actions.package_cleanup.Service; namespace sh.actions.package_cleanup.Service;
public class PackageService(IConfiguration configuration, IGiteaPackageService packageService) : IPackageService public class PackageService(IGiteaPackageService packageService) : IPackageService
{ {
public async Task<List<GiteaPackage>> GetFilteredPackages() public async Task<List<GiteaPackage>> GetFilteredPackages()
{ {
var packages = (await packageService.GetPackagesByOwnerAsync()).ToList(); return (await packageService.GetPackagesByOwnerAsync()).ToList();
return packages;
} }
public List<GiteaPackage> FilterPackagesToDelete(List<GiteaPackage> packages) public List<GiteaPackage> FilterPackagesToDelete(List<GiteaPackage> packages)
{ {
var packagesToDelete = new List<GiteaPackage>(); var versionMap = packages.ToDictionary(p => p.Version, p => SoftwareVersion.Parse(p.Version));
var versionsToKeep = new HashSet<string>();
// Group packages by versioning variant foreach (var package in packages
var semanticVersionPackages = packages.Where(p => IsSemanticVersion(p.Version)).ToList(); .Where(p => string.IsNullOrEmpty(versionMap[p.Version].Suffix))
var dateVersionPackages = packages.Where(p => IsDateVersion(p.Version)).ToList(); .GroupBy(p => versionMap[p.Version].ToString())
.Select(g => g.OrderBy(p => p.Version.Length).First()))
{
versionsToKeep.Add(package.Version);
}
// Process each group with their respective variant logic var latestVersion = packages.FirstOrDefault(p => versionMap[p.Version].IsLatest);
packagesToDelete.AddRange(FilterPackagesToDeleteBySemanticVersion(semanticVersionPackages)); if (latestVersion != null)
packagesToDelete.AddRange(FilterPackagesToDeleteByDateVersion(dateVersionPackages)); {
versionsToKeep.Add(latestVersion.Version);
}
return packagesToDelete; var suffixedVersionsToKeep = packages
} .Where(p => !string.IsNullOrEmpty(versionMap[p.Version].Suffix) &&
!(versionMap[p.Version].Major == 0 && versionMap[p.Version].Minor == 0 &&
versionMap[p.Version].Patch == 0))
.GroupBy(p => (versionMap[p.Version].Suffix, versionMap[p.Version].Major, versionMap[p.Version].Minor))
.SelectMany(g => g
.OrderByDescending(p => versionMap[p.Version].Patch)
.ThenByDescending(p => versionMap[p.Version].Build)
.Take(2)
.GroupBy(p => versionMap[p.Version].ToString())
.Select(group => group.OrderBy(p => p.Version.Length).First()));
private List<GiteaPackage> FilterPackagesToDeleteBySemanticVersion(List<GiteaPackage> packages) foreach (var package in suffixedVersionsToKeep)
{ {
var packagesToDelete = new List<GiteaPackage>(); versionsToKeep.Add(package.Version);
}
// Group packages by release type return packages.Where(p => !versionsToKeep.Contains(p.Version)).ToList();
// Note: Dev releases must be checked before prerelease since -dev is a suffix
var devPackages = packages.Where(p => IsDevRelease(p.Version))
.OrderByDescending(p => ParseSemanticVersion(p.Version))
.ThenByDescending(p => p.CreatedAt)
.ToList();
var prereleasePackages = packages.Where(p => IsPrereleaseRelease(p.Version) && !IsDevRelease(p.Version))
.OrderByDescending(p => ParseSemanticVersion(p.Version))
.ThenByDescending(p => p.CreatedAt)
.ToList();
var prodPackages = packages.Where(p => IsProdRelease(p.Version))
.OrderByDescending(p => ParseSemanticVersion(p.Version))
.ThenByDescending(p => p.CreatedAt)
.ToList();
// Apply cleanup rules for each type
packagesToDelete.AddRange(FilterProdReleases(prodPackages));
packagesToDelete.AddRange(FilterPrereleaseReleases(prereleasePackages));
packagesToDelete.AddRange(FilterDevReleases(devPackages));
return packagesToDelete;
}
private List<GiteaPackage> FilterPackagesToDeleteByDateVersion(List<GiteaPackage> packages)
{
var packagesToDelete = new List<GiteaPackage>();
// Group packages by release type
// Note: Dev releases must be checked before prerelease since -dev is a suffix
var devPackages = packages.Where(p => IsDevRelease(p.Version))
.OrderByDescending(p => ParseDateVersion(p.Version))
.ThenByDescending(p => p.CreatedAt)
.ToList();
var prereleasePackages = packages.Where(p => IsPrereleaseRelease(p.Version) && !IsDevRelease(p.Version))
.OrderByDescending(p => ParseDateVersion(p.Version))
.ThenByDescending(p => p.CreatedAt)
.ToList();
var prodPackages = packages.Where(p => IsProdRelease(p.Version))
.OrderByDescending(p => ParseDateVersion(p.Version))
.ThenByDescending(p => p.CreatedAt)
.ToList();
// Apply cleanup rules for each type
packagesToDelete.AddRange(FilterProdReleasesDateVersion(prodPackages));
packagesToDelete.AddRange(FilterPrereleaseReleasesDateVersion(prereleasePackages));
packagesToDelete.AddRange(FilterDevReleasesDateVersion(devPackages));
return packagesToDelete;
}
// Prod release methods for semantic versioning
private List<GiteaPackage> FilterProdReleases(List<GiteaPackage> packages)
{
// Check if prod cleaning is enabled
if (!GetBoolConfigValue("CLEAN_PROD_VERSIONS", false))
return new List<GiteaPackage>();
// Get keep count
var keepCount = GetIntConfigValue("KEEP_PROD_VERSIONS", 5);
// If keepCount <= 0, keep all
if (keepCount <= 0)
return new List<GiteaPackage>();
// Packages are already sorted descending by version
// Keep top N, delete rest
return packages.Skip(keepCount).ToList();
}
// Prerelease methods for semantic versioning
private List<GiteaPackage> FilterPrereleaseReleases(List<GiteaPackage> packages)
{
// Get keep count
var keepCount = GetIntConfigValue("KEEP_PRERELEASE_VERSIONS", 3);
// If keepCount <= 0, keep all
if (keepCount <= 0)
return new List<GiteaPackage>();
// Packages are already sorted descending by version
// Keep top N, delete rest
return packages.Skip(keepCount).ToList();
}
// Dev release methods for semantic versioning
private List<GiteaPackage> FilterDevReleases(List<GiteaPackage> packages)
{
// Get keep count
var keepCount = GetIntConfigValue("KEEP_DEV_VERSIONS", 5);
// If keepCount <= 0, keep all
if (keepCount <= 0)
return new List<GiteaPackage>();
// Packages are already sorted descending by version
// Keep top N, delete rest
return packages.Skip(keepCount).ToList();
}
// Prod release methods for date-based versioning
private List<GiteaPackage> FilterProdReleasesDateVersion(List<GiteaPackage> packages)
{
// Check if prod cleaning is enabled
if (!GetBoolConfigValue("CLEAN_PROD_VERSIONS", false))
return new List<GiteaPackage>();
// Get keep count
var keepCount = GetIntConfigValue("KEEP_PROD_VERSIONS", 5);
// If keepCount <= 0, keep all
if (keepCount <= 0)
return new List<GiteaPackage>();
// Packages are already sorted descending by version
// Keep top N, delete rest
return packages.Skip(keepCount).ToList();
}
// Prerelease methods for date-based versioning
private List<GiteaPackage> FilterPrereleaseReleasesDateVersion(List<GiteaPackage> packages)
{
// Get keep count
var keepCount = GetIntConfigValue("KEEP_PRERELEASE_VERSIONS", 3);
// If keepCount <= 0, keep all
if (keepCount <= 0)
return new List<GiteaPackage>();
// Packages are already sorted descending by version
// Keep top N, delete rest
return packages.Skip(keepCount).ToList();
}
// Dev release methods for date-based versioning
private List<GiteaPackage> FilterDevReleasesDateVersion(List<GiteaPackage> packages)
{
// Get keep count
var keepCount = GetIntConfigValue("KEEP_DEV_VERSIONS", 5);
// If keepCount <= 0, keep all
if (keepCount <= 0)
return new List<GiteaPackage>();
// Packages are already sorted descending by version
// Keep top N, delete rest
return packages.Skip(keepCount).ToList();
}
// Helper methods to identify release types
private bool IsProdRelease(string version)
{
// Prod versions typically don't have pre-release identifiers (alpha, beta, rc, dev, etc.)
return !version.Contains("-", StringComparison.OrdinalIgnoreCase);
}
private bool IsPrereleaseRelease(string version)
{
// Prerelease versions contain identifiers like -alpha, -beta, -rc, -preview
// But NOT -dev, which is a separate release type
var lowerVersion = version.ToLowerInvariant();
return (lowerVersion.Contains("alpha") || lowerVersion.Contains("beta") ||
lowerVersion.Contains("rc") || lowerVersion.Contains("preview")) ||
lowerVersion.Contains("staging") && !lowerVersion.Contains("dev");
}
private bool IsDevRelease(string version)
{
// Dev versions contain identifiers like dev or similar
return version.ToLowerInvariant().Contains("dev");
}
// Helper methods to identify versioning variant
private bool IsSemanticVersion(string version)
{
// Semantic versioning: major.minor.micro.build with typically smaller numbers
// Examples: 1.2.3.4, 2.0.1.100
var versionPart = version.Split('-')[0];
var parts = versionPart.Split('.').Where(p => !string.IsNullOrEmpty(p)).ToArray();
if (parts.Length < 2 || parts.Length > 4)
return false;
// Try to parse all parts as numbers
if (!parts.All(p => int.TryParse(p, out var num)))
return false;
// Semantic versioning typically has smaller major version numbers (0-20 range)
if (int.TryParse(parts[0], out var major) && major > 100)
return false; // Likely a date version if first number > 100
return true;
}
private bool IsDateVersion(string version)
{
// Date-based versioning: year.month.day.build
// Examples: 2024.12.25.1, 2025.2.14.100
var versionPart = version.Split('-')[0];
var parts = versionPart.Split('.').Where(p => !string.IsNullOrEmpty(p)).ToArray();
if (parts.Length != 4)
return false;
// Try to parse all parts as numbers
if (!parts.All(p => int.TryParse(p, out var num)))
return false;
// Date version should have year > 1000 and < 3000
if (!int.TryParse(parts[0], out var year) || year < 1000 || year > 3000)
return false;
// Month should be 1-12
if (!int.TryParse(parts[1], out var month) || month < 1 || month > 12)
return false;
// Day should be 1-31
if (!int.TryParse(parts[2], out var day) || day < 1 || day > 31)
return false;
return true;
}
// Version parsing methods
private (int major, int minor, int micro, int build) ParseSemanticVersion(string version)
{
// Remove suffix if present (e.g., "-alpha", "-dev")
var versionPart = version.Split('-')[0];
var parts = versionPart.Split('.').Select(p => int.TryParse(p, out var num) ? num : 0).ToArray();
return (
parts.Length > 0 ? parts[0] : 0,
parts.Length > 1 ? parts[1] : 0,
parts.Length > 2 ? parts[2] : 0,
parts.Length > 3 ? parts[3] : 0
);
}
private (int year, int month, int day, int build) ParseDateVersion(string version)
{
// Remove suffix if present (e.g., "-alpha", "-dev")
var versionPart = version.Split('-')[0];
var parts = versionPart.Split('.').Select(p => int.TryParse(p, out var num) ? num : 0).ToArray();
return (
parts.Length > 0 ? parts[0] : 0,
parts.Length > 1 ? parts[1] : 0,
parts.Length > 2 ? parts[2] : 0,
parts.Length > 3 ? parts[3] : 0
);
}
// Configuration helper methods
private int GetIntConfigValue(string key, int defaultValue)
{
var value = configuration[key];
if (string.IsNullOrEmpty(value))
return defaultValue;
if (int.TryParse(value, out var result))
return result;
return defaultValue;
}
private bool GetBoolConfigValue(string key, bool defaultValue)
{
var value = configuration[key];
if (string.IsNullOrEmpty(value))
return defaultValue;
if (bool.TryParse(value, out var result))
return result;
// Handle common string representations
if (value.Equals("true", StringComparison.OrdinalIgnoreCase) ||
value.Equals("1", StringComparison.OrdinalIgnoreCase))
return true;
return defaultValue;
} }
} }

View File

@@ -13,29 +13,74 @@ public class Worker(
{ {
private async Task DeletePackages(List<GiteaPackage> packages, CancellationToken cancellationToken = default) private async Task DeletePackages(List<GiteaPackage> packages, CancellationToken cancellationToken = default)
{ {
if (configuration["DRY_RUN"]?.ToLower() == "true") var dryRun = configuration["DRY_RUN"]?.ToLower() == "true";
if (dryRun)
{ {
logger.LogInformation("Dry run enabled, not deleting packages"); logger.LogInformation("Dry run enabled, not deleting {Count} packages", packages.Count);
return;
} }
foreach (var giteaPackage in packages) foreach (var giteaPackage in packages)
{ {
await giteaPackageService.DeletePackage(giteaPackage, cancellationToken); try
{
await giteaPackageService.DeletePackage(giteaPackage, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete package {PackageName} version {Version}",
giteaPackage.Name, giteaPackage.Version);
}
} }
} }
protected override async Task ExecuteAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
var packages = await packageService.GetFilteredPackages(); try
Console.WriteLine($"Found {packages.Count()} packages"); {
// Parse comma-separated names
var packagesToDelete = packageService.FilterPackagesToDelete(packages); var names = (configuration["NAMES"] ?? "")
Console.WriteLine($"Found {packagesToDelete.Count()} packages to delete"); .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
await DeletePackages(packagesToDelete, cancellationToken); if (names.Length == 0)
{
logger.LogInformation("Cleanup finished, stopping application"); logger.LogWarning("No package names configured");
appLifetime.StopApplication(); appLifetime.StopApplication();
return;
}
// Process each name separately: collect -> filter -> delete
foreach (var name in names)
{
logger.LogInformation("Processing packages for name '{Name}'", name);
var packages = (await giteaPackageService.GetPackagesByNameAsync(name, cancellationToken)).ToList();
logger.LogInformation("Found {Count} packages for name '{Name}'", packages.Count, name);
var packagesToDelete = packageService.FilterPackagesToDelete(packages);
logger.LogInformation("Found {Count} packages to delete for name '{Name}'", packagesToDelete.Count,
name);
Console.WriteLine($"Found {packagesToDelete.Count} packages to delete for name '{name}':");
foreach (var pkg in packagesToDelete)
{
Console.WriteLine($"- {pkg.Name} {pkg.Version}");
}
// await DeletePackages(packagesToDelete, cancellationToken);
logger.LogInformation("Deleted {Count} packages for name '{Name}'", packagesToDelete.Count, name);
logger.LogInformation("Cleanup finished for name '{Name}'", name);
}
logger.LogInformation("All package names processed successfully");
}
catch (Exception ex)
{
logger.LogError(ex, "Cleanup failed with an error");
}
finally
{
appLifetime.StopApplication();
}
} }
} }

View File

@@ -6,10 +6,41 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-sh.actions.package_cleanup-2b7a013f-ec22-4325-9832-0c9ca9b8ced9</UserSecretsId> <UserSecretsId>dotnet-sh.actions.package_cleanup-2b7a013f-ec22-4325-9832-0c9ca9b8ced9</UserSecretsId>
<RootNamespace>sh.actions.package_cleanup</RootNamespace> <RootNamespace>sh.actions.package_cleanup</RootNamespace>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="publish\linux-x64\appsettings.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\appsettings.local.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\appsettings.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\appsettings.local.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.deps.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.deps.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.deps.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.deps.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\sh.actions.package-cleanup.deps.json" />
<_ContentIncludedByDefault Remove="publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,7 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff7cd24f648c14c59ab25c6dcf9108aef198e00_003Fd7_003F31923dff_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd7e2ce30531b7a2d56142411c5e51c3d516aafcb44e7628167b959660a65c_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=fc220b10_002D634c_002D48e4_002Da543_002Df5162d99c787/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt;
&lt;/SessionState&gt;</s:String>
</wpf:ResourceDictionary>