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
70 changes: 57 additions & 13 deletions .github/workflows/promote-stable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,50 @@ 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 }}
TAG: ${{ inputs.tag }}
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:
GH_TOKEN: ${{ github.token }}
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", "")
Expand All @@ -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 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
Expand All @@ -85,22 +104,47 @@ 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:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
source_revision="$(<source-revision.txt)"
gh api --paginate --slurp "repos/${GH_REPO}/releases?per_page=100" > 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 \
--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: tools/release/promote-stable-images.sh promotion-plan.json

- name: Summarize immutable promotion
run: |
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/pull-request-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ 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
tools/release/test-promote-stable-images.sh

- name: Set up .NET SDK from global.json
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
Expand All @@ -202,6 +207,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

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ mono_crash.*
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
!tools/
!tools/release/
!tools/release/*.py
!tools/release/*.sh
x64/
x86/
[Ww][Ii][Nn]32/
Expand Down
4 changes: 2 additions & 2 deletions docker/docker-compose.mcp.gateway.release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docker/docker-compose.mcp.gateway.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docker/examples/mcp-http/.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 16 additions & 5 deletions docker/examples/mcp-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,31 @@ 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
`mcp-config-init` service reads the host file as root, copies it into a named
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
```

Expand All @@ -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:

Expand Down
13 changes: 12 additions & 1 deletion docs/releases.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Expand Down
Loading
Loading