From 7e7d691c948d209e970fdeba688ccc4c5fae9006 Mon Sep 17 00:00:00 2001 From: AG <288536783+codegiveness@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:10:08 +0700 Subject: [PATCH 1/3] ci: stop release re-runs from replacing published assets and NuGet versions Upload only missing GitHub assets and fail when an existing asset's SHA-256 differs from the built file; compare the registry package payload (ignoring nuget.org's repository signature) before accepting an existing NuGet version; build the win-x64 zip with fixed timestamps; track the .NET SDK pin with Dependabot. --- .github/dependabot.yml | 8 ++ .github/workflows/release.yml | 7 +- CHANGELOG.md | 4 + CONTRIBUTING.md | 11 +- tools/PostgreSqlMcp.Release/NuGetRegistry.cs | 59 +++++++++ tools/PostgreSqlMcp.Release/Program.cs | 80 +++++++++--- tools/PostgreSqlMcp.Release/ReleaseArchive.cs | 34 ++++++ tools/PostgreSqlMcp.Release/ReleaseAssets.cs | 53 ++++++++ tools/PostgreSqlMcp.Verify/Integration.cs | 1 + .../PostgreSqlMcp.Verify.csproj | 6 + tools/PostgreSqlMcp.Verify/Program.cs | 4 +- .../ReleaseRegressions.cs | 115 ++++++++++++++++++ 12 files changed, 359 insertions(+), 23 deletions(-) create mode 100644 tools/PostgreSqlMcp.Release/NuGetRegistry.cs create mode 100644 tools/PostgreSqlMcp.Release/ReleaseArchive.cs create mode 100644 tools/PostgreSqlMcp.Release/ReleaseAssets.cs create mode 100644 tools/PostgreSqlMcp.Verify/ReleaseRegressions.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ed14379..91761ba 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -28,3 +28,11 @@ updates: commit-message: prefix: build include: scope + - package-ecosystem: dotnet-sdk + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: build + include: scope diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53e945d..68587a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,10 +245,11 @@ jobs: artifact-ids: ${{ needs.preflight.outputs.tool_artifact_id }} path: release-tool digest-mismatch: error - - name: Download release artifacts + - name: Download metadata and release artifacts env: GH_TOKEN: ${{ github.token }} run: | + gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-context --dir publication-context gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-artifacts --dir artifacts - name: Require NuGet publishing account env: @@ -258,7 +259,7 @@ jobs: id: nuget-login with: user: ${{ secrets.NUGET_USERNAME }} - - name: Publish NuGet with short-lived OIDC credential + - name: Publish NuGet with short-lived OIDC credential, verifying any existing version env: NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} - run: dotnet nuget push "artifacts/packages/codegiveness.postgresql-sharp-mcp.${{ needs.package.outputs.version }}.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate + run: dotnet release-tool/PostgreSqlMcp.Release.dll nuget --context publication-context/publication-context.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a7054..225aab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **Release re-runs no longer replace published bytes.** GitHub assets are uploaded without `--clobber`: only missing assets are uploaded, and an existing asset (including `SHA256SUMS`) must be completely uploaded and match the built file's SHA-256 (GitHub's asset digest, or a downloaded copy when none is reported) or the job fails. NuGet publication moved from inline `dotnet nuget push --skip-duplicate` to `release-tool nuget`: an existing version is accepted only if every package entry's name and SHA-256 equals the built package, ignoring nuget.org's `.signature.p7s` repository signature; otherwise the job fails. A maintainer must now delete a conflicting GitHub asset deliberately; a conflicting NuGet version needs a new release version. The nuget job now also downloads the `release-context` artifact. Hosted execution is unverified. +- Build the win-x64 release zip with a fixed entry timestamp (1980-01-01, the earliest ZIP time), sorted entries and fixed modes, so it no longer depends on on-disk modification times or the build time zone. Known gap: the `tar.gz` archives still differ between builds because .NET names each pax extended header after the process ID (`PaxHeaders.`) even though entry timestamps, owners and modes are fixed; they were not made reproducible here. +- Dependabot now also tracks the .NET SDK pin in `global.json` (`dotnet-sdk` ecosystem, weekly). +- Add the offline `release-regressions` verifier command (also run by `integration`) covering asset re-run planning, NuGet payload comparison and zip reproducibility. - Show a grouped README badge table covering build/tests, security scanning, supply chain, registries, platforms, stack versions, features and project activity. Feature and practice badges link to the documentation or configuration that supports them; stack versions are read live from `global.json` and `Directory.Packages.props`. - Release npm job: install npm 12.2.0 from its registry tarball after a pinned SHA-512 check matching the published `dist.integrity`, replacing the unpinned `npm install --global` flagged by Scorecard Pinned-Dependencies. - Release npm job: accept npm 12's one-element JSON array from `npm view @ version --json`. Previously an already-published version failed with "requires an element of type 'String'" instead of being reported as not republished. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0808d38..44e2b29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,12 @@ The comparator rejects new high/critical security findings (CVSS >= 7) or error- Use raw scanner artifacts containing successful invocation evidence. GitHub-normalized API exports can omit that evidence and are intentionally rejected. An `executionSuccessful` flag does not override compiler/extraction error notifications. +The offline `release-regressions` command checks release re-run safety (asset and NuGet payload comparison) and that the Windows zip does not depend on file modification times: + +```bash +dotnet run --project tools/PostgreSqlMcp.Verify -c Release -- release-regressions +``` + For a bug, reproduce the failure before changing code and exercise the same path after the fix. Major new functionality must have automated consumer-visible behavior coverage; extend the maintained verifier with relevant boundaries, transitions and errors rather than duplicating wiring assertions. Add regression coverage for plausible consumer-visible failures or uncertain boundaries; avoid source-text, wiring-only and mock-echo assertions. Documentation-only changes need link and command review rather than unrelated database runs. ## Build and install package artifacts @@ -193,12 +199,13 @@ Keep versions, affected help/docs and release notes consistent. Update [THIRD-PA Compatible fixes use a patch increment, such as **0.3.2 → 0.3.3**; reserve a minor increment for new or incompatible 0.x contracts. Discovery and the unrestricted-default contract remain the **0.3.0** capability boundary. **0.3.3** removes npm's installed-.NET prerequisite without changing database-access behavior; the .NET tool still requires .NET. **0.4.0** adds the database lock and its startup PostgreSQL grant check, which can refuse startup for existing `POSTGRES_DATABASES` configurations. A version in source is not evidence of NuGet/npm/GitHub Release publication; check the corresponding registry endpoint and release jobs. - **CI** runs database integration and package installation checks for pushes and pull requests and uploads generated packages for inspection. -- **Release workflow** is manually dispatched from `main` for an existing `v` tag reachable from `origin/main`; pushing a tag does not start publication. Read-only preflight compiles trusted release tooling from the immutable main workflow revision and validates the tag's commit. Read-only packaging checks out that commit and verifies version consistency and behavior. Checkout-free attestation and publishing jobs consume immutable artifact IDs with digest mismatches rejected; they do not compile or execute tag-source release tooling. npm stages through package-specific GitHub OIDC, without `NPM_TOKEN` or token fallback, and waits for Windows 2FA approval; NuGet uses its own matching OIDC policy with `NUGET_USERNAME`. Existing public registry versions are skipped. Artifact creation, staging or a successful GitHub release alone does not prove registry publication. +- **Release workflow** is manually dispatched from `main` for an existing `v` tag reachable from `origin/main`; pushing a tag does not start publication. Read-only preflight compiles trusted release tooling from the immutable main workflow revision and validates the tag's commit. Read-only packaging checks out that commit and verifies version consistency and behavior. Checkout-free attestation and publishing jobs consume immutable artifact IDs with digest mismatches rejected; they do not compile or execute tag-source release tooling. npm stages through package-specific GitHub OIDC, without `NPM_TOKEN` or token fallback, and waits for Windows 2FA approval; NuGet uses its own matching OIDC policy with `NUGET_USERNAME`. Artifact creation, staging or a successful GitHub release alone does not prove registry publication. +- **Release re-runs never replace published bytes.** The GitHub job uploads only assets missing from the release (no `--clobber`). An existing asset, including `SHA256SUMS`, must be completely uploaded and have the same SHA-256 as the freshly built file, using GitHub's reported asset digest or, when absent, a downloaded copy; otherwise the job fails. The NuGet job (`release-tool nuget`) asks nuget.org whether the version exists. If not, it pushes without `--skip-duplicate`, so a concurrent push also fails. If so, it downloads the registry package and compares every entry's name and SHA-256 with the built package, ignoring only nuget.org's `.signature.p7s` repository countersignature (the registry's file bytes always differ from the local file because of it); any other difference fails. A failure means different bytes already exist under that version: a maintainer must delete the specific GitHub asset deliberately, or release a new version for NuGet. `dotnet run --project tools/PostgreSqlMcp.Verify -c Release -- release-regressions` (also run by `integration`) checks these comparisons and the zip archive builder offline. Only the win-x64 zip is byte-reproducible today; see the CHANGELOG note on tar archives. - **Dependency audit** checks direct and transitive NuGet packages against known advisories. This is not proof that all vulnerabilities are absent. - **CodeQL** scans C# and GitHub Actions with `security-extended`. C# uses real builds, including generated sources. Trusted main/scheduled runs publish code-scanning results; PRs scan both the base and candidate with read-only tokens, retain raw SARIF and run the trusted-base severity-delta gate. Extraction errors and missing reports fail closed. Review workflow changes as well as findings. - **Scorecard** publishes repository-practice findings on main and weekly. Its score is not a vulnerability-free claim or profile achievement. - **Supply-chain evidence** includes an application CycloneDX SBOM, SHA-256 checksums, requested GitHub build attestations and requested npm provenance. Verify the actual release evidence; NuGet OIDC is authentication, not NuGet package provenance. See [Security posture](docs/security-posture.md). -- **Dependabot** proposes dependency and action updates. Review advisory impact, release notes and relevant runtime behavior before merging; it does not authorize automatic publication. +- **Dependabot** proposes dependency, action and .NET SDK (`global.json`) updates. Review advisory impact, release notes and relevant runtime behavior before merging; it does not authorize automatic publication. - **Secret scanning** combines GitHub provider scanning/push protection with pinned, checksum-verified Gitleaks CLI over all reachable history. PostgreSQL URI/Npgsql and SQL Server credential rules use redacted logging, no uploaded secret reports, and exact fixture exceptions. Do not baseline leaks or widen exclusions to entire files/directories. - **Container security** builds the actual image and runs pinned, checksum-verified Trivy against OS and application packages. HIGH/CRITICAL findings include unfixed vulnerabilities and fail the gate; do not suppress failures or add broad exemptions. diff --git a/tools/PostgreSqlMcp.Release/NuGetRegistry.cs b/tools/PostgreSqlMcp.Release/NuGetRegistry.cs new file mode 100644 index 0000000..123419c --- /dev/null +++ b/tools/PostgreSqlMcp.Release/NuGetRegistry.cs @@ -0,0 +1,59 @@ +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; + +internal static class NuGetRegistry +{ + private const string FlatContainer = "https://api.nuget.org/v3-flatcontainer/"; + + // nuget.org countersigns every accepted package by adding this entry, so the registry's .nupkg bytes + // never equal the locally built file. Every other entry must match exactly. + private const string RepositorySignatureEntry = ".signature.p7s"; + + public static async Task HasVersionAsync(HttpClient http, string id, string version) + { + using HttpResponseMessage response = await http.GetAsync($"{FlatContainer}{id}/index.json"); + if (response.StatusCode == HttpStatusCode.NotFound) return false; + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("versions").EnumerateArray() + .Any(listed => string.Equals(listed.GetString(), version, StringComparison.OrdinalIgnoreCase)); + } + + public static async Task DownloadAsync(HttpClient http, string id, string version, string destination) + { + string lower = version.ToLowerInvariant(); + using HttpResponseMessage response = await http.GetAsync($"{FlatContainer}{id}/{lower}/{id}.{lower}.nupkg", HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + await using var file = File.Create(destination); + await response.Content.CopyToAsync(file); + } + + /// Differences between two packages' payloads (entry names and SHA-256 of contents), ignoring the repository signature. + public static IReadOnlyList ComparePayload(string localPackage, string registryPackage) + { + Dictionary local = Payload(localPackage), registry = Payload(registryPackage); + var differences = new List(); + foreach (string name in local.Keys.Order(StringComparer.Ordinal)) + if (!registry.TryGetValue(name, out string? hash)) differences.Add($"'{name}' is missing from the registry package"); + else if (hash != local[name]) differences.Add($"'{name}' differs"); + foreach (string name in registry.Keys.Except(local.Keys).Order(StringComparer.Ordinal)) + differences.Add($"'{name}' is only in the registry package"); + return differences; + } + + private static Dictionary Payload(string package) + { + using ZipArchive zip = ZipFile.OpenRead(package); + var payload = new Dictionary(StringComparer.Ordinal); + foreach (ZipArchiveEntry entry in zip.Entries) + { + if (entry.FullName == RepositorySignatureEntry || entry.FullName.EndsWith('/')) continue; + using Stream content = entry.Open(); + if (!payload.TryAdd(entry.FullName, Convert.ToHexStringLower(SHA256.HashData(content)))) + throw new InvalidOperationException($"Package contains duplicate entry '{entry.FullName}'."); + } + return payload; + } +} diff --git a/tools/PostgreSqlMcp.Release/Program.cs b/tools/PostgreSqlMcp.Release/Program.cs index 23471b5..c55a35d 100644 --- a/tools/PostgreSqlMcp.Release/Program.cs +++ b/tools/PostgreSqlMcp.Release/Program.cs @@ -17,7 +17,7 @@ public static async Task RunAsync(string[] args) { try { - if (args.Length == 0) throw new InvalidOperationException("Expected validate-tag, metadata, context, archives, sbom, github, npm, or require-secret command."); + if (args.Length == 0) throw new InvalidOperationException("Expected validate-tag, metadata, context, archives, sbom, github, npm, nuget, or require-secret command."); string command = args[0]; string tag = Environment.GetEnvironmentVariable("RELEASE_TAG") ?? ""; if (command == "require-secret") @@ -26,7 +26,7 @@ public static async Task RunAsync(string[] args) throw new InvalidOperationException("The required publishing environment credential is missing."); return 0; } - bool publication = command is "github" or "npm"; + bool publication = command is "github" or "npm" or "nuget"; string root = publication ? Environment.CurrentDirectory : FindRoot(); if (command == "validate-tag") { @@ -71,6 +71,9 @@ public static async Task RunAsync(string[] args) case "npm": await StageNpmAsync(root, metadata.NpmName, metadata.Version, args.Length == 4); break; + case "nuget": + await PublishNuGetAsync(root, metadata.Version); + break; default: throw new InvalidOperationException("Unknown release command."); } @@ -146,22 +149,20 @@ private static async Task ArchivesAsync(string root) await RunAsync("dotnet", ["publish", "src/PostgreSqlMcp/PostgreSqlMcp.csproj", "-c", "Release", "-r", rid, "--self-contained", "-p:RestoreLockedMode=true", "-o", temporary], root); string archive = Path.Combine(output, "postgresql-sharp-mcp-" + rid + (rid == "win-x64" ? ".zip" : ".tar.gz")); if (File.Exists(archive)) File.Delete(archive); - if (rid == "win-x64") ZipFile.CreateFromDirectory(temporary, archive); + if (rid == "win-x64") ReleaseArchive.CreateZip(temporary, archive); else { await using var file = File.Create(archive); await using var gzip = new GZipStream(file, CompressionLevel.Optimal); using var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true); - foreach (string path in Directory.EnumerateFiles(temporary, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) + foreach (string path in ReleaseArchive.OrderedFiles(temporary)) { using var data = File.OpenRead(path); - var entry = new PaxTarEntry(TarEntryType.RegularFile, Path.GetRelativePath(temporary, path).Replace(Path.DirectorySeparatorChar, '/')) + var entry = new PaxTarEntry(TarEntryType.RegularFile, ReleaseArchive.EntryName(temporary, path)) { Uid = 0, Gid = 0, UserName = "", GroupName = "", ModificationTime = DateTimeOffset.UnixEpoch, - Mode = OperatingSystem.IsWindows() - ? UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead - : File.GetUnixFileMode(path), + Mode = ReleaseArchive.EntryMode(path), DataStream = data }; tar.WriteEntry(entry); @@ -177,14 +178,16 @@ private static async Task ChecksumsAsync(string root) var files = ArtifactFiles(root, ReadMetadata(root)).Order(StringComparer.Ordinal).ToArray(); var checksums = new List(files.Length); foreach (string file in files) - { - await using var stream = File.OpenRead(file); - string hash = Convert.ToHexStringLower(await SHA256.HashDataAsync(stream)); - checksums.Add(hash + " " + Path.GetFileName(file)); - } + checksums.Add(await Sha256Async(file) + " " + Path.GetFileName(file)); await File.WriteAllLinesAsync(Path.Combine(root, "artifacts/SHA256SUMS"), checksums); } + private static async Task Sha256Async(string file) + { + await using var stream = File.OpenRead(file); + return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream)); + } + private static string[] ArtifactFiles(string root, (string Version, string NpmName) metadata) { string[] archives = Rids.Select(rid => Path.Combine(root, "artifacts/archives", "postgresql-sharp-mcp-" + rid + (rid == "win-x64" ? ".zip" : ".tar.gz"))).ToArray(); @@ -200,9 +203,13 @@ private static string[] ArtifactFiles(string root, (string Version, string NpmNa private static async Task PublishGitHubAsync(string root, string tag, (string Version, string NpmName) metadata) { string repo = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY") ?? throw new InvalidOperationException("GITHUB_REPOSITORY is required."); - string[] files = ArtifactFiles(root, metadata); + var local = new List(); + foreach (string file in ArtifactFiles(root, metadata).Append(Path.Combine(root, "artifacts/SHA256SUMS"))) + local.Add(new LocalAsset(Path.GetFileName(file), file, await Sha256Async(file))); var lookup = await CaptureAsync("gh", ["api", $"repos/{repo}/releases/tags/{tag}"], root); - if (lookup.ExitCode != 0) + IReadOnlyList existing = []; + if (lookup.ExitCode == 0) existing = ReleaseAssets.Parse(lookup.Output); + else { // Only a genuine 404 means absent; authorization/network errors must not create a second release. if (!lookup.Error.Contains("HTTP 404", StringComparison.Ordinal)) throw new InvalidOperationException("GitHub release lookup failed."); @@ -210,8 +217,47 @@ private static async Task PublishGitHubAsync(string root, string tag, (string Ve if (tag.Contains('-')) create.Add("--prerelease"); await RunAsync("gh", create, root); } - await RunAsync("gh", ["release", "upload", tag, "--repo", repo, "--clobber", .. files, Path.Combine(root, "artifacts/SHA256SUMS")], root); - await SummaryAsync($"GitHub Release {tag}: archives, packages, checksums and application SBOM uploaded.\n"); + // A re-run never replaces a published asset: existing ones must be byte-identical, only missing ones are uploaded. + var missing = await ReleaseAssets.PlanUploadsAsync(local, existing, async name => + { + string directory = Directory.CreateTempSubdirectory("postgresql-release-asset-").FullName; + try + { + await RunAsync("gh", ["release", "download", tag, "--repo", repo, "--pattern", name, "--dir", directory], root); + return await Sha256Async(Path.Combine(directory, name)); + } + finally { Directory.Delete(directory, true); } + }); + if (missing.Count > 0) + await RunAsync("gh", ["release", "upload", tag, "--repo", repo, .. missing.Select(asset => asset.Path)], root); + await SummaryAsync($"GitHub Release {tag}: uploaded {missing.Count} missing asset(s); {local.Count - missing.Count} existing asset(s) already matched the built SHA-256 digests.\n"); + } + + private static async Task PublishNuGetAsync(string root, string version) + { + const string PackageId = "codegiveness.postgresql-sharp-mcp"; + string apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY") ?? ""; + if (string.IsNullOrWhiteSpace(apiKey)) throw new InvalidOperationException("NUGET_API_KEY is required."); + string package = Path.Combine(root, "artifacts/packages", PackageId + "." + version + ".nupkg"); + if (!File.Exists(package)) throw new InvalidOperationException("The current NuGet package is missing."); + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + if (await NuGetRegistry.HasVersionAsync(http, PackageId, version)) + { + // Versions are immutable: a duplicate is success only if it is this exact payload. + string registry = Path.Combine(Directory.CreateTempSubdirectory("postgresql-nuget-").FullName, "registry.nupkg"); + try + { + await NuGetRegistry.DownloadAsync(http, PackageId, version, registry); + var differences = NuGetRegistry.ComparePayload(package, registry); + if (differences.Count > 0) + throw new InvalidOperationException($"NuGet {version} already exists with a different payload ({string.Join("; ", differences.Take(5))}); refusing to treat it as published."); + } + finally { Directory.Delete(Path.GetDirectoryName(registry)!, true); } + await SummaryAsync($"NuGet {version} already exists with an identical payload (repository signature ignored); not republished.\n"); + return; + } + await RunAsync("dotnet", ["nuget", "push", package, "--source", "https://api.nuget.org/v3/index.json", "--api-key", apiKey], root); + await SummaryAsync($"NuGet {version} pushed.\n"); } private static async Task StageNpmAsync(string root, string name, string version, bool dryRun) diff --git a/tools/PostgreSqlMcp.Release/ReleaseArchive.cs b/tools/PostgreSqlMcp.Release/ReleaseArchive.cs new file mode 100644 index 0000000..720bc50 --- /dev/null +++ b/tools/PostgreSqlMcp.Release/ReleaseArchive.cs @@ -0,0 +1,34 @@ +using System.IO.Compression; + +internal static class ReleaseArchive +{ + // The ZIP/DOS timestamp cannot represent the Unix epoch used for tar entries; 1980-01-01 is its earliest value. + public static readonly DateTimeOffset ZipTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static UnixFileMode EntryMode(string path) => + OperatingSystem.IsWindows() + ? UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead + : File.GetUnixFileMode(path); + + public static IEnumerable OrderedFiles(string directory) => + Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal); + + public static string EntryName(string directory, string path) => + Path.GetRelativePath(directory, path).Replace(Path.DirectorySeparatorChar, '/'); + + /// Writes a zip whose bytes depend only on file names, contents and modes, not on on-disk timestamps. + public static void CreateZip(string directory, string archive) + { + using var file = File.Create(archive); + using var zip = new ZipArchive(file, ZipArchiveMode.Create); + foreach (string path in OrderedFiles(directory)) + { + ZipArchiveEntry entry = zip.CreateEntry(EntryName(directory, path), CompressionLevel.Optimal); + entry.LastWriteTime = ZipTimestamp; + entry.ExternalAttributes = (int)EntryMode(path) << 16; + using var source = File.OpenRead(path); + using Stream target = entry.Open(); + source.CopyTo(target); + } + } +} diff --git a/tools/PostgreSqlMcp.Release/ReleaseAssets.cs b/tools/PostgreSqlMcp.Release/ReleaseAssets.cs new file mode 100644 index 0000000..9b80848 --- /dev/null +++ b/tools/PostgreSqlMcp.Release/ReleaseAssets.cs @@ -0,0 +1,53 @@ +using System.Text.Json; + +/// One asset as reported by the GitHub Releases API. +internal sealed record RemoteAsset(string Name, string State, string? Digest); + +/// A release asset built by this run: its upload name, path and SHA-256 (lowercase hex). +internal sealed record LocalAsset(string Name, string Path, string Sha256); + +internal static class ReleaseAssets +{ + private const string Sha256Prefix = "sha256:"; + + public static IReadOnlyList Parse(string releaseJson) + { + using var document = JsonDocument.Parse(releaseJson); + var assets = new List(); + foreach (JsonElement asset in document.RootElement.GetProperty("assets").EnumerateArray()) + { + string? digest = asset.TryGetProperty("digest", out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + assets.Add(new RemoteAsset(asset.GetProperty("name").GetString()!, asset.GetProperty("state").GetString() ?? "", digest)); + } + return assets; + } + + /// + /// Returns the built assets that are not on the release yet. An existing asset must be completely + /// uploaded and byte-identical (SHA-256) to the built one; otherwise this throws instead of replacing it. + /// computes the digest of an existing asset whose API digest is + /// absent (assets uploaded before GitHub reported digests) or not SHA-256. + /// + public static async Task> PlanUploadsAsync( + IReadOnlyList local, IReadOnlyList remote, Func> hashRemoteAsync) + { + var existing = remote.ToDictionary(asset => asset.Name, StringComparer.Ordinal); + var missing = new List(); + foreach (LocalAsset asset in local) + { + if (!existing.TryGetValue(asset.Name, out RemoteAsset? found)) + { + missing.Add(asset); + continue; + } + if (found.State != "uploaded") + throw new InvalidOperationException($"Release asset '{asset.Name}' is in state '{found.State}', not a completed upload; delete it deliberately before re-running."); + string actual = found.Digest is not null && found.Digest.StartsWith(Sha256Prefix, StringComparison.OrdinalIgnoreCase) + ? found.Digest[Sha256Prefix.Length..] + : await hashRemoteAsync(asset.Name); + if (!string.Equals(actual, asset.Sha256, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Release asset '{asset.Name}' already exists with different content (existing sha256:{actual.ToLowerInvariant()}, built sha256:{asset.Sha256}); refusing to replace a published asset. Delete it deliberately if replacement is intended."); + } + return missing; + } +} diff --git a/tools/PostgreSqlMcp.Verify/Integration.cs b/tools/PostgreSqlMcp.Verify/Integration.cs index bbfeb8f..1e2f51a 100644 --- a/tools/PostgreSqlMcp.Verify/Integration.cs +++ b/tools/PostgreSqlMcp.Verify/Integration.cs @@ -11,6 +11,7 @@ public static async Task RunAsync(string root) await Processes.RunAsync(new("dotnet", "build", Path.Combine(root, "postgresql-sharp-mcp.slnx"), "-c", "Release"), timeout: 180); await ResourceChecks.RunAsync(); SarifRegression.Run(); + ReleaseRegressions.Run(); await using var fixture = new PostgresFixture(); await fixture.StartAsync(); var command = new Command("dotnet", Path.Combine(root, "src", "PostgreSqlMcp", "bin", "Release", "net10.0", "PostgreSqlMcp.dll")); diff --git a/tools/PostgreSqlMcp.Verify/PostgreSqlMcp.Verify.csproj b/tools/PostgreSqlMcp.Verify/PostgreSqlMcp.Verify.csproj index 8aba9ba..65856fe 100644 --- a/tools/PostgreSqlMcp.Verify/PostgreSqlMcp.Verify.csproj +++ b/tools/PostgreSqlMcp.Verify/PostgreSqlMcp.Verify.csproj @@ -6,4 +6,10 @@ + + + + + + diff --git a/tools/PostgreSqlMcp.Verify/Program.cs b/tools/PostgreSqlMcp.Verify/Program.cs index d25305f..16dd808 100644 --- a/tools/PostgreSqlMcp.Verify/Program.cs +++ b/tools/PostgreSqlMcp.Verify/Program.cs @@ -18,6 +18,8 @@ public static async Task Main(string[] args) SarifGate.Run(baseline, candidate, Console.Out); else if (args is ["sarif-regressions"]) SarifRegression.Run(); + else if (args is ["release-regressions"]) + ReleaseRegressions.Run(); else if (args.Length > 0 && args[0] == "packages") { string artifacts = Path.Combine(root, "artifacts", "packages"); @@ -44,7 +46,7 @@ public static async Task Main(string[] args) await Packages.RunAsync(root, artifacts, targetsFile, installationOnly); } else - throw new VerificationException("Usage: integration | packages [--artifacts directory] [--targets-file disposable-targets.json | --installation-only] | sarif --baseline directory --candidate directory | sarif-regressions"); + throw new VerificationException("Usage: integration | packages [--artifacts directory] [--targets-file disposable-targets.json | --installation-only] | sarif --baseline directory --candidate directory | sarif-regressions | release-regressions"); return 0; } catch (Exception ex) diff --git a/tools/PostgreSqlMcp.Verify/ReleaseRegressions.cs b/tools/PostgreSqlMcp.Verify/ReleaseRegressions.cs new file mode 100644 index 0000000..7db821b --- /dev/null +++ b/tools/PostgreSqlMcp.Verify/ReleaseRegressions.cs @@ -0,0 +1,115 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; + +namespace PostgreSqlMcp.Verify; + +/// Offline regressions for release re-run safety and archive reproducibility; no network, no publication. +internal static class ReleaseRegressions +{ + public static void Run() + { + AssetPlanning().GetAwaiter().GetResult(); + ZipReproducibility(); + NuGetPayloadComparison(); + } + + private static LocalAsset Built(string name, string content) => + new(name, "/unused/" + name, Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content)))); + + private static string Digest(LocalAsset asset) => "sha256:" + asset.Sha256; + + private static async Task Fails(string description, Func action) + { + try { await action(); } + catch (InvalidOperationException) { return; } + throw new VerificationException("Release regression did not fail closed: " + description); + } + + private static async Task AssetPlanning() + { + LocalAsset a = Built("a.tar.gz", "alpha"), b = Built("b.zip", "bravo"), sums = Built("SHA256SUMS", "sums"); + LocalAsset[] local = [a, b, sums]; + Task NoDownload(string name) => throw new VerificationException($"Unexpected download of {name}."); + + var none = await ReleaseAssets.PlanUploadsAsync(local, [], NoDownload); + Check.That(none.Count == 3, "A fresh release must upload every built asset."); + + var partial = await ReleaseAssets.PlanUploadsAsync(local, [new("a.tar.gz", "uploaded", Digest(a))], NoDownload); + Check.That(partial.Select(asset => asset.Name).SequenceEqual(["b.zip", "SHA256SUMS"]), "A re-run must upload only the missing assets."); + + var complete = await ReleaseAssets.PlanUploadsAsync(local, + [new("a.tar.gz", "uploaded", Digest(a)), new("b.zip", "uploaded", "sha256:" + b.Sha256.ToUpperInvariant()), new("SHA256SUMS", "uploaded", Digest(sums))], NoDownload); + Check.That(complete.Count == 0, "Identical existing assets must not be uploaded again."); + + await Fails("different bytes under an existing name", () => ReleaseAssets.PlanUploadsAsync(local, [new("b.zip", "uploaded", Digest(Built("b.zip", "other")))], NoDownload)); + await Fails("mismatch next to matching and missing assets", () => ReleaseAssets.PlanUploadsAsync(local, + [new("a.tar.gz", "uploaded", Digest(a)), new("SHA256SUMS", "uploaded", Digest(Built("SHA256SUMS", "other")))], NoDownload)); + await Fails("incomplete upload", () => ReleaseAssets.PlanUploadsAsync(local, [new("a.tar.gz", "starter", Digest(a))], NoDownload)); + + // Assets without a SHA-256 API digest are downloaded and hashed. + var downloaded = new List(); + Task Download(string name) { downloaded.Add(name); return Task.FromResult(name == "a.tar.gz" ? a.Sha256 : Built(name, "tampered").Sha256); } + var viaDownload = await ReleaseAssets.PlanUploadsAsync([a], [new("a.tar.gz", "uploaded", null)], Download); + Check.That(viaDownload.Count == 0 && downloaded.SequenceEqual(["a.tar.gz"]), "A missing digest must fall back to hashing the downloaded asset."); + await Fails("tampered asset without API digest", () => ReleaseAssets.PlanUploadsAsync([b], [new("b.zip", "uploaded", "sha1:abc")], Download)); + + var parsed = ReleaseAssets.Parse("""{"assets":[{"name":"x","state":"uploaded","digest":"sha256:ab"},{"name":"y","state":"uploaded","digest":null},{"name":"z","state":"starter"}]}"""); + Check.That(parsed.Count == 3 && parsed[0].Digest == "sha256:ab" && parsed[1].Digest is null && parsed[2].State == "starter" && parsed[2].Digest is null, + "GitHub release asset JSON was parsed incorrectly."); + } + + private static void ZipReproducibility() + { + using TemporaryDirectory temporary = new(); + string source = Directory.CreateDirectory(Path.Combine(temporary.Path, "payload")).FullName; + Directory.CreateDirectory(Path.Combine(source, "sub")); + File.WriteAllText(Path.Combine(source, "zeta.txt"), "last"); + File.WriteAllText(Path.Combine(source, "alpha.txt"), "first"); + File.WriteAllText(Path.Combine(source, "sub", "nested.txt"), "nested"); + + string first = Path.Combine(temporary.Path, "first.zip"), second = Path.Combine(temporary.Path, "second.zip"); + ReleaseArchive.CreateZip(source, first); + foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + File.SetLastWriteTimeUtc(file, new DateTime(2031, 5, 17, 3, 4, 6, DateTimeKind.Utc)); + ReleaseArchive.CreateZip(source, second); + Check.That(File.ReadAllBytes(first).AsSpan().SequenceEqual(File.ReadAllBytes(second)), + "Zip archives must not depend on on-disk modification times."); + + using (ZipArchive zip = ZipFile.OpenRead(first)) + Check.That(zip.Entries.Select(entry => entry.FullName).SequenceEqual(["alpha.txt", "sub/nested.txt", "zeta.txt"]) + && zip.Entries.All(entry => entry.LastWriteTime.DateTime == ReleaseArchive.ZipTimestamp.DateTime), + "Zip entries must be sorted with the fixed timestamp."); + + File.WriteAllText(Path.Combine(source, "alpha.txt"), "changed"); + string third = Path.Combine(temporary.Path, "third.zip"); + ReleaseArchive.CreateZip(source, third); + Check.That(!File.ReadAllBytes(first).AsSpan().SequenceEqual(File.ReadAllBytes(third)), "A changed payload must change the zip."); + } + + private static void NuGetPayloadComparison() + { + using TemporaryDirectory temporary = new(); + string Package(string name, params (string Entry, string Content)[] entries) + { + string path = Path.Combine(temporary.Path, name); + using var file = File.Create(path); + using var zip = new ZipArchive(file, ZipArchiveMode.Create); + foreach (var (entry, content) in entries) + using (var writer = new StreamWriter(zip.CreateEntry(entry).Open(), Encoding.UTF8)) writer.Write(content); + return path; + } + string local = Package("local.nupkg", ("tool.nuspec", "spec"), ("tools/net10.0/any/app.dll", "binary")); + + Check.That(NuGetRegistry.ComparePayload(local, Package("same.nupkg", ("tools/net10.0/any/app.dll", "binary"), ("tool.nuspec", "spec"))).Count == 0, + "Identical payloads in a different entry order must match."); + Check.That(NuGetRegistry.ComparePayload(local, Package("signed.nupkg", ("tool.nuspec", "spec"), ("tools/net10.0/any/app.dll", "binary"), (".signature.p7s", "repository countersignature"))).Count == 0, + "The nuget.org repository signature must not count as a payload difference."); + Check.That(NuGetRegistry.ComparePayload(local, Package("changed.nupkg", ("tool.nuspec", "spec"), ("tools/net10.0/any/app.dll", "rebuilt"))) is [var changed] && changed.Contains("app.dll", StringComparison.Ordinal), + "A rebuilt entry under the same version must be reported."); + Check.That(NuGetRegistry.ComparePayload(local, Package("missing.nupkg", ("tool.nuspec", "spec"))).Count == 1, + "An entry missing from the registry package must be reported."); + Check.That(NuGetRegistry.ComparePayload(local, Package("extra.nupkg", ("tool.nuspec", "spec"), ("tools/net10.0/any/app.dll", "binary"), ("tools/extra.dll", "x"))).Count == 1, + "An entry only in the registry package must be reported."); + } +} From fe659e2fd412db5868269f1755b0d6193aa2e94f Mon Sep 17 00:00:00 2001 From: AG <288536783+codegiveness@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:35:04 +0700 Subject: [PATCH 2/3] ci: make tar.gz archives and the npm tarball reproducible .NET's PAX tar writer names extended headers after the process ID, so identical content hashed differently per build. Write GNU format in both the release archives and the npm tarball, and cover the archive builders in release-regressions. --- CHANGELOG.md | 4 +-- CONTRIBUTING.md | 3 +- tools/PostgreSqlMcp.Build/Program.cs | 5 ++-- tools/PostgreSqlMcp.Release/Program.cs | 21 +------------ tools/PostgreSqlMcp.Release/ReleaseArchive.cs | 23 ++++++++++++++ .../ReleaseRegressions.cs | 30 +++++++++++++++++-- 6 files changed, 59 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 225aab9..dcf8624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,9 @@ ## Unreleased - **Release re-runs no longer replace published bytes.** GitHub assets are uploaded without `--clobber`: only missing assets are uploaded, and an existing asset (including `SHA256SUMS`) must be completely uploaded and match the built file's SHA-256 (GitHub's asset digest, or a downloaded copy when none is reported) or the job fails. NuGet publication moved from inline `dotnet nuget push --skip-duplicate` to `release-tool nuget`: an existing version is accepted only if every package entry's name and SHA-256 equals the built package, ignoring nuget.org's `.signature.p7s` repository signature; otherwise the job fails. A maintainer must now delete a conflicting GitHub asset deliberately; a conflicting NuGet version needs a new release version. The nuget job now also downloads the `release-context` artifact. Hosted execution is unverified. -- Build the win-x64 release zip with a fixed entry timestamp (1980-01-01, the earliest ZIP time), sorted entries and fixed modes, so it no longer depends on on-disk modification times or the build time zone. Known gap: the `tar.gz` archives still differ between builds because .NET names each pax extended header after the process ID (`PaxHeaders.`) even though entry timestamps, owners and modes are fixed; they were not made reproducible here. +- Build release archives reproducibly: the win-x64 zip has a fixed entry timestamp (1980-01-01, the earliest ZIP time), sorted entries and fixed modes, and the tar.gz archives and the npm tarball from `tools/PostgreSqlMcp.Build` now use GNU tar format, because .NET's PAX writer names each extended header after the process ID (`PaxHeaders.`), which changed their hash on every build despite fixed timestamps. The archives and npm tarball are byte-identical across rebuilds and time zones; archive entries are the same files with unchanged names, modes and fixed owner/time metadata, and standard `tar` extracts them. Not reproducible, and unchanged: the `.nupkg` (`dotnet pack` wall-clock zip entry times, identical contents), the SBOM (random `serialNumber`, `timestamp`) and therefore `SHA256SUMS`; after a partial publication re-run the failed jobs of the same workflow run rather than dispatching a new run. - Dependabot now also tracks the .NET SDK pin in `global.json` (`dotnet-sdk` ecosystem, weekly). -- Add the offline `release-regressions` verifier command (also run by `integration`) covering asset re-run planning, NuGet payload comparison and zip reproducibility. +- Add the offline `release-regressions` verifier command (also run by `integration`) covering asset re-run planning, NuGet payload comparison and zip/tar.gz reproducibility. - Show a grouped README badge table covering build/tests, security scanning, supply chain, registries, platforms, stack versions, features and project activity. Feature and practice badges link to the documentation or configuration that supports them; stack versions are read live from `global.json` and `Directory.Packages.props`. - Release npm job: install npm 12.2.0 from its registry tarball after a pinned SHA-512 check matching the published `dist.integrity`, replacing the unpinned `npm install --global` flagged by Scorecard Pinned-Dependencies. - Release npm job: accept npm 12's one-element JSON array from `npm view @ version --json`. Previously an already-published version failed with "requires an element of type 'String'" instead of being reported as not republished. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44e2b29..2034636 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -200,7 +200,8 @@ Compatible fixes use a patch increment, such as **0.3.2 → 0.3.3**; reserve a m - **CI** runs database integration and package installation checks for pushes and pull requests and uploads generated packages for inspection. - **Release workflow** is manually dispatched from `main` for an existing `v` tag reachable from `origin/main`; pushing a tag does not start publication. Read-only preflight compiles trusted release tooling from the immutable main workflow revision and validates the tag's commit. Read-only packaging checks out that commit and verifies version consistency and behavior. Checkout-free attestation and publishing jobs consume immutable artifact IDs with digest mismatches rejected; they do not compile or execute tag-source release tooling. npm stages through package-specific GitHub OIDC, without `NPM_TOKEN` or token fallback, and waits for Windows 2FA approval; NuGet uses its own matching OIDC policy with `NUGET_USERNAME`. Artifact creation, staging or a successful GitHub release alone does not prove registry publication. -- **Release re-runs never replace published bytes.** The GitHub job uploads only assets missing from the release (no `--clobber`). An existing asset, including `SHA256SUMS`, must be completely uploaded and have the same SHA-256 as the freshly built file, using GitHub's reported asset digest or, when absent, a downloaded copy; otherwise the job fails. The NuGet job (`release-tool nuget`) asks nuget.org whether the version exists. If not, it pushes without `--skip-duplicate`, so a concurrent push also fails. If so, it downloads the registry package and compares every entry's name and SHA-256 with the built package, ignoring only nuget.org's `.signature.p7s` repository countersignature (the registry's file bytes always differ from the local file because of it); any other difference fails. A failure means different bytes already exist under that version: a maintainer must delete the specific GitHub asset deliberately, or release a new version for NuGet. `dotnet run --project tools/PostgreSqlMcp.Verify -c Release -- release-regressions` (also run by `integration`) checks these comparisons and the zip archive builder offline. Only the win-x64 zip is byte-reproducible today; see the CHANGELOG note on tar archives. +- **Release re-runs never replace published bytes.** The GitHub job uploads only assets missing from the release (no `--clobber`). An existing asset, including `SHA256SUMS`, must be completely uploaded and have the same SHA-256 as the freshly built file, using GitHub's reported asset digest or, when absent, a downloaded copy; otherwise the job fails. The NuGet job (`release-tool nuget`) asks nuget.org whether the version exists. If not, it pushes without `--skip-duplicate`, so a concurrent push also fails. If so, it downloads the registry package and compares every entry's name and SHA-256 with the built package, ignoring only nuget.org's `.signature.p7s` repository countersignature (the registry's file bytes always differ from the local file because of it); any other difference fails. A failure means different bytes already exist under that version: a maintainer must delete the specific GitHub asset deliberately, or release a new version for NuGet. `dotnet run --project tools/PostgreSqlMcp.Verify -c Release -- release-regressions` (also run by `integration`) checks these comparisons and the zip/tar.gz archive builders offline. +- **Reproducible and non-reproducible assets.** The five archives (fixed timestamps, owners, modes and entry order; the Linux/macOS tar.gz use GNU tar format because .NET's PAX writer names extended headers after the process ID) and the npm tarball built by `tools/PostgreSqlMcp.Build` are byte-identical across rebuilds of the same source. The `.nupkg` (`dotnet pack` stamps wall-clock times on zip entries; its entry contents are identical) and the CycloneDX SBOM (random `serialNumber`, generation `timestamp`) are not, and neither is `SHA256SUMS`, which lists them. Consequently, after a partial publication, re-run the failed jobs of the same workflow run (same artifacts, same digests) instead of dispatching a new run for the same tag, which would fail closed on those assets. - **Dependency audit** checks direct and transitive NuGet packages against known advisories. This is not proof that all vulnerabilities are absent. - **CodeQL** scans C# and GitHub Actions with `security-extended`. C# uses real builds, including generated sources. Trusted main/scheduled runs publish code-scanning results; PRs scan both the base and candidate with read-only tokens, retain raw SARIF and run the trusted-base severity-delta gate. Extraction errors and missing reports fail closed. Review workflow changes as well as findings. - **Scorecard** publishes repository-practice findings on main and weekly. Its score is not a vulnerability-free claim or profile achievement. diff --git a/tools/PostgreSqlMcp.Build/Program.cs b/tools/PostgreSqlMcp.Build/Program.cs index eba8e7a..e9a01b4 100644 --- a/tools/PostgreSqlMcp.Build/Program.cs +++ b/tools/PostgreSqlMcp.Build/Program.cs @@ -218,11 +218,12 @@ private static void CreateTarball(string package, string destination) { using var stream = File.Create(destination); using var gzip = new GZipStream(stream, CompressionLevel.Optimal); - using var tar = new TarWriter(gzip, TarEntryFormat.Pax); + // GNU format: .NET's PAX writer names each extended header after the process ID, which makes identical content hash differently per build. + using var tar = new TarWriter(gzip, TarEntryFormat.Gnu); foreach (var file in Directory.EnumerateFiles(package, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) { var relative = Path.GetRelativePath(package, file).Replace(Path.DirectorySeparatorChar, '/'); - var entry = new PaxTarEntry(TarEntryType.RegularFile, $"package/{relative}") + var entry = new GnuTarEntry(TarEntryType.RegularFile, $"package/{relative}") { Uid = 0, Gid = 0, diff --git a/tools/PostgreSqlMcp.Release/Program.cs b/tools/PostgreSqlMcp.Release/Program.cs index c55a35d..38479f8 100644 --- a/tools/PostgreSqlMcp.Release/Program.cs +++ b/tools/PostgreSqlMcp.Release/Program.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.Formats.Tar; -using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; using System.Text.RegularExpressions; @@ -150,24 +148,7 @@ private static async Task ArchivesAsync(string root) string archive = Path.Combine(output, "postgresql-sharp-mcp-" + rid + (rid == "win-x64" ? ".zip" : ".tar.gz")); if (File.Exists(archive)) File.Delete(archive); if (rid == "win-x64") ReleaseArchive.CreateZip(temporary, archive); - else - { - await using var file = File.Create(archive); - await using var gzip = new GZipStream(file, CompressionLevel.Optimal); - using var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true); - foreach (string path in ReleaseArchive.OrderedFiles(temporary)) - { - using var data = File.OpenRead(path); - var entry = new PaxTarEntry(TarEntryType.RegularFile, ReleaseArchive.EntryName(temporary, path)) - { - Uid = 0, Gid = 0, UserName = "", GroupName = "", - ModificationTime = DateTimeOffset.UnixEpoch, - Mode = ReleaseArchive.EntryMode(path), - DataStream = data - }; - tar.WriteEntry(entry); - } - } + else ReleaseArchive.CreateTarGz(temporary, archive); } finally { Directory.Delete(temporary, true); } } diff --git a/tools/PostgreSqlMcp.Release/ReleaseArchive.cs b/tools/PostgreSqlMcp.Release/ReleaseArchive.cs index 720bc50..f873085 100644 --- a/tools/PostgreSqlMcp.Release/ReleaseArchive.cs +++ b/tools/PostgreSqlMcp.Release/ReleaseArchive.cs @@ -1,3 +1,4 @@ +using System.Formats.Tar; using System.IO.Compression; internal static class ReleaseArchive @@ -31,4 +32,26 @@ public static void CreateZip(string directory, string archive) source.CopyTo(target); } } + + /// + /// Writes a tar.gz whose bytes depend only on file names, contents and modes. GNU format on purpose: .NET's PAX writer + /// names each extended header after the process ID, so identical content would hash differently in every build. + /// + public static void CreateTarGz(string directory, string archive) + { + using var file = File.Create(archive); + using var gzip = new GZipStream(file, CompressionLevel.Optimal); + using var tar = new TarWriter(gzip, TarEntryFormat.Gnu, leaveOpen: true); + foreach (string path in OrderedFiles(directory)) + { + using var data = File.OpenRead(path); + tar.WriteEntry(new GnuTarEntry(TarEntryType.RegularFile, EntryName(directory, path)) + { + Uid = 0, Gid = 0, UserName = "", GroupName = "", + ModificationTime = DateTimeOffset.UnixEpoch, + Mode = EntryMode(path), + DataStream = data + }); + } + } } diff --git a/tools/PostgreSqlMcp.Verify/ReleaseRegressions.cs b/tools/PostgreSqlMcp.Verify/ReleaseRegressions.cs index 7db821b..bca80ff 100644 --- a/tools/PostgreSqlMcp.Verify/ReleaseRegressions.cs +++ b/tools/PostgreSqlMcp.Verify/ReleaseRegressions.cs @@ -1,3 +1,4 @@ +using System.Formats.Tar; using System.IO.Compression; using System.Security.Cryptography; using System.Text; @@ -10,7 +11,7 @@ internal static class ReleaseRegressions public static void Run() { AssetPlanning().GetAwaiter().GetResult(); - ZipReproducibility(); + ArchiveReproducibility(); NuGetPayloadComparison(); } @@ -59,7 +60,7 @@ await Fails("mismatch next to matching and missing assets", () => ReleaseAssets. "GitHub release asset JSON was parsed incorrectly."); } - private static void ZipReproducibility() + private static void ArchiveReproducibility() { using TemporaryDirectory temporary = new(); string source = Directory.CreateDirectory(Path.Combine(temporary.Path, "payload")).FullName; @@ -85,6 +86,31 @@ private static void ZipReproducibility() string third = Path.Combine(temporary.Path, "third.zip"); ReleaseArchive.CreateZip(source, third); Check.That(!File.ReadAllBytes(first).AsSpan().SequenceEqual(File.ReadAllBytes(third)), "A changed payload must change the zip."); + + // tar.gz uses GNU format because .NET's PAX writer embeds the process ID in extended-header names, which no + // single-process comparison can see; assert the format and the fixed metadata directly as well. + string tarFirst = Path.Combine(temporary.Path, "first.tar.gz"), tarSecond = Path.Combine(temporary.Path, "second.tar.gz"); + File.WriteAllText(Path.Combine(source, "alpha.txt"), "first"); + ReleaseArchive.CreateTarGz(source, tarFirst); + foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + File.SetLastWriteTimeUtc(file, new DateTime(2032, 6, 18, 5, 6, 7, DateTimeKind.Utc)); + ReleaseArchive.CreateTarGz(source, tarSecond); + Check.That(File.ReadAllBytes(tarFirst).AsSpan().SequenceEqual(File.ReadAllBytes(tarSecond)), "tar.gz archives must not depend on on-disk modification times."); + using var tarFile = File.OpenRead(tarFirst); + using var gunzip = new GZipStream(tarFile, CompressionMode.Decompress); + using var memory = new MemoryStream(); + gunzip.CopyTo(memory); + Check.That(!Encoding.ASCII.GetString(memory.ToArray()).Contains("PaxHeaders", StringComparison.Ordinal), "tar.gz archives must not contain process-ID-named PAX headers."); + memory.Position = 0; + var names = new List(); + using (var reader = new TarReader(memory)) + while (reader.GetNextEntry() is { } entry) + { + names.Add(entry.Name); + Check.That(entry.Format == TarEntryFormat.Gnu && entry.ModificationTime == DateTimeOffset.UnixEpoch && entry.Uid == 0 && entry.Gid == 0, + "tar.gz entries must use fixed GNU-format metadata."); + } + Check.That(names.SequenceEqual(["alpha.txt", "sub/nested.txt", "zeta.txt"]), "tar.gz entries must be sorted."); } private static void NuGetPayloadComparison() From b4c8e881afb555f317ae3d1739f0912c37547abc Mon Sep 17 00:00:00 2001 From: AG <288536783+codegiveness@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:51:27 +0700 Subject: [PATCH 3/3] fix: keep the regular-file type in zip entry modes; correct re-run note --- docs/git-workflow.md | 2 +- tools/PostgreSqlMcp.Release/ReleaseArchive.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/git-workflow.md b/docs/git-workflow.md index 9bd7b34..14253ec 100644 --- a/docs/git-workflow.md +++ b/docs/git-workflow.md @@ -494,7 +494,7 @@ Creating a new `v*` tag stays allowed; moving or deleting an existing one is blo - **Required approvals ≥ 1 or required code-owner review:** GitHub forbids approving your own pull request, so a solo maintainer could never merge. Revisit if a second maintainer joins. CODEOWNERS is in place for that day. - **Auto-merge:** conflicts with the rule that every merge, including Dependabot's, is a deliberate maintainer action. -- **Immutable releases:** the release tool creates a published release and uploads its assets afterwards (and re-uploads on a re-run). With immutable releases on, uploads after publication are rejected. Enable it only after the tool creates a draft, uploads and then publishes. +- **Immutable releases:** the release tool creates a published release and uploads its assets afterwards; a re-run uploads only assets that are still missing and fails if an existing asset differs. With immutable releases on, uploads after publication are rejected. Enable it only after the tool creates a draft, uploads and then publishes. ### Check current settings diff --git a/tools/PostgreSqlMcp.Release/ReleaseArchive.cs b/tools/PostgreSqlMcp.Release/ReleaseArchive.cs index f873085..ae17302 100644 --- a/tools/PostgreSqlMcp.Release/ReleaseArchive.cs +++ b/tools/PostgreSqlMcp.Release/ReleaseArchive.cs @@ -26,7 +26,8 @@ public static void CreateZip(string directory, string archive) { ZipArchiveEntry entry = zip.CreateEntry(EntryName(directory, path), CompressionLevel.Optimal); entry.LastWriteTime = ZipTimestamp; - entry.ExternalAttributes = (int)EntryMode(path) << 16; + // Unix mode in the high 16 bits: S_IFREG (0x8000) plus the permission bits, like the .NET default entry. + entry.ExternalAttributes = (0x8000 | (int)EntryMode(path)) << 16; using var source = File.OpenRead(path); using Stream target = entry.Open(); source.CopyTo(target);