diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 17501756c80..00000000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,33 +0,0 @@ -[bumpversion] -current_version = 0.37.1-dev0 -commit = true -tag = true -tag_name = {new_version} -message = chore: bump version: {current_version} -> {new_version} -parse = (?P\d+)\.(?P\d+)(\.(?P\d+)((?P
-(dev|a|b|rc|final|post))?(?P\d+)?))?
-serialize = 
-	{major}.{minor}.{patch}{pre}{dev}
-	{major}.{minor}.{patch}
-
-[bumpversion:part:pre]
-optional_value = -final
-values = 
-	-dev
-	-rc
-	-final
-
-[bumpversion:file:VERSION]
-
-[bumpversion:file:.circleci/real_config.yml]
-
-[bumpversion:glob:*/setup.py]
-
-[bumpversion:glob:*/*/__version__.py]
-
-[bumpversion:glob:harness/determined/deploy/aws/templates/*.yaml]
-
-[bumpversion:file:webui/react/vite.config.mts]
-
-[bumpversion:file:helm/charts/determined/Chart.yaml]
-
-[bumpversion:file:docs/_static/version-switcher/versions.json]
diff --git a/.circleci/config.yml b/.circleci/config.yml
index c6362b75953..ba1adacbd5b 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -87,6 +87,16 @@ jobs:
               mv -v tmpfile "<>"
             fi
 
+      - run:
+          name: Set det-version parameter.
+          command: |
+            VERSION=$(./version.sh)
+            echo "Version is: [${VERSION}]"
+            echo "CIRCLE_TAG is: [${CIRCLE_TAG}]"
+            jq --arg version "${VERSION}" '. += {"det-version": $version}' < "<>" > tmpfile
+            mv -v tmpfile "<>"
+            cat "<>"
+
       # this must be last; persist the file to the workspace
       - persist_to_workspace:
           root: .
diff --git a/.circleci/real_config.yml b/.circleci/real_config.yml
index 3657b0b4e1f..6bb58268c78 100644
--- a/.circleci/real_config.yml
+++ b/.circleci/real_config.yml
@@ -21,7 +21,7 @@ executors:
 parameters:
   det-version:
     type: string
-    default: 0.37.1-dev0
+    default: ""
   docker-image:
     type: string
     default: determinedai/cimg-base:latest
@@ -32,8 +32,6 @@ parameters:
     type: string
     default: linux-cuda-12:default
   # DEFAULT_PT_GPU_IMAGE: Pytorch training image reference used by the tests
-  # Inject here as a parameter so that it is updated by bumpversion, and can
-  # be referenced by --ee testing.
   default-pt-gpu-hpc-image:
     type: string
     default: determinedai/pytorch-ngc-dev:e960eae
@@ -70,8 +68,8 @@ release-and-rc-filters: &release-and-rc-filters
       - /.*/
   tags:
     only:
-      - /(\d)+(\.(\d)+)+/
-      - /((\d)+(\.(\d)+)+)(-rc)(\d)+/
+      # Tags like: v2.5.0, v3.0.1+rc2
+      - /v\d+\.\d+\.\d+(rc\d+)?/
 
 rc-filters: &rc-filters
   branches:
@@ -79,7 +77,8 @@ rc-filters: &rc-filters
       - /.*/
   tags:
     only:
-      - /((\d)+(\.(\d)+)+)(-rc)(\d)+/
+      # Tags like: v2.5.0rc3
+      - /v\d+\.\d+\.\d+rc\d+/
 
 release-filters: &release-filters
   branches:
@@ -87,7 +86,17 @@ release-filters: &release-filters
       - /.*/
   tags:
     only:
-      - /(\d)+(\.(\d)+)+/
+      # Tags like: v2.1.2
+      - /v\d+\.\d+\.\d+/
+
+release-dryrun: &release-dryrun
+  branches:
+    ignore:
+      - /.*/
+  tags:
+    only:
+      # Tags like: v3.1.3+dryrun
+      - /v\d+\.\d+\.\d+\+dryrun/
 
 upstream-feature-branch: &upstream-feature-branch
   branches:
@@ -1432,12 +1441,21 @@ commands:
             - run: make -C agent check
 
   make-package:
+    parameters:
+      dryrun:
+        type: boolean
+        default: false
     steps:
       - attach_workspace:
           at: .
       - run:
           no_output_timeout: 30m
-          command: make package
+          command: |
+            if <>; then
+              make package-dryrun
+            else
+              make package
+            fi
 
   make-package-ee:
     steps:
@@ -1469,6 +1487,31 @@ commands:
           command: devcluster --oneshot -c .circleci/devcluster/<> --target-stage <> || circleci-agent step halt
           background: true
 
+  make-component:
+    description: "Basic parameterized version of make for building Determined components."
+    parameters:
+      component:
+        type: string
+        default: ""
+      target:
+        type: string
+        default: ""
+      dryrun:
+        type: boolean
+        default: false
+      no_output_timeout:
+        type: string
+        default: "10m"
+    steps:
+      - run:
+          no_output_timeout: <>
+          command: |
+            if <>; then
+              make -C <> <>
+            else
+              make -C <> <>-dryrun
+            fi
+
 jobs:
   build-helm:
     docker:
@@ -1556,7 +1599,7 @@ jobs:
       ee:
         type: boolean
         default: false
-      dry-run:
+      dryrun:
         type: boolean
         default: false
     docker:
@@ -1568,7 +1611,7 @@ jobs:
       - run: apk add make curl py3-pip
       - run: pip install --break-system-packages awscli  # used in the terraform apply
       - run: |
-          if <>; then
+          if <>; then
             echo 'Doing a dry run!'
             if <>; then
               export DET_VARIANT=EE
@@ -1743,6 +1786,10 @@ jobs:
       - run: mkdir /tmp/pkgs && cp -v */dist/*.{rpm,deb,tar.gz} /tmp/pkgs
 
   package-and-push-system-rc:
+    parameters:
+      dryrun:
+        type: boolean
+        default: false
     docker:
       - image: <>
         environment:
@@ -1763,9 +1810,16 @@ jobs:
           username: ${NGC_API_USERNAME}
           password: ${NGC_API_KEY}
       - pre-package-and-push-system
-      - make-package
-      - run: make -C master publish
-      - run: make -C agent publish
+      - make-package:
+          dryrun: <>
+      - make-component:
+          component: master
+          target: publish
+          dryrun: <>
+      - make-component:
+          component: agent
+          target: publish
+          dryrun: <>
       - run: mkdir /tmp/pkgs && cp -v */dist/*.{rpm,deb,tar.gz} /tmp/pkgs
       - store_artifacts:
           path: /tmp/pkgs
@@ -1794,6 +1848,10 @@ jobs:
       - run: mkdir /tmp/pkgs && cp -v */dist/*.{rpm,deb,tar.gz} /tmp/pkgs
 
   package-and-push-system-release:
+    parameters:
+      dryrun:
+        type: boolean
+        default: false
     docker:
       - image: <>
         environment:
@@ -1814,12 +1872,14 @@ jobs:
           username: ${NGC_API_USERNAME}
           password: ${NGC_API_KEY}
       - pre-package-and-push-system
-      - run:
-          no_output_timeout: 30m
-          command: make -C master release
-      - run:
-          no_output_timeout: 30m
-          command: make -C agent release
+      - make-component:
+          component: master
+          target: release
+          no_output_timeout: "30m"
+      - make-component:
+          component: agent
+          target: release
+          no_output_timeout: "30m"
       - run: mkdir /tmp/pkgs && cp -v */dist/*.{rpm,deb,tar.gz} /tmp/pkgs
       - store_artifacts:
           path: /tmp/pkgs
@@ -1855,6 +1915,9 @@ jobs:
       ee:
         type: boolean
         default: false
+      dryrun:
+        type: boolean
+        default: false
     docker:
       - image: <>
         environment:
@@ -1864,12 +1927,19 @@ jobs:
       - attach_workspace:
           at: .
       - reinstall-go
-      - run: make -C helm release-gh
       - run: |
           if <>; then
-            make -C helm release-gh-ee
+            if <>; then
+              make -C helm release-gh-ee-dryrun
+            else
+              make -C helm release-gh-ee
+            fi
           else
-            make -C helm release-gh
+            if <>; then
+              make -C helm release-gh-dryrun
+            else
+              make -C helm release-gh
+            fi
           fi
 
   publish-python-package:
@@ -1879,6 +1949,9 @@ jobs:
       ee:
         type: boolean
         default: false
+      dryrun:
+        type: boolean
+        default: false
     docker:
       - image: <>
     steps:
@@ -1888,11 +1961,21 @@ jobs:
           extras-requires: "build twine"
           executor: <>
       - run: make -C <> build
+        # The following run step is right about at the point where it should be
+        # split off into its own shell script.
       - run: |
           if <>; then
-            make -C <> publish-ee
+            if <>; then
+              make -C <> publish-ee-dryrun
+            else
+              make -C <> publish-ee
+            fi
           else
-            make -C <> publish
+            if <>; then
+              make -C <> publish-dryrun
+            else
+              make -C <> publish
+            fi
           fi
 
   check-ts-bindings:
@@ -2589,13 +2672,15 @@ jobs:
       executor-name:
         type: string
     executor: << parameters.executor-name >>
+    environment:
+      VERSION: "<< pipeline.parameters.det-version >>"
     steps:
       - checkout
       - python-report
       # Running the pip executable causes an error with the win/default executor for some reason.
       - run: python -m pip install --upgrade --user pip
-      - run: pip install wheel setuptools
-      - run: cd harness; python setup.py bdist_wheel -d ../build
+      - run: pip install wheel setuptools build
+      - run: cd harness; python -m build --wheel --outdir ../build
       - run: pip install --find-links build determined==<< pipeline.parameters.det-version >>
       - run: pip freeze --all
       # Allow this to fail, but it is useful for debugging.
@@ -5359,6 +5444,65 @@ workflows:
             - github-read
             - dev-ci-cluster-default-user-credentials
 
+  release-dryrun:
+    jobs:
+      - build-helm:
+          filters: *release-dryrun
+
+      - build-proto:
+          filters: *release-dryrun
+
+      - build-react:
+          context: determined-production
+          filters: *release-dryrun
+
+      - build-docs:
+          context: determined-production
+          filters: *release-dryrun
+          requires:
+            - build-helm
+            - build-proto
+
+      - upload-docs-search-index:
+          requires:
+            - build-docs
+          context: determined-production
+          filters: *release-dryrun
+
+      - package-and-push-system-release:
+          dryrun: true
+          requires:
+            - build-react
+            - build-docs
+          context: determined-production
+          filters: *release-dryrun
+
+      - publish-python-package:
+          name: publish-python-package-release
+          matrix:
+            parameters:
+              path: ["harness"]
+              dryrun: [true]
+          context: determined-dryrun
+          filters: *release-dryrun
+          requires:
+            - package-and-push-system-release
+
+      - publish-docs:
+          dryrun: true
+          name: publish-docs
+          requires:
+            - build-docs
+          context: determined-production
+          filters: *release-dryrun
+
+      - publish-helm-gh:
+          dryrun: true
+          requires:
+            - build-helm
+          context: determined-production
+          filters: *release-dryrun
+
   release:
     jobs:
       - build-helm:
@@ -5421,7 +5565,7 @@ workflows:
           name: publish-docs-rc
           matrix:
             parameters:
-              dry-run: [true]
+              dryrun: [true]
           requires:
             - build-docs
           context: determined-production
diff --git a/.devcontainer/server.sh b/.devcontainer/server.sh
index ab5bed2501f..40c43016920 100755
--- a/.devcontainer/server.sh
+++ b/.devcontainer/server.sh
@@ -1,7 +1,7 @@
 #!/usr/bin/env bash
 set -xeuo pipefail
 
-VERSION=$(cat VERSION)
+VERSION=$(./version.sh)
 GO_LDFLAGS="-X github.com/determined-ai/determined/master/version.Version=${VERSION}"
 
 nodemon --watch './**/*' -e go --signal SIGTERM --exec \
diff --git a/.github/workflows/lint-python.yml b/.github/workflows/lint-python.yml
index ab0389a698e..a70d9e73c0e 100644
--- a/.github/workflows/lint-python.yml
+++ b/.github/workflows/lint-python.yml
@@ -28,7 +28,7 @@ jobs:
       - uses: actions/checkout@v4
       - name: set VERSION env var
         shell: bash
-        run: echo "VERSION=$(< ./VERSION )" >> $GITHUB_ENV
+        run: echo "VERSION=$(./version.sh)" >> $GITHUB_ENV
       - name: Setup Python
         uses: actions/setup-python@v5
         with:
diff --git a/Makefile b/Makefile
index 2285a794182..65c3276f48c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,5 @@
+export VERSION := $(shell ./version.sh)
+
 .PHONY: all
 all:
 	$(MAKE) get-deps
@@ -35,6 +37,11 @@ package:
 	$(MAKE) -C agent $@
 	$(MAKE) -C master $@
 
+.PHONY: package-dryrun
+package-dryrun:
+	$(MAKE) -C agent $@
+	$(MAKE) -C master $@
+
 .PHONY: package-ee
 package-ee:
 	$(MAKE) -C agent $@
diff --git a/VERSION b/VERSION
deleted file mode 100644
index 956c87feadb..00000000000
--- a/VERSION
+++ /dev/null
@@ -1 +0,0 @@
-0.37.1-dev0
\ No newline at end of file
diff --git a/agent/.goreleaser_dryrun.yml b/agent/.goreleaser_dryrun.yml
new file mode 100644
index 00000000000..c9f8d5e6b8a
--- /dev/null
+++ b/agent/.goreleaser_dryrun.yml
@@ -0,0 +1,146 @@
+project_name: determined-agent-dryrun
+
+snapshot:
+  name_template: "{{ .Env.VERSION }}"
+
+builds:
+  - main: ./cmd/determined-agent
+    id: determined-agent
+    binary: determined-agent
+    goos:
+      - linux
+      - darwin
+    goarch:
+      - amd64
+      - arm64
+
+archives:
+  - wrap_in_directory: "true"
+    rlcp: true
+    name_template: "determined-agent_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 \"v1\") }}{{ .Amd64 }}{{ end }}"
+    files:
+      - src: "packaging/agent.yaml"
+        dst: "etc/determined/"
+        strip_parent: true
+      - src: "packaging/LICENSE"
+        strip_parent: true
+
+brews:
+  - name: determined-agent
+    tap:
+      owner: determined-ai
+      name: homebrew-determined-dryrun
+    url_template: "https://github.com/determined-ai/determined-dryrun/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
+    caveats: |
+      Determined agent config is located at #{etc}/determined/agent.yaml
+    homepage: "https://github.com/determined-ai/determined-dryrun"
+    license: "Apache-2.0"
+    folder: Formula
+    install: |
+      bin.install "determined-agent"
+
+      doc.install "LICENSE"
+
+      (var/"log/determined").mkpath
+
+      (etc/"determined").mkpath
+      inreplace "etc/determined/agent.yaml" do |s|
+        s.gsub! "# master_host: 0.0.0.0", "master_host: 127.0.0.1"
+        s.gsub! "# master_port: 80", "master_port: 8080"
+      end
+
+      Pathname("etc/determined/agent.yaml").append_lines <<~EOS
+        container_master_host: host.docker.internal
+      EOS
+
+      etc.install "etc/determined/agent.yaml" => "determined/agent.yaml"
+    service: |
+      run [opt_bin/"determined-agent", "--config-file", etc/"determined/agent.yaml"]
+      keep_alive false
+      error_log_path var/"log/determined/agent-stderr.log"
+      log_path var/"log/determined/agent-stdout.log"
+
+nfpms:
+  - maintainer: "Determined AI "
+    file_name_template: "determined-agent_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 \"v1\") }}{{ .Amd64 }}{{ end }}"
+    formats:
+      - deb
+      - rpm
+    contents:
+      - src: "packaging/agent.yaml"
+        dst: "/etc/determined/agent.yaml"
+        type: config|noreplace
+        file_info:
+            mode: 0644
+      - src: "packaging/determined-agent.service"
+        dst: "/lib/systemd/system/determined-agent.service"
+
+      - src: "packaging/LICENSE"
+        dst: "/usr/share/licenses/determined-agent/LICENSE"
+        packager: rpm
+
+      - src: "packaging/LICENSE"
+        dst: "/usr/share/doc/determined-agent/copyright"
+        packager: deb
+
+    overrides:
+      deb:
+        scripts:
+          postinstall: packaging/debian/agent.postinst
+          preremove: packaging/debian/agent.prerm
+          postremove: packaging/debian/agent.postrm
+
+release:
+  github:
+    owner: determined-ai
+    name: determined-dryrun
+
+  # be sure to keep this in sync between agent/master/helm
+  # the "include" functionality is only in the pro version
+  header: |
+    ## Release Notes
+    [{{ .Tag }}](https://github.com/determined-ai/determined-dryrun/blob/{{ .Tag }}/docs/release-notes.rst)
+
+dockers:
+  # amd64
+  - goos: linux
+    goarch: amd64
+    use: buildx
+    build_flag_templates:
+      - --platform=linux/amd64
+      - --builder=buildx-build
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+    extra_files:
+      - packaging/entrypoint.sh
+      - packaging/LICENSE
+  # arm64
+  - goos: linux
+    goarch: arm64
+    use: buildx
+    build_flag_templates:
+      - --platform=linux/arm64
+      - --builder=buildx-build
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
+    extra_files:
+      - packaging/entrypoint.sh
+      - packaging/LICENSE
+
+docker_manifests:
+  - name_template: "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}"
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
+  - name_template: "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.ShortCommit}}"
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
+  - name_template: "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.Env.VERSION_DOCKER}}"
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
+  - name_template: "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:latest"
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
diff --git a/agent/Makefile b/agent/Makefile
index b5d2271c2f5..a0c2ad9a3b2 100644
--- a/agent/Makefile
+++ b/agent/Makefile
@@ -1,16 +1,21 @@
 .DEFAULT_GOAL := build
 SHELL := bash
 
-export VERSION:=$(shell cat ../VERSION)
+export VERSION:=$(shell ../version.sh)
+export VERSION_TAG:=$(shell ../version.sh -t)
+export VERSION_DOCKER := $(shell ../version.sh -d)
+
 export GO111MODULE := on
 export DOCKER_REPO ?= determinedai
 
 FULL_COMMIT = $(shell git rev-parse HEAD)
 SHORT_COMMIT = $(shell git rev-parse HEAD | head -c9)
 PROJECT_NAME = determined-agent
+PROJECT_NAME_DRYRUN = determined-agent-dryrun
 EE_PROJECT_NAME = hpe-mlde-agent
 ARCHS = amd64 arm64
 MULTI_ARCH_IMAGES = $(shell for arch in $(ARCHS); do echo $(DOCKER_REPO)/$(PROJECT_NAME):$(FULL_COMMIT)-$$arch; done)
+MULTI_ARCH_IMAGES_DRYRUN = $(shell for arch in $(ARCHS); do echo $(DOCKER_REPO)$(PROJECT_NAME_DRYRUN):$(FULL_COMMIT)-$$arch; done)
 EE_MULTI_ARCH_IMAGES = $(shell for arch in $(ARCHS); do echo $(DOCKER_REPO)/$(EE_PROJECT_NAME):$(FULL_COMMIT)-$$arch; done)
 
 NVCR_REPO ?= nvcr.io/isv-ngc-partner/determined
@@ -20,6 +25,11 @@ PUB_MANIFESTS = \
 	$(DOCKER_REPO)/$(PROJECT_NAME):$(SHORT_COMMIT) \
 	$(DOCKER_REPO)/$(PROJECT_NAME):$(VERSION)
 
+PUB_MANIFESTS_DRYRUN = \
+	$(DOCKER_REPO)/$(PROJECT_NAME_DRYRUN):$(FULL_COMMIT) \
+	$(DOCKER_REPO)/$(PROJECT_NAME_DRYRUN):$(SHORT_COMMIT) \
+	$(DOCKER_REPO)/$(PROJECT_NAME_DRYRUN):$(VERSION)
+
 EE_PUB_MANIFESTS = \
 	$(DOCKER_REPO)/$(EE_PROJECT_NAME):$(FULL_COMMIT) \
 	$(DOCKER_REPO)/$(EE_PROJECT_NAME):$(SHORT_COMMIT) \
@@ -102,25 +112,37 @@ packaging/LICENSE: $(shell find ../tools/scripts/licenses -type f)
 	../tools/scripts/gen-attributions.py agent $@
 
 .PHONY: package
-package: export GORELEASER_CURRENT_TAG := $(VERSION)
+package: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
 package: packaging/LICENSE buildx
 	goreleaser --snapshot --rm-dist
 
+.PHONY: package-dryrun
+package-dryrun: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
+package-dryrun: packaging/LICENSE buildx
+	goreleaser --snapshot --rm-dist -f ./.goreleaser_dryrun.yml
+
 .PHONY: package-ee
-package-ee: export GORELEASER_CURRENT_TAG := $(VERSION)-ee
+package-ee: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)-ee
 package-ee: packaging/LICENSE buildx
 	goreleaser --snapshot --rm-dist -f ./.goreleaser_ee.yml
 
 .PHONY: release
-release: export GORELEASER_CURRENT_TAG := $(VERSION)
-release: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+$$' | grep "$(VERSION)" -A1 | sed -n '2 p')
+release: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
+release: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+$$' | grep "$(VERSION_TAG)" -A1 | sed -n '2 p')
 release: packaging/LICENSE buildx
 	goreleaser --rm-dist
 	make publish-nvcr
 
+.PHONY: release-dryrun
+release-dryrun: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
+release-dryrun: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+$$' | grep "$(VERSION_TAG)" -A1 | sed -n '2 p')
+# We intentionally do not invoke `make publish-nvcr(-dryrun)` here.
+release-dryrun: packaging/LICENSE buildx
+	goreleaser --rm-dist -f ./.goreleaser_dryrun.yml
+
 .PHONY: release-ee
-release-ee: export GORELEASER_CURRENT_TAG := $(VERSION)-ee
-release-ee: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+-ee$$' | grep "$(VERSION)-ee" -A1 | sed -n '2 p')
+release-ee: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)-ee
+release-ee: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+-ee$$' | grep "$(VERSION_TAG)-ee" -A1 | sed -n '2 p')
 release-ee: packaging/LICENSE buildx
 	goreleaser --rm-dist -f ./.goreleaser_ee.yml
 
