diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 46ed2f88..f0c2425a 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -53,18 +53,3 @@ jobs:
           config: .github/typos.toml
       - name: Lint
         run: bun lint
-      # Disable version check until https://github.com/coder/modules/pull/426 is merged.
-      # This will allow us to use separate versioning for each module without failing CI. The backend already supports that.
-      # - name: Check version
-      #   shell: bash
-      #   run: |
-      #     # check for version changes
-      #     ./update-version.sh
-      #     # Check if any changes were made in README.md files
-      #     if [[ -n "$(git status --porcelain -- '**/README.md')" ]]; then
-      #       echo "Version mismatch detected. Please run ./update-version.sh and commit the updated README.md files."
-      #       git diff -- '**/README.md'
-      #       exit 1
-      #     else
-      #       echo "No version mismatch detected. All versions are up to date."
-      #     fi
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 60b1260b..2c7ba8bf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -22,7 +22,8 @@ Follow the instructions to ensure that Bun is available globally. Once Bun has b
 
 ## Testing a Module
 
-> **Note:** It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR.
+> [!NOTE]
+> It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR.
 
 A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers.
 
@@ -53,23 +54,44 @@ module "example" {
 
 ## Releases
 
-> [!WARNING]
-> When creating a new release, make sure that your new version number is fully accurate. If a version number is incorrect or does not exist, we may end up serving incorrect/old data for our various tools and providers.
+The release process is automated with these steps:
+
+## 1. Create and Merge PR
+
+- Create a PR with your module changes
+- Get your PR reviewed, approved, and merged to `main`
+
+## 2. Prepare Release (Maintainer Task)
+
+After merging to `main`, a maintainer will:
+
+- View all modules and their current versions:
+
+  ```shell
+  ./release.sh --list
+  ```
+
+- Determine the next version number based on changes:
+
+  - **Patch version** (1.2.3 → 1.2.4): Bug fixes
+  - **Minor version** (1.2.3 → 1.3.0): New features, adding inputs, deprecating inputs
+  - **Major version** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing input types)
 
-Much of our release process is automated. To cut a new release:
+- Create and push an annotated tag:
 
