diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fdb7b38..6abbeec 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -123,14 +123,59 @@ Scopes for this repository: | `ruby` | Ruby tool installation | | `go` | Go tool installation | | `javascript` | JavaScript/TypeScript tool installation | +| `rust` | Rust tool installation | | `security` | Security tool installation (trivy, gitleaks) | | `changelog` | Changelog generation tooling (git-cliff) | | `ci` | CI/CD workflows | +| `release` | Release process and tooling | Examples: - `feat(python): add ruff linter to install script` - `fix(container): resolve arm64 build failure` - `chore(ci): update build workflow to use latest actions` +- `chore(release): prepare v1.6.0` + +## Releasing + +The dev-toolchain container is released via semver-tagged Docker images published to GHCR. + +### When to Cut a Release + +| Change Type | Version Bump | Example | +|---|---|---| +| New language ecosystem | Minor (x.Y.0) | Adding Java support → v1.7.0 | +| New tool or significant feature | Minor (x.Y.0) | Adding a new Makefile target | +| Bug fix or config correction | Patch (x.y.Z) | Fixing a broken install script | +| Breaking change | Major (X.0.0) | Removing a language or changing Makefile contract | +| Routine weekly rebuild | Patch (auto) | Handled automatically by scheduled CI | + +Weekly cron builds (Monday 6AM UTC) automatically bump the patch version and publish. Manual releases are only needed for feature or breaking changes that warrant a specific version number. + +### How to Release + +1. Ensure all changes are merged to `main` and `make check` passes. +2. Run: + ```bash + make release VERSION=1.6.0 + ``` +3. The script will: + - Validate preconditions (on main, clean state, tag doesn't exist) + - Update CHANGELOG.md (move [Unreleased] entries under the new version) + - Commit with `chore(release): prepare v1.6.0` + - Create annotated tag `v1.6.0` + - Prompt for confirmation before pushing + +### What Happens After Push + +Once the tag is pushed to origin, GitHub Actions handles everything: + +1. **build.yml** -- Builds multi-arch container image (amd64 + arm64) and publishes to GHCR +2. **release.yml** -- Creates a GitHub Release with auto-generated release notes and updates the `v1` floating tag +3. **build.yml (version-manifest)** -- Generates `tool-versions.json` from the published container and attaches it to the release + +### Routine Rebuilds + +Weekly Monday builds require no manual action. The `auto-version` job in `build.yml` finds the latest tag, bumps the patch version, and triggers the full build+release pipeline. This keeps tool versions current without manual intervention. diff --git a/Makefile b/Makefile index ac948e7..65110f0 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ HAS_RUST := $(filter rust,$(LANGUAGES)) # --------------------------------------------------------------------------- # .PHONY declarations # --------------------------------------------------------------------------- -.PHONY: help build lint format fix test security scan docs changelog check install-hooks init +.PHONY: help build lint format fix test security scan docs changelog check install-hooks init release .PHONY: _lint _format _fix _test _security _scan _docs _changelog _check _check-config _init # =========================================================================== @@ -106,6 +106,13 @@ init: ## Scaffold config files for declared languages lint: ## Run all linters $(DOCKER_RUN) make _lint +release: ## Cut a versioned release (usage: make release VERSION=1.6.0) + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION is required. Usage: make release VERSION=1.6.0"; \ + exit 2; \ + fi + @bash scripts/release.sh $(VERSION) + scan: ## Run universal scanners (trivy, gitleaks) $(DOCKER_RUN) make _scan diff --git a/README.md b/README.md index 1827f74..1b8d428 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ help Show this help init Scaffold config files for declared languages install-hooks Install pre-commit hooks lint Run all linters +release Cut a versioned release (usage: make release VERSION=1.6.0) scan Run universal scanners (trivy, gitleaks) security Run language-specific security scanners test Run validation tests diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..d69b3de --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# scripts/release.sh — Cut a versioned release of dev-toolchain +# +# Purpose: Updates CHANGELOG.md, commits the changelog, creates a semver tag, +# and pushes to origin. The tag push triggers the existing GitHub +# Actions build and release workflows. +# Usage: bash scripts/release.sh VERSION +# VERSION is a semver string without the v prefix (e.g., 1.6.0) +# Dependencies: git, lib/log.sh +# +# This script runs on the HOST, not inside the container. It needs git push +# access to create and push tags. + +set -euo pipefail + +# --- Resolve library path --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEVRAIL_LIB="${DEVRAIL_LIB:-${SCRIPT_DIR}/../lib}" + +# Force human-readable output for interactive use (must be set before sourcing) +export DEVRAIL_LOG_FORMAT="${DEVRAIL_LOG_FORMAT:-human}" + +# shellcheck source=../lib/log.sh +source "${DEVRAIL_LIB}/log.sh" + +# --- Help --- +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + log_info "release.sh — Cut a versioned release of dev-toolchain" + printf '\n' >&2 + log_info "Usage: bash scripts/release.sh VERSION" + log_info " make release VERSION=1.6.0" + printf '\n' >&2 + log_info " VERSION Semver version without v prefix (e.g., 1.6.0)" + printf '\n' >&2 + log_info "What this script does:" + log_info " 1. Validates preconditions (on main, clean state, valid semver)" + log_info " 2. Updates CHANGELOG.md (moves [Unreleased] to new version)" + log_info " 3. Commits with: chore(release): prepare vX.Y.Z" + log_info " 4. Creates annotated tag vX.Y.Z" + log_info " 5. Pushes commit and tag (triggers build + release workflows)" + printf '\n' >&2 + log_info "When to use:" + log_info " New language ecosystem → minor bump (e.g., 1.6.0)" + log_info " New tool or feature → minor bump (e.g., 1.6.0)" + log_info " Bug fix → patch bump (e.g., 1.5.1)" + log_info " Breaking change → major bump (e.g., 2.0.0)" + log_info " Routine weekly rebuild → automatic (no manual action needed)" + exit 0 +fi + +# --- Parse version argument --- +VERSION="${1:-}" +if is_empty "${VERSION}"; then + die "VERSION argument required. Usage: bash scripts/release.sh 1.6.0" +fi + +# Strip v prefix if accidentally provided +VERSION="${VERSION#v}" + +# Validate semver format (MAJOR.MINOR.PATCH only, no pre-release) +if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + die "Invalid semver: '${VERSION}'. Expected format: MAJOR.MINOR.PATCH (e.g., 1.6.0)" +fi + +TAG="v${VERSION}" + +# --- Precondition checks --- + +require_cmd "git" "git is required" + +# Must be on main branch +CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [[ "${CURRENT_BRANCH}" != "main" ]]; then + die "Must be on main branch (currently on '${CURRENT_BRANCH}')" +fi + +# Must have clean working tree +if ! git diff --quiet || ! git diff --cached --quiet; then + die "Working tree is not clean. Commit or stash changes first." +fi + +# Tag must not already exist +if git rev-parse "${TAG}" &>/dev/null; then + die "Tag '${TAG}' already exists" +fi + +# Must be up to date with remote +git fetch origin main --quiet +LOCAL_SHA="$(git rev-parse HEAD)" +REMOTE_SHA="$(git rev-parse origin/main)" +if [[ "${LOCAL_SHA}" != "${REMOTE_SHA}" ]]; then + die "Local main is not up to date with origin/main. Run 'git pull' first." +fi + +log_info "Preconditions passed for release ${TAG}" + +# --- Update CHANGELOG.md --- + +CHANGELOG="CHANGELOG.md" +TODAY="$(date -u +%Y-%m-%d)" + +if [[ ! -f "${CHANGELOG}" ]]; then + die "${CHANGELOG} not found" +fi + +# Verify [Unreleased] section exists +if ! grep -q '## \[Unreleased\]' "${CHANGELOG}"; then + die "No [Unreleased] section found in ${CHANGELOG}" +fi + +# Check that there's actual content under [Unreleased] +UNRELEASED_CONTENT="$(sed -n '/^## \[Unreleased\]/,/^## \[/{/^## \[/!p}' "${CHANGELOG}" | grep -v '^$' || true)" +if is_empty "${UNRELEASED_CONTENT}"; then + die "No entries under [Unreleased] in ${CHANGELOG}. Nothing to release." +fi + +log_info "Updating ${CHANGELOG} for ${TAG}" + +# Insert new version header after [Unreleased], preserving unreleased section for future use +sed -i "s/^## \[Unreleased\]$/## [Unreleased]\n\n## [${VERSION}] - ${TODAY}/" "${CHANGELOG}" + +log_info "CHANGELOG.md updated: [Unreleased] entries moved under [${VERSION}] - ${TODAY}" + +# --- Commit, tag, and push --- + +git add "${CHANGELOG}" + +COMMIT_MSG="chore(release): prepare ${TAG}" +log_info "Committing: ${COMMIT_MSG}" +git commit -m "${COMMIT_MSG}" + +log_info "Creating tag: ${TAG}" +git tag -a "${TAG}" -m "Release ${TAG}" + +# Confirm before push +log_info "Ready to push commit and tag '${TAG}' to origin." +log_info "This will trigger the build and release workflows." +printf '\n Push to origin? [y/N] ' >&2 +read -r CONFIRM +if [[ "${CONFIRM}" != "y" && "${CONFIRM}" != "Y" ]]; then + log_warn "Aborted. Commit and tag created locally but NOT pushed." + log_warn "To push manually: git push origin main && git push origin ${TAG}" + exit 0 +fi + +log_info "Pushing to origin..." +git push origin main +git push origin "${TAG}" + +MAJOR="$(echo "${VERSION}" | cut -d. -f1)" +log_info "Release ${TAG} pushed successfully" +printf '\n' >&2 +log_info "GitHub Actions will now:" +log_info " 1. Build and publish the container image to GHCR" +log_info " 2. Create a GitHub release with release notes" +log_info " 3. Update the v${MAJOR} floating tag" +log_info " 4. Generate and attach the tool version manifest"