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