From 9300924aaa519a3e2a139c3d9546eb6b665bf552 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Sat, 19 Sep 2026 14:26:57 +0200 Subject: [PATCH 1/3] fix: harden stable promotion and MCP gateway failures --- .github/workflows/promote-stable.yml | 82 +++++++++-- .github/workflows/pull-request-validation.yml | 6 + .gitignore | 3 + docker/docker-compose.mcp.gateway.release.yml | 4 +- docker/docker-compose.mcp.gateway.yml | 4 +- docker/examples/mcp-http/.env.example | 4 +- docker/examples/mcp-http/README.md | 21 ++- docs/releases.md | 13 +- .../GatewayDelegationAuthenticationHandler.cs | 84 +++++++++-- .../Api/McpGatewayRealAccessPipelineTests.cs | 25 +++- tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs | 64 +++++++-- .../test-deployment-archive-compose-config.sh | 50 +++++++ tools/ci/test-mcp-compose-config.sh | 32 ++++- .../release/test-validate-stable-promotion.py | 117 +++++++++++++++ tools/release/validate-stable-promotion.py | 133 ++++++++++++++++++ 15 files changed, 594 insertions(+), 48 deletions(-) create mode 100755 tools/ci/test-deployment-archive-compose-config.sh create mode 100644 tools/release/test-validate-stable-promotion.py create mode 100644 tools/release/validate-stable-promotion.py diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml index f888351e..b792b948 100644 --- a/.github/workflows/promote-stable.yml +++ b/.github/workflows/promote-stable.yml @@ -22,7 +22,14 @@ jobs: permissions: contents: read packages: write + env: + GH_REPO: ${{ github.repository }} steps: + - name: Check out main history for tag ancestry validation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Validate requested stable tag and published release env: GH_TOKEN: ${{ github.token }} @@ -30,8 +37,19 @@ jobs: run: | set -euo pipefail [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Only a stable SemVer tag may promote latest." >&2; exit 64; } - release="$(gh release view "$TAG" --json isDraft,isPrerelease,tagName)" + release="$(gh release view "$TAG" --repo "$GH_REPO" --json isDraft,isPrerelease,tagName,assets)" python3 -c 'import json,sys; r=json.load(sys.stdin); assert r["tagName"] == sys.argv[1] and not r["isDraft"] and not r["isPrerelease"], "Promotion requires an already-published stable release"' "$TAG" <<<"$release" + printf '%s\n' "$release" > release.json + + - name: Resolve the peeled tag commit and require main ancestry + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG" main + source_revision="$(git rev-parse "$TAG^{}")" + git merge-base --is-ancestor "$source_revision" origin/main + printf '%s\n' "$source_revision" > source-revision.txt - name: Reject an older stable release env: @@ -39,14 +57,15 @@ jobs: TAG: ${{ inputs.tag }} run: | set -euo pipefail - gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > releases.json + gh api --paginate --slurp "repos/${GH_REPO}/releases?per_page=100" > releases.json python3 - "$TAG" releases.json <<'PY' import json import re import sys candidate = tuple(map(int, sys.argv[1][1:].split("."))) - releases = json.load(open(sys.argv[2], encoding="utf-8")) + pages = json.load(open(sys.argv[2], encoding="utf-8")) + releases = [release for page in pages for release in page] if pages and isinstance(pages[0], list) else pages versions = [] for release in releases: tag = release.get("tag_name", "") @@ -64,7 +83,7 @@ jobs: run: | set -euo pipefail mkdir release-assets - gh release download "$TAG" --pattern release-manifest.json --pattern release-manifest.json.sha256 --dir release-assets + gh release download "$TAG" --repo "$GH_REPO" --pattern release-manifest.json --pattern release-manifest.json.sha256 --dir release-assets (cd release-assets && sha256sum --check release-manifest.json.sha256) python3 - "$TAG" release-assets/release-manifest.json <<'PY' import json, re, sys @@ -85,22 +104,59 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Verify image architectures and promote recorded digests + - name: Verify all immutable image inputs before promotion + env: + TAG: ${{ inputs.tag }} run: | set -euo pipefail + source_revision="$( release-pages.json + mkdir image-inspections python3 - release-assets/release-manifest.json <<'PY' > promotion-inputs.txt import json, sys for item in json.load(open(sys.argv[1], encoding="utf-8"))["containers"]: - print(item["image"].rsplit(":", 1)[0], item["digest"]) + print(item["image"], item["digest"], sep="\t") PY - while read -r image digest; do - inspect="$(docker buildx imagetools inspect "$image@$digest")" - grep -q 'linux/amd64' <<<"$inspect" - grep -q 'linux/arm64' <<<"$inspect" - # This creates only a new mutable reference to the recorded - # manifest; it does not rebuild or overwrite versioned tags. - docker buildx imagetools create --tag "$image:latest" "$image@$digest" + while IFS=$'\t' read -r image digest; do + file="image-inspections/$(basename "${image%%:*}").json" + docker buildx imagetools inspect "$image@$digest" --raw > "$file" done < promotion-inputs.txt + python3 - image-inspections release-assets/release-manifest.json <<'PY' > image-inspections.json + import json, pathlib, sys + directory, manifest_path = map(pathlib.Path, sys.argv[1:]) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + result = {} + for container in manifest["containers"]: + image = container["image"] + result[image] = json.loads((directory / f"{image.rsplit('/', 1)[-1].rsplit(':', 1)[0]}.json").read_text(encoding="utf-8")) + print(json.dumps(result)) + PY + python3 tools/release/validate-stable-promotion.py \ + --tag "$TAG" \ + --registry-owner "${GH_REPO%%/*}" \ + --source-revision "$source_revision" \ + --release release.json \ + --manifest release-assets/release-manifest.json \ + --manifest-checksum release-assets/release-manifest.json.sha256 \ + --release-pages release-pages.json \ + --inspections image-inspections.json > promotion-plan.json + + - name: Promote immutable digests and read back mutable references + run: | + set -euo pipefail + promoted=() + while IFS=$'\t' read -r image digest; do + if ! docker buildx imagetools create --tag "$image:latest" "$image@$digest"; then + printf 'Partial promotion; already updated: %s. Re-run this exact tag after correcting registry access; do not roll latest backwards.\n' "${promoted[*]:-none}" >&2 + exit 1 + fi + actual="$(docker buildx imagetools inspect "$image:latest" --format '{{.Digest}}')" + if [[ "$actual" != "$digest" ]]; then + printf 'Partial promotion; %s latest read back as %s, expected %s. Re-run this exact tag after investigating.\n' "$image" "$actual" "$digest" >&2 + exit 1 + fi + promoted+=("$image") + done < <(jq -r '.images[] | [.image, .digest] | @tsv' promotion-plan.json) - name: Summarize immutable promotion run: | diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index d5707bd8..66818868 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -185,6 +185,9 @@ jobs: - name: Check out triggering commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Test stable-promotion validation helper without registry writes + run: python3 tools/release/test-validate-stable-promotion.py + - name: Set up .NET SDK from global.json uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: @@ -202,6 +205,9 @@ jobs: (cd release-assets && sha256sum --check SHA256SUMS) test -s release-assets/release-manifest.json + - name: Render MCP Compose recipes from the extracted deployment archive + run: tools/ci/test-deployment-archive-compose-config.sh release-assets/rateldesk-deployment-0.0.0-pr.tar.gz + - name: Execute extracted Linux x64 archives run: tools/release/test-executable-archives.sh release-assets linux-x64 diff --git a/.gitignore b/.gitignore index 7f0e531c..a628c83a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ mono_crash.* [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ +!tools/ +!tools/release/ +!tools/release/*.py x64/ x86/ [Ww][Ii][Nn]32/ diff --git a/docker/docker-compose.mcp.gateway.release.yml b/docker/docker-compose.mcp.gateway.release.yml index af214c0c..79ce10d1 100644 --- a/docker/docker-compose.mcp.gateway.release.yml +++ b/docker/docker-compose.mcp.gateway.release.yml @@ -24,8 +24,8 @@ services: RATELDESK_MCP_CONFIG: /run/rateldesk-mcp/config.json Helpdesk__Mcp__Instance: ${RATELDESK_MCP_INSTANCE:-local} Helpdesk__Mcp__ExpectedApiBaseUrl: http://api:8222/ - Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:-https://localhost:8223/mcp} - Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:-http://localhost:8223} + Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical public HTTPS MCP URL, for example https://mcp.example.test/mcp.} + Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:?Set the browser origin allowed to call this MCP endpoint.} Helpdesk__Mcp__AuthenticationMode: gateway volumes: - mcp-http-config:/run/rateldesk-mcp:ro diff --git a/docker/docker-compose.mcp.gateway.yml b/docker/docker-compose.mcp.gateway.yml index 859a7350..407506db 100644 --- a/docker/docker-compose.mcp.gateway.yml +++ b/docker/docker-compose.mcp.gateway.yml @@ -29,8 +29,8 @@ services: RATELDESK_MCP_CONFIG: /run/rateldesk-mcp/config.json Helpdesk__Mcp__Instance: ${RATELDESK_MCP_INSTANCE:-local} Helpdesk__Mcp__ExpectedApiBaseUrl: http://api:8222/ - Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:-https://localhost:8223/mcp} - Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:-http://localhost:8223} + Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical public HTTPS MCP URL, for example https://mcp.example.test/mcp.} + Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:?Set the browser origin allowed to call this MCP endpoint.} Helpdesk__Mcp__AuthenticationMode: gateway volumes: - mcp-http-config:/run/rateldesk-mcp:ro diff --git a/docker/examples/mcp-http/.env.example b/docker/examples/mcp-http/.env.example index ec799dc5..fda18aea 100644 --- a/docker/examples/mcp-http/.env.example +++ b/docker/examples/mcp-http/.env.example @@ -1,5 +1,7 @@ # Copy to .env and replace every example value. Do not commit the resulting file. -RATELDESK_VERSION=0.1.0-rc.9 +# This must be an exact published version (for example 0.1.1-beta.2), never latest. +# It is intentionally blank so an extracted archive cannot silently select a stale release. +RATELDESK_VERSION= RATELDESK_MCP_INSTANCE=example RATELDESK_API_BASE_URL=https://api.example.test/ RATELDESK_MCP_PUBLIC_RESOURCE_URI=https://mcp.example.test/mcp diff --git a/docker/examples/mcp-http/README.md b/docker/examples/mcp-http/README.md index ddf52eb7..3efc3b68 100644 --- a/docker/examples/mcp-http/README.md +++ b/docker/examples/mcp-http/README.md @@ -15,8 +15,11 @@ chmod 600 config.json docker compose up -d ``` -Set `RATELDESK_API_BASE_URL` to the exact API target, -`RATELDESK_MCP_PUBLIC_RESOURCE_URI` to the public HTTPS `/mcp` URL. Configure +Set `RATELDESK_VERSION` to one exact published version (for example +`0.1.1-beta.2`), never `latest`. Set `RATELDESK_API_BASE_URL` to the exact API target, +`RATELDESK_MCP_PUBLIC_RESOURCE_URI` to the public HTTPS `/mcp` URL served by +your TLS proxy. The container's HTTP listener on port 8223 does not itself +provide TLS, so `https://localhost:8223/mcp` is not a usable default. Configure `RATELDESK_MCP_ALLOWED_ORIGIN` to the exact browser origin (not a path). The MCP container receives only its protected configuration file and does not mount the API database or data-protection key ring. The one-shot @@ -24,15 +27,19 @@ mount the API database or data-protection key ring. The one-shot volume with owner `10001:10001` and mode `0400`, then exits. The running MCP gateway remains non-root and mounts only that copied file read-only. -For the combined local Web/API/MCP stack, use the gateway-specific overlay; +For the combined disposable Web/API/MCP stack, use the gateway-specific overlay; it has no Authentik variables. The API target is internal (`http://api:8222/`) while `RATELDESK_MCP_PUBLIC_RESOURCE_URI` is the URI configured in the MCP -client and paired credential. +client and paired credential. Select a distinct Compose project, ports, and +volumes from any existing deployment; do not use this recipe to recreate an +existing Web/API stack. ```sh cp docker/examples/mcp-http/config.gateway.local.example.json config.gateway.json chmod 600 config.gateway.json RATELDESK_MCP_CONFIG_FILE="$PWD/config.gateway.json" \ +RATELDESK_MCP_PUBLIC_RESOURCE_URI=https://mcp.example.test/mcp \ +RATELDESK_MCP_ALLOWED_ORIGIN=https://app.example.test \ docker compose -f docker/docker-compose.yml -f docker/docker-compose.mcp.gateway.yml up --build ``` @@ -48,7 +55,11 @@ with `Helpdesk__Mcp__AuthenticationMode=authentik`. Copy placeholder: it supplies the downstream service-account configuration, while the Compose settings below supply the separate ingress issuer, audience, scope, and group checks. Authentik mode is explicit and is never used as a -fallback for local paired credentials. +fallback for local paired credentials. It validates an ingress MCP JWT but +uses the separately configured downstream API service identity; it is not +per-user delegated RatelDesk RBAC. The distinct linked-OIDC-user journey uses +an account-owned MCP credential in gateway mode after the OIDC user is linked +to an application account. For a source checkout, run from the repository root: diff --git a/docs/releases.md b/docs/releases.md index 68e72ff9..1b9338f0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,6 +1,6 @@ # Releases -RatelDesk has one repository-owned release line. The root `Directory.Build.props` is the source of truth: maintainers update `VersionPrefix` when preparing the next release. All application projects inherit that value. `VersionSuffix` creates prereleases without changing the release line. The current planned test release is `0.1.1-beta.1`; the current stable tag remains `0.1.0`. +RatelDesk has one repository-owned release line. The root `Directory.Build.props` is the source of truth: maintainers update `VersionPrefix` when preparing the next release. All application projects inherit that value. `VersionSuffix` creates prereleases without changing the release line. The current planned test release is `0.1.1-beta.2`; beta.1 remains an immutable published prerelease. ## Channel policy @@ -19,6 +19,17 @@ separate protected channel-promotion procedure. Do not promote an older version over an existing stable channel; serialize that procedure and preserve the immutable exact-version tags as the recovery source. +Before a maintainer dispatches `promote-stable.yml`, configure the repository +`release-promotion` environment with required reviewers and deployment-branch +policy in GitHub repository settings. The YAML environment name alone does not +prove those approval rules exist. Promotion validates the peeled tag commit, +its `main` ancestry, all release pages, the complete archive contract, and all +three immutable multi-architecture image digests before it changes any +`latest` reference. The three registry references are not atomic: if one write +or read-back fails, the workflow names the references already updated; after +correcting registry access, rerun the same stable tag rather than moving the +channel backwards or rebuilding an immutable image. + ## 0.1.0 — first usable alpha This is the first RatelDesk release intended for real alpha evaluation. It includes the complete first-run local-account setup flow, scoped role and tenant authorization, containerized Web/API deployment, PostgreSQL and SQLite support, deterministic API documentation, and bounded integration credentials for the CLI, stdio MCP, and HTTP MCP gateway modes. diff --git a/src/Helpdesk.Mcp.Http/Authorization/GatewayDelegationAuthenticationHandler.cs b/src/Helpdesk.Mcp.Http/Authorization/GatewayDelegationAuthenticationHandler.cs index 4dd2787d..41e2a736 100644 --- a/src/Helpdesk.Mcp.Http/Authorization/GatewayDelegationAuthenticationHandler.cs +++ b/src/Helpdesk.Mcp.Http/Authorization/GatewayDelegationAuthenticationHandler.cs @@ -25,6 +25,7 @@ public sealed class GatewayDelegationAuthenticationHandler( { public const string SchemeName = "HelpdeskMcpGateway"; public const string ExecutionTokenItemKey = "RatelDesk.Mcp.ExecutionToken"; + internal const string DelegationFailureItemKey = "RatelDesk.Mcp.DelegationFailure"; public const string DelegationClientName = "Helpdesk.Mcp.Http.Delegation"; internal const int DefaultExchangeTimeoutSeconds = 20; internal const int MaximumDelegationResponseBytes = 16 * 1024; @@ -46,16 +47,22 @@ protected override async Task HandleAuthenticateAsync() using var response = await clients.CreateClient(DelegationClientName) .SendAsync(delegationRequest, HttpCompletionOption.ResponseHeadersRead, deadline.Token) .ConfigureAwait(false); - if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) - return AuthenticateResult.Fail("The MCP credential could not be delegated for this gateway."); + if (response.StatusCode == HttpStatusCode.Unauthorized) + return Fail(GatewayDelegationFailure.InvalidCredential); + if (response.StatusCode == HttpStatusCode.Forbidden) + return Fail(GatewayDelegationFailure.Forbidden); + if (response.StatusCode == HttpStatusCode.TooManyRequests) + return Fail(GatewayDelegationFailure.Throttled(GetBoundedRetryAfter(response))); if (!response.IsSuccessStatusCode) - return AuthenticateResult.Fail("The MCP delegation service is temporarily unavailable."); + return Fail(response.StatusCode >= HttpStatusCode.InternalServerError + ? GatewayDelegationFailure.Unavailable + : GatewayDelegationFailure.BadGateway); var delegated = await ReadDelegatedCredentialAsync(response.Content, deadline.Token).ConfigureAwait(false); if (delegated is null || string.IsNullOrWhiteSpace(delegated.AccessToken) || delegated.ExpiresAtUtc <= DateTimeOffset.UtcNow || string.IsNullOrWhiteSpace(delegated.UserId)) { - return AuthenticateResult.Fail("The MCP gateway received an invalid execution credential."); + return Fail(GatewayDelegationFailure.BadGateway); } Context.Items[ExecutionTokenItemKey] = delegated.AccessToken; @@ -82,22 +89,56 @@ protected override async Task HandleAuthenticateAsync() } catch (OperationCanceledException) { - return AuthenticateResult.Fail("The MCP delegation service timed out."); + return Fail(GatewayDelegationFailure.DeadlineExceeded); } - catch (JsonException) + catch (DelegationResponseException) { - return AuthenticateResult.Fail("The MCP gateway received an invalid execution credential."); + return Fail(GatewayDelegationFailure.BadGateway); + } + catch (IOException) + { + return Fail(GatewayDelegationFailure.BadGateway); } catch (HttpRequestException) { - return AuthenticateResult.Fail("The MCP gateway could not reach the delegation endpoint."); + return Fail(GatewayDelegationFailure.Unavailable); } } + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + if (Response.HasStarted) + return Task.CompletedTask; + + var failure = Context.Items[DelegationFailureItemKey] as GatewayDelegationFailure; + Response.StatusCode = failure?.StatusCode ?? StatusCodes.Status401Unauthorized; + if (failure?.RetryAfterSeconds is { } retryAfterSeconds) + Response.Headers["Retry-After"] = retryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (Response.StatusCode == StatusCodes.Status401Unauthorized) + Response.Headers["WWW-Authenticate"] = "Bearer"; + return Task.CompletedTask; + } + + private AuthenticateResult Fail(GatewayDelegationFailure failure) + { + Context.Items[DelegationFailureItemKey] = failure; + return AuthenticateResult.Fail(failure.SafeDiagnostic); + } + + private static int? GetBoundedRetryAfter(HttpResponseMessage response) + { + var retryAfter = response.Headers.RetryAfter; + var delay = retryAfter?.Delta ?? (retryAfter?.Date - DateTimeOffset.UtcNow); + if (delay is null || delay <= TimeSpan.Zero) + return null; + + return Math.Clamp((int)Math.Ceiling(delay.Value.TotalSeconds), 1, 60); + } + private static async Task ReadDelegatedCredentialAsync(HttpContent content, CancellationToken cancellationToken) { if (content.Headers.ContentLength is > MaximumDelegationResponseBytes) - throw new JsonException("The delegation response exceeds the permitted size."); + throw new DelegationResponseException(); await using var stream = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var buffer = new MemoryStream(); @@ -109,13 +150,34 @@ protected override async Task HandleAuthenticateAsync() break; if (buffer.Length + count > MaximumDelegationResponseBytes) - throw new JsonException("The delegation response exceeds the permitted size."); + throw new DelegationResponseException(); await buffer.WriteAsync(chunk.AsMemory(0, count), cancellationToken).ConfigureAwait(false); } - return JsonSerializer.Deserialize(buffer.GetBuffer().AsSpan(0, checked((int)buffer.Length)), SerializerOptions); + try + { + return JsonSerializer.Deserialize(buffer.GetBuffer().AsSpan(0, checked((int)buffer.Length)), SerializerOptions); + } + catch (JsonException exception) + { + throw new DelegationResponseException(exception); + } } + internal sealed record GatewayDelegationFailure(int StatusCode, string SafeDiagnostic, int? RetryAfterSeconds = null) + { + public static GatewayDelegationFailure InvalidCredential { get; } = new(StatusCodes.Status401Unauthorized, "The MCP credential could not be delegated for this gateway."); + public static GatewayDelegationFailure Forbidden { get; } = new(StatusCodes.Status403Forbidden, "The MCP credential is not permitted for this gateway."); + public static GatewayDelegationFailure Unavailable { get; } = new(StatusCodes.Status503ServiceUnavailable, "The MCP delegation service is temporarily unavailable."); + public static GatewayDelegationFailure DeadlineExceeded { get; } = new(StatusCodes.Status504GatewayTimeout, "The MCP delegation service did not respond before the gateway deadline."); + public static GatewayDelegationFailure BadGateway { get; } = new(StatusCodes.Status502BadGateway, "The MCP gateway received an invalid delegation response."); + + public static GatewayDelegationFailure Throttled(int? retryAfterSeconds) => + new(StatusCodes.Status429TooManyRequests, "The MCP delegation service is throttling this request.", retryAfterSeconds); + } + + private sealed class DelegationResponseException(Exception? innerException = null) : Exception("Invalid delegation response.", innerException); + private sealed record DelegatedCredential( string AccessToken, DateTimeOffset ExpiresAtUtc, diff --git a/tests/Helpdesk.Tests/Api/McpGatewayRealAccessPipelineTests.cs b/tests/Helpdesk.Tests/Api/McpGatewayRealAccessPipelineTests.cs index 65c2980d..2573f211 100644 --- a/tests/Helpdesk.Tests/Api/McpGatewayRealAccessPipelineTests.cs +++ b/tests/Helpdesk.Tests/Api/McpGatewayRealAccessPipelineTests.cs @@ -104,6 +104,27 @@ public async Task Execution_token_rechecks_resource_role_revocation_and_account_ Assert.Equal(HttpStatusCode.Unauthorized, disabledOwner.StatusCode); } + [Fact] + public async Task Simultaneous_gateway_credentials_do_not_share_owner_scope_or_cached_authorization() + { + await using var harness = await GatewayAccessHarness.CreatePostgreSqlAsync(); + var credentialA = await harness.CreateCredentialAsync("org-a"); + var credentialB = await harness.CreateCredentialAsync("org-b"); + + var executions = await Task.WhenAll(harness.DelegateAsync(credentialA), harness.DelegateAsync(credentialB)); + var reads = await Task.WhenAll( + harness.GetAsync>(executions[0], "/api/v1/self-service/requests"), + harness.GetAsync>(executions[1], "/api/v1/self-service/requests")); + + Assert.Equal(HttpStatusCode.OK, reads[0].StatusCode); + Assert.Equal("request-a", Assert.Single(reads[0].Body!.Items).Id); + Assert.Equal(HttpStatusCode.OK, reads[1].StatusCode); + Assert.Empty(reads[1].Body!.Items); + + var apiPurposeCredential = await harness.CreateCredentialAsync("org-a", purpose: "api"); + await harness.AssertDelegationStatusAsync(apiPurposeCredential, GatewayAccessHarness.ResourceUri, HttpStatusCode.Unauthorized); + } + internal sealed class GatewayAccessHarness(WebApplication application, IAsyncDisposable database) : IAsyncDisposable { public const string ResourceUri = "https://helpdesk.example/mcp"; @@ -250,7 +271,7 @@ private static async Task CreateAsync(Action CreateCredentialAsync(string organizationId) + public async Task CreateCredentialAsync(string organizationId, string? purpose = null) { var id = Guid.NewGuid(); var secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); @@ -263,7 +284,7 @@ public async Task CreateCredentialAsync(string organizationId) Name = $"MCP {organizationId}", Prefix = $"rdk_{id:N}"[..16], SecretHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))), - Purpose = IntegrationCredentialAuthenticationHandler.McpPurpose, + Purpose = purpose ?? IntegrationCredentialAuthenticationHandler.McpPurpose, McpResourceUri = ResourceUri, OrganizationId = organizationId, Permissions = HelpdeskPermissions.SelfServiceUser, diff --git a/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs b/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs index 918572cd..0653c583 100644 --- a/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs +++ b/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs @@ -213,9 +213,12 @@ public async Task Paired_gateway_mode_does_not_advertise_oauth_metadata() } [Theory] - [InlineData(HttpStatusCode.TooManyRequests)] - [InlineData(HttpStatusCode.ServiceUnavailable)] - public async Task Paired_gateway_does_not_fall_back_when_delegation_dependency_rejects_or_is_unavailable(HttpStatusCode statusCode) + [InlineData(HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.ServiceUnavailable, HttpStatusCode.ServiceUnavailable)] + [InlineData(HttpStatusCode.InternalServerError, HttpStatusCode.ServiceUnavailable)] + public async Task Paired_gateway_does_not_fall_back_when_delegation_dependency_rejects_or_is_unavailable( + HttpStatusCode statusCode, + HttpStatusCode expectedStatusCode) { using var environment = new McpHostEnvironment(gateway: true); var delegation = new GatewayDelegationProbe((_, _) => Task.FromResult(new HttpResponseMessage(statusCode))); @@ -237,11 +240,56 @@ public async Task Paired_gateway_does_not_fall_back_when_delegation_dependency_r request.Headers.Accept.ParseAdd("application/json, text/event-stream"); using var response = await client.SendAsync(request); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Empty(await response.Content.ReadAsStringAsync()); Assert.Equal(1, delegation.Requests); Assert.Equal(0, outbound.ApiRequests); } + [Theory] + [InlineData(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden, HttpStatusCode.Forbidden)] + public async Task Paired_gateway_maps_credential_and_authorization_failures_without_exposing_dependency_content( + HttpStatusCode delegationStatus, + HttpStatusCode expectedStatus) + { + using var environment = new McpHostEnvironment(gateway: true); + var delegation = new GatewayDelegationProbe((_, _) => Task.FromResult(new HttpResponseMessage(delegationStatus) + { + Content = new StringContent("sensitive upstream body", Encoding.UTF8) + })); + var outbound = new AgentClientBoundaryProbe(); + using var factory = CreateFactory(environment.SigningKey, boundaryProbe: outbound, delegationProbe: delegation); + using var client = CreateClient(factory); + using var response = await client.SendAsync(CreateGatewayRequest()); + + Assert.Equal(expectedStatus, response.StatusCode); + Assert.DoesNotContain("sensitive", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + if (expectedStatus == HttpStatusCode.Unauthorized) + Assert.Contains(response.Headers.WwwAuthenticate, header => string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase)); + else + Assert.Empty(response.Headers.WwwAuthenticate); + Assert.Equal(0, outbound.ApiRequests); + } + + [Fact] + public async Task Paired_gateway_sanitizes_and_bounds_delegation_retry_after() + { + using var environment = new McpHostEnvironment(gateway: true); + var delegation = new GatewayDelegationProbe((_, _) => + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromMinutes(5)); + return Task.FromResult(response); + }); + using var factory = CreateFactory(environment.SigningKey, delegationProbe: delegation); + using var client = CreateClient(factory); + using var response = await client.SendAsync(CreateGatewayRequest()); + + Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode); + Assert.Equal("60", Assert.Single(response.Headers.GetValues("Retry-After"))); + } + [Theory] [InlineData("not-json")] [InlineData("{\"accessToken\":\"")] @@ -262,7 +310,7 @@ public async Task Paired_gateway_rejects_invalid_or_oversized_delegation_bodies_ using var response = await client.SendAsync(request); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); Assert.Equal(1, delegation.Requests); Assert.Equal(0, outbound.ApiRequests); } @@ -278,7 +326,7 @@ public async Task Paired_gateway_binds_its_exchange_deadline_to_response_headers using var headersClient = CreateClient(headersFactory); using var headersResponse = await headersClient.SendAsync(CreateGatewayRequest()); - Assert.Equal(HttpStatusCode.Unauthorized, headersResponse.StatusCode); + Assert.Equal(HttpStatusCode.GatewayTimeout, headersResponse.StatusCode); Assert.Equal(0, outbound.ApiRequests); var bodyNeverCompletes = new GatewayDelegationProbe((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) @@ -289,7 +337,7 @@ public async Task Paired_gateway_binds_its_exchange_deadline_to_response_headers using var bodyClient = CreateClient(bodyFactory); using var bodyResponse = await bodyClient.SendAsync(CreateGatewayRequest()); - Assert.Equal(HttpStatusCode.Unauthorized, bodyResponse.StatusCode); + Assert.Equal(HttpStatusCode.GatewayTimeout, bodyResponse.StatusCode); } [Fact] @@ -302,7 +350,7 @@ public async Task Paired_gateway_refused_delegation_connection_does_not_fall_bac using var client = CreateClient(factory); using var response = await client.SendAsync(CreateGatewayRequest()); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); Assert.Equal(1, delegation.Requests); Assert.Equal(0, outbound.ApiRequests); } diff --git a/tools/ci/test-deployment-archive-compose-config.sh b/tools/ci/test-deployment-archive-compose-config.sh new file mode 100755 index 00000000..ea5977e4 --- /dev/null +++ b/tools/ci/test-deployment-archive-compose-config.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +archive=${1:?Usage: test-deployment-archive-compose-config.sh } +work_directory=$(mktemp -d) +extracted_directory="$work_directory/extracted" +config_file="$work_directory/config.json" + +cleanup() { + rm -rf "$work_directory" +} +trap cleanup EXIT + +printf '%s\n' '{"apiBaseUrl":"https://api.example.test/","credentialMode":"gateway"}' > "$config_file" +chmod 0600 "$config_file" +mkdir -p "$extracted_directory" +tar -C "$extracted_directory" -xzf "$archive" +deployment_directory=$(find "$extracted_directory" -mindepth 1 -maxdepth 1 -type d -print -quit) +test -n "$deployment_directory" + +test -f "$deployment_directory/examples/mcp-http/docker-compose.yml" +test -f "$deployment_directory/docker-compose.mcp.gateway.release.yml" +test -f "$deployment_directory/docker-compose.mcp.release.yml" +grep -Fqx 'RATELDESK_VERSION=' "$deployment_directory/examples/mcp-http/.env.example" +if grep -Eq '^RATELDESK_VERSION=[0-9]+\.[0-9]+' "$deployment_directory/examples/mcp-http/.env.example"; then + echo "The extracted standalone MCP example must require an explicit release version." >&2 + exit 1 +fi + +compose_environment=( + 'RATELDESK_VERSION=0.0.0-pr' + "RATELDESK_MCP_CONFIG_FILE=$config_file" + 'RATELDESK_API_BASE_URL=https://api.example.test/' + 'RATELDESK_MCP_PUBLIC_RESOURCE_URI=https://mcp.example.test/mcp' + 'RATELDESK_MCP_ALLOWED_ORIGIN=https://client.example.test' + 'RATELDESK_MCP_PORT=18223' +) +authentik_environment=( + "${compose_environment[@]}" + 'RATELDESK_MCP_AUTHENTIK_AUTHORITY=https://auth.example.test/' +) + +env "${compose_environment[@]}" docker compose \ + -f "$deployment_directory/examples/mcp-http/docker-compose.yml" config --quiet +env "${compose_environment[@]}" docker compose \ + -f "$deployment_directory/docker-compose.release.yml" \ + -f "$deployment_directory/docker-compose.mcp.gateway.release.yml" config --quiet +env "${authentik_environment[@]}" docker compose \ + -f "$deployment_directory/docker-compose.release.yml" \ + -f "$deployment_directory/docker-compose.mcp.release.yml" config --quiet diff --git a/tools/ci/test-mcp-compose-config.sh b/tools/ci/test-mcp-compose-config.sh index 9078db1a..2303a0af 100755 --- a/tools/ci/test-mcp-compose-config.sh +++ b/tools/ci/test-mcp-compose-config.sh @@ -3,6 +3,7 @@ set -Eeuo pipefail image=${1:?Usage: test-mcp-compose-config.sh } work_directory=$(mktemp -d) +archive_directory="$work_directory/archive" project_name="rateldesk-mcp-config-${RANDOM}${RANDOM}" image_tag="compose-validation-${RANDOM}${RANDOM}" mcp_image="ghcr.io/bostontechnologies/rateldesk-mcp-http:$image_tag" @@ -34,15 +35,40 @@ compose_environment=( 'RATELDESK_API_BASE_URL=https://api.example.test/' 'RATELDESK_MCP_PUBLIC_RESOURCE_URI=https://mcp.example.test/mcp' 'RATELDESK_MCP_ALLOWED_ORIGIN=https://client.example.test' - 'RATELDESK_MCP_AUTHENTIK_AUTHORITY=https://auth.example.test/' 'RATELDESK_MCP_PORT=18223' ) +authentik_environment=( + "${compose_environment[@]}" + 'RATELDESK_MCP_AUTHENTIK_AUTHORITY=https://auth.example.test/' +) docker tag "$image" "$mcp_image" env "${compose_environment[@]}" docker compose "${standalone_compose[@]}" config --quiet -env "${compose_environment[@]}" docker compose -f docker/docker-compose.yml -f docker/docker-compose.mcp.yml config --quiet -env "${compose_environment[@]}" docker compose -f docker/docker-compose.release.yml -f docker/docker-compose.mcp.release.yml config --quiet +env "${compose_environment[@]}" docker compose -f docker/docker-compose.yml -f docker/docker-compose.mcp.gateway.yml config --quiet +env "${compose_environment[@]}" docker compose -f docker/docker-compose.release.yml -f docker/docker-compose.mcp.gateway.release.yml config --quiet +env "${authentik_environment[@]}" docker compose -f docker/docker-compose.yml -f docker/docker-compose.mcp.yml config --quiet +env "${authentik_environment[@]}" docker compose -f docker/docker-compose.release.yml -f docker/docker-compose.mcp.release.yml config --quiet + +# Render the same image-only recipes after extracting the deployment payload. +# This guards the archive layout and ensures examples never require a checkout. +mkdir -p "$archive_directory/source" "$archive_directory/extracted" +cp -a docker/. "$archive_directory/source/" +tar -C "$archive_directory/source" -czf "$archive_directory/deployment.tar.gz" . +tar -C "$archive_directory/extracted" -xzf "$archive_directory/deployment.tar.gz" +grep -Fqx 'RATELDESK_VERSION=' "$archive_directory/extracted/examples/mcp-http/.env.example" +if grep -Eq '^RATELDESK_VERSION=[0-9]+\.[0-9]+' "$archive_directory/extracted/examples/mcp-http/.env.example"; then + echo "The extracted standalone MCP example must require an explicit release version." >&2 + exit 1 +fi +env "${compose_environment[@]}" docker compose \ + -f "$archive_directory/extracted/examples/mcp-http/docker-compose.yml" config --quiet +env "${compose_environment[@]}" docker compose \ + -f "$archive_directory/extracted/docker-compose.release.yml" \ + -f "$archive_directory/extracted/docker-compose.mcp.gateway.release.yml" config --quiet +env "${authentik_environment[@]}" docker compose \ + -f "$archive_directory/extracted/docker-compose.release.yml" \ + -f "$archive_directory/extracted/docker-compose.mcp.release.yml" config --quiet env "${compose_environment[@]}" docker compose "${standalone_compose[@]}" up --detach --wait curl --retry 6 --retry-all-errors --retry-delay 1 --fail --show-error --silent \ diff --git a/tools/release/test-validate-stable-promotion.py b/tools/release/test-validate-stable-promotion.py new file mode 100644 index 00000000..27820cf0 --- /dev/null +++ b/tools/release/test-validate-stable-promotion.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""No-push regression tests for validate-stable-promotion.py. + +The tests run the helper from a temporary non-git directory and only use +fixture JSON. They never contact GitHub or a registry. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +HELPER = ROOT / "tools/release/validate-stable-promotion.py" +VERSION = "1.2.3" +TAG = f"v{VERSION}" +REVISION = "a" * 40 +IMAGES = [f"ghcr.io/public-owner/{name}:{VERSION}" for name in ("rateldesk-api", "rateldesk-web", "rateldesk-mcp-http")] + + +def archives() -> set[str]: + return { + "rateldesk-cli-1.2.3-linux-x64.tar.gz", "rateldesk-cli-1.2.3-linux-arm64.tar.gz", "rateldesk-cli-1.2.3-win-x64.zip", + "rateldesk-mcp-stdio-1.2.3-linux-x64.tar.gz", "rateldesk-mcp-stdio-1.2.3-linux-arm64.tar.gz", "rateldesk-mcp-stdio-1.2.3-win-x64.zip", + "rateldesk-deployment-1.2.3.tar.gz", + } + + +class StablePromotionValidatorTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.directory = Path(self.temp.name) + self.manifest = { + "tag": TAG, + "version": VERSION, + "sourceRevision": REVISION, + "assets": {name: "b" * 64 for name in archives()}, + "containers": [{"image": image, "digest": f"sha256:{character * 64}"} for image, character in zip(IMAGES, "cde", strict=True)], + } + self.release = {"tagName": TAG, "isDraft": False, "isPrerelease": False, "assets": [{"name": name} for name in archives() | {"SHA256SUMS", "release-manifest.json", "release-manifest.json.sha256"}]} + self.pages = [[{"tag_name": TAG, "draft": False, "prerelease": False}]] + self.inspections = {image: {"manifests": [{"os": "linux", "architecture": "amd64"}, {"os": "linux", "architecture": "arm64"}]} for image in IMAGES} + self.checksum_override: str | None = None + + def tearDown(self) -> None: + self.temp.cleanup() + + def run_helper(self) -> subprocess.CompletedProcess[str]: + paths = {"release": self.directory / "release.json", "manifest": self.directory / "release-manifest.json", "pages": self.directory / "pages.json", "inspections": self.directory / "inspections.json"} + for key, value in (("release", self.release), ("manifest", self.manifest), ("pages", self.pages), ("inspections", self.inspections)): + paths[key].write_text(json.dumps(value), encoding="utf-8") + checksum = self.directory / "release-manifest.json.sha256" + checksum.write_text(f"{self.checksum_override or hashlib.sha256(paths['manifest'].read_bytes()).hexdigest()} release-manifest.json\n", encoding="utf-8") + return subprocess.run( + [sys.executable, str(HELPER), "--tag", TAG, "--registry-owner", "public-owner", "--source-revision", REVISION, "--release", str(paths["release"]), "--manifest", str(paths["manifest"]), "--manifest-checksum", str(checksum), "--release-pages", str(paths["pages"]), "--inspections", str(paths["inspections"])], + cwd=self.directory, + text=True, + capture_output=True, + check=False, + ) + + def assert_rejected(self, text: str) -> None: + result = self.run_helper() + self.assertNotEqual(0, result.returncode) + self.assertIn(text, result.stderr) + + def test_valid_plan_runs_from_empty_non_git_directory_and_is_idempotent(self) -> None: + first = self.run_helper() + second = self.run_helper() + self.assertEqual(0, first.returncode, first.stderr) + self.assertEqual(first.stdout, second.stdout) + + def test_rejects_mismatched_source_and_draft_or_prerelease_tags(self) -> None: + self.manifest["sourceRevision"] = "f" * 40 + self.assert_rejected("source revision") + self.manifest["sourceRevision"] = REVISION + self.release["isPrerelease"] = True + self.assert_rejected("published stable") + + def test_rejects_missing_asset_altered_checksum_and_older_release(self) -> None: + self.manifest["assets"].pop(next(iter(self.manifest["assets"]))) + self.assert_rejected("exact required archive set") + self.manifest["assets"] = {name: "b" * 64 for name in archives()} + self.checksum_override = "0" * 64 + self.assert_rejected("checksum") + self.checksum_override = None + self.manifest["assets"] = {name: "b" * 64 for name in archives()} + self.pages = [[{"tag_name": "v9.0.0", "draft": False, "prerelease": False}], *self.pages] + self.assert_rejected("move latest backwards") + + def test_rejects_malformed_duplicate_and_wrong_repository_images(self) -> None: + self.manifest["containers"][0]["digest"] = "not-a-digest" + self.assert_rejected("invalid image digest") + self.manifest["containers"][0]["digest"] = "sha256:" + "c" * 64 + self.manifest["containers"][1]["image"] = self.manifest["containers"][0]["image"] + self.assert_rejected("unknown or duplicate") + self.manifest["containers"][1]["image"] = "ghcr.io/wrong-owner/rateldesk-web:1.2.3" + self.assert_rejected("unknown or duplicate") + + def test_rejects_missing_architecture_in_each_later_image_before_a_plan_is_emitted(self) -> None: + for image in IMAGES[1:]: + self.inspections[image]["manifests"].pop() + result = self.run_helper() + self.assertNotEqual(0, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("missing a required Linux platform", result.stderr) + self.inspections[image]["manifests"].append({"os": "linux", "architecture": "arm64"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release/validate-stable-promotion.py b/tools/release/validate-stable-promotion.py new file mode 100644 index 00000000..b35f9bd5 --- /dev/null +++ b/tools/release/validate-stable-promotion.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Validate the immutable inputs to a stable-channel promotion. + +This helper is deliberately side-effect free so it can be exercised from an +empty, non-git directory. The workflow obtains release and registry data, +then calls this program before it creates any mutable registry reference. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + + +STABLE_TAG = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +IMAGE_NAMES = ("rateldesk-api", "rateldesk-web", "rateldesk-mcp-http") +REQUIRED_PLATFORMS = {("linux", "amd64"), ("linux", "arm64")} + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def version(tag: str) -> tuple[int, int, int]: + match = STABLE_TAG.fullmatch(tag) + if not match: + fail("Only a stable SemVer tag may promote latest.") + return tuple(int(part) for part in match.groups()) + + +def expected_assets(release_version: str) -> set[str]: + archives = { + *(f"rateldesk-cli-{release_version}-{rid}.{'zip' if rid == 'win-x64' else 'tar.gz'}" for rid in ("linux-x64", "linux-arm64", "win-x64")), + *(f"rateldesk-mcp-stdio-{release_version}-{rid}.{'zip' if rid == 'win-x64' else 'tar.gz'}" for rid in ("linux-x64", "linux-arm64", "win-x64")), + f"rateldesk-deployment-{release_version}.tar.gz", + } + return archives + + +def release_pages(value: Any) -> list[dict[str, Any]]: + if isinstance(value, list) and all(isinstance(page, list) for page in value): + return [release for page in value for release in page] + if isinstance(value, list): + return value + fail("Release pages must be an array or an array of pages.") + + +def file_sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def validate(args: argparse.Namespace) -> dict[str, Any]: + candidate_version = version(args.tag) + release = load_json(args.release) + if release.get("tagName") != args.tag or release.get("isDraft") or release.get("isPrerelease"): + fail("Promotion requires an already-published stable release.") + + manifest = load_json(args.manifest) + checksum = args.manifest_checksum.read_text(encoding="utf-8").strip().split() + if len(checksum) != 2 or checksum[1] != "release-manifest.json" or checksum[0] != file_sha256(args.manifest): + fail("The release manifest checksum is missing or mismatched.") + release_version = args.tag[1:] + if manifest.get("tag") != args.tag or manifest.get("version") != release_version: + fail("The release manifest tag and version must match the requested stable tag.") + if manifest.get("sourceRevision") != args.source_revision: + fail("The release manifest source revision does not match the peeled tag commit.") + + pages = release_pages(load_json(args.release_pages)) + published_stable = [version(item["tag_name"]) for item in pages if not item.get("draft") and not item.get("prerelease") and STABLE_TAG.fullmatch(item.get("tag_name", ""))] + if published_stable and candidate_version < max(published_stable): + fail("Refusing to move latest backwards from a newer published stable release.") + + assets = manifest.get("assets") + if not isinstance(assets, dict) or set(assets) != expected_assets(release_version): + fail("The release manifest does not contain the exact required archive set.") + if any(not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) for digest in assets.values()): + fail("The release manifest contains an invalid archive checksum.") + published_asset_names = {asset.get("name") for asset in release.get("assets", [])} + required_release_assets = set(assets) | {"SHA256SUMS", "release-manifest.json", "release-manifest.json.sha256"} + if published_asset_names != required_release_assets: + fail("The published release does not contain the exact expected asset set.") + + expected_images = {f"ghcr.io/{args.registry_owner.lower()}/{name}:{release_version}" for name in IMAGE_NAMES} + containers = manifest.get("containers") + if not isinstance(containers, list) or len(containers) != len(expected_images): + fail("The release manifest must contain exactly three container entries.") + observed_images = [item.get("image") for item in containers if isinstance(item, dict)] + if set(observed_images) != expected_images or len(set(observed_images)) != len(expected_images): + fail("The release manifest contains an unknown or duplicate container destination.") + image_digests = {item["image"]: item.get("digest") for item in containers} + if any(not isinstance(digest, str) or not DIGEST.fullmatch(digest) for digest in image_digests.values()): + fail("The release manifest contains an invalid image digest.") + + inspections = load_json(args.inspections) + if set(inspections) != expected_images: + fail("Registry inspection data must cover exactly the three expected images.") + for image, digest in image_digests.items(): + platforms = { + ((item.get("platform") or item).get("os"), (item.get("platform") or item).get("architecture")) + for item in inspections[image].get("manifests", []) + } + if REQUIRED_PLATFORMS - platforms: + fail(f"{image}@{digest} is missing a required Linux platform.") + + return {"tag": args.tag, "sourceRevision": args.source_revision, "images": [{"image": image, "digest": image_digests[image]} for image in sorted(expected_images)]} + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--tag", required=True) + parser.add_argument("--registry-owner", required=True) + parser.add_argument("--source-revision", required=True) + parser.add_argument("--release", type=Path, required=True) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--manifest-checksum", type=Path, required=True) + parser.add_argument("--release-pages", type=Path, required=True) + parser.add_argument("--inspections", type=Path, required=True) + args = parser.parse_args() + print(json.dumps(validate(args), sort_keys=True)) + + +if __name__ == "__main__": + main() From f1ef05a66b0c70f3ac01e356f67e6d51ed08a0d8 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Sat, 19 Sep 2026 15:36:26 +0200 Subject: [PATCH 2/3] fix: validate stable promotion targets and assets --- .github/workflows/promote-stable.yml | 19 ++----- .github/workflows/pull-request-validation.yml | 4 +- .gitignore | 1 + tools/release/promote-stable-images.sh | 24 +++++++++ tools/release/test-promote-stable-images.sh | 49 +++++++++++++++++++ .../release/test-validate-stable-promotion.py | 36 ++++++++++++-- tools/release/validate-stable-promotion.py | 30 ++++++++++-- 7 files changed, 138 insertions(+), 25 deletions(-) create mode 100755 tools/release/promote-stable-images.sh create mode 100755 tools/release/test-promote-stable-images.sh diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml index b792b948..652c525d 100644 --- a/.github/workflows/promote-stable.yml +++ b/.github/workflows/promote-stable.yml @@ -83,7 +83,7 @@ jobs: run: | set -euo pipefail mkdir release-assets - gh release download "$TAG" --repo "$GH_REPO" --pattern release-manifest.json --pattern release-manifest.json.sha256 --dir release-assets + gh release download "$TAG" --repo "$GH_REPO" --pattern SHA256SUMS --pattern release-manifest.json --pattern release-manifest.json.sha256 --dir release-assets (cd release-assets && sha256sum --check release-manifest.json.sha256) python3 - "$TAG" release-assets/release-manifest.json <<'PY' import json, re, sys @@ -138,25 +138,12 @@ jobs: --release release.json \ --manifest release-assets/release-manifest.json \ --manifest-checksum release-assets/release-manifest.json.sha256 \ + --sha256sums release-assets/SHA256SUMS \ --release-pages release-pages.json \ --inspections image-inspections.json > promotion-plan.json - name: Promote immutable digests and read back mutable references - run: | - set -euo pipefail - promoted=() - while IFS=$'\t' read -r image digest; do - if ! docker buildx imagetools create --tag "$image:latest" "$image@$digest"; then - printf 'Partial promotion; already updated: %s. Re-run this exact tag after correcting registry access; do not roll latest backwards.\n' "${promoted[*]:-none}" >&2 - exit 1 - fi - actual="$(docker buildx imagetools inspect "$image:latest" --format '{{.Digest}}')" - if [[ "$actual" != "$digest" ]]; then - printf 'Partial promotion; %s latest read back as %s, expected %s. Re-run this exact tag after investigating.\n' "$image" "$actual" "$digest" >&2 - exit 1 - fi - promoted+=("$image") - done < <(jq -r '.images[] | [.image, .digest] | @tsv' promotion-plan.json) + run: tools/release/promote-stable-images.sh promotion-plan.json - name: Summarize immutable promotion run: | diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 66818868..01be71ad 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -186,7 +186,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Test stable-promotion validation helper without registry writes - run: python3 tools/release/test-validate-stable-promotion.py + run: | + python3 tools/release/test-validate-stable-promotion.py + tools/release/test-promote-stable-images.sh - name: Set up .NET SDK from global.json uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 diff --git a/.gitignore b/.gitignore index a628c83a..d5758534 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ mono_crash.* !tools/ !tools/release/ !tools/release/*.py +!tools/release/*.sh x64/ x86/ [Ww][Ii][Nn]32/ diff --git a/tools/release/promote-stable-images.sh b/tools/release/promote-stable-images.sh new file mode 100755 index 00000000..8d03b502 --- /dev/null +++ b/tools/release/promote-stable-images.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +plan="$1" +docker_bin="${DOCKER_BIN:-docker}" +promoted=() + +while IFS=$'\t' read -r image latest digest; do + if ! "$docker_bin" buildx imagetools create --tag "$latest" "$image@$digest"; then + printf 'Partial promotion; already updated: %s. Re-run this exact tag after correcting registry access; do not roll latest backwards.\n' "${promoted[*]:-none}" >&2 + exit 1 + fi + actual="$("$docker_bin" buildx imagetools inspect "$latest" --format '{{.Digest}}')" + if [[ "$actual" != "$digest" ]]; then + printf 'Partial promotion; %s read back as %s, expected %s. Re-run this exact tag after investigating.\n' "$latest" "$actual" "$digest" >&2 + exit 1 + fi + promoted+=("$latest") +done < <(jq -r '.images[] | [.image, .latest, .digest] | @tsv' "$plan") diff --git a/tools/release/test-promote-stable-images.sh b/tools/release/test-promote-stable-images.sh new file mode 100755 index 00000000..f7fa0b71 --- /dev/null +++ b/tools/release/test-promote-stable-images.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +work_directory="$(mktemp -d)" +trap 'rm -rf "$work_directory"' EXIT + +plan="$work_directory/plan.json" +log="$work_directory/docker.log" +mock_docker="$work_directory/docker" +digest="sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +printf '%s\n' '{"images":[' > "$plan" +for image in rateldesk-api rateldesk-web rateldesk-mcp-http; do + printf '{"image":"ghcr.io/public-owner/%s:1.2.3","latest":"ghcr.io/public-owner/%s:latest","digest":"%s"},\n' "$image" "$image" "$digest" >> "$plan" +done +sed -i '$ s/,$/]/' "$plan" +printf '%s\n' '}' >> "$plan" + +cat > "$mock_docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$MOCK_DOCKER_LOG" +if [[ "$*" == *"imagetools create"* && "${FAKE_FAIL_TARGET:-}" != "" && "$*" == *"$FAKE_FAIL_TARGET"* ]]; then + exit 42 +fi +if [[ "$*" == *"imagetools inspect"* ]]; then + printf '%s\n' "$MOCK_DOCKER_DIGEST" +fi +EOF +chmod 0700 "$mock_docker" + +MOCK_DOCKER_LOG="$log" MOCK_DOCKER_DIGEST="$digest" DOCKER_BIN="$mock_docker" \ + "$repository_root/tools/release/promote-stable-images.sh" "$plan" + +grep -Fqx "buildx imagetools create --tag ghcr.io/public-owner/rateldesk-api:latest ghcr.io/public-owner/rateldesk-api:1.2.3@$digest" "$log" +grep -Fqx "buildx imagetools create --tag ghcr.io/public-owner/rateldesk-web:latest ghcr.io/public-owner/rateldesk-web:1.2.3@$digest" "$log" +grep -Fqx "buildx imagetools create --tag ghcr.io/public-owner/rateldesk-mcp-http:latest ghcr.io/public-owner/rateldesk-mcp-http:1.2.3@$digest" "$log" +if grep -Fq ':1.2.3:latest' "$log"; then + echo "The promotion command constructed an invalid mutable image reference." >&2 + exit 1 +fi + +if MOCK_DOCKER_LOG="$log" MOCK_DOCKER_DIGEST="$digest" FAKE_FAIL_TARGET='rateldesk-web:latest' DOCKER_BIN="$mock_docker" \ + "$repository_root/tools/release/promote-stable-images.sh" "$plan" >"$work_directory/partial.stdout" 2>"$work_directory/partial.stderr"; then + echo "The simulated partial registry failure unexpectedly succeeded." >&2 + exit 1 +fi +grep -Fq 'already updated: ghcr.io/public-owner/rateldesk-api:latest' "$work_directory/partial.stderr" diff --git a/tools/release/test-validate-stable-promotion.py b/tools/release/test-validate-stable-promotion.py index 27820cf0..637eeb3f 100644 --- a/tools/release/test-validate-stable-promotion.py +++ b/tools/release/test-validate-stable-promotion.py @@ -43,22 +43,35 @@ def setUp(self) -> None: "assets": {name: "b" * 64 for name in archives()}, "containers": [{"image": image, "digest": f"sha256:{character * 64}"} for image, character in zip(IMAGES, "cde", strict=True)], } - self.release = {"tagName": TAG, "isDraft": False, "isPrerelease": False, "assets": [{"name": name} for name in archives() | {"SHA256SUMS", "release-manifest.json", "release-manifest.json.sha256"}]} + self.release = { + "tagName": TAG, + "isDraft": False, + "isPrerelease": False, + "assets": [ + {"name": name, "digest": f"sha256:{self.manifest['assets'][name]}" if name in self.manifest["assets"] else "sha256:" + "f" * 64} + for name in archives() | {"SHA256SUMS", "release-manifest.json", "release-manifest.json.sha256"} + ], + } self.pages = [[{"tag_name": TAG, "draft": False, "prerelease": False}]] self.inspections = {image: {"manifests": [{"os": "linux", "architecture": "amd64"}, {"os": "linux", "architecture": "arm64"}]} for image in IMAGES} self.checksum_override: str | None = None + self.sums_override: str | None = None def tearDown(self) -> None: self.temp.cleanup() def run_helper(self) -> subprocess.CompletedProcess[str]: - paths = {"release": self.directory / "release.json", "manifest": self.directory / "release-manifest.json", "pages": self.directory / "pages.json", "inspections": self.directory / "inspections.json"} + paths = {"release": self.directory / "release.json", "manifest": self.directory / "release-manifest.json", "pages": self.directory / "pages.json", "inspections": self.directory / "inspections.json", "sums": self.directory / "SHA256SUMS"} for key, value in (("release", self.release), ("manifest", self.manifest), ("pages", self.pages), ("inspections", self.inspections)): paths[key].write_text(json.dumps(value), encoding="utf-8") checksum = self.directory / "release-manifest.json.sha256" checksum.write_text(f"{self.checksum_override or hashlib.sha256(paths['manifest'].read_bytes()).hexdigest()} release-manifest.json\n", encoding="utf-8") + paths["sums"].write_text( + self.sums_override or "".join(f"{digest} {name}\n" for name, digest in sorted(self.manifest["assets"].items())), + encoding="utf-8", + ) return subprocess.run( - [sys.executable, str(HELPER), "--tag", TAG, "--registry-owner", "public-owner", "--source-revision", REVISION, "--release", str(paths["release"]), "--manifest", str(paths["manifest"]), "--manifest-checksum", str(checksum), "--release-pages", str(paths["pages"]), "--inspections", str(paths["inspections"])], + [sys.executable, str(HELPER), "--tag", TAG, "--registry-owner", "public-owner", "--source-revision", REVISION, "--release", str(paths["release"]), "--manifest", str(paths["manifest"]), "--manifest-checksum", str(checksum), "--sha256sums", str(paths["sums"]), "--release-pages", str(paths["pages"]), "--inspections", str(paths["inspections"])], cwd=self.directory, text=True, capture_output=True, @@ -70,11 +83,17 @@ def assert_rejected(self, text: str) -> None: self.assertNotEqual(0, result.returncode) self.assertIn(text, result.stderr) - def test_valid_plan_runs_from_empty_non_git_directory_and_is_idempotent(self) -> None: + def test_valid_plan_runs_from_empty_non_git_directory_is_idempotent_and_has_exact_latest_targets(self) -> None: first = self.run_helper() second = self.run_helper() self.assertEqual(0, first.returncode, first.stderr) self.assertEqual(first.stdout, second.stdout) + plan = json.loads(first.stdout) + self.assertEqual( + {f"ghcr.io/public-owner/{name}:latest" for name in ("rateldesk-api", "rateldesk-web", "rateldesk-mcp-http")}, + {item["latest"] for item in plan["images"]}, + ) + self.assertTrue(all(f":{VERSION}:latest" not in item["latest"] for item in plan["images"])) def test_rejects_mismatched_source_and_draft_or_prerelease_tags(self) -> None: self.manifest["sourceRevision"] = "f" * 40 @@ -83,7 +102,7 @@ def test_rejects_mismatched_source_and_draft_or_prerelease_tags(self) -> None: self.release["isPrerelease"] = True self.assert_rejected("published stable") - def test_rejects_missing_asset_altered_checksum_and_older_release(self) -> None: + def test_rejects_missing_asset_altered_checksum_asset_digest_and_older_release(self) -> None: self.manifest["assets"].pop(next(iter(self.manifest["assets"]))) self.assert_rejected("exact required archive set") self.manifest["assets"] = {name: "b" * 64 for name in archives()} @@ -91,6 +110,13 @@ def test_rejects_missing_asset_altered_checksum_and_older_release(self) -> None: self.assert_rejected("checksum") self.checksum_override = None self.manifest["assets"] = {name: "b" * 64 for name in archives()} + first_archive = next(iter(self.manifest["assets"])) + self.sums_override = f"{'0' * 64} {first_archive}\n" + self.assert_rejected("SHA256SUMS does not exactly match") + self.sums_override = None + self.release["assets"][0]["digest"] = "sha256:" + "0" * 64 + self.assert_rejected("asset digest mismatch") + self.release["assets"][0]["digest"] = f"sha256:{self.manifest['assets'][self.release['assets'][0]['name']]}" if self.release["assets"][0]["name"] in self.manifest["assets"] else "sha256:" + "f" * 64 self.pages = [[{"tag_name": "v9.0.0", "draft": False, "prerelease": False}], *self.pages] self.assert_rejected("move latest backwards") diff --git a/tools/release/validate-stable-promotion.py b/tools/release/validate-stable-promotion.py index b35f9bd5..1cc62293 100644 --- a/tools/release/validate-stable-promotion.py +++ b/tools/release/validate-stable-promotion.py @@ -59,6 +59,16 @@ def file_sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() +def sha256sums(path: Path) -> dict[str, str]: + checksums: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + parts = line.split(maxsplit=1) + if len(parts) != 2 or not re.fullmatch(r"[0-9a-f]{64}", parts[0]) or not parts[1] or parts[1] in checksums: + fail("SHA256SUMS is malformed or contains duplicate assets.") + checksums[parts[1]] = parts[0] + return checksums + + def validate(args: argparse.Namespace) -> dict[str, Any]: candidate_version = version(args.tag) release = load_json(args.release) @@ -85,10 +95,16 @@ def validate(args: argparse.Namespace) -> dict[str, Any]: fail("The release manifest does not contain the exact required archive set.") if any(not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) for digest in assets.values()): fail("The release manifest contains an invalid archive checksum.") - published_asset_names = {asset.get("name") for asset in release.get("assets", [])} + if sha256sums(args.sha256sums) != assets: + fail("SHA256SUMS does not exactly match the release manifest archive checksums.") + + published_assets = {asset.get("name"): asset.get("digest") for asset in release.get("assets", [])} required_release_assets = set(assets) | {"SHA256SUMS", "release-manifest.json", "release-manifest.json.sha256"} - if published_asset_names != required_release_assets: + if set(published_assets) != required_release_assets or len(published_assets) != len(release.get("assets", [])): fail("The published release does not contain the exact expected asset set.") + for name, expected_hash in assets.items(): + if published_assets.get(name) != f"sha256:{expected_hash}": + fail(f"Published release asset digest mismatch: {name}") expected_images = {f"ghcr.io/{args.registry_owner.lower()}/{name}:{release_version}" for name in IMAGE_NAMES} containers = manifest.get("containers") @@ -112,7 +128,14 @@ def validate(args: argparse.Namespace) -> dict[str, Any]: if REQUIRED_PLATFORMS - platforms: fail(f"{image}@{digest} is missing a required Linux platform.") - return {"tag": args.tag, "sourceRevision": args.source_revision, "images": [{"image": image, "digest": image_digests[image]} for image in sorted(expected_images)]} + return { + "tag": args.tag, + "sourceRevision": args.source_revision, + "images": [ + {"image": image, "latest": f"{image.rsplit(':', 1)[0]}:latest", "digest": image_digests[image]} + for image in sorted(expected_images) + ], + } def main() -> None: @@ -123,6 +146,7 @@ def main() -> None: parser.add_argument("--release", type=Path, required=True) parser.add_argument("--manifest", type=Path, required=True) parser.add_argument("--manifest-checksum", type=Path, required=True) + parser.add_argument("--sha256sums", type=Path, required=True) parser.add_argument("--release-pages", type=Path, required=True) parser.add_argument("--inspections", type=Path, required=True) args = parser.parse_args() From 4304d17626f47bf88db89bf91e120c7121fc1f14 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Sat, 19 Sep 2026 16:16:00 +0200 Subject: [PATCH 3/3] fix: report stable promotion read-back failures --- .github/workflows/promote-stable.yml | 1 + tools/release/promote-stable-images.sh | 10 ++++++--- tools/release/test-promote-stable-images.sh | 21 +++++++++++++++++++ .../release/test-validate-stable-promotion.py | 8 ++++--- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml index 652c525d..24b9955f 100644 --- a/.github/workflows/promote-stable.yml +++ b/.github/workflows/promote-stable.yml @@ -106,6 +106,7 @@ jobs: - name: Verify all immutable image inputs before promotion env: + GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag }} run: | set -euo pipefail diff --git a/tools/release/promote-stable-images.sh b/tools/release/promote-stable-images.sh index 8d03b502..81d5dae6 100755 --- a/tools/release/promote-stable-images.sh +++ b/tools/release/promote-stable-images.sh @@ -15,10 +15,14 @@ while IFS=$'\t' read -r image latest digest; do printf 'Partial promotion; already updated: %s. Re-run this exact tag after correcting registry access; do not roll latest backwards.\n' "${promoted[*]:-none}" >&2 exit 1 fi - actual="$("$docker_bin" buildx imagetools inspect "$latest" --format '{{.Digest}}')" + updated=("${promoted[@]}" "$latest") + if ! actual="$("$docker_bin" buildx imagetools inspect "$latest" --format '{{.Digest}}')"; then + printf 'Partial promotion; already updated: %s. Read-back of %s failed. Re-run this exact tag after investigating.\n' "${updated[*]}" "$latest" >&2 + exit 1 + fi if [[ "$actual" != "$digest" ]]; then - printf 'Partial promotion; %s read back as %s, expected %s. Re-run this exact tag after investigating.\n' "$latest" "$actual" "$digest" >&2 + printf 'Partial promotion; already updated: %s. %s read back as %s, expected %s. Re-run this exact tag after investigating.\n' "${updated[*]}" "$latest" "$actual" "$digest" >&2 exit 1 fi - promoted+=("$latest") + promoted=("${updated[@]}") done < <(jq -r '.images[] | [.image, .latest, .digest] | @tsv' "$plan") diff --git a/tools/release/test-promote-stable-images.sh b/tools/release/test-promote-stable-images.sh index f7fa0b71..dfe7aa07 100755 --- a/tools/release/test-promote-stable-images.sh +++ b/tools/release/test-promote-stable-images.sh @@ -25,6 +25,13 @@ if [[ "$*" == *"imagetools create"* && "${FAKE_FAIL_TARGET:-}" != "" && "$*" == exit 42 fi if [[ "$*" == *"imagetools inspect"* ]]; then + if [[ "${FAKE_INSPECT_FAIL_TARGET:-}" != "" && "$*" == *"$FAKE_INSPECT_FAIL_TARGET"* ]]; then + exit 43 + fi + if [[ "${FAKE_INSPECT_MISMATCH_TARGET:-}" != "" && "$*" == *"$FAKE_INSPECT_MISMATCH_TARGET"* ]]; then + printf '%s\n' 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + exit 0 + fi printf '%s\n' "$MOCK_DOCKER_DIGEST" fi EOF @@ -47,3 +54,17 @@ if MOCK_DOCKER_LOG="$log" MOCK_DOCKER_DIGEST="$digest" FAKE_FAIL_TARGET='ratelde exit 1 fi grep -Fq 'already updated: ghcr.io/public-owner/rateldesk-api:latest' "$work_directory/partial.stderr" + +if MOCK_DOCKER_LOG="$log" MOCK_DOCKER_DIGEST="$digest" FAKE_INSPECT_FAIL_TARGET='rateldesk-web:latest' DOCKER_BIN="$mock_docker" \ + "$repository_root/tools/release/promote-stable-images.sh" "$plan" >"$work_directory/readback-failure.stdout" 2>"$work_directory/readback-failure.stderr"; then + echo "The simulated registry read-back failure unexpectedly succeeded." >&2 + exit 1 +fi +grep -Fq 'already updated: ghcr.io/public-owner/rateldesk-api:latest ghcr.io/public-owner/rateldesk-web:latest. Read-back of ghcr.io/public-owner/rateldesk-web:latest failed.' "$work_directory/readback-failure.stderr" + +if MOCK_DOCKER_LOG="$log" MOCK_DOCKER_DIGEST="$digest" FAKE_INSPECT_MISMATCH_TARGET='rateldesk-web:latest' DOCKER_BIN="$mock_docker" \ + "$repository_root/tools/release/promote-stable-images.sh" "$plan" >"$work_directory/readback-mismatch.stdout" 2>"$work_directory/readback-mismatch.stderr"; then + echo "The simulated registry read-back mismatch unexpectedly succeeded." >&2 + exit 1 +fi +grep -Fq 'already updated: ghcr.io/public-owner/rateldesk-api:latest ghcr.io/public-owner/rateldesk-web:latest. ghcr.io/public-owner/rateldesk-web:latest read back as sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' "$work_directory/readback-mismatch.stderr" diff --git a/tools/release/test-validate-stable-promotion.py b/tools/release/test-validate-stable-promotion.py index 637eeb3f..5db487e9 100644 --- a/tools/release/test-validate-stable-promotion.py +++ b/tools/release/test-validate-stable-promotion.py @@ -49,7 +49,7 @@ def setUp(self) -> None: "isPrerelease": False, "assets": [ {"name": name, "digest": f"sha256:{self.manifest['assets'][name]}" if name in self.manifest["assets"] else "sha256:" + "f" * 64} - for name in archives() | {"SHA256SUMS", "release-manifest.json", "release-manifest.json.sha256"} + for name in sorted(archives() | {"SHA256SUMS", "release-manifest.json", "release-manifest.json.sha256"}) ], } self.pages = [[{"tag_name": TAG, "draft": False, "prerelease": False}]] @@ -114,9 +114,11 @@ def test_rejects_missing_asset_altered_checksum_asset_digest_and_older_release(s self.sums_override = f"{'0' * 64} {first_archive}\n" self.assert_rejected("SHA256SUMS does not exactly match") self.sums_override = None - self.release["assets"][0]["digest"] = "sha256:" + "0" * 64 + archive_asset = next(asset for asset in self.release["assets"] if asset["name"] in self.manifest["assets"]) + original_digest = archive_asset["digest"] + archive_asset["digest"] = "sha256:" + "0" * 64 self.assert_rejected("asset digest mismatch") - self.release["assets"][0]["digest"] = f"sha256:{self.manifest['assets'][self.release['assets'][0]['name']]}" if self.release["assets"][0]["name"] in self.manifest["assets"] else "sha256:" + "f" * 64 + archive_asset["digest"] = original_digest self.pages = [[{"tag_name": "v9.0.0", "draft": False, "prerelease": False}], *self.pages] self.assert_rejected("move latest backwards")