From 47798e296f966499dab67b2bb5397cc9b44e77c6 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 23:39:45 -0700 Subject: [PATCH 1/2] Restore independent Dex release pins for 0.5.1 --- .github/workflows/dex-release-upgrade.yml | 59 ------ .github/workflows/github-release-ui.yml | 68 ------ .gitignore | 1 + CONTRIBUTING.md | 22 +- Makefile | 7 +- README.md | 2 +- dex-release.lock.json | 23 --- ...rk-and-input-consumption-reconciliation.md | 2 +- docs/adr/0014-registered-rpc-options.md | 9 +- docs/dex-v0.10-upgrade.md | 24 +-- docs/flow-model.md | 6 +- go.mod | 3 +- go.sum | 4 +- script/check_dex_release.py | 133 ------------ script/check_dex_versions.py | 74 +++++++ script/update_dex_release.py | 174 ---------------- script/update_dex_release_test.py | 195 ------------------ script/update_dex_versions.py | 117 +++++++++++ script/update_dex_versions_test.py | 98 +++++++++ 19 files changed, 323 insertions(+), 698 deletions(-) delete mode 100644 .github/workflows/dex-release-upgrade.yml delete mode 100644 dex-release.lock.json delete mode 100644 script/check_dex_release.py create mode 100644 script/check_dex_versions.py delete mode 100644 script/update_dex_release.py delete mode 100644 script/update_dex_release_test.py create mode 100644 script/update_dex_versions.py create mode 100644 script/update_dex_versions_test.py diff --git a/.github/workflows/dex-release-upgrade.yml b/.github/workflows/dex-release-upgrade.yml deleted file mode 100644 index a1c8ef7..0000000 --- a/.github/workflows/dex-release-upgrade.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Prepare Dex release upgrade - -on: - repository_dispatch: - types: [dex-release-published] - -permissions: - contents: read - -concurrency: - group: superagent-dex-${{ github.event.client_payload.version }} - cancel-in-progress: false - -jobs: - upgrade: - if: github.event.sender.type == 'Bot' - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - name: Create repository-scoped release automation token - id: app-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ vars.RELEASE_AUTOMATION_APP_ID }} - private-key: ${{ secrets.RELEASE_AUTOMATION_PRIVATE_KEY }} - owner: superdurable - repositories: superagent - - uses: actions/checkout@v7 - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - name: Update immutable Dex release pins - env: - DEX_MANIFEST_SHA256: ${{ github.event.client_payload.manifest_sha256 }} - DEX_MANIFEST_URL: ${{ github.event.client_payload.manifest_url }} - run: | - python3 script/update_dex_release.py \ - --manifest-url "${DEX_MANIFEST_URL}" \ - --manifest-sha256 "${DEX_MANIFEST_SHA256}" - GOWORK=off go mod tidy - python3 -m unittest script/update_dex_release_test.py - python3 script/check_dex_release.py - - name: Open draft upgrade pull request before compilation - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ steps.app-token.outputs.token }} - branch: automation/dex-v${{ github.event.client_payload.version }} - delete-branch: true - draft: true - commit-message: Upgrade SuperAgent to Dex ${{ github.event.client_payload.version }} - title: Upgrade SuperAgent to Dex ${{ github.event.client_payload.version }} - body: | - Automated from the immutable Dex compatibility manifest. - - Review `openFlowsCompatibility` before merging. It defaults to - `cancel-required`; automation never claims open-Flow compatibility. - - Normal pull-request CI owns compilation and tests. When a Dex SDK - API breaks, continue the required migration in this draft PR. diff --git a/.github/workflows/github-release-ui.yml b/.github/workflows/github-release-ui.yml index 1785310..6f6cdef 100644 --- a/.github/workflows/github-release-ui.yml +++ b/.github/workflows/github-release-ui.yml @@ -106,71 +106,3 @@ jobs: install_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/${asset_name}" echo "Install with: npm install ${install_url} react" >> "${GITHUB_STEP_SUMMARY}" - - notify-downstreams: - name: Request IaC and SuperVerse upgrades - if: github.event_name == 'release' - needs: attach - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.event.release.tag_name }} - - name: Validate release compatibility declaration - id: release - env: - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - python3 script/check_dex_release.py - python3 - "${RELEASE_TAG}" "${GITHUB_OUTPUT}" <<'PY' - import json - from pathlib import Path - import re - import subprocess - import sys - - tag, output_path = sys.argv[1:] - if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag) is None: - raise SystemExit("SuperAgent release tag is not stable semver") - lock = json.loads(Path("dex-release.lock.json").read_text(encoding="utf-8")) - commit = subprocess.run( - ["git", "rev-parse", "HEAD"], check=True, capture_output=True, text=True - ).stdout.strip() - with Path(output_path).open("a", encoding="utf-8") as output: - for name, value in { - "dex_version": lock["release"], - "manifest_url": lock["manifest"]["url"], - "manifest_sha256": lock["manifest"]["sha256"], - "open_flows_compatibility": lock["openFlowsCompatibility"], - "superagent_commit": commit, - }.items(): - print(f"{name}={value}", file=output) - PY - - name: Create repository-scoped release automation token - id: app-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ vars.RELEASE_AUTOMATION_APP_ID }} - private-key: ${{ secrets.RELEASE_AUTOMATION_PRIVATE_KEY }} - owner: superdurable - repositories: iac,superverse - - name: Dispatch audited downstream upgrades - env: - DEX_MANIFEST_SHA256: ${{ steps.release.outputs.manifest_sha256 }} - DEX_MANIFEST_URL: ${{ steps.release.outputs.manifest_url }} - DEX_VERSION: ${{ steps.release.outputs.dex_version }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - OPEN_FLOWS_COMPATIBILITY: ${{ steps.release.outputs.open_flows_compatibility }} - SUPERAGENT_COMMIT: ${{ steps.release.outputs.superagent_commit }} - SUPERAGENT_RELEASE: ${{ github.event.release.tag_name }} - run: | - for repository in iac superverse; do - gh api --method POST "repos/superdurable/${repository}/dispatches" \ - -f event_type=superagent-release-published \ - -f "client_payload[dex_version]=${DEX_VERSION}" \ - -f "client_payload[manifest_url]=${DEX_MANIFEST_URL}" \ - -f "client_payload[manifest_sha256]=${DEX_MANIFEST_SHA256}" \ - -f "client_payload[superagent_release]=${SUPERAGENT_RELEASE}" \ - -f "client_payload[superagent_commit]=${SUPERAGENT_COMMIT}" \ - -f "client_payload[open_flows_compatibility]=${OPEN_FLOWS_COMPATIBILITY}" - done diff --git a/.gitignore b/.gitignore index 34d339c..4e34d46 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ /web/packages/*/node_modules/ /web/playwright-report/ /web/test-results/ +__pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d038827..b897ce6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,25 +12,17 @@ against the installed released SDK and a version-matched runnable example or real-server compile-contract test. Snapshot, Stream, Channel size snapshot, and Attribute wait code target Dex Go -SDK `v0.9.1` and Server `v0.10.0`. Workers negotiate the Server protocol before -binding, so deployments upgrade the Server before the Worker. Recheck +SDK `v0.10.0`. Workers negotiate the Server protocol before binding. Recheck the installed SDK source and the installed skill before changing resource projection or errors. Never infer an API from a design screenshot or unreleased branch. -`dex-release.lock.json` binds the Server to its immutable compatibility manifest. -It binds an independently released Go SDK patch to its tag, source commit, and -Go module checksums. For a Server-only upgrade, run -`script/update_dex_release.py` with `--server-only`, `--manifest-url`, and -`--manifest-sha256`. This preserves either SDK lock form and requires the SDK -and Server protocol intervals to overlap. Dex Go SDK `v0.9.1` registers RPCs -explicitly and fixes their execution options at Flow registration. -A Dex publication opens an automated upgrade PR with open Flow -compatibility set to `cancel-required` for review. Publishing the subsequent -SuperAgent release dispatches the reviewed IaC and SuperVerse upgrades. -The automation opens the draft after asset validation and mechanical pin -updates, before product compilation. Resolve breaking SDK API migrations in -that draft; normal pull-request CI remains the merge gate. +Dex Go SDK and dexcli are independent direct dependencies. Run +`script/update_dex_versions.py` with explicit component versions, then run +`script/check_dex_versions.py`. The updater reads dexcli's native +`checksums.txt`; SuperAgent does not consume a cross-component compatibility +manifest. Resolve SDK API changes in the same pull request. Normal compilation, +real-Server integration, and browser E2E are the merge gates. ## Deployment boundary diff --git a/Makefile b/Makefile index 5dbf30e..5d249dd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: audit-web build-api build-web check check-agent-rules check-flow-definition \ +.PHONY: audit-web build-api build-web check check-agent-rules check-dex-versions check-flow-definition \ check-generated copyright-check flow-visualize format-check fuzz generate \ generate-go generate-web governance-check install-dexcli install-osv-scanner install-temporal lint lint-go lint-web lint-workflows \ test test-agent test-api test-app test-config test-dex-integration test-mcp test-model test-openai-live \ @@ -48,6 +48,9 @@ copyright-check: governance-check: check-agent-rules copyright-check +check-dex-versions: + @python3 script/check_dex_versions.py + install-dexcli: $(DEXCLI_BINARY) $(DEXCLI_BINARY): script/install-dexcli.sh @@ -168,4 +171,4 @@ test-web: test-openai-live: @GOCACHE=$(GO_BUILD_CACHE) GOWORK=off go test -tags=live -count=1 -run '^TestLiveOpenAIResponses$$' ./internal/model -check: governance-check check-generated format-check build-api build-web vet lint test test-race test-web vulnerability-check audit-web +check: governance-check check-dex-versions check-generated format-check build-api build-web vet lint test test-race test-web vulnerability-check audit-web diff --git a/README.md b/README.md index e24aa5d..7ba7035 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ make build-api make build-web ``` -Start a compatible Dex server. Dex Go SDK `v0.9.1` Workers require the Server +Start a compatible Dex server. Dex Go SDK `v0.10.0` Workers require the Server compatibility RPC, so upgrade the Server before the Worker. Then run the API and Worker: diff --git a/dex-release.lock.json b/dex-release.lock.json deleted file mode 100644 index 2064404..0000000 --- a/dex-release.lock.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "schemaVersion": 1, - "release": "0.10.0", - "manifest": { - "url": "https://github.com/superdurable/dex/releases/download/server/v0.10.0/dex-compatibility-v0.10.0.json", - "sha256": "dec8dd6d9a3734e8afbf14acfb740830dc63c361d4957d1fa9f4810a61f311d9" - }, - "sourceCommit": "90dbc4ef121d575e4f79505cde0889cf49583fc2", - "sdkGoVersion": "0.9.1", - "protocol": { - "minimum": 1, - "maximum": 1 - }, - "runningFlowsCompatibility": "compatible", - "persistenceCompatibility": "compatible", - "openFlowsCompatibility": "cancel-required", - "sdkGoRelease": { - "tag": "sdk-go/v0.9.1", - "sourceCommit": "81e0ddf014b40065f23cd1e9ada8849aa55df4ff", - "moduleChecksum": "h1:j7q+gpS1E8i0JvrR1gmpBUnWBHDZr6iTqYAOiwPzC2U=", - "goModChecksum": "h1:8Wj5wPf9dyb7hDnA40j8xISR/zjhX57NrUDVcXgf5x8=" - } -} diff --git a/docs/adr/0012-watermark-and-input-consumption-reconciliation.md b/docs/adr/0012-watermark-and-input-consumption-reconciliation.md index f59f9d5..906f9bf 100644 --- a/docs/adr/0012-watermark-and-input-consumption-reconciliation.md +++ b/docs/adr/0012-watermark-and-input-consumption-reconciliation.md @@ -75,7 +75,7 @@ Consumed IDs suppress stale queue data until durable history replaces the temporary projection. Snapshot remains the only authoritative durable reconciliation model. -Deployments must use Dex Server `v0.10.0` and Go SDK `v0.9.1`, and the matching +Deployments must use Dex Server `v0.10.0` and Go SDK `v0.10.0`, and the matching Worker and browser behavior together. The Server must be upgraded first because Workers reject Servers without protocol negotiation. Deployments must stop or clear Agent Flows created with the removed schema before rollout; there is no diff --git a/docs/adr/0014-registered-rpc-options.md b/docs/adr/0014-registered-rpc-options.md index 45f1a32..9beb620 100644 --- a/docs/adr/0014-registered-rpc-options.md +++ b/docs/adr/0014-registered-rpc-options.md @@ -6,7 +6,7 @@ Accepted on 2026-09-17. ## Context -Dex Go SDK `v0.9.1` replaces reflected RPC discovery with explicit `GetRPCs` +Dex Go SDK `v0.10.0` replaces reflected RPC discovery with explicit `GetRPCs` definitions. Timeout, locks, transactional execution, and selective collection loads belong to the registered RPC definition. Callers can impose a shorter context deadline, but cannot change those options per invocation. @@ -28,9 +28,8 @@ only the requested immutable ten-message chunk. It still excludes current messages and pending Channels. Retention remains the bound on loaded archive state. Integration-only RPCs use the same explicit registration contract. -The release lock records Server `v0.10.0` through its compatibility manifest. -Because `sdk-go/v0.9.1` is an SDK-only release without a Server manifest, the -lock records its tag, source commit, and Go module checksums separately. +The Go SDK and dexcli use their native version pins and checksums. Component +upgrades use ordinary pull requests and application validation. ## Consequences @@ -39,7 +38,7 @@ or locks fail during registry construction. Call sites cannot accidentally weaken transactional behavior or select undeclared state. An archive page now hydrates every retained archive chunk before returning one -page. This is a known cost of the released `v0.9.1` contract, not an SLA change. +page. This is a known cost of the released `v0.10.0` contract, not an SLA change. A future bounded design requires a new durable storage boundary or a released SDK facility for input-derived instance selection; it must not emulate mutable per-call options in application code. diff --git a/docs/dex-v0.10-upgrade.md b/docs/dex-v0.10-upgrade.md index fce726d..ae1aef6 100644 --- a/docs/dex-v0.10-upgrade.md +++ b/docs/dex-v0.10-upgrade.md @@ -1,29 +1,21 @@ # Dex Server v0.10.0 upgrade -Status: implementation and verification in progress. +Status: released SDK and Server integration. ## Scope -Upgrade Server and CLI to v0.10.0 and Go SDK to v0.9.1. The SDK explicitly +Upgrade Server and CLI to v0.10.0 and Go SDK to v0.10.0. The SDK explicitly registers RPCs and fixes their execution options at registration. It removes invocation-specific selective loads, including the former single-instance load used by `GetArchivedMessages`. -The Server lock uses its immutable compatibility manifest. The SDK-only patch -has no Server manifest, so its lock records the release tag, source commit, and -Go module checksums. Validation also requires the SDK and Server protocol -intervals to overlap. - -## Release prerequisite - -The missing Server v0.10.0 manifest was backfilled after the Dex partial-release -workflow was corrected. SuperAgent pins that asset and its SHA-256. The -`sdk-go/v0.9.1` release is pinned independently because compatibility manifests -are Server release contracts and the patch published only the Go SDK. +Components are independently published. SuperAgent directly pins the released +Go SDK in `go.mod` and dexcli in its installer. The CLI installer verifies native +release checksums. Application upgrades use ordinary pull requests. ## Tests -The published CLI v0.10.0 archive checksums and SDK v0.9.1 module checksums are +The published CLI v0.10.0 archive checksums and SDK v0.10.0 module checksums are locked. Unit compilation verifies the explicit RPC registration API. Real Server integration, visualization, complete checks, and browser E2E must pass before release. @@ -36,9 +28,9 @@ regression gate. ## Documentation -CONTRIBUTING documents the mixed Server/SDK lock. The Flow model and ADR 0014 +CONTRIBUTING documents component pins and the local version updater. The Flow model and ADR 0014 document immutable registered RPC options and the whole-map archive load imposed -by the v0.9.1 contract. +by the v0.10.0 contract. ## UI/UX diff --git a/docs/flow-model.md b/docs/flow-model.md index 5fdb542..03a1d19 100644 --- a/docs/flow-model.md +++ b/docs/flow-model.md @@ -14,7 +14,7 @@ `GetArchivedMessages` - Browser synchronization Attribute: `WaitingInputRound` -The implementation requires Dex Go SDK `v0.9.1` and Server `v0.10.0`. Each +The implementation requires Dex Go SDK `v0.10.0` and Server `v0.10.0`. Each `WaitFor`, `Execute`, and RPC invocation is an independent Dex atomic commit. Provider and MCP calls are external effects and are not part of a Dex transaction. @@ -184,7 +184,7 @@ the Flow's ASYNC default. A long-running definition explicitly selects SYNC. ASYNC fallback changes where the attempt runs; it does not change that resolved durability. -Dex Server `v0.10.0` and Go SDK `v0.9.1` expose Channel size metadata in `WaitFor` and +Dex Server `v0.10.0` and Go SDK `v0.10.0` expose Channel size metadata in `WaitFor` and `Execute`. `AwaitUser.WaitFor` reads the sizes of `SteeredUserMessages`, `QueuedUserMessages`, and the current `PlanExecutions` instance without loading message payloads. It increments @@ -288,7 +288,7 @@ description, and pending Channels. It returns `WaitingInputRound` and stable application message IDs. Snapshot contains application state only; it does not project Dex lifecycle or terminal failure metadata. Archive paging returns exactly one immutable chunk and the bounded sequence metadata needed for continuation. Its registered -`v0.9.1` RPC options load the retained archive map because the requested chunk +`v0.10.0` RPC options load the retained archive map because the requested chunk key is an RPC input and invocation-specific selective loads no longer exist. The browser begins with the Snapshot round, waits for `round > watermark`, uses diff --git a/go.mod b/go.mod index 99be94b..5f70fa1 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/ogen-go/ogen v1.24.0 github.com/openai/openai-go/v3 v3.55.0 github.com/superdurable/dex/blob-cache-go v0.1.0 - github.com/superdurable/dex/sdk-go v0.9.1 + github.com/superdurable/dex/sdk-go v0.10.0 golang.org/x/net v0.58.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -54,4 +54,5 @@ require ( ) tool github.com/ogen-go/ogen/cmd/ogen + tool github.com/ogen-go/ogen/cmd/jschemagen diff --git a/go.sum b/go.sum index a46d42f..3266885 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/superdurable/dex/blob-cache-go v0.1.0 h1:+c3H5YBWG3DlICOHbgT9IUM5vTlfLYGP5Vd5sWr7WTY= github.com/superdurable/dex/blob-cache-go v0.1.0/go.mod h1:Atepb7+sztvDCztVKmlvEKCSKFCkHKtDhoFYjaFmtEw= -github.com/superdurable/dex/sdk-go v0.9.1 h1:j7q+gpS1E8i0JvrR1gmpBUnWBHDZr6iTqYAOiwPzC2U= -github.com/superdurable/dex/sdk-go v0.9.1/go.mod h1:8Wj5wPf9dyb7hDnA40j8xISR/zjhX57NrUDVcXgf5x8= +github.com/superdurable/dex/sdk-go v0.10.0 h1:TtXm17mxRE3ZdXI9hltIWDb3v6UsboHj/K5gEncO4Xg= +github.com/superdurable/dex/sdk-go v0.10.0/go.mod h1:8Wj5wPf9dyb7hDnA40j8xISR/zjhX57NrUDVcXgf5x8= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= diff --git a/script/check_dex_release.py b/script/check_dex_release.py deleted file mode 100644 index 3f5cf1d..0000000 --- a/script/check_dex_release.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Super Durable, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# SPDX-License-Identifier: Apache-2.0 - -"""Verify SuperAgent's direct Dex dependency against its immutable release lock.""" - -from __future__ import annotations - -import json -from pathlib import Path -import re - -import update_dex_release - - -ROOT = Path(__file__).resolve().parents[1] - - -def main() -> None: - lock = json.loads((ROOT / "dex-release.lock.json").read_text(encoding="utf-8")) - if set(lock) - {"sdkManifest", "sdkGoRelease"} != { - "schemaVersion", - "release", - "manifest", - "sourceCommit", - "sdkGoVersion", - "protocol", - "runningFlowsCompatibility", - "persistenceCompatibility", - "openFlowsCompatibility", - }: - raise update_dex_release.UpgradeError("Dex release lock has unexpected fields") - update_dex_release.require( - not ({"sdkManifest", "sdkGoRelease"} <= set(lock)), - "Dex release lock cannot use two SDK sources", - ) - content = update_dex_release.download(lock["manifest"]["url"]) - manifest = update_dex_release.validate_manifest( - lock["manifest"]["url"], lock["manifest"]["sha256"], content - ) - sdk_manifest = manifest - if "sdkManifest" in lock: - source = lock["sdkManifest"] - sdk_manifest = update_dex_release.validate_manifest( - source["url"], source["sha256"], update_dex_release.download(source["url"]) - ) - sdk_release = lock.get("sdkGoRelease") - if sdk_release is not None: - update_dex_release.require( - set(sdk_release) == {"tag", "sourceCommit", "moduleChecksum", "goModChecksum"}, - "Dex Go SDK release lock has unexpected fields", - ) - version = lock["sdkGoVersion"] - update_dex_release.require( - sdk_release["tag"] == f"sdk-go/v{version}", - "Dex Go SDK tag mismatch", - ) - update_dex_release.require( - re.fullmatch(r"[0-9a-f]{40}", sdk_release["sourceCommit"]) is not None, - "Dex Go SDK source commit is invalid", - ) - update_dex_release.require( - re.fullmatch(r"h1:[A-Za-z0-9+/]+={0,2}", sdk_release["moduleChecksum"]) - is not None, - "Dex Go SDK module checksum is invalid", - ) - update_dex_release.require( - re.fullmatch(r"h1:[A-Za-z0-9+/]+={0,2}", sdk_release["goModChecksum"]) - is not None, - "Dex Go SDK go.mod checksum is invalid", - ) - requirements = dict( - re.findall(r"(?m)^\s*([^\s()]+)\s+(v[^\s]+)(?:\s+//.*)?$", (ROOT / "go.mod").read_text(encoding="utf-8")) - ) - update_dex_release.require(lock["schemaVersion"] == 1, "unsupported Dex release lock") - update_dex_release.require(lock["release"] == manifest["release"], "Dex release mismatch") - update_dex_release.require( - lock["sourceCommit"] == manifest["sourceCommit"], "Dex source commit mismatch" - ) - if sdk_release is None: - update_dex_release.require( - lock["sdkGoVersion"] == sdk_manifest["components"]["sdkGo"]["version"], - "Dex Go SDK version mismatch", - ) - update_dex_release.require( - requirements.get("github.com/superdurable/dex/sdk-go") == f'v{lock["sdkGoVersion"]}', - "SuperAgent must directly require the locked Dex Go SDK", - ) - if sdk_release is None: - update_dex_release.require( - lock["protocol"] == sdk_manifest["protocol"]["clients"]["sdkGo"], - "Dex protocol mismatch", - ) - else: - sums = (ROOT / "go.sum").read_text(encoding="utf-8").splitlines() - module = f'github.com/superdurable/dex/sdk-go v{lock["sdkGoVersion"]}' - update_dex_release.require( - f'{module} {sdk_release["moduleChecksum"]}' in sums, - "Dex Go SDK module checksum mismatch", - ) - update_dex_release.require( - f'{module}/go.mod {sdk_release["goModChecksum"]}' in sums, - "Dex Go SDK go.mod checksum mismatch", - ) - server_protocol = manifest["protocol"]["server"] - update_dex_release.require( - max(lock["protocol"]["minimum"], server_protocol["minimum"]) - <= min(lock["protocol"]["maximum"], server_protocol["maximum"]), - "locked Go SDK and Server protocols are incompatible", - ) - for field in ("runningFlowsCompatibility", "persistenceCompatibility"): - update_dex_release.require(lock[field] == manifest[field], f"Dex {field} mismatch") - update_dex_release.require( - lock["openFlowsCompatibility"] in {"compatible", "cancel-required"}, - "invalid open Flow compatibility", - ) - print(f'SuperAgent locks Dex Server {lock["release"]} and Go SDK {lock["sdkGoVersion"]}') - - -if __name__ == "__main__": - main() diff --git a/script/check_dex_versions.py b/script/check_dex_versions.py new file mode 100644 index 0000000..58179b9 --- /dev/null +++ b/script/check_dex_versions.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Super Durable, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# SPDX-License-Identifier: Apache-2.0 + +"""Verify SuperAgent's direct Dex Go SDK and dexcli version pins.""" + +from __future__ import annotations + +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] +SEMVER = r"[0-9]+\.[0-9]+\.[0-9]+" + + +class DexVersionError(RuntimeError): + """A Dex component version pin is missing or inconsistent.""" + + +def require_match(pattern: str, content: str, label: str) -> re.Match[str]: + match = re.search(pattern, content, flags=re.MULTILINE) + if match is None: + raise DexVersionError(f"{label} version pin is missing") + return match + + +def read_versions(root: Path) -> tuple[str, str]: + go_mod = (root / "go.mod").read_text(encoding="utf-8") + sdk_version = require_match( + rf"^\s*github\.com/superdurable/dex/sdk-go\s+v({SEMVER})\s*$", + go_mod, + "Dex Go SDK", + ).group(1) + + makefile = (root / "Makefile").read_text(encoding="utf-8") + dexcli_version = require_match( + rf"^DEXCLI_VERSION := v({SEMVER})$", makefile, "dexcli Makefile" + ).group(1) + + installer = (root / "script/install-dexcli.sh").read_text(encoding="utf-8") + archives = re.findall( + r"dexcli_v([0-9]+\.[0-9]+\.[0-9]+)_(?:darwin|linux)_(?:amd64|arm64)\.tar\.gz", + installer, + ) + if len(archives) != 4 or set(archives) != {dexcli_version}: + raise DexVersionError("dexcli installer versions do not match the Makefile") + + workflow = (root / ".github/workflows/ci.yml").read_text(encoding="utf-8") + expected_binary = f".cache/dexcli-v{dexcli_version} dev" + if expected_binary not in workflow: + raise DexVersionError("dexcli CI version does not match the Makefile") + return sdk_version, dexcli_version + + +def main() -> None: + sdk_version, dexcli_version = read_versions(ROOT) + print(f"SuperAgent uses Dex Go SDK {sdk_version} and dexcli {dexcli_version}") + + +if __name__ == "__main__": + main() diff --git a/script/update_dex_release.py b/script/update_dex_release.py deleted file mode 100644 index 12dc1a9..0000000 --- a/script/update_dex_release.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Super Durable, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# SPDX-License-Identifier: Apache-2.0 - -"""Prepare a reviewed SuperAgent upgrade from one immutable Dex manifest.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -from pathlib import Path -import re -import urllib.request -from typing import Any - - -ROOT = Path(__file__).resolve().parents[1] -SEMVER = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") -SHA256 = re.compile(r"[0-9a-f]{64}") -DEX_MANIFEST_URL = re.compile( - r"https://github\.com/superdurable/dex/releases/download/server/v" - r"([0-9]+\.[0-9]+\.[0-9]+)/dex-compatibility-v\1\.json" -) - - -class UpgradeError(RuntimeError): - """The requested Dex upgrade is incomplete or inconsistent.""" - - -def require(condition: bool, message: str) -> None: - if not condition: - raise UpgradeError(message) - - -def download(url: str) -> bytes: - request = urllib.request.Request(url, headers={"User-Agent": "superagent-dex-upgrade/1"}) - with urllib.request.urlopen(request, timeout=30) as response: - return response.read() - - -def replace_once(path: Path, pattern: str, replacement: str) -> None: - content = path.read_text(encoding="utf-8") - updated, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE) - require(count == 1, f"expected one version pin in {path}") - path.write_text(updated, encoding="utf-8") - - -def validate_manifest( - manifest_url: str, - manifest_sha256: str, - content: bytes, -) -> dict[str, Any]: - match = DEX_MANIFEST_URL.fullmatch(manifest_url) - require(match is not None, "manifest URL is not an immutable Dex Server release asset") - require(SHA256.fullmatch(manifest_sha256) is not None, "manifest SHA-256 is invalid") - require(hashlib.sha256(content).hexdigest() == manifest_sha256, "manifest SHA-256 mismatch") - manifest = json.loads(content) - version = match.group(1) - require(manifest["release"] == version, "manifest release does not match its URL") - require(manifest["rolloutOrder"] == "server-first", "Dex rollout must be server-first") - require( - manifest["components"]["sdkGo"]["version"] == version, - "Dex Go SDK version does not match the release", - ) - server_protocol = manifest["protocol"]["server"] - go_protocol = manifest["protocol"]["clients"]["sdkGo"] - require( - max(server_protocol["minimum"], go_protocol["minimum"]) - <= min(server_protocol["maximum"], go_protocol["maximum"]), - "Dex Go SDK and Server protocols are incompatible", - ) - require(manifest["persistenceCompatibility"] == "compatible", "Dex persistence is incompatible") - return manifest - - -def update_repository( - root: Path, - manifest_url: str, - manifest_sha256: str, - manifest: dict[str, Any], - *, - server_only: bool = False, -) -> None: - version = manifest["release"] - previous = None - if server_only: - previous = json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")) - server_protocol = manifest["protocol"]["server"] - sdk_protocol = previous["protocol"] - require( - max(server_protocol["minimum"], sdk_protocol["minimum"]) - <= min(server_protocol["maximum"], sdk_protocol["maximum"]), - "retained Go SDK and new Server protocols are incompatible", - ) - checksums = manifest["components"]["cli"]["checksums"] - archives = tuple( - f"dexcli_v{version}_{platform}_{architecture}.tar.gz" - for platform in ("darwin", "linux") - for architecture in ("amd64", "arm64") - ) - require(set(checksums) == set(archives), "Dex CLI checksums are incomplete") - if not server_only: - replace_once( - root / "go.mod", - r"(github\.com/superdurable/dex/sdk-go\s+)v[^\s]+", - rf"\g<1>v{version}", - ) - replace_once(root / "Makefile", r"^DEXCLI_VERSION := v[^\s]+$", f"DEXCLI_VERSION := v{version}") - installer = root / "script/install-dexcli.sh" - installer_content = installer.read_text(encoding="utf-8") - cases = "\n".join( - f" {archive}) checksum={checksums[archive]} ;;" - for archive in archives - ) - updated, count = re.subn( - r" dexcli_v[^\n]+\n dexcli_v[^\n]+\n dexcli_v[^\n]+\n dexcli_v[^\n]+", - cases, - installer_content, - count=1, - ) - require(count == 1, "expected four Dex CLI checksum pins") - installer.write_text(updated, encoding="utf-8") - - lock = { - "schemaVersion": 1, - "release": version, - "manifest": {"url": manifest_url, "sha256": manifest_sha256}, - "sourceCommit": manifest["sourceCommit"], - "sdkGoVersion": manifest["components"]["sdkGo"]["version"], - "protocol": manifest["protocol"]["clients"]["sdkGo"], - "runningFlowsCompatibility": manifest["runningFlowsCompatibility"], - "persistenceCompatibility": manifest["persistenceCompatibility"], - "openFlowsCompatibility": "cancel-required", - } - if previous is not None: - lock["sdkGoVersion"] = previous["sdkGoVersion"] - lock["protocol"] = previous["protocol"] - if "sdkGoRelease" in previous: - lock["sdkGoRelease"] = previous["sdkGoRelease"] - else: - lock["sdkManifest"] = previous.get("sdkManifest", previous["manifest"]) - (root / "dex-release.lock.json").write_text( - json.dumps(lock, indent=2) + "\n", - encoding="utf-8", - ) - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--manifest-url", required=True) - parser.add_argument("--manifest-sha256", required=True) - parser.add_argument("--server-only", action="store_true", help="Retain the locked Go SDK and verify protocol overlap") - args = parser.parse_args() - content = download(args.manifest_url) - manifest = validate_manifest(args.manifest_url, args.manifest_sha256, content) - update_repository(ROOT, args.manifest_url, args.manifest_sha256, manifest, server_only=args.server_only) - print(f'Prepared SuperAgent for Dex {manifest["release"]}; open Flows require review') - - -if __name__ == "__main__": - main() diff --git a/script/update_dex_release_test.py b/script/update_dex_release_test.py deleted file mode 100644 index 2971e98..0000000 --- a/script/update_dex_release_test.py +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Super Durable, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import copy -import hashlib -import importlib.util -import json -from pathlib import Path -import sys -import tempfile -import unittest - - -MODULE_PATH = Path(__file__).with_name("update_dex_release.py") -SPEC = importlib.util.spec_from_file_location("update_dex_release", MODULE_PATH) -assert SPEC and SPEC.loader -MODULE = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - - -def manifest() -> dict[str, object]: - return { - "release": "1.2.3", - "sourceCommit": "a" * 40, - "rolloutOrder": "server-first", - "runningFlowsCompatibility": "compatible", - "persistenceCompatibility": "compatible", - "protocol": { - "server": {"minimum": 2, "maximum": 3}, - "clients": {"sdkGo": {"minimum": 2, "maximum": 3}}, - }, - "components": { - "sdkGo": {"version": "1.2.3"}, - "cli": { - "checksums": { - "dexcli_v1.2.3_darwin_amd64.tar.gz": "1" * 64, - "dexcli_v1.2.3_darwin_arm64.tar.gz": "2" * 64, - "dexcli_v1.2.3_linux_amd64.tar.gz": "3" * 64, - "dexcli_v1.2.3_linux_arm64.tar.gz": "4" * 64, - } - }, - }, - } - - -class UpdateDexReleaseTests(unittest.TestCase): - def test_validates_and_updates_all_superagent_pins(self) -> None: - value = manifest() - content = (json.dumps(value) + "\n").encode() - digest = hashlib.sha256(content).hexdigest() - url = ( - "https://github.com/superdurable/dex/releases/download/server/v1.2.3/" - "dex-compatibility-v1.2.3.json" - ) - validated = MODULE.validate_manifest(url, digest, content) - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "script").mkdir() - (root / "go.mod").write_text( - "require github.com/superdurable/dex/sdk-go v0.9.0\n", encoding="utf-8" - ) - (root / "Makefile").write_text("DEXCLI_VERSION := v0.9.0\n", encoding="utf-8") - (root / "script/install-dexcli.sh").write_text( - "case x in\n" - + "\n".join(f" dexcli_v0.9.0_{name}.tar.gz) checksum=old ;;" for name in ( - "darwin_amd64", "darwin_arm64", "linux_amd64", "linux_arm64" - )) - + "\nesac\n", - encoding="utf-8", - ) - MODULE.update_repository(root, url, digest, validated) - lock = json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")) - self.assertEqual(lock["release"], "1.2.3") - self.assertEqual(lock["protocol"], {"minimum": 2, "maximum": 3}) - self.assertEqual(lock["openFlowsCompatibility"], "cancel-required") - self.assertIn("sdk-go v1.2.3", (root / "go.mod").read_text(encoding="utf-8")) - self.assertIn("DEXCLI_VERSION := v1.2.3", (root / "Makefile").read_text(encoding="utf-8")) - self.assertIn("checksum=" + "4" * 64, (root / "script/install-dexcli.sh").read_text(encoding="utf-8")) - - newer = copy.deepcopy(validated) - newer["release"] = "1.2.4" - newer["components"]["sdkGo"]["version"] = "1.2.4" - newer["components"]["cli"]["checksums"] = { - name.replace("1.2.3", "1.2.4"): checksum - for name, checksum in newer["components"]["cli"]["checksums"].items() - } - newer_url = url.replace("1.2.3", "1.2.4") - newer_digest = hashlib.sha256(json.dumps(newer).encode()).hexdigest() - MODULE.update_repository(root, newer_url, newer_digest, newer, server_only=True) - retained = json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")) - self.assertEqual(retained["release"], "1.2.4") - self.assertEqual(retained["sdkGoVersion"], "1.2.3") - self.assertEqual(retained["sdkManifest"], lock["manifest"]) - self.assertEqual(retained["protocol"], lock["protocol"]) - self.assertIn("sdk-go v1.2.3", (root / "go.mod").read_text(encoding="utf-8")) - self.assertIn("DEXCLI_VERSION := v1.2.4", (root / "Makefile").read_text(encoding="utf-8")) - newer["protocol"]["server"] = {"minimum": 4, "maximum": 4} - with self.assertRaisesRegex(MODULE.UpgradeError, "retained Go SDK.*incompatible"): - MODULE.update_repository(root, newer_url, newer_digest, newer, server_only=True) - self.assertEqual( - json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")), retained - ) - - def test_rejects_tampering_and_incompatible_protocol(self) -> None: - value = manifest() - content = (json.dumps(value) + "\n").encode() - url = ( - "https://github.com/superdurable/dex/releases/download/server/v1.2.3/" - "dex-compatibility-v1.2.3.json" - ) - with self.assertRaisesRegex(MODULE.UpgradeError, "SHA-256 mismatch"): - MODULE.validate_manifest(url, "0" * 64, content) - incompatible = copy.deepcopy(value) - incompatible["protocol"]["clients"]["sdkGo"] = {"minimum": 4, "maximum": 4} - incompatible_content = (json.dumps(incompatible) + "\n").encode() - with self.assertRaisesRegex(MODULE.UpgradeError, "protocols are incompatible"): - MODULE.validate_manifest( - url, - hashlib.sha256(incompatible_content).hexdigest(), - incompatible_content, - ) - - def test_server_only_upgrade_preserves_checksum_locked_sdk_release(self) -> None: - value = manifest() - content = (json.dumps(value) + "\n").encode() - digest = hashlib.sha256(content).hexdigest() - url = ( - "https://github.com/superdurable/dex/releases/download/server/v1.2.3/" - "dex-compatibility-v1.2.3.json" - ) - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "script").mkdir() - (root / "go.mod").write_text( - "require github.com/superdurable/dex/sdk-go v0.9.1\n", encoding="utf-8" - ) - (root / "Makefile").write_text("DEXCLI_VERSION := v1.2.2\n", encoding="utf-8") - (root / "script/install-dexcli.sh").write_text( - "case x in\n" - + "\n".join(f" dexcli_v1.2.2_{name}.tar.gz) checksum=old ;;" for name in ( - "darwin_amd64", "darwin_arm64", "linux_amd64", "linux_arm64" - )) - + "\nesac\n", - encoding="utf-8", - ) - sdk_release = { - "tag": "sdk-go/v0.9.1", - "sourceCommit": "b" * 40, - "moduleChecksum": "h1:module", - "goModChecksum": "h1:gomod", - } - (root / "dex-release.lock.json").write_text(json.dumps({ - "sdkGoVersion": "0.9.1", - "protocol": {"minimum": 2, "maximum": 3}, - "sdkGoRelease": sdk_release, - }), encoding="utf-8") - - MODULE.update_repository(root, url, digest, value, server_only=True) - - lock = json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")) - self.assertEqual(lock["sdkGoVersion"], "0.9.1") - self.assertEqual(lock["sdkGoRelease"], sdk_release) - self.assertNotIn("sdkManifest", lock) - - def test_upgrade_workflow_opens_a_draft_before_product_ci(self) -> None: - workflow = (MODULE.ROOT / ".github/workflows/dex-release-upgrade.yml").read_text( - encoding="utf-8" - ) - self.assertIn("draft: true", workflow) - self.assertIn("Normal pull-request CI owns compilation and tests", workflow) - self.assertNotIn("make governance-check format-check vet test", workflow) - self.assertLess( - workflow.index("Update immutable Dex release pins"), - workflow.index("Open draft upgrade pull request before compilation"), - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/script/update_dex_versions.py b/script/update_dex_versions.py new file mode 100644 index 0000000..d0e486e --- /dev/null +++ b/script/update_dex_versions.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Super Durable, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# SPDX-License-Identifier: Apache-2.0 + +"""Update SuperAgent's independent Dex Go SDK and dexcli pins.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import urllib.request + + +ROOT = Path(__file__).resolve().parents[1] +SEMVER = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") +PLATFORMS = ("darwin_amd64", "darwin_arm64", "linux_amd64", "linux_arm64") + + +class DexVersionError(RuntimeError): + """A requested Dex component version or release asset is invalid.""" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise DexVersionError(message) + + +def replace_once(path: Path, pattern: str, replacement: str) -> None: + content = path.read_text(encoding="utf-8") + updated, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE) + require(count == 1, f"expected one version pin in {path}") + path.write_text(updated, encoding="utf-8") + + +def download_cli_checksums(version: str) -> bytes: + url = f"https://github.com/superdurable/dex/releases/download/cli-v{version}/checksums.txt" + request = urllib.request.Request(url, headers={"User-Agent": "superagent-dexcli-upgrade/1"}) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + + +def parse_cli_checksums(version: str, content: bytes) -> dict[str, str]: + checksums: dict[str, str] = {} + for line in content.decode("utf-8").splitlines(): + match = re.fullmatch(r"([0-9a-f]{64})\s+\*?\.?/?(dexcli_v[^/\s]+\.tar\.gz)", line) + if match is not None: + checksums[match.group(2)] = match.group(1) + expected = {f"dexcli_v{version}_{platform}.tar.gz" for platform in PLATFORMS} + require(set(checksums) == expected, "dexcli checksums.txt does not contain the four release archives") + return checksums + + +def update_repository( + root: Path, + sdk_go_version: str, + dexcli_version: str, + cli_checksums: dict[str, str], +) -> None: + require(SEMVER.fullmatch(sdk_go_version) is not None, "invalid Dex Go SDK version") + require(SEMVER.fullmatch(dexcli_version) is not None, "invalid dexcli version") + replace_once( + root / "go.mod", + r"(github\.com/superdurable/dex/sdk-go\s+)v[^\s]+", + rf"\g<1>v{sdk_go_version}", + ) + replace_once( + root / "Makefile", r"^DEXCLI_VERSION := v[^\s]+$", f"DEXCLI_VERSION := v{dexcli_version}" + ) + installer = root / "script/install-dexcli.sh" + cases = "\n".join( + f" {archive}) checksum={cli_checksums[archive]} ;;" + for archive in sorted(cli_checksums) + ) + content = installer.read_text(encoding="utf-8") + updated, count = re.subn( + r" dexcli_v[^\n]+\n dexcli_v[^\n]+\n dexcli_v[^\n]+\n dexcli_v[^\n]+", + cases, + content, + count=1, + ) + require(count == 1, "expected four dexcli checksum pins") + installer.write_text(updated, encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--sdk-go-version", required=True) + parser.add_argument("--dexcli-version", required=True) + parser.add_argument("--checksums-file", type=Path) + arguments = parser.parse_args() + checksum_content = ( + arguments.checksums_file.read_bytes() + if arguments.checksums_file is not None + else download_cli_checksums(arguments.dexcli_version) + ) + checksums = parse_cli_checksums(arguments.dexcli_version, checksum_content) + update_repository(ROOT, arguments.sdk_go_version, arguments.dexcli_version, checksums) + print( + f"Updated Dex Go SDK to {arguments.sdk_go_version} and dexcli to {arguments.dexcli_version}" + ) + + +if __name__ == "__main__": + main() diff --git a/script/update_dex_versions_test.py b/script/update_dex_versions_test.py new file mode 100644 index 0000000..6e5df35 --- /dev/null +++ b/script/update_dex_versions_test.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Super Durable, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + + +def load_module(name: str): + path = Path(__file__).with_name(f"{name}.py") + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +CHECK = load_module("check_dex_versions") +UPDATE = load_module("update_dex_versions") + + +def checksums(version: str) -> bytes: + return "\n".join( + f"{'1234abcd' * 8} ./dexcli_v{version}_{platform}.tar.gz" + for platform in UPDATE.PLATFORMS + ).encode() + + +class DexVersionTests(unittest.TestCase): + def test_updates_and_checks_independent_component_versions(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "script").mkdir() + (root / ".github/workflows").mkdir(parents=True) + (root / "go.mod").write_text( + "require (\n\tgithub.com/superdurable/dex/sdk-go v0.9.0\n)\n", encoding="utf-8" + ) + (root / "Makefile").write_text("DEXCLI_VERSION := v0.9.0\n", encoding="utf-8") + (root / "script/install-dexcli.sh").write_text( + "\n".join( + f" dexcli_v0.9.0_{platform}.tar.gz) checksum=old ;;" + for platform in UPDATE.PLATFORMS + ) + + "\n", + encoding="utf-8", + ) + (root / ".github/workflows/ci.yml").write_text( + ".cache/dexcli-v1.2.4 dev\n", encoding="utf-8" + ) + parsed = UPDATE.parse_cli_checksums("1.2.4", checksums("1.2.4")) + UPDATE.update_repository(root, "1.2.3", "1.2.4", parsed) + self.assertEqual(CHECK.read_versions(root), ("1.2.3", "1.2.4")) + + def test_rejects_incomplete_checksums_and_version_drift(self) -> None: + with self.assertRaisesRegex(UPDATE.DexVersionError, "four release archives"): + UPDATE.parse_cli_checksums("1.2.3", b"") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "script").mkdir() + (root / ".github/workflows").mkdir(parents=True) + (root / "go.mod").write_text( + "require (\n\tgithub.com/superdurable/dex/sdk-go v1.2.3\n)\n", encoding="utf-8" + ) + (root / "Makefile").write_text("DEXCLI_VERSION := v1.2.3\n", encoding="utf-8") + (root / "script/install-dexcli.sh").write_text( + "\n".join( + f" dexcli_v1.2.2_{platform}.tar.gz) checksum=old ;;" + for platform in UPDATE.PLATFORMS + ), + encoding="utf-8", + ) + (root / ".github/workflows/ci.yml").write_text( + ".cache/dexcli-v1.2.3 dev\n", encoding="utf-8" + ) + with self.assertRaisesRegex(CHECK.DexVersionError, "installer versions"): + CHECK.read_versions(root) + + +if __name__ == "__main__": + unittest.main() From 6782c59013fadc034dc2b967c710db10b53385b0 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 23:53:44 -0700 Subject: [PATCH 2/2] Reconcile Snapshot after recovering activity Stream tails --- web/src/Conversation.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/src/Conversation.tsx b/web/src/Conversation.tsx index d568b22..c0bb04f 100644 --- a/web/src/Conversation.tsx +++ b/web/src/Conversation.tsx @@ -166,10 +166,15 @@ export function Conversation({ const newest = recent.events.at(-1); resumeToken = newest?.resumeToken; resumeTokens.current[stream] = resumeToken; - dispatch({ - type: "stream-recovered", - updates: recent.events.map((event) => liveUpdate(stream, event)), - }); + const updates = recent.events.map((event) => + liveUpdate(stream, event), + ); + dispatch({ type: "stream-recovered", updates }); + if (updates.some(shouldReconcileAfter)) { + requestSnapshot({ blocking: false }); + // Stream visibility can precede the durable wait commit. + requestSnapshot({ blocking: false }); + } } catch (reason: unknown) { if (isAbortError(reason)) return; isCurrent = false;