@@ -147,6 +169,10 @@ publish-dev-ee:
 publish:
 	@$(call manifest_publish, $(PUB_MANIFESTS), $(MULTI_ARCH_IMAGES))
 
+.PHONY: publish-dryrun
+publish-dryrun:
+	@$(call manifest_publish, $(PUB_MANIFESTS_DRYRUN), $(MULTI_ARCH_IMAGES_DRYRUN))
+
 .PHONY: publish-ee
 publish-ee:
 	@$(call manifest_publish, $(EE_PUB_MANIFESTS), $(EE_MULTI_ARCH_IMAGES))
diff --git a/agent/internal/agent.go b/agent/internal/agent.go
index e30003f92bf..2ccdc008e07 100644
--- a/agent/internal/agent.go
+++ b/agent/internal/agent.go
@@ -6,6 +6,7 @@ import (
 	"crypto/x509"
 	"fmt"
 	"io"
+	"net/url"
 	"os"
 	"strings"
 	"time"
@@ -242,7 +243,7 @@ func (a *Agent) connect(ctx context.Context, reconnect bool) (*MasterWebsocket,
 
 	masterAddr := fmt.Sprintf(
 		"%s://%s:%d/agents?id=%s&version=%s&resource_pool=%s&reconnect=%v&hostname=%s",
-		masterProto, a.opts.MasterHost, a.opts.MasterPort, a.opts.AgentID, a.version,
+		masterProto, a.opts.MasterHost, a.opts.MasterPort, a.opts.AgentID, url.QueryEscape(a.version),
 		a.opts.ResourcePool, reconnect, hostname,
 	)
 	a.log.Infof("connecting to master at: %s", masterAddr)
diff --git a/docs/Makefile b/docs/Makefile
index 461b359427b..ebbff40a047 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -1,4 +1,4 @@
-export VERSION := $(shell cat ../VERSION)
+export VERSION := $(shell ../version.sh)
 .DEFAULT_GOAL := build
 
 SPHINXOPTS    = -W
@@ -52,8 +52,12 @@ build/swagger.stamp: ../proto/build/swagger/determined/api/v1/api.swagger.json s
 	mkdir -p build
 	touch $@
 
+.PHONY: gen-versions
+gen-versions:
+	python3 gen-versions.py -o _static/version-switcher/versions.json
+
 .PHONY: build
-build: build/sp-html.stamp
+build: gen-versions build/sp-html.stamp
 
 .PHONY: xml
 xml: build/sp-xml.stamp
diff --git a/docs/README.md b/docs/README.md
index b4463bb478f..f01e1fb71d8 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -98,7 +98,7 @@ anything other than `/latest`.
 
 ## Version Switcher
 
-To provide a docs version dropdown for users to select the version of docs they want to use, we configured the bumpversion tool to read/update the version information stored in the `versions.json` file located at `docs/_static/version-switcher/‘ within the project directory.
+To provide a docs version dropdown for users to select the version of docs they want to use, we dynamically generate the `versions.json` file required by the Sphinx theme. This was previously managed statically by `bump2version`, but as part of a release redesign, the build process now generates the file dynamically and places it in its previous location: `docs/_static/version-switcher/versions.json`. See `gen-versions.py` for more information.
 
 ## Theming
 
diff --git a/docs/_static/version-switcher/.gitignore b/docs/_static/version-switcher/.gitignore
new file mode 100644
index 00000000000..5e7d2734cfc
--- /dev/null
+++ b/docs/_static/version-switcher/.gitignore
@@ -0,0 +1,4 @@
+# Ignore everything in this directory
+*
+# Except this file
+!.gitignore
diff --git a/docs/_static/version-switcher/versions.json b/docs/_static/version-switcher/versions.json
deleted file mode 100644
index 9636fb8277f..00000000000
--- a/docs/_static/version-switcher/versions.json
+++ /dev/null
@@ -1,118 +0,0 @@
-[
-    {
-        "version": "0.37.1-dev0",
-        "url": "https://docs.determined.ai/latest/"
-    },
-    {
-        "version": "0.37.0",
-        "url": "https://docs.determined.ai/0.37.0/"
-    },
-    {
-        "version": "0.36.0",
-        "url": "https://docs.determined.ai/0.36.0/"
-    },
-    {
-        "version": "0.35.0",
-        "url": "https://docs.determined.ai/0.35.0/"
-    },
-    {
-        "version": "0.34.0",
-        "url": "https://docs.determined.ai/0.34.0/"
-    },
-    {
-        "version": "0.33.0",
-        "url": "https://docs.determined.ai/0.33.0/"
-    },
-    {
-        "version": "0.32.1",
-        "url": "https://docs.determined.ai/0.32.1/"
-    },
-    {
-        "version": "0.32.0",
-        "url": "https://docs.determined.ai/0.32.0/"
-    },
-    {
-        "version": "0.31.0",
-        "url": "https://docs.determined.ai/0.31.0/"
-    },
-    {
-        "version": "0.30.0",
-        "url": "https://docs.determined.ai/0.30.0/"
-    },
-    {
-        "version": "0.29.1",
-        "url": "https://docs.determined.ai/0.29.1/"
-    },
-    {
-        "version": "0.29.0",
-        "url": "https://docs.determined.ai/0.29.0/"
-    },
-    {
-        "version": "0.28.1",
-        "url": "https://docs.determined.ai/0.28.1/"
-    },
-    {
-        "version": "0.28.0",
-        "url": "https://docs.determined.ai/0.28.0/"
-    },
-    {
-        "version": "0.27.1",
-        "url": "https://docs.determined.ai/0.27.1/"
-    },
-    {
-        "version": "0.27.0",
-        "url": "https://docs.determined.ai/0.27.0/"
-    },
-    {
-        "version": "0.26.7",
-        "url": "https://docs.determined.ai/0.26.7/"
-    },
-    {
-        "version": "0.26.6",
-        "url": "https://docs.determined.ai/0.26.6/"
-    },
-    {
-        "version": "0.26.4",
-        "url": "https://docs.determined.ai/0.26.4/"
-    },
-    {
-        "version": "0.26.3",
-        "url": "https://docs.determined.ai/0.26.3/"
-    },
-    {
-        "version": "0.26.2",
-        "url": "https://docs.determined.ai/0.26.2/"
-    },
-    {
-        "version": "0.26.1",
-        "url": "https://docs.determined.ai/0.26.1/"
-    },
-    {
-        "version": "0.26.0",
-        "url": "https://docs.determined.ai/0.26.0/"
-    },
-    {
-        "version": "0.25.1",
-        "url": "https://docs.determined.ai/0.25.1/"
-    },
-    {
-        "version": "0.25.0",
-        "url": "https://docs.determined.ai/0.25.0/"
-    },
-    {
-        "version": "0.24.0",
-        "url": "https://docs.determined.ai/0.24.0/"
-    },
-    {
-        "version": "0.23.0",
-        "url": "https://docs.determined.ai/0.23.0/"
-    },
-    {
-        "version": "0.22.0",
-        "url": "https://docs.determined.ai/0.22.0/"
-    },
-    {
-        "version": "0.21.0",
-        "url": "https://docs.determined.ai/0.21.0/"
-    }
-]
diff --git a/docs/conf.py b/docs/conf.py
index 84dfa300319..5a49827d411 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -5,7 +5,6 @@
 
 import json
 import os
-import pathlib
 import sys
 import time
 
@@ -20,7 +19,19 @@
 html_title = "Determined AI Documentation"
 copyright = time.strftime("%Y, Determined AI")
 author = "ai-open-source@hpe.com"
-version = pathlib.Path(__file__).parents[1].joinpath("VERSION").read_text().strip()
+
+class VersionError(Exception):
+    pass
+
+# Read the application version string from the VERSION environment
+# variable. Previously, this was read from a static VERSION file at the root of
+# the repository. Now, we read it from an environment variable set by a
+# Makefile, generated from version.sh in the repository root.
+try:
+    version = os.environ["VERSION"]
+except KeyError as e:
+    raise VersionError("Please ensure VERSION environment variable is set.").with_traceback(e.__traceback__)
+
 release = version
 language = "en"
 
diff --git a/docs/deploy/Makefile b/docs/deploy/Makefile
index b3f96aaa185..e46690fe098 100644
--- a/docs/deploy/Makefile
+++ b/docs/deploy/Makefile
@@ -1,4 +1,4 @@
-export TF_VAR_det_version := $(shell cat ../../VERSION)
+export TF_VAR_det_version := $(shell ../../version.sh)
 export DET_VARIANT ?= OSS
 export TF_VAR_det_variant := ${DET_VARIANT}
 export TF_CLI_ARGS_init :=  "-backend-config=backend_${DET_VARIANT}.conf"
diff --git a/docs/deploy/scrape.py b/docs/deploy/scrape.py
index 8f676d86e27..7e679271d99 100644
--- a/docs/deploy/scrape.py
+++ b/docs/deploy/scrape.py
@@ -109,6 +109,10 @@ class ExtractionError(Exception):
     pass
 
 
+class VersionError(Exception):
+    pass
+
+
 def xmldumps(node):
     return ElementTree.tostring(node).decode("utf8")
 
@@ -292,10 +296,15 @@ def upload(app_id, api_key, records, version):
     )
     args = parser.parse_args()
 
-    # Pick the correct version.
-    HERE = pathlib.Path(__file__).parent
-    with (HERE / ".." / ".." / "VERSION").open() as f:
-        version = f.read().strip()
+    # Read the application version string from the VERSION environment
+    # variable. Previously, this was read from a static VERSION file at the root
+    # of the repository. Now, we read it from an environment variable set by a
+    # Makefile, generated from version.sh in the repository root.
+    try:
+        version = os.environ["VERSION"]
+    except KeyError as e:
+        raise VersionError("Please ensure VERSION environment variable is set.").with_traceback(e.__traceback__)
+
     if "-dev" in version:
         # Dev builds search against a special dev index that is update with every push to master.
         version = "dev"
diff --git a/docs/deploy/upload.py b/docs/deploy/upload.py
index 1640d08c446..af289940e51 100644
--- a/docs/deploy/upload.py
+++ b/docs/deploy/upload.py
@@ -7,6 +7,11 @@
 
 import boto3
 
+
+class VersionError(Exception):
+    pass
+
+
 HERE = pathlib.Path(__file__).parent
 
 if __name__ == "__main__":
@@ -24,12 +29,6 @@ def dir_path(string):
         default=True,
         help="whether the upload should go under the short-lived /previews path",
     )
-    parser.add_argument(
-        "--version-file",
-        type=str,
-        default=HERE / ".." / ".." / "VERSION",
-        help="file containing version string for local docs build",
-    )
     parser.add_argument(
         "--bucket-id",
         type=str,
@@ -68,9 +67,15 @@ def dir_path(string):
     )
     args = parser.parse_args()
 
-    # check version file to determine upload path
-    with args.version_file.open() as f:
-        version = f.read().strip()
+    # Read the application version string from the VERSION environment
+    # variable to determine upload path. Previously, this was read
+    # from a static VERSION file at the root of the repository. Now,
+    # we read it from an environment variable set by a Makefile,
+    # generated from version.sh in the repository root.
+    try:
+        version = os.environ["VERSION"]
+    except KeyError as e:
+        raise VersionError("Please ensure VERSION environment variable is set.").with_traceback(e.__traceback__)
 
     # ya know, jic
     if version == "latest":
diff --git a/docs/exclude-versions.txt b/docs/exclude-versions.txt
new file mode 100644
index 00000000000..6b9622e972c
--- /dev/null
+++ b/docs/exclude-versions.txt
@@ -0,0 +1,9 @@
+0.21.1
+0.21.2
+0.22.1
+0.22.2
+0.23.1
+0.23.2
+0.23.3
+0.23.4
+0.26.5
diff --git a/docs/gen-versions.py b/docs/gen-versions.py
new file mode 100755
index 00000000000..9d353dc6b81
--- /dev/null
+++ b/docs/gen-versions.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+
+def parse_args():
+    parser = argparse.ArgumentParser(
+        prog="gen-versions.py",
+        description="Generate Sphinx version switcher JSON file from git tags.",
+    )
+
+    parser.add_argument(
+        "-o",
+        "--out-file",
+        help="path to output file, including filename, for generated versions JSON file",
+        metavar="path",
+        default=None,
+    )
+
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+
+    completed = subprocess.run(["./gen-versions.sh"], capture_output=True)
+    versions = completed.stdout.splitlines()
+
+    output = []
+
+    # Special case for latest.
+    latest = versions.pop(0)
+    latest = latest.decode()
+    output.append(
+        {
+            "version": latest,
+            "url": "https://docs.determined.ai/latest/",
+        }
+    )
+
+    for version in versions:
+        version = version.decode()
+        output.append(
+            {
+                "version": version,
+                "url": f"https://docs.determined.ai/{version}/",
+            }
+        )
+
+    if args.out_file is not None:
+        try:
+            with open(args.out_file, "w") as fd:
+                json.dump(output, fd, indent=4)
+        except FileNotFoundError as e:
+            print("File not found: {e}. Do all parent directories exist?", file=sys.stderr)
+            raise
+    else:
+        print(json.dumps(output, indent=4))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/docs/gen-versions.sh b/docs/gen-versions.sh
new file mode 100755
index 00000000000..a673cfa55d6
--- /dev/null
+++ b/docs/gen-versions.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+# Script to return all versions on a branch between a given EPOCH and HEAD,
+# while removing specific excluded versions. This is used to generate the
+# versions.json file the Sphinx version picker uses. This also replaces some
+# functionality that used to be present in gen-versions.py, using GitPython,
+# which returned version tags in a way that doesn't match how we're working with
+# version tags elsewhere. I.e. this git command is sensitive to where HEAD is in
+# the DAG, and will not return versions that could logically come after where
+# HEAD is; previous versions would return all later tags regardless.
+
+EPOCH="0.21.0"
+
+VERSIONS=$(git \
+    -c versionsort.suffix='-rc' \
+    tag \
+    --sort='v:refname:short' \
+    --format='%(refname:short)' \
+    --no-contains=$(git merge-base HEAD main) \
+    --contains=$(git rev-parse $(git merge-base HEAD ${EPOCH})~1) \
+    | grep -E -v 'v0.12|-ee' \
+    | grep -E -o '\d+\.\d+\.\d+$')
+
+comm -2 -3 <(cat <<<"${VERSIONS}") exclude-versions.txt | sort -Vr
diff --git a/docs/insert-version-url.py b/docs/insert-version-url.py
deleted file mode 100644
index 61d40a8084b..00000000000
--- a/docs/insert-version-url.py
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/usr/bin/env python3
-
-"""
-Insert an entry into the version dropdown json file.
-
-Usage: insert-version-url.py your.version.here
-"""
-
-import json
-import os
-import sys
-
-
-def insert_entry(json_file, entry):
-    # The current dev version comes first and all released versions are in reverse chronological
-    # order, so the latest release always goes in index 1.
-    entry_position = 1
-    with open(json_file, "rb") as f:
-        all_urls = json.load(f)
-
-    all_urls.insert(entry_position, entry)
-
-    with open(json_file, "w") as f:
-        json.dump(all_urls, f, indent=4 * " ")
-        f.write("\n")
-
-
-def insert_version_url(json_file, version):
-    insert_entry(json_file, {"version": version, "url": f"https://docs.determined.ai/{version}/"})
-
-    print(f"Added dropdown link for version {version}")
-
-
-if __name__ == "__main__":
-    if len(sys.argv) < 2:
-        print(__doc__, file=sys.stderr)
-        sys.exit(1)
-
-    current_directory = os.path.dirname(__file__)
-    versions_json_path = os.path.join(current_directory, "_static/version-switcher/versions.json")
-    insert_version_url(versions_json_path, sys.argv[1])
diff --git a/harness/Makefile b/harness/Makefile
index e3cf4071eae..fd0f2aff16a 100644
--- a/harness/Makefile
+++ b/harness/Makefile
@@ -2,6 +2,8 @@ TEST_RESULTS_DIR=/tmp/test-results
 py_bindings_dest=determined/common/api/bindings.py
 cuda_available=$(shell python -c "import torch; print(torch.cuda.is_available())") \
 
+export VERSION:=$(shell ../version.sh)
+
 .PHONY: build
 build:
 	PYTHONWARNINGS=ignore:Normalizing:UserWarning:setuptools.dist \
@@ -15,6 +17,12 @@ publish:
 publish-ee:
 	twine upload --verbose --non-interactive dist/* --repository-url https://push.fury.io/determined-ai
 
+.PHONY: publish-dryrun
+publish-dryrun:
+	twine upload --verbose --non-interactive --repository testpypi dist/*
+
+publish-ee-dryrun: ;
+
 .PHONY: fmt
 fmt:
 	isort .
diff --git a/harness/determined/__init__.py b/harness/determined/__init__.py
index b0eeb69d1cb..cb581e9ded8 100644
--- a/harness/determined/__init__.py
+++ b/harness/determined/__init__.py
@@ -1,4 +1,5 @@
-from determined.__version__ import __version__
+from importlib import metadata
+
 from determined._experiment_config import ExperimentConfig
 from determined._info import RendezvousInfo, TrialInfo, ResourcesInfo, ClusterInfo, get_cluster_info
 from determined._import import import_from_path
@@ -23,6 +24,14 @@
 from determined import errors
 from determined import util
 
+try:
+    # Attempt to get the version from the package metadata. This should
+    # exist if using a built distribution (i.e. for most users), which
+    # includes pip editable installations.
+    __version__ = metadata.version(__name__)
+except metadata.PackageNotFoundError:
+    __version__ = "2!0.0.0+unknown"
+
 # LOG_FORMAT is the standard format for use with the logging module, which is required for the
 # WebUI's log viewer to filter logs by log level.
 #
diff --git a/harness/determined/__version__.py b/harness/determined/__version__.py
deleted file mode 100644
index 7510c02d537..00000000000
--- a/harness/determined/__version__.py
+++ /dev/null
@@ -1 +0,0 @@
-__version__ = "0.37.1-dev0"
diff --git a/harness/determined/core/_log_shipper.py b/harness/determined/core/_log_shipper.py
index 2606bed2edb..87a106d7d1c 100644
--- a/harness/determined/core/_log_shipper.py
+++ b/harness/determined/core/_log_shipper.py
@@ -21,7 +21,7 @@ def __init__(
         session: api.Session,
         trial_id: int,
         task_id: str,
-        distributed: Optional[core.DistributedContext] = None
+        distributed: Optional[core.DistributedContext] = None,
     ) -> None:
         self._session = session
         self._trial_id = trial_id
diff --git a/harness/determined/deploy/aws/cli.py b/harness/determined/deploy/aws/cli.py
index c8e965331b6..9d7619b5cf5 100644
--- a/harness/determined/deploy/aws/cli.py
+++ b/harness/determined/deploy/aws/cli.py
@@ -10,6 +10,7 @@
 import termcolor
 from botocore import exceptions
 
+import determined
 from determined import cli
 from determined.deploy import errors
 from determined.deploy.aws import aws, constants, preflight
@@ -512,6 +513,7 @@ def handle_dump_master_config_template(args: argparse.Namespace) -> None:
                 cli.Arg(
                     "--det-version",
                     type=str,
+                    default=determined.__version__,
                     help=argparse.SUPPRESS,
                 ),
                 cli.Arg(
diff --git a/harness/determined/deploy/aws/templates/efs.yaml b/harness/determined/deploy/aws/templates/efs.yaml
index 5ed770390c6..19f8a2e0c27 100644
--- a/harness/determined/deploy/aws/templates/efs.yaml
+++ b/harness/determined/deploy/aws/templates/efs.yaml
@@ -102,7 +102,6 @@ Parameters:
   Version:
     Type: String
     Description: Determined version or commit for master image
-    Default: 0.37.1-dev0
 
   DBPassword:
     Type: String
diff --git a/harness/determined/deploy/aws/templates/fsx.yaml b/harness/determined/deploy/aws/templates/fsx.yaml
index 31f3092be58..f0ce54cc7d5 100644
--- a/harness/determined/deploy/aws/templates/fsx.yaml
+++ b/harness/determined/deploy/aws/templates/fsx.yaml
@@ -102,7 +102,6 @@ Parameters:
   Version:
     Type: String
     Description: Determined version or commit for master image
-    Default: 0.37.1-dev0
 
   DBPassword:
     Type: String
diff --git a/harness/determined/deploy/aws/templates/govcloud.yaml b/harness/determined/deploy/aws/templates/govcloud.yaml
index ad4244899c6..c6bc1e3a77a 100644
--- a/harness/determined/deploy/aws/templates/govcloud.yaml
+++ b/harness/determined/deploy/aws/templates/govcloud.yaml
@@ -68,7 +68,6 @@ Parameters:
   Version:
     Type: String
     Description: Determined version or commit for master docker image
-    Default: 0.37.1-dev0
 
   DBPassword:
     Type: String
diff --git a/harness/determined/deploy/aws/templates/lore.yaml b/harness/determined/deploy/aws/templates/lore.yaml
index 0955451ff0d..77efc1a427e 100644
--- a/harness/determined/deploy/aws/templates/lore.yaml
+++ b/harness/determined/deploy/aws/templates/lore.yaml
@@ -102,7 +102,6 @@ Parameters:
   Version:
     Type: String
     Description: Determined version or commit for master image
-    Default: 0.37.1-dev0
 
   DBPassword:
     Type: String
diff --git a/harness/determined/deploy/aws/templates/secure.yaml b/harness/determined/deploy/aws/templates/secure.yaml
index af938d9f4e1..7ae502ded9b 100644
--- a/harness/determined/deploy/aws/templates/secure.yaml
+++ b/harness/determined/deploy/aws/templates/secure.yaml
@@ -123,7 +123,6 @@ Parameters:
   Version:
     Type: String
     Description: Determined version or commit for master image
-    Default: 0.37.1-dev0
 
   DBPassword:
     Type: String
diff --git a/harness/determined/deploy/aws/templates/simple-rds.yaml b/harness/determined/deploy/aws/templates/simple-rds.yaml
index e0da0a988c8..d35df445104 100644
--- a/harness/determined/deploy/aws/templates/simple-rds.yaml
+++ b/harness/determined/deploy/aws/templates/simple-rds.yaml
@@ -94,7 +94,6 @@ Parameters:
   Version:
     Type: String
     Description: Determined version or commit for master docker image
-    Default: 0.37.1-dev0
 
   DBPassword:
     Type: String
diff --git a/harness/determined/deploy/aws/templates/simple.yaml b/harness/determined/deploy/aws/templates/simple.yaml
index e5623ca20b9..640fb6d0353 100644
--- a/harness/determined/deploy/aws/templates/simple.yaml
+++ b/harness/determined/deploy/aws/templates/simple.yaml
@@ -94,7 +94,6 @@ Parameters:
   Version:
     Type: String
     Description: Determined version or commit for master docker image
-    Default: 0.37.1-dev0
 
   DBPassword:
     Type: String
diff --git a/harness/pyproject.toml b/harness/pyproject.toml
index 41cbaf05dea..92ea3fe3f17 100644
--- a/harness/pyproject.toml
+++ b/harness/pyproject.toml
@@ -1,4 +1,79 @@
+[build-system]
+# Minimum requirements for the build system to execute.
+requires = ["setuptools>=64", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "determined"
+description = "Determined AI: The fastest and easiest way to build deep learning models."
+requires-python = ">=3.8"
+
+authors = [
+  { name = "Determined AI", email = "ai-open-source@hpe.com" },
+]
+
+dependencies = [
+  "matplotlib",
+  "packaging",
+  "numpy>=1.16.2",
+  "psutil",
+  "pyzmq>=18.1.0",
+  # Common:
+  "certifi",
+  "filelock",
+  "requests<2.32.0",  # TODO(MD-415) remove this pin.
+  "google-cloud-storage",
+  "lomond>=0.3.3",
+  "pathspec>=0.6.0",
+  "azure-core",
+  "azure-storage-blob",
+  "termcolor>=1.1.0",
+  "boto3",
+  "oschmod;platform_system=='Windows'",
+  # CLI:
+  "argcomplete>=1.9.4",
+  "gitpython>=3.1.3",
+  "pyOpenSSL>= 19.1.0",
+  "python-dateutil",
+  "pytz",
+  "tabulate>=0.8.3",
+  "ruamel.yaml",
+  # Deploy
+  "docker[ssh]>=3.7.3",
+  "google-api-python-client>=1.12.1",
+  "paramiko>=2.4.2",  # explicitly pull in paramiko to prevent DistributionNotFound error
+  "tqdm",
+  "appdirs",
+  # Telemetry
+  "analytics-python",
+]
+
+classifiers = [
+  "License :: OSI Approved :: Apache Software License",
+]
+
+# We can't seem to use pyproject.toml to include the Determined README
+# relative to pyproject.toml, so it has to be dynamic here.
+dynamic = ["version", "readme"]
+
+[project.scripts]
+# Replaces entry_points console_scripts in setup.py.
+det = "determined.cli.__main__:main"
+
+[project.urls]
+Homepage = "https://determined.ai/"
+Documentation = "https://docs.determined.ai/"
+
 [tool.black]
 line-length = 100
 exclude = '(_gen.py|determined/_swagger/client/*)'
 
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.packages.find]
+# Use find_namespace_packages because it will include data-only packages (that
+# is, directories containing only non-python files, like our gcp terraform
+# directory).
+include = ["determined*"]
+namespaces = true
diff --git a/harness/setup.py b/harness/setup.py
index c68b5df7681..81d3639126d 100644
--- a/harness/setup.py
+++ b/harness/setup.py
@@ -1,66 +1,40 @@
+import os
+import subprocess
+
 import setuptools
 
-# open README.md from parent folder and read it into a string
-with open("../README.md", "r") as readme:
-    markdown_description = "".join(readme.readlines())
+
+def readme() -> str:
+    with open("../README.md", "r") as fd:
+        return fd.read()
+
+
+def version() -> str:
+    def get_version_from_sh() -> str:
+        try:
+            # This feels more disgusting than it is. Numpy does something similar,
+            # although they generate a version.py file from their Meson build file
+            # that returns a static version string. I'm not thrilled about calling a
+            # shell script during Determined's __init__.py (i.e. on import), but I'm
+            # running out of ideas to make editable installs work comfortably, and
+            # this shouldn't ever run for end users anyway.
+            output = subprocess.run(["../version.sh"], capture_output=True, shell=True)
+        except subprocess.CalledProcessError:
+            # version.sh failed for whatever reason. Return an unknown version with
+            # epoch set to 1 so at least pip dependency resolution should succeed.
+            return "1!0.0.0+unknown"
+        else:
+            # version.sh succeeded. Collect the output.
+            return output.stdout.decode("utf-8")
+
+    return os.environ.get("VERSION", get_version_from_sh())
+
 
 setuptools.setup(
-    name="determined",
-    version="0.37.1-dev0",
-    author="Determined AI",
-    author_email="ai-open-source@hpe.com",
-    url="https://determined.ai/",
-    description="Determined AI: The fastest and easiest way to build deep learning models.",
-    long_description=markdown_description,
+    # We can't seem to use pyproject.toml to include the Determined README
+    # relative to pyproject.toml. But that's okay, because we can still keep
+    # setup.py for this.
+    long_description=readme(),
     long_description_content_type="text/markdown",
-    license="Apache License 2.0",
-    classifiers=["License :: OSI Approved :: Apache Software License"],
-    # Use find_namespace_packages because it will include data-only packages (that is, directories
-    # containing only non-python files, like our gcp terraform directory).
-    packages=setuptools.find_namespace_packages(include=["determined*"]),
-    # Technically, we haven't supported 3.6 or tested against it since it went EOL. But some users
-    # are still using it successfully so there's hardly a point in breaking them.
-    python_requires=">=3.8",
-    include_package_data=True,
-    install_requires=[
-        "matplotlib",
-        "packaging",
-        "numpy>=1.16.2",
-        "psutil",
-        "pyzmq>=18.1.0",
-        # Common:
-        "certifi",
-        "docker>=7.1.0",
-        "filelock",
-        "google-cloud-storage",
-        "lomond>=0.3.3",
-        "pathspec>=0.6.0",
-        "azure-core",
-        "azure-storage-blob",
-        "termcolor>=1.1.0",
-        "boto3",
-        "oschmod;platform_system=='Windows'",
-        # CLI:
-        "argcomplete>=1.9.4",
-        "gitpython>=3.1.3",
-        "pyOpenSSL>= 19.1.0",
-        "python-dateutil",
-        "pytz",
-        "tabulate>=0.8.3",
-        "ruamel.yaml",
-        # Deploy
-        "docker[ssh]>=3.7.3",
-        "google-api-python-client>=1.12.1",
-        "paramiko>=2.4.2",  # explicitly pull in paramiko to prevent DistributionNotFound error
-        "tqdm",
-        "appdirs",
-        # Telemetry
-        "analytics-python",
-    ],
-    zip_safe=False,
-    entry_points={
-        "console_scripts": [
-            "det = determined.cli.__main__:main",
-        ]
-    },
+    version=version(),
 )
diff --git a/helm/.goreleaser_dryrun.yml b/helm/.goreleaser_dryrun.yml
new file mode 100644
index 00000000000..f992c1c9d65
--- /dev/null
+++ b/helm/.goreleaser_dryrun.yml
@@ -0,0 +1,19 @@
+project_name: determined-helm
+
+build:
+  skip: true
+
+release:
+  github:
+    owner: determined-ai
+    name: determined-dryrun
+  mode: keep-existing
+  extra_files:
+    - glob: build/determined-latest.tgz
+      name_template: "determined-helm-chart_{{ .Env.VERSION }}.tgz"
+
+  # be sure to keep this in sync between agent/master/helm
+  # the "include" functionality is only in the pro version
+  header: |
+    ## Release Notes
+    [{{ .Tag }}](https://github.com/determined-ai/determined-dryrun/blob/{{ .Tag }}/docs/release-notes.rst)
diff --git a/helm/Makefile b/helm/Makefile
index a5fa7167708..8e2f5657bab 100644
--- a/helm/Makefile
+++ b/helm/Makefile
@@ -1,9 +1,10 @@
-export VERSION:=$(shell cat ../VERSION)
+export VERSION:=$(shell ../version.sh)
+export VERSION_TAG:=$(shell ../version.sh -t)
 
 build/stamp: $(shell find charts -type f)
 	mkdir -p build
 	rm -rf build/*.tgz
-	helm package --destination build charts/determined
+	helm package --destination build charts/determined --version $(VERSION) --app-version $(VERSION)
 	cd build/ && ln -s determined-*.tgz determined-latest.tgz && cd ..
 	touch $@
 
@@ -18,17 +19,27 @@ clean:
 	rm -rf build/
 
 .PHONY: release-gh
-release-gh: export GORELEASER_CURRENT_TAG := $(VERSION)
-release-gh: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+$$' | grep "$(VERSION)" -A1 | sed -n '2 p')
+release-gh: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
+release-gh: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+$$' | grep "$(VERSION_TAG)" -A1 | sed -n '2 p')
 release-gh:
 	go install github.com/goreleaser/goreleaser@v1.14.1
 	git clean -df
 	goreleaser --rm-dist
 
+.PHONY: release-gh-dryrun
+release-gh-dryrun: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
+release-gh-dryrun: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+$$' | grep "$(VERSION_TAG)" -A1 | sed -n '2 p')
+release-gh-dryrun:
+	go install github.com/goreleaser/goreleaser@v1.14.1
+	git clean -df
+	goreleaser --rm-dist -f ./.goreleaser_dryrun.yml
+
 .PHONY: release-gh-ee
-release-gh-ee: export GORELEASER_CURRENT_TAG := $(VERSION)-ee
-release-gh-ee: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+-ee$$' | grep "$(VERSION)-ee" -A1 | sed -n '2 p')
+release-gh-ee: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)-ee
+release-gh-ee: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+-ee$$' | grep "$(VERSION_TAG)-ee" -A1 | sed -n '2 p')
 release-gh-ee:
 	go install github.com/goreleaser/goreleaser@v1.14.1
 	git clean -df
 	goreleaser --rm-dist
+
+release-gh-ee-dryrun: ;
diff --git a/helm/charts/determined/Chart.yaml b/helm/charts/determined/Chart.yaml
index 2719e158823..98089187be0 100644
--- a/helm/charts/determined/Chart.yaml
+++ b/helm/charts/determined/Chart.yaml
@@ -1,12 +1,19 @@
 apiVersion: v1
 name: determined
 description: A Helm chart for Determined
-version: "0.37.1-dev0"
+
+# This is a placeholder value. The actual version string is passed through the
+# --version flag during helm package.
+version: "0.0.0"
+
 icon: https://github.com/determined-ai/determined/blob/main/determined-logo.svg?raw=true
 home: https://github.com/determined-ai/determined.git
 
 # appVersion controls the version HPE MLDE / Determined OSS that is deployed.
 # If using a non-release version (e.g., X.Y.Z.dev0) you will have to specify an
-# existing official release version (e.g., X.Y.Z) or specify a commit has
-# that has been publicly published (all commits from main).
-appVersion: "0.37.1-dev0"
+# existing official release version (e.g., X.Y.Z) or specify a commit that has
+# been publicly published (all commits from main).
+#
+# This is a placeholder value. The actual version string is passed through the
+# --app-version flag during helm package.
+appVersion: "0.0.0"
diff --git a/master/.goreleaser_dryrun.yml b/master/.goreleaser_dryrun.yml
new file mode 100644
index 00000000000..31155629a57
--- /dev/null
+++ b/master/.goreleaser_dryrun.yml
@@ -0,0 +1,203 @@
+project_name: determined-master-dryrun
+
+before:
+  hooks:
+    - make pre-package
+
+snapshot:
+  name_template: "{{ .Tag }}"
+
+builds:
+  - main: ./cmd/determined-master
+    id: determined-master
+    binary: determined-master
+    ldflags:
+      - -X github.com/determined-ai/determined/master/version.Version={{.Env.VERSION}}
+      - -X github.com/determined-ai/determined/master/internal/config.DefaultSegmentMasterKey={{.Env.DET_SEGMENT_MASTER_KEY}}
+      - -X github.com/determined-ai/determined/master/internal/config.DefaultSegmentWebUIKey={{.Env.DET_SEGMENT_WEBUI_KEY}}
+    goos:
+      - linux
+      - darwin
+    goarch:
+      - amd64
+      - arm64
+  - main: ./cmd/determined-gotmpl
+    id: determined-gotmpl
+    binary: determined-gotmpl
+    goos:
+      - linux
+      - darwin
+    goarch:
+      - amd64
+      - arm64
+
+archives:
+  - wrap_in_directory: "true"
+    rlcp: true
+    name_template: "determined-master_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 \"v1\") }}{{ .Amd64 }}{{ end }}"
+    files:
+      - src: "packaging/master.yaml"
+        dst: "etc/determined/"
+        strip_parent: true
+
+      - src: "packaging/determined-master.service"
+        dst: "lib/systemd/system/"
+        strip_parent: true
+      - src: "packaging/determined-master.socket"
+        dst: "lib/systemd/system/"
+        strip_parent: true
+      - src: "packaging/LICENSE"
+        strip_parent: true
+      - src: "static/**/*"
+        dst: "share/static"
+      - src: "build/**/*"
+        dst: "share"
+
+brews:
+  - name: determined-master
+    tap:
+      owner: determined-ai
+      name: homebrew-determined-dryrun
+    url_template: "https://github.com/determined-ai/determined-dryrun/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
+    caveats: |
+      Determined master config is located at #{etc}/determined/master.yaml
+
+      Make sure to setup the determined database:
+        brew services start postgresql@14
+        createuser postgres
+        createdb determined
+
+      Checkpoints are stored in #{var}/determined/data by default.
+      Make sure to configure it as a shared path for Docker for Mac in
+      Docker -> Preferences... -> Resources -> File Sharing.
+    homepage: "https://github.com/determined-ai/determined-dryrun"
+    license: "Apache-2.0"
+    folder: Formula
+    dependencies:
+      - "postgresql@14"
+    install: |
+      bin.install "determined-master"
+
+      doc.install "LICENSE"
+      pkgshare.install Dir["share/*"]
+
+      (var/"cache/determined").mkpath
+      (var/"determined/data").mkpath
+      (var/"log/determined").mkpath
+
+      (etc/"determined").mkpath
+      inreplace "etc/determined/master.yaml" do |s|
+        s.gsub! "  host_path: /tmp", "  host_path: #{var}/determined/data"
+      end
+      Pathname("etc/determined/master.yaml").append_lines <<~EOS
+        root: #{opt_pkgshare}
+        cache:
+          cache_dir: #{var}/cache/determined
+      EOS
+      etc.install "etc/determined/master.yaml" => "determined/master.yaml"
+    service: |
+      run [opt_bin/"determined-master", "--config-file", etc/"determined/master.yaml"]
+      keep_alive false
+      error_log_path var/"log/determined/master-stderr.log"
+      log_path var/"log/determined/master-stdout.log"
+
+nfpms:
+  - maintainer: "Determined AI "
+    file_name_template: "determined-master_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 \"v1\") }}{{ .Amd64 }}{{ end }}"
+    formats:
+      - deb
+      - rpm
+    contents:
+      - src: "packaging/master.yaml"
+        dst: "/etc/determined/master.yaml"
+        type: config|noreplace
+        file_info:
+            mode: 0600
+      - src: "build/**/*"
+        dst: "/usr/share/determined/master"
+      - src: "static/**/*"
+        dst: "/usr/share/determined/master/static"
+      - src: "packaging/determined-master.service"
+        dst: "/lib/systemd/system/determined-master.service"
+      - src: "packaging/determined-master.socket"
+        dst: "/lib/systemd/system/determined-master.socket"
+
+      - src: "packaging/LICENSE"
+        dst: "/usr/share/doc/determined-master/copyright"
+        packager: deb
+
+      - src: "packaging/LICENSE"
+        dst: "/usr/share/licenses/determined-master/LICENSE"
+        packager: rpm
+
+    overrides:
+      deb:
+        scripts:
+          postinstall: packaging/debian/master.postinst
+          preremove: packaging/debian/master.prerm
+          postremove: packaging/debian/master.postrm
+
+release:
+  github:
+    owner: determined-ai
+    name: determined-dryrun
+
+  # be sure to keep this in sync between agent/master/helm
+  # the "include" functionality is only in the pro version
+  header: |
+    ## Release Notes
+    [{{ .Tag }}](https://github.com/determined-ai/determined-dryrun/blob/{{ .Tag }}/docs/release-notes.rst)
+
+dockers:
+  # amd64
+  - goos: linux
+    goarch: amd64
+    use: buildx
+    build_flag_templates:
+      - --platform=linux/amd64
+      - --builder=buildx-build
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+    extra_files:
+      - "packaging/master.yaml"
+      - "packaging/LICENSE"
+      - "build"
+      - "static"
+    ids:
+      - determined-master
+      - determined-gotmpl
+  # arm64
+  - goos: linux
+    goarch: arm64
+    use: buildx
+    build_flag_templates:
+      - --platform=linux/arm64
+      - --builder=buildx-build
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
+    extra_files:
+      - "packaging/master.yaml"
+      - "packaging/LICENSE"
+      - "build"
+      - "static"
+    ids:
+      - determined-master
+      - determined-gotmpl
+
+docker_manifests:
+  - name_template: "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}"
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
+  - name_template: "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.ShortCommit}}"
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
+  - name_template: "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.Env.VERSION_DOCKER}}"
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
+  - name_template: "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:latest"
+    image_templates:
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-amd64"
+      - "{{.Env.DOCKER_REPO}}/{{.ProjectName}}:{{.FullCommit}}-arm64"
diff --git a/master/Makefile b/master/Makefile
index a326e51fcce..11616bec416 100644
--- a/master/Makefile
+++ b/master/Makefile
@@ -8,17 +8,24 @@ STREAM_TS_CLIENT = ../webui/react/src/services/stream/wire.ts
 MOCK_INPUTS = Makefile ./internal/sproto/task.go ./internal/db/database.go ./internal/command/authz_iface.go ../go.mod ../go.sum ./internal/rm/resource_manager_iface.go ./internal/task/allocation_service_iface.go
 GORELEASER = goreleaser
 
-export VERSION := $(shell cat ../VERSION)
+export VERSION := $(shell ../version.sh)
+export VERSION_TAG:=$(shell ../version.sh -t)
+export VERSION_DOCKER := $(shell ../version.sh -d)
+
 export GO111MODULE := on
+
+# The Docker Hub organization.
 export DOCKER_REPO ?= determinedai
 
 FULL_COMMIT = $(shell git rev-parse HEAD)
 SHORT_COMMIT = $(shell git rev-parse HEAD | head -c9)
 PROJECT_NAME = determined-master
+PROJECT_NAME_DRYRUN = determined-master-dryrun
 EE_PROJECT_NAME = hpe-mlde-master
 ARCHS = amd64 arm64
 ARCH_SMALL = amd64-shared-cluster
 MULTI_ARCH_IMAGES = $(shell for arch in $(ARCHS); do echo $(DOCKER_REPO)/$(PROJECT_NAME):$(FULL_COMMIT)-$$arch; done)
+MULTI_ARCH_IMAGES_DRYRUN = $(shell for arch in $(ARCHS); do echo $(DOCKER_REPO)/$(PROJECT_NAME_DRYRUN):$(FULL_COMMIT)-$$arch; done)
 EE_MULTI_ARCH_IMAGES = $(shell for arch in $(ARCHS); do echo $(DOCKER_REPO)/$(EE_PROJECT_NAME):$(FULL_COMMIT)-$$arch; done)
 SHARED_CLUSTER_IMAGE=$(shell echo $(DOCKER_REPO)/$(PROJECT_NAME):$(FULL_COMMIT)-$(ARCH_SMALL))
 
@@ -29,6 +36,11 @@ PUB_MANIFESTS = \
 	$(DOCKER_REPO)/$(PROJECT_NAME):$(SHORT_COMMIT) \
 	$(DOCKER_REPO)/$(PROJECT_NAME):$(VERSION)
 
+PUB_MANIFESTS_DRYRUN = \
+	$(DOCKER_REPO)/$(PROJECT_NAME_DRYRUN):$(FULL_COMMIT) \
+	$(DOCKER_REPO)/$(PROJECT_NAME_DRYRUN):$(SHORT_COMMIT) \
+	$(DOCKER_REPO)/$(PROJECT_NAME_DRYRUN):$(VERSION)
+
 EE_PUB_MANIFESTS = \
 	$(DOCKER_REPO)/$(EE_PROJECT_NAME):$(FULL_COMMIT) \
 	$(DOCKER_REPO)/$(EE_PROJECT_NAME):$(SHORT_COMMIT) \
@@ -227,42 +239,58 @@ packaging/LICENSE: $(shell find ../tools/scripts/licenses -type f)
 .PHONY: package
 package: export DET_SEGMENT_MASTER_KEY ?=
 package: export DET_SEGMENT_WEBUI_KEY ?=
-package: export GORELEASER_CURRENT_TAG := $(VERSION)
+package: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
 package: gen buildx
-	$(GORELEASER) --snapshot --rm-dist 
+	$(GORELEASER) --snapshot --rm-dist
+
+.PHONY: package-dryrun
+package-dryrun: export DET_SEGMENT_MASTER_KEY ?=
+package-dryrun: export DET_SEGMENT_WEBUI_KEY ?=
+package-dryrun: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
+package-dryrun: gen buildx
+	$(GORELEASER) --snapshot --rm-dist ./.goreleaser_dryrun.yml
 
 .PHONY: package-ee
 package-ee: export DET_SEGMENT_MASTER_KEY ?=
 package-ee: export DET_SEGMENT_WEBUI_KEY ?=
 package-ee: export DET_EE_LICENSE_KEY = $(shell cat ../license.txt)
 package-ee: export DET_EE_PUBLIC_KEY = $(shell cat ../public.txt)
-package-ee: export GORELEASER_CURRENT_TAG := $(VERSION)-ee
+package-ee: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)-ee
 package-ee: gen buildx
 	$(GORELEASER) --snapshot --rm-dist -f ./.goreleaser_ee.yml
 
 .PHONY: package-small
 package-small: export DET_SEGMENT_MASTER_KEY ?=
 package-small: export DET_SEGMENT_WEBUI_KEY ?=
-package-small: export GORELEASER_CURRENT_TAG := $(VERSION)
+package-small: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
 package-small: gen buildx-small
 	$(GORELEASER) --snapshot --rm-dist -f ./.goreleaser_sharedcluster.yml
 
 .PHONY: release
 release: export DET_SEGMENT_MASTER_KEY ?=
 release: export DET_SEGMENT_WEBUI_KEY ?=
-release: export GORELEASER_CURRENT_TAG := $(VERSION)
-release: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+$$' | grep "$(VERSION)" -A1 | sed -n '2 p')
+release: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
+release: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+$$' | grep "$(VERSION_TAG)" -A1 | sed -n '2 p')
 release: gen buildx
 	$(GORELEASER) --rm-dist
 	make publish-nvcr
 
+.PHONY: release-dryrun
+release-dryrun: export DET_SEGMENT_MASTER_KEY ?=
+release-dryrun: export DET_SEGMENT_WEBUI_KEY ?=
+release-dryrun: export GORELEASER_CURRENT_TAG := $(VERSION_TAG)
+release-dryrun: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+$$' | grep "$(VERSION_TAG)" -A1 | sed -n '2 p')
+# We intentionally do not invoke `make publish-nvcr(-dryrun)` here.
+release-dryrun: gen buildx
+	$(GORELEASER) --rm-dist -f ./.goreleaser_dryrun.yml
+
 .PHONY: release-ee
 release-ee: export DET_SEGMENT_MASTER_KEY ?=
 release-ee: export DET_SEGMENT_WEBUI_KEY ?=
 release-ee: export DET_EE_LICENSE_KEY = $(shell cat ../license.txt)
 release-ee: export DET_EE_PUBLIC_KEY = $(shell cat ../public.txt)
 release-ee: export GORELEASER_CURRENT_TAG := $(VERSION)-ee
-release-ee: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+-ee$$' | grep "$(VERSION)-ee" -A1 | sed -n '2 p')
+release-ee: export GORELEASER_PREVIOUS_TAG := $(shell git tag --sort=-creatordate | grep -E '^[0-9.]+-ee$$' | grep "$(VERSION_TAG)-ee" -A1 | sed -n '2 p')
 release-ee: gen buildx
 	$(GORELEASER) --rm-dist -f ./.goreleaser_ee.yml
 
@@ -293,6 +321,12 @@ publish-dev-small:
 publish:
 	@$(call manifest_publish, $(PUB_MANIFESTS), $(MULTI_ARCH_IMAGES))
 
+.PHONY: publish-dryrun
+# Build and upload production images to the determinedai/determined-dryrun
+# repository.
+publish-dryrun:
+	@$(call manifest_publish, $(PUB_MANIFESTS_DRYRUN), $(MULTI_ARCH_IMAGES_DRYRUN))
+
 .PHONY: publish-ee
 publish-ee:
 	@$(call manifest_publish, $(EE_PUB_MANIFESTS), $(EE_MULTI_ARCH_IMAGES))
diff --git a/master/pkg/tasks/copy.go b/master/pkg/tasks/copy.go
index 9e6fd46eb8c..28d545d1e9b 100644
--- a/master/pkg/tasks/copy.go
+++ b/master/pkg/tasks/copy.go
@@ -40,6 +40,7 @@ func harnessArchive(harnessPath string, aug *model.AgentUserGroup) cproto.RunArc
 		panic(errors.Wrapf(err, "error finding Python wheel files for version %s in path: %s",
 			version.Version, harnessPath))
 	}
+
 	for _, path := range wheelPaths {
 		info, err := os.Stat(path)
 		if err != nil {
diff --git a/requirements.txt b/requirements.txt
index 29bc496bca3..819d39390c1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,13 +21,13 @@ flake8-tidy-imports
 isort==5.11.5 # Note: this should be kept in sync with the version in `.pre-commit-config.yaml`.
 jax==0.3.15  # Versions 0.4.* use self-documenting f-strings, which are incompatible with Python 3.7, which mypy is configured for. Later patches of 0.3.* cause other mypy errors.
 mypy==0.910
-bump2version>=1.0.1
 pre-commit~=2.20
 # Earlier versions had different type annotations
 pyzmq>=23.2.1
 devcluster>=1,<2
 coverage
 sqlfluff==2.1.4
+setuptools==70.0.0
 # typeshed
 types-certifi
 types-chardet
diff --git a/tools/scripts/insert-dropdown-url.sh b/tools/scripts/insert-dropdown-url.sh
deleted file mode 100755
index 0d133002cc1..00000000000
--- a/tools/scripts/insert-dropdown-url.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/bash -ex
-
-# check for dirty changes
-if [[ -n "$(git status --porcelain)" ]]; then
-    echo "untracked or dirty files are not allowed, cleanup before running insert-dropdown-url.sh"
-    exit 1
-fi
-
-# insert-version-url.py inserts a new entry into the versions.json--adding a dropdown link
-python3 docs/insert-version-url.py "$(grep -oE '^[0-9.]*' VERSION)"
-
-git add --update
-git commit -m "chore: add docs dropdown link for new version"
diff --git a/version.sh b/version.sh
new file mode 100755
index 00000000000..0472da9025a
--- /dev/null
+++ b/version.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+
+# This script dynamically determines an appropriate version string for the
+# currently checked-out commit. As tag versions will typically be provided by CI
+# for releases, this script is primarily to support local builds that work as
+# one would expect.
+#
+# Consider the following git DAG representing a hypothetical Determined git
+# tree:
+#
+#                     HEAD
+#                      |
+#                  I---J (feat-behind-latest-tag)
+#                /
+#...A---B---C---D---E---F---M---N---O (main)
+#    \                   \
+#     G---H (1.1.x)       K---L (1.2.x)
+#         |                   |
+#       1.1.0               1.2.0
+#
+# The newest version is 1.2.0, as tagged on the release branch 1.2.x, at L. The
+# previous version is 1.1.0, on release branch 1.1.x, at H. Our feature branch
+# is feat-behind-latest-tag, with HEAD at J. The goal is to output the nearest
+# tag on the release branch behind wherever we are. At first, I tried to finagle
+# git-rev-list to make this happen, but I was unable to get a working
+# solution. So instead, I used a combination of git-describe and git-tag.
+#
+# git-describe will give us a tag if we're currently on a release branch, or any
+# branch with tags, which is unlikely, but possible. If we're on a feature
+# branch, which is much more likely, we need to use git-tag and git-merge-base
+# to work backward: we find the merge-base of HEAD and main, then search for all
+# tags that don't contain that commit (i.e. all tags created before that
+# commit). From there, we just sort and filter the tag list, and grab the top
+# element, which is the most recent, previous tag.
+#
+# So, in our diagram, if run from B, C, D, E, F, I, J, or K, the script will return
+# 1.1.0. If run from L, M, N, or O, it will return 1.2.0. And so on.
+
+OPTSTRING=":td"
+
+# Options parsing
+# -t outputs the full git tag, necessary for some build steps.
+# -d outputs a Docker-friendly image tag, necessary for pushing to Docker Hub.
+# The default case simply strips the leading 'v'.
+while getopts ${OPTSTRING} opt; do
+    case ${opt} in
+        t)
+            TAG_OUTPUT=1
+            ;;
+        d)
+            DOCKER_OUTPUT=1
+            ;;
+        ?)
+            echo "Invalid option: -${OPTARG}."
+            exit 1
+            ;;
+    esac
+done
+
+# Set VERSION to CIRCLE_TAG in case we're running in CircleCI. This makes it
+# easier to avoid fiddling with environment variables there.
+if [[ -n ${CIRCLE_TAG} ]]; then
+    VERSION=${CIRCLE_TAG}
+fi
+
+# If VERSION is unset or the empty string, "". This will be the default case for
+# local builds.
+if [[ -z ${VERSION} ]]; then
+    # Check if this branch has any tags (typically, only release branches will
+    # have tags).
+    MAYBE_TAG=$(git describe --tags --abbrev=0 2>/dev/null | grep -Eo 'v?\d+\.\d+\.\d+')
+    SHA=$(git rev-parse --short HEAD)
+
+    # No tag on current branch.
+    if [[ -z ${MAYBE_TAG} ]]; then
+        # Use git to find the merge base between the current branch and main,
+        # and then find the closest tag behind that, using --no-contains. Then,
+        # use grep to remove some special cases, namely: old Determined version
+        # tags beginning with 'v', and all tags that end in '-ee'. Then, use
+        # head to grab the first one, since the list is sorted in descending
+        # order, handling -rc tags correctly courtesy of
+        # versionsort.suffix.
+        MAYBE_TAG=$(
+            git \
+                -c versionsort.suffix='-rc' \
+                tag \
+                --sort='-v:refname:short' \
+                --format='%(refname:short)' \
+                --no-contains=$(git merge-base HEAD main) \
+                | grep -E -v 'v0.12|-ee' \
+                | head -n 1
+        )
+    fi
+
+    # Munge the tag into the form we want. Note: we always append a SHA hash,
+    # even if we're on the commit with the tag. This is partially because I feel
+    # like it will be more consistent and result in fewer surprises, but also it
+    # might help indicate that this is a local version.
+    if [[ -n ${TAG_OUTPUT} ]]; then
+        echo -n "${MAYBE_TAG}+${SHA}"
+    elif [[ -n ${DOCKER_OUTPUT} ]]; then
+        # Docker image tags must have the following format:
+        # [A-Za-z0-9_][A-Za-z0-9_\.\-]{0,127}
+        echo -n "${MAYBE_TAG#v}-${SHA}" | sed -e 's/\+/-/g'
+    else
+        echo -n "${MAYBE_TAG#v}+${SHA}"
+    fi
+else
+    # Use existing VERSION, which is much easier. This should be the default
+    # case for CI, as VERSION will already be set. We also remove the 'v' from
+    # the tag for the version string, as that is what the current CI
+    # functionality expects. Some build steps expect
+    # the version string to be the full tag, so check for a -t flag and return
+    # the full tag version string if it's set. Otherwise, return the version
+    # string without the 'v'.
+    if [[ -n ${TAG_OUTPUT} ]]; then
+        echo -n "${VERSION}"
+    elif [[ -n ${DOCKER_OUTPUT} ]]; then
+        # Docker image tags must have the following format:
+        # [A-Za-z0-9_][A-Za-z0-9_\.\-]{0,127}
+        echo -n "${VERSION#v}" | sed -e 's/\+/-/g'
+    else
+        echo -n "${VERSION#v}"
+    fi
+fi
diff --git a/webui/react/vite.config.mts b/webui/react/vite.config.mts
index 8d5fdaf830c..3d9fa879ceb 100644
--- a/webui/react/vite.config.mts
+++ b/webui/react/vite.config.mts
@@ -1,6 +1,7 @@
 import crypto from 'crypto';
 import fs from 'fs';
 import path from 'path';
+import * as child from 'child_process';
 
 import { svgToReact } from '@hpe.com/vite-plugin-svg-to-jsx';
 import react from '@vitejs/plugin-react-swc';
@@ -89,7 +90,7 @@ export default defineConfig(({ mode }) => ({
     'process.env.IS_DEV': JSON.stringify(mode === 'development'),
     'process.env.PUBLIC_URL': JSON.stringify((mode !== 'test' && publicUrl) || ''),
     'process.env.SERVER_ADDRESS': JSON.stringify(process.env.SERVER_ADDRESS),
-    'process.env.VERSION': '"0.37.1-dev0"',
+    'process.env.VERSION': JSON.stringify(process.env.VERSION || child.execSync('../../version.sh').toString().trimEnd()),
   },
   optimizeDeps: {
     include: ['notebook'],