Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 4 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<pid>`), 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 <name>@<version> version --json`. Previously an already-published version failed with "requires an element of type 'String'" instead of being reported as not republished.
Expand Down
12 changes: 10 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<version>` 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<version>` 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.

Expand Down
2 changes: 1 addition & 1 deletion docs/git-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions tools/PostgreSqlMcp.Build/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions tools/PostgreSqlMcp.Release/NuGetRegistry.cs
Original file line number Diff line number Diff line change
@@ -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<bool> 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);
}

/// <summary>Differences between two packages' payloads (entry names and SHA-256 of contents), ignoring the repository signature.</summary>
public static IReadOnlyList<string> ComparePayload(string localPackage, string registryPackage)
{
Dictionary<string, string> local = Payload(localPackage), registry = Payload(registryPackage);
var differences = new List<string>();
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<string, string> Payload(string package)
{
using ZipArchive zip = ZipFile.OpenRead(package);
var payload = new Dictionary<string, string>(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;
}
}
Loading
Loading