-1. Navigate to [GitHub's Releases page](https://github.com/coder/modules/releases)
-2. Click "Draft a new release"
-3. Click the "Choose a tag" button and type a new release number in the format `v<major>.<minor>.<patch>` (e.g., `v1.18.0`). Then click "Create new tag".
-4. Click the "Generate release notes" button, and clean up the resulting README. Be sure to remove any notes that would not be relevant to end-users (e.g., bumping dependencies).
-5. Once everything looks good, click the "Publish release" button.
+  ```shell
+  # Fetch latest changes
+  git fetch origin
+  
+  # Create and push tag
+  ./release.sh module-name 1.2.3 --push
+  ```
 
-Once the release has been cut, a script will run to check whether there are any modules that will require that the new release number be published to Terraform. If there are any, a new pull request will automatically be generated. Be sure to approve this PR and merge it into the `main` branch.
+  The tag format will be: `release/module-name/v1.2.3`
 
-Following that, our automated processes will handle publishing new data for [`registry.coder.com`](https://github.com/coder/registry.coder.com/):
+## 3. Publishing to Registry
 
-1. Publishing new versions to Coder's [Terraform Registry](https://registry.terraform.io/providers/coder/coder/latest)
-2. Publishing new data to the [Coder Registry](https://registry.coder.com)
+Our automated processes will handle publishing new data to [registry.coder.com](https://registry.coder.com).
 
 > [!NOTE]
-> Some data in `registry.coder.com` is fetched on demand from the Module repo's main branch. This data should be updated almost immediately after a new release, but other changes will take some time to propagate.
+> Some data in registry.coder.com is fetched on demand from the [coder/modules](https://github.com/coder/modules) repo's `main` branch. This data should update almost immediately after a release, while other changes will take some time to propagate.
diff --git a/package.json b/package.json
index eea421d8..a122f4f2 100644
--- a/package.json
+++ b/package.json
@@ -2,10 +2,9 @@
   "name": "modules",
   "scripts": {
     "test": "bun test",
-    "fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf",
+    "fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh terraform_validate.sh release.sh update_version.sh  **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf",
     "fmt:ci": "bun x prettier --check **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt -check **/*.tf .sample/main.tf",
-    "lint": "bun run lint.ts && ./terraform_validate.sh",
-    "update-version": "./update-version.sh"
+    "lint": "bun run lint.ts && ./terraform_validate.sh"
   },
   "devDependencies": {
     "bun-types": "^1.1.23",
diff --git a/release.sh b/release.sh
new file mode 100755
index 00000000..167fc1cd
--- /dev/null
+++ b/release.sh
@@ -0,0 +1,196 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+  cat <<EOF
+Usage: $0 [OPTIONS] [<MODULE> <VERSION>]
+
+Create annotated git tags for module releases.
+
+This script is used by maintainers to create annotated tags for module 
+releases. When a tag is pushed, it triggers a GitHub workflow that 
+updates README versions.
+
+Options:
+  -l, --list     List all modules with their versions
+  -n, --dry-run  Show what would be done without making changes
+  -p, --push     Push the created tag to the remote repository
+  -h, --help     Show this help message
+
+Examples:
+  $0 --list
+  $0 nodejs 1.2.3
+  $0 nodejs 1.2.3 --push
+  $0 --dry-run nodejs 1.2.3
+EOF
+  exit "${1:-0}"
+}
+
+check_getopt() {
+  # Check if we have GNU or BSD getopt.
+  if getopt --test >/dev/null 2>&1; then
+    # Exit status 4 means GNU getopt is available.
+    if [[ $? -ne 4 ]]; then
+      echo "Error: GNU getopt is not available." >&2
+      echo "On macOS, you can install GNU getopt and add it to your PATH:" >&2
+      echo
+      echo $'\tbrew install gnu-getopt' >&2
+      echo $'\texport PATH="$(brew --prefix gnu-getopt)/bin:$PATH"' >&2
+      exit 1
+    fi
+  fi
+}
+
+maybe_dry_run() {
+  if [[ $dry_run == true ]]; then
+    echo "[DRY RUN] $*"
+    return 0
+  fi
+  "$@"
+}
+
+get_readme_version() {
+  grep -o 'version *= *"[0-9]\+\.[0-9]\+\.[0-9]\+"' "$1" |
+    head -1 |
+    grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' ||
+    echo "0.0.0"
+}
+
+list_modules() {
+  printf "\nListing all modules and their latest versions:\n"
+  printf "%s\n" "--------------------------------------------------------------"
+  printf "%-30s %-15s %-15s\n" "MODULE" "README VERSION" "LATEST TAG"
+  printf "%s\n" "--------------------------------------------------------------"
+
+  # Process each module directory.
+  for dir in */; do
+    # Skip non-module directories.
+    [[ ! -d $dir || ! -f ${dir}README.md || $dir == ".git/" ]] && continue
+
+    module="${dir%/}"
+    readme_version=$(get_readme_version "${dir}README.md")
+    latest_tag=$(git tag -l "release/${module}/v*" | sort -V | tail -n 1)
+    tag_version="none"
+    if [[ -n $latest_tag ]]; then
+      tag_version="${latest_tag#"release/${module}/v"}"
+    fi
+
+    printf "%-30s %-15s %-15s\n" "$module" "$readme_version" "$tag_version"
+  done
+
+  printf "%s\n" "--------------------------------------------------------------"
+}
+
+is_valid_version() {
+  if ! [[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+    echo "Error: Version must be in format X.Y.Z (e.g., 1.2.3)" >&2
+    return 1
+  fi
+}
+
+get_tag_name() {
+  local module="$1"
+  local version="$2"
+  local tag_name="release/$module/v$version"
+  local readme_path="$module/README.md"
+
+  if [[ ! -d $module || ! -f $readme_path ]]; then
+    echo "Error: Module '$module' not found or missing README.md" >&2
+    return 1
+  fi
+
+  local readme_version
+  readme_version=$(get_readme_version "$readme_path")
+
+  {
+    echo "Module: $module"
+    echo "Current README version: $readme_version"
+    echo "New tag version: $version"
+    echo "Tag name: $tag_name"
+  } >&2
+
+  echo "$tag_name"
+}
+
+# Ensure getopt is available.
+check_getopt
+
+# Set defaults.
+list=false
+dry_run=false
+push=false
+module=
+version=
+
+# Parse command-line options.
+if ! temp=$(getopt -o ldph --long list,dry-run,push,help -n "$0" -- "$@"); then
+  echo "Error: Failed to parse arguments" >&2
+  usage 1
+fi
+eval set -- "$temp"
+
+while true; do
+  case "$1" in
+  -l | --list)
+    list=true
+    shift
+    ;;
+  -d | --dry-run)
+    dry_run=true
+    shift
+    ;;
+  -p | --push)
+    push=true
+    shift
+    ;;
+  -h | --help)
+    usage
+    ;;
+  --)
+    shift
+    break
+    ;;
+  *)
+    echo "Error: Internal error!" >&2
+    exit 1
+    ;;
+  esac
+done
+
+if [[ $list == true ]]; then
+  list_modules
+  exit 0
+fi
+
+if [[ $# -ne 2 ]]; then
+  echo "Error: MODULE and VERSION are required when not using --list" >&2
+  usage 1
+fi
+
+module="$1"
+version="$2"
+
+if ! is_valid_version "$version"; then
+  exit 1
+fi
+
+if ! tag_name=$(get_tag_name "$module" "$version"); then
+  exit 1
+fi
+
+if git rev-parse -q --verify "refs/tags/$tag_name" >/dev/null 2>&1; then
+  echo "Notice: Tag '$tag_name' already exists" >&2
+else
+  maybe_dry_run git tag -a "$tag_name" -m "Release $module v$version"
+  if [[ $push == true ]]; then
+    maybe_dry_run echo "Tag '$tag_name' created."
+  else
+    maybe_dry_run echo "Tag '$tag_name' created locally. Use --push to push it to remote."
+    maybe_dry_run "ℹ️ Note: Remember to push the tag when ready."
+  fi
+fi
+
+if [[ $push == true ]]; then
+  maybe_dry_run git push origin "$tag_name"
+  maybe_dry_run echo "Success! Tag '$tag_name' pushed to remote."
+fi
diff --git a/update-version.sh b/update-version.sh
deleted file mode 100755
index 09547f9c..00000000
--- a/update-version.sh
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env bash
-
-# This script increments the version number in the README.md files of all modules
-# by 1 patch version. It is intended to be run from the root
-# of the repository or by using the `bun update-version` command.
-
-set -euo pipefail
-
-current_tag=$(git describe --tags --abbrev=0)
-
-# Increment the patch version
-LATEST_TAG=$(echo "$current_tag" | sed 's/^v//' | awk -F. '{print $1"."$2"."$3+1}') || exit $?
-
-# List directories with changes that are not README.md or test files
-mapfile -t changed_dirs < <(git diff --name-only "$current_tag" -- ':!**/README.md' ':!**/*.test.ts' | xargs dirname | grep -v '^\.' | sort -u)
-
-echo "Directories with changes: ${changed_dirs[*]}"
-
-# Iterate over directories and update version in README.md
-for dir in "${changed_dirs[@]}"; do
-  if [[ -f "$dir/README.md" ]]; then
-    file="$dir/README.md"
-    tmpfile=$(mktemp /tmp/tempfile.XXXXXX)
-    awk -v tag="$LATEST_TAG" '
-      BEGIN { in_code_block = 0; in_nested_block = 0 }
-      {
-        # Detect the start and end of Markdown code blocks.
-        if ($0 ~ /^```/) {
-          in_code_block = !in_code_block
-          # Reset nested block tracking when exiting a code block.
-          if (!in_code_block) {
-            in_nested_block = 0
-          }
-        }
-
-        # Handle nested blocks within a code block.
-        if (in_code_block) {
-          # Detect the start of a nested block (skipping "module" blocks).
-          if ($0 ~ /{/ && !($1 == "module" || $1 ~ /^[a-zA-Z0-9_]+$/)) {
-            in_nested_block++
-          }
-
-          # Detect the end of a nested block.
-          if ($0 ~ /}/ && in_nested_block > 0) {
-            in_nested_block--
-          }
-
-          # Update "version" only if not in a nested block.
-          if (!in_nested_block && $1 == "version" && $2 == "=") {
-            sub(/"[^"]*"/, "\"" tag "\"")
-          }
-        }
-
-        print
-      }
-    ' "$file" > "$tmpfile" && mv "$tmpfile" "$file"
-
-    # Check if the README.md file has changed
-    if ! git diff --quiet -- "$dir/README.md"; then
-      echo "Bumping version in $dir/README.md from $current_tag to $LATEST_TAG (incremented)"
-    else
-      echo "Version in $dir/README.md is already up to date"
-    fi
-  fi
-done
diff --git a/update_version.sh b/update_version.sh
new file mode 100755
index 00000000..1f85a907
--- /dev/null
+++ b/update_version.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+  cat <<EOF
+Usage: $0 [OPTIONS] <MODULE> <VERSION>
+
+Update or check the version in a module's README.md file.
+
+Options:
+  -c, --check  Check if README.md version matches VERSION without updating
+  -h, --help   Display this help message and exit
+
+Examples:
+  $0 code-server 1.2.3          # Update version in code-server/README.md
+  $0 --check code-server 1.2.3  # Check if version matches 1.2.3
+EOF
+  exit "${1:-0}"
+}
+
+is_valid_version() {
+  if ! [[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+    echo "Error: Version must be in format X.Y.Z (e.g., 1.2.3)" >&2
+    return 1
+  fi
+}
+
+update_version() {
+  local file="$1" current_tag="$2" latest_tag="$3" tmpfile
+  tmpfile=$(mktemp)
+
+  echo "Updating version in $file from $current_tag to $latest_tag..."
+
+  awk -v tag="$latest_tag" '
+    BEGIN { in_code_block = 0; in_nested_block = 0 }
+    {
+      # Detect the start and end of Markdown code blocks.
+      if ($0 ~ /^```/) {
+        in_code_block = !in_code_block
+        # Reset nested block tracking when exiting a code block.
+        if (!in_code_block) {
+          in_nested_block = 0
+        }
+      }
+
+      # Handle nested blocks within a code block.
+      if (in_code_block) {
+        # Detect the start of a nested block (skipping "module" blocks).
+        if ($0 ~ /{/ && !($1 == "module" || $1 ~ /^[a-zA-Z0-9_]+$/)) {
+          in_nested_block++
+        }
+
+        # Detect the end of a nested block.
+        if ($0 ~ /}/ && in_nested_block > 0) {
+          in_nested_block--
+        }
+
+        # Update "version" only if not in a nested block.
+        if (!in_nested_block && $1 == "version" && $2 == "=") {
+          sub(/"[^"]*"/, "\"" tag "\"")
+        }
+      }
+
+      print
+    }
+  ' "$file" >"$tmpfile" && mv "$tmpfile" "$file"
+}
+
+get_readme_version() {
+  grep -o 'version *= *"[0-9]\+\.[0-9]\+\.[0-9]\+"' "$1" |
+    head -1 |
+    grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' ||
+    echo "0.0.0"
+}
+
+# Set defaults.
+check_only=false
+
+# Parse command-line options.
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+  -c | --check)
+    check_only=true
+    shift
+    ;;
+  -h | --help)
+    usage 0
+    ;;
+  -*)
+    echo "Error: Unknown option: $1" >&2
+    usage 1
+    ;;
+  *)
+    break
+    ;;
+  esac
+done
+
+if [[ $# -ne 2 ]]; then
+  echo "Error: MODULE and VERSION are required" >&2
+  usage 1
+fi
+
+module_name="$1"
+version="$2"
+
+if [[ ! -d $module_name ]]; then
+  echo "Error: Module directory '$module_name' not found" >&2
+  echo >&2
+  echo "Available modules:" >&2
+  echo >&2
+  find . -type d -mindepth 1 -maxdepth 1 -not -path "*/\.*" | sed 's|^./|\t|' | sort >&2
+  exit 1
+fi
+
+if ! is_valid_version "$version"; then
+  exit 1
+fi
+
+readme_path="$module_name/README.md"
+if [[ ! -f $readme_path ]]; then
+  echo "Error: README.md not found in '$module_name' directory" >&2
+  exit 1
+fi
+
+readme_version=$(get_readme_version "$readme_path")
+
+# In check mode, just return success/failure based on version match.
+if [[ $check_only == true ]]; then
+  if [[ $readme_version == "$version" ]]; then
+    echo "✅ Success: Version in $readme_path matches $version"
+    exit 0
+  else
+    echo "❌ Error: Version mismatch in $readme_path"
+    echo "Expected: $version"
+    echo "Found: $readme_version"
+    exit 1
+  fi
+fi
+
+if [[ $readme_version != "$version" ]]; then
+  update_version "$readme_path" "$readme_version" "$version"
+  echo "✅ Version updated successfully to $version"
+else
+  echo "ℹ️ Version in $readme_path already set to $version, no update needed"
+fi