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 7f89606..d58cfdb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,10 +253,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: @@ -266,7 +267,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 867bd61..fd4b9f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ - Tooling: replace seven copies of the child-process runner (verifier, release tool, package builder, fuzz campaign, npm installer, MCP client and resource checks) and three repository-root finders with `tools/Shared/ProcessRunner.cs` and `RepositoryRoot.cs`, linked into each tool project. Every call states its timeout and output policy (`Inherit`, `Capture`, `Suppress`, `Redact`); existing timeouts and exit-code handling are kept. Process-tree cleanup, drain observation and deadlines now live in one place. - Release tool: child output is echoed through the `Redact` policy, which replaces the values of credential-looking environment variables (names containing TOKEN, SECRET, PASSWORD, KEY or CREDENTIAL, values of 8+ characters) with `***`. Previously raw output was printed while the failure message claimed "redacted runner diagnostics". A timed-out release subprocess now reports `TimeoutException` instead of a bare cancellation. - Verifier: add a Linux regression for the runner's `Capture`, `Suppress` and `Redact` policies (no echo for the first two; no secret in the redacted echo or result). +- **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` and the `provenance.sigstore.json` bundle) 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. The provenance bundle is signed once per workflow run, so it matches only when failed jobs of the same run are re-run. 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 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/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 7e071d4..437c912 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,6 +71,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 @@ -200,12 +206,14 @@ 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 capability boundary introduced by **0.3.1**, the first release that contains it (source version 0.3.0 was never tagged or released). **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` and the `provenance.sigstore.json` attestation bundle, 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, nor the provenance bundle, which is signed once per workflow run. 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. - **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/docs/git-workflow.md b/docs/git-workflow.md index e27def8..e473123 100644 --- a/docs/git-workflow.md +++ b/docs/git-workflow.md @@ -508,7 +508,7 @@ gh repo view codegiveness/postgresql-sharp-mcp --json description,homepageUrl,re - **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.Build/Program.cs b/tools/PostgreSqlMcp.Build/Program.cs index 2711851..0edcc31 100644 --- a/tools/PostgreSqlMcp.Build/Program.cs +++ b/tools/PostgreSqlMcp.Build/Program.cs @@ -206,11 +206,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/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 ec2eb0c..677836e 100644 --- a/tools/PostgreSqlMcp.Release/Program.cs +++ b/tools/PostgreSqlMcp.Release/Program.cs @@ -1,6 +1,4 @@ using System.Collections; -using System.Formats.Tar; -using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; using System.Text.RegularExpressions; @@ -17,7 +15,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 +24,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 : RepositoryRoot.Find() ?? throw new InvalidOperationException("Run release automation from the repository."); if (command == "validate-tag") { @@ -71,6 +69,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."); } @@ -139,27 +140,8 @@ 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); - 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)) - { - using var data = File.OpenRead(path); - var entry = new PaxTarEntry(TarEntryType.RegularFile, Path.GetRelativePath(temporary, path).Replace(Path.DirectorySeparatorChar, '/')) - { - Uid = 0, Gid = 0, UserName = "", GroupName = "", - ModificationTime = DateTimeOffset.UnixEpoch, - Mode = OperatingSystem.IsWindows() - ? UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead - : File.GetUnixFileMode(path), - DataStream = data - }; - tar.WriteEntry(entry); - } - } + if (rid == "win-x64") ReleaseArchive.CreateZip(temporary, archive); + else ReleaseArchive.CreateTarGz(temporary, archive); } finally { Directory.Delete(temporary, true); } } @@ -197,8 +179,13 @@ private static async Task PublishGitHubAsync(string root, string tag, (string Ve string repo = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY") ?? throw new InvalidOperationException("GITHUB_REPOSITORY is required."); string[] files = [.. ArtifactFiles(root, metadata), Path.Combine(root, "artifacts/SHA256SUMS")]; string provenance = await ProvenanceBundleAsync(root, files); + var local = new List(); + foreach (string file in files.Append(provenance)) + 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."); @@ -206,8 +193,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, provenance], root); - await SummaryAsync($"GitHub Release {tag}: archives, packages, checksums, application SBOM and provenance bundle 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"); } // The attest job's Sigstore bundle is published so provenance can be verified from the release itself. diff --git a/tools/PostgreSqlMcp.Release/ReleaseArchive.cs b/tools/PostgreSqlMcp.Release/ReleaseArchive.cs new file mode 100644 index 0000000..ae17302 --- /dev/null +++ b/tools/PostgreSqlMcp.Release/ReleaseArchive.cs @@ -0,0 +1,58 @@ +using System.Formats.Tar; +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; + // 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); + } + } + + /// + /// 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.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 16901c5..54caeaa 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 21e0df4..58cb59a 100644 --- a/tools/PostgreSqlMcp.Verify/PostgreSqlMcp.Verify.csproj +++ b/tools/PostgreSqlMcp.Verify/PostgreSqlMcp.Verify.csproj @@ -7,6 +7,10 @@ + + + + diff --git a/tools/PostgreSqlMcp.Verify/Program.cs b/tools/PostgreSqlMcp.Verify/Program.cs index eef8038..6778c39 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..bca80ff --- /dev/null +++ b/tools/PostgreSqlMcp.Verify/ReleaseRegressions.cs @@ -0,0 +1,141 @@ +using System.Formats.Tar; +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(); + ArchiveReproducibility(); + 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 ArchiveReproducibility() + { + 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."); + + // 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() + { + 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."); + } +}