diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d49da3..f413f19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,8 +72,8 @@ jobs: exit 1 fi - build: - name: Build mem-mcp binaries + build-mcp: + name: Build mem-mcp (${{ matrix.goos }}/${{ matrix.goarch }}) needs: [preflight] runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -147,9 +147,88 @@ jobs: if-no-files-found: error retention-days: 1 + build-server: + name: Build server (${{ matrix.goos }}/${{ matrix.goarch }}) + needs: [preflight] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + steps: + - name: Check out exact tag commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.preflight.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Revalidate release source + env: + EXPECTED_COMMIT: ${{ needs.preflight.outputs.commit }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + run: | + set -euo pipefail + git fetch --no-tags origin \ + "refs/heads/main:refs/remotes/origin/main" \ + "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + actual_commit="$(./scripts/validate_release_source.sh "${RELEASE_TAG}")" + [[ "${actual_commit}" == "${EXPECTED_COMMIT}" ]] + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + + - name: Build server binaries (${{ matrix.goos }}/${{ matrix.goarch }}) + working-directory: server + env: + EXPECTED_COMMIT: ${{ needs.preflight.outputs.commit }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + run: | + set -euo pipefail + semver="${RELEASE_TAG#v}" + revision="${EXPECTED_COMMIT}" + contract="durable-context.v1" + ldflags="-s -w -buildvcs=true" + ldflags="${ldflags} -X github.com/PeterGuy326/mem/server/internal/api.Version=${semver}" + ldflags="${ldflags} -X github.com/PeterGuy326/mem/server/internal/api.Revision=${revision}" + ldflags="${ldflags} -X github.com/PeterGuy326/mem/server/internal/api.ContractVersion=${contract}" + + mkdir -p "${RUNNER_TEMP}/assets" + suffix="${{ matrix.goos }}-${{ matrix.goarch }}" + + for target in memd mem-migrate mem-healthcheck mem; do + output="${RUNNER_TEMP}/assets/${target}-${suffix}" + CGO_ENABLED=0 GOOS="${{ matrix.goos }}" GOARCH="${{ matrix.goarch }}" \ + go build -buildvcs=true -trimpath \ + -ldflags="${ldflags}" \ + -o "${output}" "./cmd/${target}" + go version -m "${output}" | grep -F "vcs.revision=${revision}" + go version -m "${output}" | grep -F 'vcs.modified=false' + done + + - name: Upload server assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: server-${{ matrix.goos }}-${{ matrix.goarch }} + path: ${{ runner.temp }}/assets/* + if-no-files-found: error + retention-days: 1 + release: name: Create GitHub Release - needs: [preflight, build] + needs: [preflight, build-mcp, build-server] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -181,7 +260,6 @@ jobs: uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: /tmp/release-assets - pattern: mem-mcp-* merge-multiple: true - name: Validate exact assets and generate checksums @@ -209,13 +287,29 @@ jobs: fi assets=( + /tmp/release-assets/memd-darwin-amd64 + /tmp/release-assets/memd-darwin-arm64 + /tmp/release-assets/memd-linux-amd64 + /tmp/release-assets/memd-linux-arm64 + /tmp/release-assets/mem-migrate-darwin-amd64 + /tmp/release-assets/mem-migrate-darwin-arm64 + /tmp/release-assets/mem-migrate-linux-amd64 + /tmp/release-assets/mem-migrate-linux-arm64 + /tmp/release-assets/mem-healthcheck-darwin-amd64 + /tmp/release-assets/mem-healthcheck-darwin-arm64 + /tmp/release-assets/mem-healthcheck-linux-amd64 + /tmp/release-assets/mem-healthcheck-linux-arm64 + /tmp/release-assets/mem-darwin-amd64 + /tmp/release-assets/mem-darwin-arm64 + /tmp/release-assets/mem-linux-amd64 + /tmp/release-assets/mem-linux-arm64 /tmp/release-assets/mem-mcp-darwin-amd64 /tmp/release-assets/mem-mcp-darwin-arm64 /tmp/release-assets/mem-mcp-linux-amd64 /tmp/release-assets/mem-mcp-linux-arm64 /tmp/release-assets/mem-mcp-windows-amd64.exe /tmp/release-assets/mem-mcp-windows-arm64.exe - /tmp/release-assets/mem-mcp-checksums.txt + /tmp/release-assets/mem-checksums.txt ) release_flags=() if [[ "${RELEASE_TAG}" == *-rc.* ]]; then @@ -244,21 +338,38 @@ jobs: jq -e --arg tag "${RELEASE_TAG}" ' .tagName == $tag and .isDraft == true and - (.assets | length == 7) and + (.assets | length == 23) and all(.assets[]; ((.size | type) == "number") and (.size > 0)) ' <<< "${release_json}" >/dev/null expected_assets="$(printf '%s\n' \ + mem-checksums.txt \ + mem-darwin-amd64 \ + mem-darwin-arm64 \ + mem-linux-amd64 \ + mem-linux-arm64 \ + mem-healthcheck-darwin-amd64 \ + mem-healthcheck-darwin-arm64 \ + mem-healthcheck-linux-amd64 \ + mem-healthcheck-linux-arm64 \ mem-mcp-checksums.txt \ mem-mcp-darwin-amd64 \ mem-mcp-darwin-arm64 \ mem-mcp-linux-amd64 \ mem-mcp-linux-arm64 \ mem-mcp-windows-amd64.exe \ - mem-mcp-windows-arm64.exe | LC_ALL=C sort)" + mem-mcp-windows-arm64.exe \ + mem-migrate-darwin-amd64 \ + mem-migrate-darwin-arm64 \ + mem-migrate-linux-amd64 \ + mem-migrate-linux-arm64 \ + memd-darwin-amd64 \ + memd-darwin-arm64 \ + memd-linux-amd64 \ + memd-linux-arm64 | LC_ALL=C sort)" actual_assets="$(jq -r '.assets[].name' <<< "${release_json}" | LC_ALL=C sort)" if [[ "${actual_assets}" != "${expected_assets}" ]]; then - echo "draft Release asset inventory does not match the expected seven files" >&2 + echo "draft Release asset inventory does not match the expected files" >&2 printf 'expected:\n%s\nactual:\n%s\n' "${expected_assets}" "${actual_assets}" >&2 exit 1 fi @@ -283,17 +394,34 @@ jobs: jq -e --arg tag "${RELEASE_TAG}" ' .tagName == $tag and .isDraft == true and - (.assets | length == 7) and + (.assets | length == 23) and all(.assets[]; ((.size | type) == "number") and (.size > 0)) ' <<< "${release_json}" >/dev/null expected_assets="$(printf '%s\n' \ + mem-checksums.txt \ + mem-darwin-amd64 \ + mem-darwin-arm64 \ + mem-linux-amd64 \ + mem-linux-arm64 \ + mem-healthcheck-darwin-amd64 \ + mem-healthcheck-darwin-arm64 \ + mem-healthcheck-linux-amd64 \ + mem-healthcheck-linux-arm64 \ mem-mcp-checksums.txt \ mem-mcp-darwin-amd64 \ mem-mcp-darwin-arm64 \ mem-mcp-linux-amd64 \ mem-mcp-linux-arm64 \ mem-mcp-windows-amd64.exe \ - mem-mcp-windows-arm64.exe | LC_ALL=C sort)" + mem-mcp-windows-arm64.exe \ + mem-migrate-darwin-amd64 \ + mem-migrate-darwin-arm64 \ + mem-migrate-linux-amd64 \ + mem-migrate-linux-arm64 \ + memd-darwin-amd64 \ + memd-darwin-arm64 \ + memd-linux-amd64 \ + memd-linux-arm64 | LC_ALL=C sort)" actual_assets="$(jq -r '.assets[].name' <<< "${release_json}" | LC_ALL=C sort)" [[ "${actual_assets}" == "${expected_assets}" ]] diff --git a/CHANGELOG.md b/CHANGELOG.md index 583287f..f82a34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ## [Unreleased] +### Added + +- Publish installable server binaries (`memd`, `mem-migrate`, `mem-healthcheck`, + `mem`) alongside `mem-mcp` in the release workflow for `darwin/arm64`, + `darwin/amd64`, `linux/arm64` and `linux/amd64`, with per-group checksum + manifests. +- `/v1/version` now exposes `version` (semver), `revision` (40-hex git commit) + and `contract` (durable-context wire contract) as distinct fields, so clients + can pin a revision or accept a version range without a third mechanism. Both + the release workflow and the Docker image inject all three at build time. +- Documented first-run path in `docs/DEPLOYMENT.md` that yields a reachable + endpoint, a workspace, a token with write and recall scopes, and the + corresponding durable-context grant. + ### Changed - Migrate GitHub repository, Release, issue, badge, and raw-content coordinates diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 83acc7a..a2e659d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -65,10 +65,12 @@ model-free Worker; optional heavy extras must be explicitly selected. ```bash export MEM_VERSION=0.1.1 +export MEM_REVISION="$(git rev-parse HEAD)" export MEM_REGISTRY=registry.example.internal/mem docker build \ --build-arg VERSION="$MEM_VERSION" \ + --build-arg REVISION="$MEM_REVISION" \ -t "$MEM_REGISTRY/server:$MEM_VERSION" server docker build \ -t "$MEM_REGISTRY/worker:$MEM_VERSION" worker @@ -96,6 +98,117 @@ MEM_VALIDATE_BUILD_IMAGES=1 make test-deploy The first command validates Compose and Helm. The second also builds all three images from the current checkout. +## Version coordinate and client preflight + +Every release publishes a single version grammar that clients pin against. The +`/v1/version` endpoint returns three distinct fields: + +| Field | Example | Meaning | +| --- | --- | --- | +| `version` | `"0.1.1"` | Semver release tag (without the `v` prefix) | +| `revision` | `"10d4bf7a48fd5ab0ce6fc67caa407a717f81830e"` | 40-hex git commit the binary was built from | +| `contract` | `"durable-context.v1"` | Durable-context wire contract the server speaks | + +Both build paths — the release workflow and the Docker image — inject all three +fields at build time via `-ldflags`. A binary produced by either path answers +`/v1/version` with the same shape. A client may pin either the `revision` (exact +commit) or accept a `version` range; the `contract` field is informational and +changes only when the durable-context wire format breaks compatibility. + +The release workflow verifies that every published binary embeds the exact +release commit via `go version -m`. The Dockerfile accepts `VERSION`, `REVISION` +and `CONTRACT_VERSION` build args; the Compose and Helm deployment paths pass +the release tag as `VERSION` and the tag commit as `REVISION`. + +No step in the documented deployment path requires hand-editing a build flag to +become compatible with a pinned client. + +## First-run path + +After starting memd from a release artifact (Compose, Docker image or bare +binary), complete these steps to reach a working endpoint with a workspace, a +token and the scopes needed for both write and recall operations. + +### 1. Verify the server is reachable + +```bash +curl --fail "$(mem config get server)/healthz" +curl --fail "$(mem config get server)/v1/version" +``` + +The `/v1/version` response must contain `version`, `revision` and `contract` +fields. If the endpoint is unreachable, the server is not running or the +configured URL is wrong. Run `mem doctor` to diagnose common preconditions. + +### 2. Register the first user + +With `MEM_REGISTRATION_MODE=first_user` (the Compose default), the first +registration creates the owner account and disables further registration +automatically: + +```bash +mem auth login +``` + +Follow the interactive prompt. The CLI saves the session token to +`~/.mem/config.yaml`. Verify with: + +```bash +mem auth status +``` + +### 3. Create an API token with write and recall scopes + +The durable-context recall endpoint requires a token whose scopes cover both +`write` and `read`. Create one: + +```bash +mem auth token create \ + --name "digital-employee" \ + --scope "read,write" +``` + +Store the returned token. It is shown exactly once. + +### 4. Create a durable-context grant + +Recall operations require an admin-created grant that binds a principal to an +approved set of memories. From the admin session: + +```bash +curl -X POST "$(mem config get server)/v1/durable-context/grants" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"contract":"durable-context.v1","principal":"digital-employee","memory_ids":[""]}' +``` + +The grant ties the principal name to the specific memories the recall scope is +allowed to read. Without this grant, recall returns `scope_denied` even with a +valid token. + +### 5. Verify end-to-end + +With the token and grant in place, a client adapter can complete one write and +one recall: + +```bash +# Write a memory +curl -X POST "$(mem config get server)/v1/memories" \ + -H "Authorization: Bearer ${API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"content":"test memory","tags":["smoke-test"]}' + +# Recall durable context +curl -X POST "$(mem config get server)/v1/durable-context/recall" \ + -H "Authorization: Bearer ${API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"contract":"durable-context.v1","principal":"digital-employee"}' +``` + +If any step fails with a named precondition error (`scope_denied`, +`contract_unsupported`, `registration_disabled`), the error message identifies +the missing configuration rather than a generic connection failure. + ## Single-node Compose ### Host and network diff --git a/scripts/generate_release_checksums.sh b/scripts/generate_release_checksums.sh index 4fb27fd..80b43ba 100755 --- a/scripts/generate_release_checksums.sh +++ b/scripts/generate_release_checksums.sh @@ -4,7 +4,6 @@ set -euo pipefail tag="${1:-}" commit="${2:-}" asset_dir="${3:-}" -output="${asset_dir}/mem-mcp-checksums.txt" die() { printf 'ERROR: %s\n' "$*" >&2 @@ -17,7 +16,7 @@ fi [[ "${commit}" =~ ^[0-9a-f]{40}$ ]] || die "invalid release commit: ${commit:-}" [[ -d "${asset_dir}" ]] || die "asset directory does not exist: ${asset_dir:-}" -assets=( +mcp_assets=( mem-mcp-darwin-amd64 mem-mcp-darwin-arm64 mem-mcp-linux-amd64 @@ -26,43 +25,72 @@ assets=( mem-mcp-windows-arm64.exe ) +server_assets=( + memd-darwin-amd64 + memd-darwin-arm64 + memd-linux-amd64 + memd-linux-arm64 + mem-migrate-darwin-amd64 + mem-migrate-darwin-arm64 + mem-migrate-linux-amd64 + mem-migrate-linux-arm64 + mem-healthcheck-darwin-amd64 + mem-healthcheck-darwin-arm64 + mem-healthcheck-linux-amd64 + mem-healthcheck-linux-arm64 + mem-darwin-amd64 + mem-darwin-arm64 + mem-linux-amd64 + mem-linux-arm64 +) + +all_assets=() +for a in "${mcp_assets[@]}" "${server_assets[@]}"; do + all_assets[${#all_assets[@]}]="${a}" +done + actual_assets=() while IFS= read -r actual_asset; do actual_assets[${#actual_assets[@]}]="${actual_asset}" done < <( find "${asset_dir}" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort ) -if [[ "${actual_assets[*]}" != "${assets[*]}" ]]; then +expected_sorted="$(printf '%s\n' "${all_assets[@]}" | LC_ALL=C sort)" +actual_sorted="$(printf '%s\n' "${actual_assets[@]}" | LC_ALL=C sort)" +if [[ "${actual_sorted}" != "${expected_sorted}" ]]; then printf 'ERROR: release assets differ from the exact expected set\n' >&2 - printf 'expected: %s\n' "${assets[*]}" >&2 - printf 'actual: %s\n' "${actual_assets[*]:-}" >&2 + printf 'expected:\n%s\n' "${expected_sorted}" >&2 + printf 'actual:\n%s\n' "${actual_sorted:-}" >&2 exit 1 fi -for asset in "${assets[@]}"; do +for asset in "${all_assets[@]}"; do [[ -f "${asset_dir}/${asset}" && ! -L "${asset_dir}/${asset}" ]] || die "release asset is not a regular file: ${asset}" [[ -s "${asset_dir}/${asset}" ]] || die "release asset is empty: ${asset}" done -tmp_output="$(mktemp "${asset_dir}/.mem-mcp-checksums.XXXXXX")" -cleanup() { - rm -f -- "${tmp_output}" -} -trap cleanup EXIT - -{ +generate_checksums() { + local output_name="$1" + shift + local assets=("$@") + local tmp_output + tmp_output="$(mktemp "${asset_dir}/.${output_name}.XXXXXX")" ( cd -- "${asset_dir}" sha256sum "${assets[@]}" - ) -} > "${tmp_output}" -mv -- "${tmp_output}" "${output}" -trap - EXIT + ) > "${tmp_output}" + mv -- "${tmp_output}" "${asset_dir}/${output_name}" +} + +generate_checksums "mem-mcp-checksums.txt" "${mcp_assets[@]}" +generate_checksums "mem-checksums.txt" "${server_assets[@]}" ( cd -- "${asset_dir}" - sha256sum --check --strict --ignore-missing "$(basename -- "${output}")" + sha256sum --check --strict mem-mcp-checksums.txt + sha256sum --check --strict mem-checksums.txt ) -printf 'PASS: checksums bind six release assets to %s at %s\n' "${tag}" "${commit}" +printf 'PASS: checksums bind %d release assets to %s at %s\n' \ + "${#all_assets[@]}" "${tag}" "${commit}" diff --git a/scripts/test_release_guards.sh b/scripts/test_release_guards.sh index 4366a82..4f2c5ca 100755 --- a/scripts/test_release_guards.sh +++ b/scripts/test_release_guards.sh @@ -66,7 +66,7 @@ source_validator_line="$( die "release workflow must contain exactly one Release creation call" [[ "$(grep -Fc -- 'gh release edit' "${release_workflow}" || true)" == 1 ]] || die "release workflow must contain exactly one Release publication call" -grep -Fq -- 'needs: [preflight, build]' "${release_workflow}" || +grep -Fq -- 'needs: [preflight, build-mcp, build-server]' "${release_workflow}" || die "Release creation must depend on preflight and every build" grep -Fq -- '--verify-tag' "${release_workflow}" || die "Release creation must refuse an absent remote tag" @@ -74,8 +74,8 @@ grep -Eq -- '^[[:space:]]+--draft([[:space:]]|$)' "${release_workflow}" || die "Release assets must first upload to a draft" grep -Fq -- '--draft=false' "${release_workflow}" || die "the verified draft must be published explicitly" -grep -Fq -- '(.assets | length == 7)' "${release_workflow}" || - die "remote draft validation must require exactly seven assets" +grep -Fq -- '(.assets | length == 23)' "${release_workflow}" || + die "remote draft validation must require exactly 23 assets" grep -Fq -- '(.size > 0)' "${release_workflow}" || die "remote draft validation must reject empty assets" @@ -193,6 +193,22 @@ expect_failure "annotated tag version mismatch" env \ asset_dir="${tmp_dir}/assets" mkdir -p -- "${asset_dir}" assets=( + memd-darwin-amd64 + memd-darwin-arm64 + memd-linux-amd64 + memd-linux-arm64 + mem-migrate-darwin-amd64 + mem-migrate-darwin-arm64 + mem-migrate-linux-amd64 + mem-migrate-linux-arm64 + mem-healthcheck-darwin-amd64 + mem-healthcheck-darwin-arm64 + mem-healthcheck-linux-amd64 + mem-healthcheck-linux-arm64 + mem-darwin-amd64 + mem-darwin-arm64 + mem-linux-amd64 + mem-linux-arm64 mem-mcp-darwin-amd64 mem-mcp-darwin-arm64 mem-mcp-linux-amd64 @@ -206,17 +222,20 @@ done "${repo_root}/scripts/generate_release_checksums.sh" \ "${current_tag}" "${same_commit}" "${asset_dir}" >/dev/null -manifest="${asset_dir}/mem-mcp-checksums.txt" -[[ "$(wc -l < "${manifest}")" == 6 ]] || die "checksum manifest must have six rows" +mcp_manifest="${asset_dir}/mem-mcp-checksums.txt" +server_manifest="${asset_dir}/mem-checksums.txt" +[[ "$(wc -l < "${mcp_manifest}")" == 6 ]] || die "mcp checksum manifest must have six rows" +[[ "$(wc -l < "${server_manifest}")" == 16 ]] || die "server checksum manifest must have 16 rows" ( cd -- "${asset_dir}" - sha256sum --check --strict "$(basename -- "${manifest}")" >/dev/null + sha256sum --check --strict "$(basename -- "${mcp_manifest}")" >/dev/null + sha256sum --check --strict "$(basename -- "${server_manifest}")" >/dev/null ) printf 'tampered\n' >> "${asset_dir}/${assets[0]}" expect_failure "tampered asset" verify_manifest "${asset_dir}" -rm -f -- "${manifest}" "${asset_dir}/${assets[0]}" +rm -f -- "${mcp_manifest}" "${server_manifest}" "${asset_dir}/${assets[0]}" expect_failure "missing asset" \ "${repo_root}/scripts/generate_release_checksums.sh" \ "${current_tag}" "${same_commit}" "${asset_dir}" diff --git a/scripts/test_release_helpers_compat.sh b/scripts/test_release_helpers_compat.sh index 0757d1f..fb36686 100755 --- a/scripts/test_release_helpers_compat.sh +++ b/scripts/test_release_helpers_compat.sh @@ -30,6 +30,22 @@ fi asset_dir="${tmp_dir}/assets" mkdir -p -- "${asset_dir}" assets=( + memd-darwin-amd64 + memd-darwin-arm64 + memd-linux-amd64 + memd-linux-arm64 + mem-migrate-darwin-amd64 + mem-migrate-darwin-arm64 + mem-migrate-linux-amd64 + mem-migrate-linux-arm64 + mem-healthcheck-darwin-amd64 + mem-healthcheck-darwin-arm64 + mem-healthcheck-linux-amd64 + mem-healthcheck-linux-arm64 + mem-darwin-amd64 + mem-darwin-arm64 + mem-linux-amd64 + mem-linux-arm64 mem-mcp-darwin-amd64 mem-mcp-darwin-arm64 mem-mcp-linux-amd64 @@ -76,5 +92,6 @@ if ! ( fi [[ "$(wc -l < "${asset_dir}/mem-mcp-checksums.txt")" == 6 ]] +[[ "$(wc -l < "${asset_dir}/mem-checksums.txt")" == 16 ]] printf 'PASS: release version and checksum helpers run without Bash 4-only collection builtins\n' diff --git a/server/Dockerfile b/server/Dockerfile index 41a3ed7..2cf902b 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -9,7 +9,13 @@ ARG GOPROXY=https://proxy.golang.org,direct RUN GOPROXY="${GOPROXY}" go mod download COPY . . ARG VERSION=dev -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X github.com/PeterGuy326/mem/server/internal/api.Version=${VERSION}" \ +ARG REVISION=unknown +ARG CONTRACT_VERSION=durable-context.v1 +RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w \ + -X github.com/PeterGuy326/mem/server/internal/api.Version=${VERSION} \ + -X github.com/PeterGuy326/mem/server/internal/api.Revision=${REVISION} \ + -X github.com/PeterGuy326/mem/server/internal/api.ContractVersion=${CONTRACT_VERSION}" \ -o /out/memd ./cmd/memd && \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \ -o /out/mem-migrate ./cmd/mem-migrate && \ diff --git a/server/cmd/mem/cmds_file.go b/server/cmd/mem/cmds_file.go index 37f30a1..a8bc0c5 100644 --- a/server/cmd/mem/cmds_file.go +++ b/server/cmd/mem/cmds_file.go @@ -496,10 +496,13 @@ func newVersionCmd() *cobra.Command { if cfg != nil && cfg.Server != "" { c := newHTTPClient(cfg) var resp struct { - Version string `json:"version"` + Version string `json:"version"` + Revision string `json:"revision"` + Contract string `json:"contract"` } if err := c.doJSON(http.MethodGet, "/v1/version", nil, &resp); err == nil { - fmt.Printf("server: %s (%s)\n", resp.Version, cfg.Server) + fmt.Printf("server: %s (revision %s, contract %s, %s)\n", + resp.Version, resp.Revision, resp.Contract, cfg.Server) } } return nil diff --git a/server/cmd/memd/main.go b/server/cmd/memd/main.go index 37cb47d..01e061f 100644 --- a/server/cmd/memd/main.go +++ b/server/cmd/memd/main.go @@ -70,6 +70,8 @@ func run() error { "s3_endpoint", cfg.S3Endpoint, "s3_bucket", cfg.S3Bucket, "version", api.Version, + "revision", api.Revision, + "contract", api.ContractVersion, "workspace_bundle_max_bytes", cfg.WorkspaceBundleMaxBytes, "workspace_transfer_max_concurrent", cfg.WorkspaceTransferMaxConcurrent, "workspace_transfer_timeout", cfg.WorkspaceTransferTimeout, diff --git a/server/internal/api/api.go b/server/internal/api/api.go index e8c6d07..34679ff 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -50,8 +50,21 @@ import ( "github.com/PeterGuy326/mem/server/internal/workspacetransfer" ) -// Version is overridden by ldflags at release-build time. -var Version = "dev" +// Version coordinate set for client/server preflight (mem#151). +// All three are overridden by ldflags at build time: +// +// -X github.com/PeterGuy326/mem/server/internal/api.Version=${SEMVER} +// -X github.com/PeterGuy326/mem/server/internal/api.Revision=${GIT_COMMIT} +// -X github.com/PeterGuy326/mem/server/internal/api.ContractVersion=${CONTRACT} +// +// Version is the semver release tag (e.g. "0.1.1"). +// Revision is the 40-hex git commit the binary was built from. +// ContractVersion is the durable-context wire contract the server speaks. +var ( + Version = "dev" + Revision = "unknown" + ContractVersion = "durable-context.v1" +) // MemoryService is the write/read port used by HTTP handlers. Keeping the // handlers behind an interface makes authorization and error mapping testable @@ -209,7 +222,11 @@ func (s *Server) Router() http.Handler { }) r.Get("/readyz", s.handleReadiness) r.Get("/v1/version", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{"version": Version}) + writeJSON(w, http.StatusOK, map[string]any{ + "version": Version, + "revision": Revision, + "contract": ContractVersion, + }) }) // Public auth diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index a81782d..a38032f 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "log/slog" "net/http" "net/http/httptest" @@ -79,3 +80,41 @@ func TestCORSDisabledByDefault(t *testing.T) { t.Fatalf("CORS should be off when unconfigured, got Allow-Origin %q", got) } } + +func TestVersionEndpointExposesAllCoordinates(t *testing.T) { + prev_version := Version + prev_revision := Revision + prev_contract := ContractVersion + t.Cleanup(func() { + Version = prev_version + Revision = prev_revision + ContractVersion = prev_contract + }) + Version = "0.2.0" + Revision = "abcdef0123456789abcdef0123456789abcdef01" + ContractVersion = "durable-context.v1" + + s := &Server{Auth: auth.New(nil), Log: slog.Default()} + h := s.Router() + + req := httptest.NewRequest(http.MethodGet, "/v1/version", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["version"] != "0.2.0" { + t.Errorf("version = %q, want %q", got["version"], "0.2.0") + } + if got["revision"] != "abcdef0123456789abcdef0123456789abcdef01" { + t.Errorf("revision = %q, want 40-hex commit", got["revision"]) + } + if got["contract"] != "durable-context.v1" { + t.Errorf("contract = %q, want %q", got["contract"], "durable-context.v1") + } +}