From a5da3c4d89dae750787b884a66232a8fac31f51a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 7 Apr 2026 14:48:51 -0400 Subject: [PATCH 1/4] feat(ci): automate Go toolchain version updates via govulncheck failures Adds a workflow that detects govulncheck failures on main and release/** branches and automatically opens a PR bumping the Go toolchain to the latest supported patch (or latest minor if the current one is EOL). - `.github/scripts/bump-go-version.sh`: standalone script that updates the `toolchain` directive in `go.work` and all `go.mod` files, and syncs the hardcoded Go version in `checks.yaml` and `sonarcloud.yml`. Supports `--dry-run` (prints unified diff, no file changes), `--target` (skip API lookup), and `--api-url` (mock for tests). - `.github/scripts/bump-go-version.bats`: 9 BATS unit tests covering patch bumps, minor-version bumps, EOL detection, dry-run mode, and already-at-latest no-op behaviour. - `.github/scripts/fixtures/go-versions.json`: mock go.dev API response for offline test runs. - `.github/workflows/go-version-update.yaml`: automation workflow (workflow_dispatch + weekly schedule) that calls the script and creates/updates a PR as `opentdf-automation[bot]` with the `autobump` label. - `.github/workflows/checks.yaml`: adds `trigger-go-version-update` job (dispatches the update workflow when govulncheck artifacts are found on push to main/release/**) and `script-tests` job (runs the BATS tests; added to the `ci` gate). - `.policy.yml`: extends the `autobump` rule's branch pattern to also match `chore/bump-go-toolchain(-...)?` so bot PRs auto-merge on CI pass. - `AGENTS.md`: documents that Go toolchain bumps should go through this automation rather than being done by hand in feature PRs. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- .github/scripts/bump-go-version.bats | 215 +++++++++++++++++ .github/scripts/bump-go-version.sh | 268 ++++++++++++++++++++++ .github/scripts/fixtures/go-versions.json | 42 ++++ .github/workflows/checks.yaml | 50 ++++ .github/workflows/go-version-update.yaml | 204 ++++++++++++++++ .policy.yml | 2 +- AGENTS.md | 14 ++ 7 files changed, 794 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/bump-go-version.bats create mode 100755 .github/scripts/bump-go-version.sh create mode 100644 .github/scripts/fixtures/go-versions.json create mode 100644 .github/workflows/go-version-update.yaml diff --git a/.github/scripts/bump-go-version.bats b/.github/scripts/bump-go-version.bats new file mode 100755 index 0000000000..bdafaaeddc --- /dev/null +++ b/.github/scripts/bump-go-version.bats @@ -0,0 +1,215 @@ +#!/usr/bin/env bats +# Unit tests for bump-go-version.sh +# +# Requirements: bats-core, jq, go (for go mod edit / go work edit) + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)/bump-go-version.sh" +FIXTURES="$(cd "$(dirname "$BATS_TEST_FILENAME")/fixtures" && pwd)" + +# Local file URL for the mock go.dev API response. +API_URL="file://${FIXTURES}/go-versions.json" + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +# build_repo ROOT TOOLCHAIN_VER [GO_DIRECTIVE] +# +# Populates ROOT with the minimal file set that bump-go-version.sh touches: +# go.work, one go.mod per MODULE_DIRS entry, and stub workflow YAML files. +# +# TOOLCHAIN_VER e.g. "1.25.7" +# GO_DIRECTIVE e.g. "1.25.0" (defaults to TOOLCHAIN_VER if omitted) +build_repo() { + local root="$1" + local toolchain="$2" + local go_dir="${3:-${toolchain}}" + + # go.work + mkdir -p "$root" + cat > "${root}/go.work" < "${root}/${mod_dir}/go.mod" < "${root}/.github/workflows/checks.yaml" < "${root}/.github/workflows/sonarcloud.yml" </dev/null || stat -f %m "${root}/go.work")" + + run "$SCRIPT" --repo-root "$root" --target "1.25.9" + [ "$status" -eq 2 ] + + local after_mtime + after_mtime="$(stat -c %Y "${root}/go.work" 2>/dev/null || stat -f %m "${root}/go.work")" + [ "$before_mtime" -eq "$after_mtime" ] +} + +@test "dry-run outputs a unified diff and does not modify files" { + local root="${BATS_TEST_TMPDIR}/repo" + build_repo "$root" "1.25.7" "1.25.0" + + # Capture original content + local orig_toolchain + orig_toolchain="$(grep "^toolchain " "${root}/go.work")" + + run "$SCRIPT" --repo-root "$root" --target "1.25.9" --dry-run + [ "$status" -eq 0 ] + + # Output must contain a diff header + [[ "$output" == *"--- a/go.work"* ]] + [[ "$output" == *"+toolchain go1.25.9"* ]] + + # Original file must be untouched + grep -q "$orig_toolchain" "${root}/go.work" +} + +@test "dry-run with already-at-latest exits 2 and produces no diff output" { + local root="${BATS_TEST_TMPDIR}/repo" + build_repo "$root" "1.25.9" "1.25.0" + + run "$SCRIPT" --repo-root "$root" --target "1.25.9" --dry-run + [ "$status" -eq 2 ] + [ -z "$output" ] || [[ "$output" != *"---"* ]] +} + +@test "CI YAML files are updated unconditionally even if they lag the toolchain" { + local root="${BATS_TEST_TMPDIR}/repo" + # Repo toolchain is 1.25.8 but YAML files still reference 1.25.7 + build_repo "$root" "1.25.8" "1.25.0" + + run "$SCRIPT" --repo-root "$root" --target "1.25.9" + [ "$status" -eq 0 ] + + grep -q 'go-version-input: "1.25.9"' "${root}/.github/workflows/checks.yaml" + grep -q 'go-version: "1.25.9"' "${root}/.github/workflows/sonarcloud.yml" +} + +@test "EOL minor version detection upgrades to latest supported minor" { + local root="${BATS_TEST_TMPDIR}/repo" + # 1.23.x is EOL (not in the two most recent minors per fixtures: 1.25, 1.24) + build_repo "$root" "1.23.8" "1.23.0" + + run "$SCRIPT" --repo-root "$root" --api-url "$API_URL" + [ "$status" -eq 0 ] + + # Should have been bumped to the latest minor (1.25.9 per fixtures) + grep -q "toolchain go1\.25\." "${root}/go.work" + grep -q "^go 1\.25\.0$" "${root}/go.work" +} + +@test "API lookup picks latest patch for current in-support minor" { + local root="${BATS_TEST_TMPDIR}/repo" + # 1.25.7 is not the latest patch in fixtures (1.25.9 is) + build_repo "$root" "1.25.7" "1.25.0" + + run "$SCRIPT" --repo-root "$root" --api-url "$API_URL" + [ "$status" -eq 0 ] + + grep -q "toolchain go1\.25\.9" "${root}/go.work" + # go directive must NOT change (still a patch bump) + grep -q "^go 1\.25\.0$" "${root}/go.work" +} diff --git a/.github/scripts/bump-go-version.sh b/.github/scripts/bump-go-version.sh new file mode 100755 index 0000000000..9cdd225754 --- /dev/null +++ b/.github/scripts/bump-go-version.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +# bump-go-version.sh - Updates the Go toolchain version across the monorepo. +# +# Usage: bump-go-version.sh [OPTIONS] +# +# --dry-run Print a unified diff of what would change; do not modify +# any files. Exits 0 if changes are needed, 2 if already +# at the target version. +# --target VERSION Use VERSION as the target (e.g. "1.25.9"); skips the +# go.dev API lookup. +# --repo-root DIR Path to the repository root (default: two directories +# above this script). +# --api-url URL Override the go.dev download API URL (default: +# https://go.dev/dl/?mode=json). Useful for testing. +# --help Print this message. +# +# Exit codes: +# 0 Changes applied successfully (normal mode) or diff produced (dry-run). +# 1 Error. +# 2 Already at the target version; no changes needed. +# +# Files touched (when changes are needed): +# go.work — toolchain directive; also the go directive on a minor-version bump +# {module}/go.mod — same (for all modules listed in MODULE_DIRS) +# .github/workflows/checks.yaml — go-version-input in the govulncheck step +# .github/workflows/sonarcloud.yml — go-version in the setup-go step + +set -euo pipefail + +# ── Defaults ────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${REPO_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" +API_URL="${API_URL:-https://go.dev/dl/?mode=json}" +DRY_RUN=false +TARGET_VERSION="" + +# Modules that carry a go.mod (relative to REPO_ROOT). +# Includes test/integration even though it is not in go.work — it still has a +# toolchain directive that should stay in sync. +MODULE_DIRS=( + examples + sdk + service + lib/fixtures + lib/flattening + lib/identifier + lib/ocrypto + protocol/go + tests-bdd + test/integration +) + +# ── Argument parsing ─────────────────────────────────────────────────────────── + +usage() { + sed -n '/^# Usage:/,/^[^#]/{ /^#/{ s/^# \{0,1\}//; p } }' "$0" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --target) TARGET_VERSION="$2"; shift ;; + --repo-root) REPO_ROOT="$2"; shift ;; + --api-url) API_URL="$2"; shift ;; + --help) usage; exit 0 ;; + *) echo "[ERROR] Unknown option: $1" >&2; usage; exit 1 ;; + esac + shift +done + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +log() { echo "[INFO] $*"; } +err() { echo "[ERROR] $*" >&2; } + +# Portable in-place sed: handles both GNU sed (Linux CI) and BSD sed (macOS). +sed_inplace() { + if sed --version 2>/dev/null | grep -q GNU; then + sed -i "$@" + else + sed -i "" "$@" + fi +} + +# Reads the toolchain version (e.g. "1.25.8") from a go.work file. +read_toolchain() { + local gowork="$1" + grep "^toolchain " "$gowork" | sed 's/toolchain go//' +} + +# Returns the minor component (e.g. "1.25") from a full version string. +minor_of() { + echo "$1" | cut -d. -f1,2 +} + +# ── Version resolution ───────────────────────────────────────────────────────── + +GOWORK="${REPO_ROOT}/go.work" + +if [[ ! -f "$GOWORK" ]]; then + err "go.work not found at ${GOWORK}" + exit 1 +fi + +CURRENT="$(read_toolchain "$GOWORK")" +if [[ -z "$CURRENT" ]]; then + err "Could not read toolchain directive from ${GOWORK}" + exit 1 +fi +CURRENT_MINOR="$(minor_of "$CURRENT")" + +if [[ -n "$TARGET_VERSION" ]]; then + LATEST_PATCH="$TARGET_VERSION" + TARGET_MINOR="$(minor_of "$TARGET_VERSION")" + if [[ "$CURRENT_MINOR" != "$TARGET_MINOR" ]]; then + NEEDS_MINOR_BUMP=true + else + NEEDS_MINOR_BUMP=false + fi +else + log "Fetching Go release list from ${API_URL}" + VERSIONS_JSON="$(curl -sf "$API_URL")" + + # Extract all distinct minor versions from stable releases, sorted ascending. + ALL_MINORS="$( + echo "$VERSIONS_JSON" \ + | jq -r '[ + .[].version + | ltrimstr("go") + | split(".") + | .[0:2] + | join(".") + ] | unique | sort_by(split(".") | map(tonumber)) | .[]' + )" + + LATEST_MINOR="$(echo "$ALL_MINORS" | tail -1)" + PREV_MINOR="$(echo "$ALL_MINORS" | tail -2 | head -1)" + + NEEDS_MINOR_BUMP=false + if [[ "$CURRENT_MINOR" != "$LATEST_MINOR" && "$CURRENT_MINOR" != "$PREV_MINOR" ]]; then + log "Go ${CURRENT_MINOR} is no longer in the two most-recent supported minors" \ + "(${PREV_MINOR}, ${LATEST_MINOR}); upgrading minor version." + TARGET_MINOR="$LATEST_MINOR" + NEEDS_MINOR_BUMP=true + else + TARGET_MINOR="$CURRENT_MINOR" + fi + + # Latest patch release for the target minor. + LATEST_PATCH="$( + echo "$VERSIONS_JSON" \ + | jq -r --arg prefix "go${TARGET_MINOR}." \ + '[.[].version | select(startswith($prefix))] | first | ltrimstr("go")' + )" + + if [[ -z "$LATEST_PATCH" || "$LATEST_PATCH" == "null" ]]; then + err "Could not determine latest patch for Go ${TARGET_MINOR}" + exit 1 + fi +fi + +log "Current toolchain: ${CURRENT} → target: ${LATEST_PATCH}" + +# ── Early exit if already current ───────────────────────────────────────────── + +if [[ "$CURRENT" == "$LATEST_PATCH" ]]; then + log "Already at Go ${LATEST_PATCH}; nothing to do." + exit 2 +fi + +# ── Core update function ─────────────────────────────────────────────────────── +# apply_changes_to TARGET_DIR +# +# Applies all version updates to files rooted at TARGET_DIR. TARGET_DIR may +# be the real REPO_ROOT (normal mode) or a temp copy (dry-run mode). + +apply_changes_to() { + local root="$1" + + # go.work — toolchain (always) + go directive (minor bumps only) + go work edit -toolchain="go${LATEST_PATCH}" "${root}/go.work" + if [[ "$NEEDS_MINOR_BUMP" == "true" ]]; then + go work edit -go="${TARGET_MINOR}.0" "${root}/go.work" + fi + + # Each module's go.mod + for dir in "${MODULE_DIRS[@]}"; do + local modfile="${root}/${dir}/go.mod" + if [[ ! -f "$modfile" ]]; then + log "Skipping ${dir}/go.mod (not found)" + continue + fi + go mod edit -toolchain="go${LATEST_PATCH}" "$modfile" + if [[ "$NEEDS_MINOR_BUMP" == "true" ]]; then + go mod edit -go="${TARGET_MINOR}.0" "$modfile" + fi + done + + # CI workflow YAML files — always overwrite to latest regardless of current + # value (handles drift between files). + local checks="${root}/.github/workflows/checks.yaml" + local sonar="${root}/.github/workflows/sonarcloud.yml" + + if [[ -f "$checks" ]]; then + sed_inplace "s/go-version-input: \"[0-9.]*\"/go-version-input: \"${LATEST_PATCH}\"/" "$checks" + fi + if [[ -f "$sonar" ]]; then + sed_inplace "s/go-version: \"[0-9.]*\"/go-version: \"${LATEST_PATCH}\"/" "$sonar" + fi +} + +# ── List of all files the update touches ────────────────────────────────────── +target_files() { + echo "go.work" + for dir in "${MODULE_DIRS[@]}"; do + echo "${dir}/go.mod" + done + echo ".github/workflows/checks.yaml" + echo ".github/workflows/sonarcloud.yml" +} + +# ── Dry-run: copy files, apply, diff ────────────────────────────────────────── + +if [[ "$DRY_RUN" == "true" ]]; then + TMPDIR="$(mktemp -d)" + trap 'rm -rf "$TMPDIR"' EXIT + + # Copy only the files we will modify, preserving directory structure. + while IFS= read -r rel; do + local_file="${REPO_ROOT}/${rel}" + if [[ -f "$local_file" ]]; then + mkdir -p "${TMPDIR}/$(dirname "$rel")" + cp "$local_file" "${TMPDIR}/${rel}" + fi + done < <(target_files) + + apply_changes_to "$TMPDIR" + + # Emit a unified diff for every changed file. + HAS_DIFF=false + while IFS= read -r rel; do + orig="${REPO_ROOT}/${rel}" + modified="${TMPDIR}/${rel}" + if [[ ! -f "$orig" || ! -f "$modified" ]]; then + continue + fi + if ! diff -q "$orig" "$modified" > /dev/null 2>&1; then + diff -u "$orig" "$modified" \ + --label "a/${rel}" \ + --label "b/${rel}" \ + || true # diff exits 1 when files differ — that is expected + HAS_DIFF=true + fi + done < <(target_files) + + if [[ "$HAS_DIFF" == "false" ]]; then + log "No differences — already at target version." + exit 2 + fi + exit 0 +fi + +# ── Normal mode: apply in place ─────────────────────────────────────────────── + +log "Applying Go ${LATEST_PATCH} updates to ${REPO_ROOT}" +apply_changes_to "$REPO_ROOT" +log "Done." diff --git a/.github/scripts/fixtures/go-versions.json b/.github/scripts/fixtures/go-versions.json new file mode 100644 index 0000000000..c568a4af97 --- /dev/null +++ b/.github/scripts/fixtures/go-versions.json @@ -0,0 +1,42 @@ +[ + { + "version": "go1.25.9", + "stable": true, + "files": [] + }, + { + "version": "go1.25.8", + "stable": true, + "files": [] + }, + { + "version": "go1.25.7", + "stable": true, + "files": [] + }, + { + "version": "go1.24.3", + "stable": true, + "files": [] + }, + { + "version": "go1.24.2", + "stable": true, + "files": [] + }, + { + "version": "go1.24.1", + "stable": true, + "files": [] + }, + { + "version": "go1.23.8", + "stable": true, + "files": [] + }, + { + "version": "go1.23.7", + "stable": true, + "files": [] + } +] diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 4035b1508a..a4c0e265f1 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -618,6 +618,55 @@ jobs: - name: Check that make proto-generate and connect-wrapper-generate have run before PR submission; see above for error details run: git diff-files --quiet --ignore-submodules + # Dispatch the go-version-update workflow when govulncheck fails on a + # protected branch. Only fires for direct pushes (not PRs or workflow_call) + # so the automation PR itself will never re-trigger this job. + trigger-go-version-update: + if: | + !cancelled() && + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) + needs: go + runs-on: ubuntu-22.04 + permissions: + actions: write + contents: read + steps: + - name: Check for govulncheck failure artifacts + id: check-artifacts + continue-on-error: true + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + pattern: govulncheck-failure-* + path: govulncheck-failures + merge-multiple: true + - name: Dispatch go-version-update workflow + if: steps.check-artifacts.outcome == 'success' + run: | + gh workflow run go-version-update.yaml \ + --field base_branch="${GITHUB_REF_NAME}" + env: + GH_TOKEN: ${{ github.token }} + + # Run unit tests for .github/scripts using bats-core. + # These tests do not require a running server. + script-tests: + permissions: + contents: read + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: service/go.mod + check-latest: false + - name: Setup Bats and bats libs + uses: bats-core/bats-action@42fcc8700f773c075a16a90eb11674c0318ad507 # 3.0.1 + - name: Run script unit tests + run: bats .github/scripts/bump-go-version.bats + ci: permissions: {} needs: @@ -628,6 +677,7 @@ jobs: - benchmark - license - platform-xtest + - script-tests - tests-bdd - otdfctl-test runs-on: ubuntu-22.04 diff --git a/.github/workflows/go-version-update.yaml b/.github/workflows/go-version-update.yaml new file mode 100644 index 0000000000..b6bd58e40d --- /dev/null +++ b/.github/workflows/go-version-update.yaml @@ -0,0 +1,204 @@ +name: "Go Version Update" + +# Automatically creates or updates a PR that bumps the Go toolchain version +# whenever govulncheck fails on a protected branch. +# +# Trigger paths: +# 1. workflow_dispatch — dispatched by the `trigger-go-version-update` job in +# checks.yaml when govulncheck failure artifacts are detected on a push to +# main or release/**. Passes the failing branch as `base_branch`. +# 2. schedule — weekly backstop in case a Go release occurs while main is +# quiet (no pushes triggering Checks). +# 3. Manual dispatch — for ad-hoc use or testing. + +on: + workflow_dispatch: + inputs: + base_branch: + description: "Base branch to target with the PR (e.g. main, release/service/v0.8)" + required: false + default: "main" + type: string + schedule: + # Weekly Monday 03:42 UTC — backstop for main + - cron: "42 3 * * 1" + +permissions: {} + +jobs: + update: + name: bump Go toolchain + runs-on: ubuntu-22.04 + permissions: + contents: write + pull-requests: write + + steps: + - name: Obtain app token + id: app-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.AUTOMATION_KEY }} + + - name: Determine base branch + id: base + run: | + branch="${{ inputs.base_branch || 'main' }}" + echo "branch=${branch}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ steps.base.outputs.branch }} + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + persist-credentials: true + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + # Use the version from go.work so go mod edit / go work edit are + # available with the current workspace toolchain. + go-version-file: go.work + check-latest: false + + - name: Determine latest Go version + id: versions + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSIONS_JSON=$(curl -sf "https://go.dev/dl/?mode=json") + + # Current toolchain from go.work (e.g. "1.25.8") + CURRENT=$(grep "^toolchain " go.work | sed 's/toolchain go//') + CURRENT_MINOR=$(echo "$CURRENT" | cut -d. -f1,2) + + # All distinct minor versions in stable releases, sorted ascending. + ALL_MINORS=$( + echo "$VERSIONS_JSON" \ + | jq -r '[ + .[].version + | ltrimstr("go") + | split(".") + | .[0:2] + | join(".") + ] | unique | sort_by(split(".") | map(tonumber)) | .[]' + ) + + LATEST_MINOR=$(echo "$ALL_MINORS" | tail -1) + PREV_MINOR=$(echo "$ALL_MINORS" | tail -2 | head -1) + + TARGET_MINOR="$CURRENT_MINOR" + if [[ "$CURRENT_MINOR" != "$LATEST_MINOR" && "$CURRENT_MINOR" != "$PREV_MINOR" ]]; then + echo "Go ${CURRENT_MINOR} is EOL; upgrading to ${LATEST_MINOR}" + TARGET_MINOR="$LATEST_MINOR" + fi + + LATEST_PATCH=$( + echo "$VERSIONS_JSON" \ + | jq -r --arg prefix "go${TARGET_MINOR}." \ + '[.[].version | select(startswith($prefix))] | first | ltrimstr("go")' + ) + + echo "current=${CURRENT}" >> "$GITHUB_OUTPUT" + echo "latest_patch=${LATEST_PATCH}" >> "$GITHUB_OUTPUT" + + if [[ "$CURRENT" == "$LATEST_PATCH" ]]; then + echo "up_to_date=true" >> "$GITHUB_OUTPUT" + else + echo "up_to_date=false" >> "$GITHUB_OUTPUT" + fi + + - name: Apply Go version updates + if: steps.versions.outputs.up_to_date != 'true' + run: | + .github/scripts/bump-go-version.sh \ + --target "${{ steps.versions.outputs.latest_patch }}" + + - name: Commit and push PR branch + id: push + if: steps.versions.outputs.up_to_date != 'true' + run: | + BASE="${{ steps.base.outputs.branch }}" + NEW="${{ steps.versions.outputs.latest_patch }}" + + # Derive a git-safe PR branch name. + if [[ "$BASE" == "main" ]]; then + BRANCH="chore/bump-go-toolchain" + else + BRANCH="chore/bump-go-toolchain-$(echo "$BASE" | tr '/' '-')" + fi + + git config user.name "opentdf-automation[bot]" + git config user.email "149537512+opentdf-automation[bot]@users.noreply.github.com" + + # Hard-reset the PR branch to the tip of the base branch so the + # commit is always a clean, single-commit branch (no stale history). + git checkout -B "$BRANCH" + + git add go.work + git add -- '**/go.mod' + git add .github/workflows/checks.yaml + git add .github/workflows/sonarcloud.yml + + if git diff-index --quiet HEAD; then + echo "No changes to commit after applying updates." + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git commit -s -m "fix(ci): bump go toolchain to ${NEW}" + git push origin "$BRANCH" --force + + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + echo "changed=true" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Create or update PR + if: >- + steps.versions.outputs.up_to_date != 'true' && + steps.push.outputs.changed == 'true' + run: | + BASE="${{ steps.base.outputs.branch }}" + BRANCH="${{ steps.push.outputs.branch }}" + NEW="${{ steps.versions.outputs.latest_patch }}" + OLD="${{ steps.versions.outputs.current }}" + + TITLE="fix(ci): bump go toolchain to ${NEW}" + BODY="$(cat < Date: Tue, 7 Apr 2026 15:29:32 -0400 Subject: [PATCH 2/4] fix(ci): address zizmor and Copilot review findings - Script: guard option flags against missing values (--target, --repo-root, --api-url) with a clear error message rather than crashing on unbound var - Script/workflow: filter .stable == true in all jq queries to exclude betas and release candidates from version selection - Script/workflow: sort by version numerically and take last entry instead of relying on API response ordering (guard against go1.25.10 > go1.25.9) - Script: add --connect-timeout 5 --max-time 30 to curl to prevent hangs - Script: replace GNU-only diff --label with portable -L flags - Workflow: move all ${{ }} template expansions in run: blocks to env: vars to prevent code injection (zizmor finding) - Fixture: add go1.26rc1 (stable: false) entry; add BATS test verifying prereleases are excluded from version selection Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- .github/scripts/bump-go-version.bats | 13 +++++++ .github/scripts/bump-go-version.sh | 40 ++++++++++++------- .github/scripts/fixtures/go-versions.json | 5 +++ .github/workflows/go-version-update.yaml | 47 ++++++++++++++--------- 4 files changed, 73 insertions(+), 32 deletions(-) diff --git a/.github/scripts/bump-go-version.bats b/.github/scripts/bump-go-version.bats index bdafaaeddc..7675ebd281 100755 --- a/.github/scripts/bump-go-version.bats +++ b/.github/scripts/bump-go-version.bats @@ -213,3 +213,16 @@ EOF # go directive must NOT change (still a patch bump) grep -q "^go 1\.25\.0$" "${root}/go.work" } + +@test "prerelease versions (stable: false) are excluded from version selection" { + local root="${BATS_TEST_TMPDIR}/repo" + # Fixtures include go1.26rc1 (stable: false); current minor 1.25 is still supported. + # The script must NOT upgrade to 1.26 and must pick 1.25.9 as the latest stable patch. + build_repo "$root" "1.25.7" "1.25.0" + + run "$SCRIPT" --repo-root "$root" --api-url "$API_URL" + [ "$status" -eq 0 ] + + grep -q "toolchain go1\.25\.9" "${root}/go.work" + ! grep -q "toolchain go1\.26" "${root}/go.work" +} diff --git a/.github/scripts/bump-go-version.sh b/.github/scripts/bump-go-version.sh index 9cdd225754..491d945f14 100755 --- a/.github/scripts/bump-go-version.sh +++ b/.github/scripts/bump-go-version.sh @@ -57,13 +57,21 @@ usage() { sed -n '/^# Usage:/,/^[^#]/{ /^#/{ s/^# \{0,1\}//; p } }' "$0" } +needs_value() { + if [[ $# -lt 2 || -z "$2" || "$2" == --* ]]; then + echo "[ERROR] Option $1 requires a non-empty value" >&2 + usage + exit 1 + fi +} + while [[ $# -gt 0 ]]; do case "$1" in - --dry-run) DRY_RUN=true ;; - --target) TARGET_VERSION="$2"; shift ;; - --repo-root) REPO_ROOT="$2"; shift ;; - --api-url) API_URL="$2"; shift ;; - --help) usage; exit 0 ;; + --dry-run) DRY_RUN=true ;; + --target) needs_value "$@"; TARGET_VERSION="$2"; shift ;; + --repo-root) needs_value "$@"; REPO_ROOT="$2"; shift ;; + --api-url) needs_value "$@"; API_URL="$2"; shift ;; + --help) usage; exit 0 ;; *) echo "[ERROR] Unknown option: $1" >&2; usage; exit 1 ;; esac shift @@ -120,13 +128,15 @@ if [[ -n "$TARGET_VERSION" ]]; then fi else log "Fetching Go release list from ${API_URL}" - VERSIONS_JSON="$(curl -sf "$API_URL")" + VERSIONS_JSON="$(curl -sf --connect-timeout 5 --max-time 30 "$API_URL")" - # Extract all distinct minor versions from stable releases, sorted ascending. + # Extract all distinct minor versions from *stable* releases, sorted ascending. + # Filter .stable == true to exclude betas and release candidates. ALL_MINORS="$( echo "$VERSIONS_JSON" \ | jq -r '[ - .[].version + .[] | select(.stable == true) | + .version | ltrimstr("go") | split(".") | .[0:2] @@ -147,11 +157,15 @@ else TARGET_MINOR="$CURRENT_MINOR" fi - # Latest patch release for the target minor. + # Latest *stable* patch release for the target minor. + # Sort numerically and take the last entry to guard against non-ordered API responses. LATEST_PATCH="$( echo "$VERSIONS_JSON" \ | jq -r --arg prefix "go${TARGET_MINOR}." \ - '[.[].version | select(startswith($prefix))] | first | ltrimstr("go")' + '[.[] | select(.stable == true) | .version | select(startswith($prefix))] + | sort_by(ltrimstr("go") | split(".") | map(tonumber)) + | last + | ltrimstr("go")' )" if [[ -z "$LATEST_PATCH" || "$LATEST_PATCH" == "null" ]]; then @@ -246,9 +260,9 @@ if [[ "$DRY_RUN" == "true" ]]; then continue fi if ! diff -q "$orig" "$modified" > /dev/null 2>&1; then - diff -u "$orig" "$modified" \ - --label "a/${rel}" \ - --label "b/${rel}" \ + # Use -L (short form) for portability: --label is GNU diff only. + diff -u -L "a/${rel}" -L "b/${rel}" \ + "$orig" "$modified" \ || true # diff exits 1 when files differ — that is expected HAS_DIFF=true fi diff --git a/.github/scripts/fixtures/go-versions.json b/.github/scripts/fixtures/go-versions.json index c568a4af97..5bfa6fc3bc 100644 --- a/.github/scripts/fixtures/go-versions.json +++ b/.github/scripts/fixtures/go-versions.json @@ -1,4 +1,9 @@ [ + { + "version": "go1.26rc1", + "stable": false, + "files": [] + }, { "version": "go1.25.9", "stable": true, diff --git a/.github/workflows/go-version-update.yaml b/.github/workflows/go-version-update.yaml index b6bd58e40d..d9a371fdb3 100644 --- a/.github/workflows/go-version-update.yaml +++ b/.github/workflows/go-version-update.yaml @@ -43,8 +43,10 @@ jobs: - name: Determine base branch id: base + env: + INPUT_BASE_BRANCH: ${{ inputs.base_branch }} run: | - branch="${{ inputs.base_branch || 'main' }}" + branch="${INPUT_BASE_BRANCH:-main}" echo "branch=${branch}" >> "$GITHUB_OUTPUT" - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -72,11 +74,13 @@ jobs: CURRENT=$(grep "^toolchain " go.work | sed 's/toolchain go//') CURRENT_MINOR=$(echo "$CURRENT" | cut -d. -f1,2) - # All distinct minor versions in stable releases, sorted ascending. + # All distinct minor versions from *stable* releases, sorted ascending. + # Filter .stable == true to exclude betas and release candidates. ALL_MINORS=$( echo "$VERSIONS_JSON" \ | jq -r '[ - .[].version + .[] | select(.stable == true) | + .version | ltrimstr("go") | split(".") | .[0:2] @@ -93,10 +97,15 @@ jobs: TARGET_MINOR="$LATEST_MINOR" fi + # Latest *stable* patch for the target minor; sort numerically and + # take the last entry to guard against non-ordered API responses. LATEST_PATCH=$( echo "$VERSIONS_JSON" \ | jq -r --arg prefix "go${TARGET_MINOR}." \ - '[.[].version | select(startswith($prefix))] | first | ltrimstr("go")' + '[.[] | select(.stable == true) | .version | select(startswith($prefix))] + | sort_by(ltrimstr("go") | split(".") | map(tonumber)) + | last + | ltrimstr("go")' ) echo "current=${CURRENT}" >> "$GITHUB_OUTPUT" @@ -110,17 +119,19 @@ jobs: - name: Apply Go version updates if: steps.versions.outputs.up_to_date != 'true' + env: + TARGET_VERSION: ${{ steps.versions.outputs.latest_patch }} run: | - .github/scripts/bump-go-version.sh \ - --target "${{ steps.versions.outputs.latest_patch }}" + .github/scripts/bump-go-version.sh --target "$TARGET_VERSION" - name: Commit and push PR branch id: push if: steps.versions.outputs.up_to_date != 'true' + env: + BASE: ${{ steps.base.outputs.branch }} + NEW: ${{ steps.versions.outputs.latest_patch }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - BASE="${{ steps.base.outputs.branch }}" - NEW="${{ steps.versions.outputs.latest_patch }}" - # Derive a git-safe PR branch name. if [[ "$BASE" == "main" ]]; then BRANCH="chore/bump-go-toolchain" @@ -152,19 +163,19 @@ jobs: echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT" - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - name: Create or update PR if: >- steps.versions.outputs.up_to_date != 'true' && steps.push.outputs.changed == 'true' + env: + BASE: ${{ steps.base.outputs.branch }} + BRANCH: ${{ steps.push.outputs.branch }} + NEW: ${{ steps.versions.outputs.latest_patch }} + OLD: ${{ steps.versions.outputs.current }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/workflows/go-version-update.yaml + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - BASE="${{ steps.base.outputs.branch }}" - BRANCH="${{ steps.push.outputs.branch }}" - NEW="${{ steps.versions.outputs.latest_patch }}" - OLD="${{ steps.versions.outputs.current }}" - TITLE="fix(ci): bump go toolchain to ${NEW}" BODY="$(cat < Date: Tue, 7 Apr 2026 15:30:22 -0400 Subject: [PATCH 3/4] remove cache --- .github/workflows/checks.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index a4c0e265f1..d239899cba 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -661,6 +661,7 @@ jobs: - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: service/go.mod + cache: false check-latest: false - name: Setup Bats and bats libs uses: bats-core/bats-action@42fcc8700f773c075a16a90eb11674c0318ad507 # 3.0.1 From f2dd52b05939146b89b74cfed113774e848387eb Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 8 Apr 2026 14:43:00 -0400 Subject: [PATCH 4/4] =?UTF-8?q?refactor(ci):=20simplify=20Go=20version=20m?= =?UTF-8?q?anagement=20=E2=80=94=20go.work=20as=20single=20source=20of=20t?= =?UTF-8?q?ruth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `toolchain` directive from all go.mod files. The Go spec makes this directive local-only (it never affects downstream consumers), and in a workspace the go.work toolchain takes precedence. Experimentally verified that `go mod tidy` does not re-add it. Replace all hardcoded Go version strings in CI workflows with `go-version-file: go.work`: - checks.yaml govulncheck: `go-version-input: ""` + `go-version-file: go.work` - sonarcloud.yml: `go-version-file: go.work` Update work-init.sh to preserve the toolchain directive when regenerating go.work for release branches, so govulncheck continues to read the correct version. Simplify bump-go-version.sh: patch bumps now update only go.work (1 file instead of 13). Minor bumps additionally update go directives in go.mod files. No YAML edits needed in either case. Before: patch bump touched go.work + 10 go.mod + 2 YAML = 13 files After: patch bump touches go.work = 1 file Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Dave Mihalcik --- .github/scripts/bump-go-version.bats | 131 ++++++++++------------- .github/scripts/bump-go-version.sh | 81 ++++++-------- .github/scripts/work-init.sh | 11 ++ .github/workflows/checks.yaml | 3 +- .github/workflows/go-version-update.yaml | 9 +- .github/workflows/sonarcloud.yml | 2 +- AGENTS.md | 10 +- examples/go.mod | 2 - lib/fixtures/go.mod | 2 - lib/flattening/go.mod | 2 - lib/identifier/go.mod | 2 - lib/ocrypto/go.mod | 2 - protocol/go/go.mod | 2 - sdk/go.mod | 2 - service/go.mod | 2 - test/integration/go.mod | 2 - tests-bdd/go.mod | 2 - 17 files changed, 110 insertions(+), 157 deletions(-) diff --git a/.github/scripts/bump-go-version.bats b/.github/scripts/bump-go-version.bats index 7675ebd281..0ed0d49383 100755 --- a/.github/scripts/bump-go-version.bats +++ b/.github/scripts/bump-go-version.bats @@ -14,16 +14,19 @@ API_URL="file://${FIXTURES}/go-versions.json" # build_repo ROOT TOOLCHAIN_VER [GO_DIRECTIVE] # # Populates ROOT with the minimal file set that bump-go-version.sh touches: -# go.work, one go.mod per MODULE_DIRS entry, and stub workflow YAML files. +# go.work, one go.mod per MODULE_DIRS entry. +# go.mod files intentionally have NO toolchain directive (matches production). # -# TOOLCHAIN_VER e.g. "1.25.7" -# GO_DIRECTIVE e.g. "1.25.0" (defaults to TOOLCHAIN_VER if omitted) +# TOOLCHAIN_VER e.g. "1.25.7" — written to go.work only +# GO_DIRECTIVE e.g. "1.25.0" (defaults to minor.0 of TOOLCHAIN_VER) build_repo() { local root="$1" local toolchain="$2" - local go_dir="${3:-${toolchain}}" + local minor + minor="$(echo "$toolchain" | cut -d. -f1,2)" + local go_dir="${3:-${minor}.0}" - # go.work + # go.work — the only place that carries a toolchain directive mkdir -p "$root" cat > "${root}/go.work" < "${root}/.github/workflows/checks.yaml" < "${root}/.github/workflows/sonarcloud.yml" </dev/null || stat -f %m "${root}/go.work")" @@ -150,48 +117,49 @@ EOF @test "dry-run outputs a unified diff and does not modify files" { local root="${BATS_TEST_TMPDIR}/repo" - build_repo "$root" "1.25.7" "1.25.0" + build_repo "$root" "1.25.7" - # Capture original content local orig_toolchain orig_toolchain="$(grep "^toolchain " "${root}/go.work")" run "$SCRIPT" --repo-root "$root" --target "1.25.9" --dry-run [ "$status" -eq 0 ] - # Output must contain a diff header + # Output must contain a diff for go.work [[ "$output" == *"--- a/go.work"* ]] [[ "$output" == *"+toolchain go1.25.9"* ]] + # Output must NOT contain go.mod diffs (patch bump = go.work only) + [[ "$output" != *"go.mod"* ]] # Original file must be untouched grep -q "$orig_toolchain" "${root}/go.work" } -@test "dry-run with already-at-latest exits 2 and produces no diff output" { +@test "dry-run with already-at-latest exits 2" { local root="${BATS_TEST_TMPDIR}/repo" - build_repo "$root" "1.25.9" "1.25.0" + build_repo "$root" "1.25.9" run "$SCRIPT" --repo-root "$root" --target "1.25.9" --dry-run [ "$status" -eq 2 ] - [ -z "$output" ] || [[ "$output" != *"---"* ]] } -@test "CI YAML files are updated unconditionally even if they lag the toolchain" { +@test "dry-run for minor bump includes go.mod diffs" { local root="${BATS_TEST_TMPDIR}/repo" - # Repo toolchain is 1.25.8 but YAML files still reference 1.25.7 - build_repo "$root" "1.25.8" "1.25.0" + build_repo "$root" "1.24.3" - run "$SCRIPT" --repo-root "$root" --target "1.25.9" + run "$SCRIPT" --repo-root "$root" --target "1.26.1" --dry-run [ "$status" -eq 0 ] - grep -q 'go-version-input: "1.25.9"' "${root}/.github/workflows/checks.yaml" - grep -q 'go-version: "1.25.9"' "${root}/.github/workflows/sonarcloud.yml" + # Must show diffs for go.work AND go.mod files + [[ "$output" == *"--- a/go.work"* ]] + [[ "$output" == *"--- a/sdk/go.mod"* ]] + [[ "$output" == *"+go 1.26.0"* ]] } @test "EOL minor version detection upgrades to latest supported minor" { local root="${BATS_TEST_TMPDIR}/repo" # 1.23.x is EOL (not in the two most recent minors per fixtures: 1.25, 1.24) - build_repo "$root" "1.23.8" "1.23.0" + build_repo "$root" "1.23.8" run "$SCRIPT" --repo-root "$root" --api-url "$API_URL" [ "$status" -eq 0 ] @@ -199,26 +167,26 @@ EOF # Should have been bumped to the latest minor (1.25.9 per fixtures) grep -q "toolchain go1\.25\." "${root}/go.work" grep -q "^go 1\.25\.0$" "${root}/go.work" + # go.mod go directive also updated (minor bump) + grep -q "^go 1\.25\.0$" "${root}/service/go.mod" } @test "API lookup picks latest patch for current in-support minor" { local root="${BATS_TEST_TMPDIR}/repo" - # 1.25.7 is not the latest patch in fixtures (1.25.9 is) - build_repo "$root" "1.25.7" "1.25.0" + build_repo "$root" "1.25.7" run "$SCRIPT" --repo-root "$root" --api-url "$API_URL" [ "$status" -eq 0 ] grep -q "toolchain go1\.25\.9" "${root}/go.work" - # go directive must NOT change (still a patch bump) + # go directive must NOT change (patch bump) grep -q "^go 1\.25\.0$" "${root}/go.work" } @test "prerelease versions (stable: false) are excluded from version selection" { local root="${BATS_TEST_TMPDIR}/repo" # Fixtures include go1.26rc1 (stable: false); current minor 1.25 is still supported. - # The script must NOT upgrade to 1.26 and must pick 1.25.9 as the latest stable patch. - build_repo "$root" "1.25.7" "1.25.0" + build_repo "$root" "1.25.7" run "$SCRIPT" --repo-root "$root" --api-url "$API_URL" [ "$status" -eq 0 ] @@ -226,3 +194,12 @@ EOF grep -q "toolchain go1\.25\.9" "${root}/go.work" ! grep -q "toolchain go1\.26" "${root}/go.work" } + +@test "missing --target value produces a helpful error" { + local root="${BATS_TEST_TMPDIR}/repo" + build_repo "$root" "1.25.7" + + run "$SCRIPT" --repo-root "$root" --target + [ "$status" -eq 1 ] + [[ "$output" == *"requires a non-empty value"* ]] +} diff --git a/.github/scripts/bump-go-version.sh b/.github/scripts/bump-go-version.sh index 491d945f14..2c01f748b4 100755 --- a/.github/scripts/bump-go-version.sh +++ b/.github/scripts/bump-go-version.sh @@ -19,11 +19,14 @@ # 1 Error. # 2 Already at the target version; no changes needed. # -# Files touched (when changes are needed): -# go.work — toolchain directive; also the go directive on a minor-version bump -# {module}/go.mod — same (for all modules listed in MODULE_DIRS) -# .github/workflows/checks.yaml — go-version-input in the govulncheck step -# .github/workflows/sonarcloud.yml — go-version in the setup-go step +# Patch bumps update only go.work (toolchain directive). CI workflows read +# the Go version from go.work via go-version-file, so no YAML edits are +# needed. Individual go.mod files intentionally omit the toolchain directive +# — the workspace go.work governs builds. +# +# Minor-version bumps (triggered when the current minor falls out of Go's +# two-release support window) additionally update the go directive in go.work +# and in every module's go.mod. set -euo pipefail @@ -35,9 +38,8 @@ API_URL="${API_URL:-https://go.dev/dl/?mode=json}" DRY_RUN=false TARGET_VERSION="" -# Modules that carry a go.mod (relative to REPO_ROOT). -# Includes test/integration even though it is not in go.work — it still has a -# toolchain directive that should stay in sync. +# Modules whose go.mod carries a go directive that must be updated on a +# minor-version bump (relative to REPO_ROOT). MODULE_DIRS=( examples sdk @@ -82,15 +84,6 @@ done log() { echo "[INFO] $*"; } err() { echo "[ERROR] $*" >&2; } -# Portable in-place sed: handles both GNU sed (Linux CI) and BSD sed (macOS). -sed_inplace() { - if sed --version 2>/dev/null | grep -q GNU; then - sed -i "$@" - else - sed -i "" "$@" - fi -} - # Reads the toolchain version (e.g. "1.25.8") from a go.work file. read_toolchain() { local gowork="$1" @@ -186,52 +179,40 @@ fi # ── Core update function ─────────────────────────────────────────────────────── # apply_changes_to TARGET_DIR # -# Applies all version updates to files rooted at TARGET_DIR. TARGET_DIR may -# be the real REPO_ROOT (normal mode) or a temp copy (dry-run mode). +# Applies version updates to files rooted at TARGET_DIR. TARGET_DIR may be +# the real REPO_ROOT (normal mode) or a temp copy (dry-run mode). +# +# Patch bumps: only go.work toolchain directive. +# Minor bumps: go.work toolchain + go directive, and go directive in every +# module's go.mod. apply_changes_to() { local root="$1" - # go.work — toolchain (always) + go directive (minor bumps only) + # go.work — toolchain always, go directive on minor bumps go work edit -toolchain="go${LATEST_PATCH}" "${root}/go.work" if [[ "$NEEDS_MINOR_BUMP" == "true" ]]; then go work edit -go="${TARGET_MINOR}.0" "${root}/go.work" - fi - - # Each module's go.mod - for dir in "${MODULE_DIRS[@]}"; do - local modfile="${root}/${dir}/go.mod" - if [[ ! -f "$modfile" ]]; then - log "Skipping ${dir}/go.mod (not found)" - continue - fi - go mod edit -toolchain="go${LATEST_PATCH}" "$modfile" - if [[ "$NEEDS_MINOR_BUMP" == "true" ]]; then + # Each module's go.mod — go directive only (no toolchain in go.mod) + for dir in "${MODULE_DIRS[@]}"; do + local modfile="${root}/${dir}/go.mod" + if [[ ! -f "$modfile" ]]; then + log "Skipping ${dir}/go.mod (not found)" + continue + fi go mod edit -go="${TARGET_MINOR}.0" "$modfile" - fi - done - - # CI workflow YAML files — always overwrite to latest regardless of current - # value (handles drift between files). - local checks="${root}/.github/workflows/checks.yaml" - local sonar="${root}/.github/workflows/sonarcloud.yml" - - if [[ -f "$checks" ]]; then - sed_inplace "s/go-version-input: \"[0-9.]*\"/go-version-input: \"${LATEST_PATCH}\"/" "$checks" - fi - if [[ -f "$sonar" ]]; then - sed_inplace "s/go-version: \"[0-9.]*\"/go-version: \"${LATEST_PATCH}\"/" "$sonar" + done fi } -# ── List of all files the update touches ────────────────────────────────────── +# ── List of all files the update may touch ──────────────────────────────────── target_files() { echo "go.work" - for dir in "${MODULE_DIRS[@]}"; do - echo "${dir}/go.mod" - done - echo ".github/workflows/checks.yaml" - echo ".github/workflows/sonarcloud.yml" + if [[ "$NEEDS_MINOR_BUMP" == "true" ]]; then + for dir in "${MODULE_DIRS[@]}"; do + echo "${dir}/go.mod" + done + fi } # ── Dry-run: copy files, apply, diff ────────────────────────────────────────── diff --git a/.github/scripts/work-init.sh b/.github/scripts/work-init.sh index 878f7fd6ef..80f7cecef8 100755 --- a/.github/scripts/work-init.sh +++ b/.github/scripts/work-init.sh @@ -36,6 +36,11 @@ if ! cd "$ROOT_DIR"; then exit 1 fi +# Preserve the toolchain directive from the original go.work so that CI steps +# reading go-version-file: go.work (e.g. govulncheck) continue to use the +# correct Go version after the workspace is regenerated. +ORIG_TOOLCHAIN=$(grep "^toolchain " go.work 2>/dev/null | awk '{print $2}') + echo "[INFO] Rebuilding partial go.work for [${component}]" case $component in lib/ocrypto | lib/fixtures | lib/flattening | lib/identifier | protocol/go) @@ -64,3 +69,9 @@ examples) exit 1 ;; esac + +# Restore the toolchain directive if it was present in the original go.work. +if [[ -n "${ORIG_TOOLCHAIN:-}" && -f go.work ]]; then + go work edit -toolchain="$ORIG_TOOLCHAIN" + echo "[INFO] Restored toolchain ${ORIG_TOOLCHAIN} in go.work" +fi diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index d239899cba..c4dcc929fa 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -74,7 +74,8 @@ jobs: continue-on-error: true uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 with: - go-version-input: "1.25.7" + go-version-input: "" + go-version-file: go.work work-dir: ${{ matrix.directory }} - if: steps.govulncheck.outcome == 'failure' run: echo "$MODULE_DIR" > "/tmp/govulncheck-failure-${JOB_INDEX}.txt" diff --git a/.github/workflows/go-version-update.yaml b/.github/workflows/go-version-update.yaml index d9a371fdb3..1cf4b76b8f 100644 --- a/.github/workflows/go-version-update.yaml +++ b/.github/workflows/go-version-update.yaml @@ -148,8 +148,6 @@ jobs: git add go.work git add -- '**/go.mod' - git add .github/workflows/checks.yaml - git add .github/workflows/sonarcloud.yml if git diff-index --quiet HEAD; then echo "No changes to commit after applying updates." @@ -182,9 +180,10 @@ jobs: Changes: - \`go.work\` — toolchain directive (and \`go\` directive if minor-version bump) - - All \`go.mod\` files — same - - \`.github/workflows/checks.yaml\` — \`go-version-input\` - - \`.github/workflows/sonarcloud.yml\` — \`go-version\` + - All \`go.mod\` \`go\` directives (minor-version bumps only) + + CI workflows read the Go version from \`go.work\` via \`go-version-file\`, + so no YAML changes are needed. Generated by the [go-version-update](${WORKFLOW_URL}) workflow. EOF diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 2aaf6a3c9a..f5fe8e8b0c 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -26,7 +26,7 @@ jobs: - name: "Setup Go" uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: "1.25.7" + go-version-file: go.work check-latest: false cache-dependency-path: | service/go.sum diff --git a/AGENTS.md b/AGENTS.md index 76ca0d37d0..ef00d36d7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,17 +64,21 @@ Prefer `make` targets at repo root: ## Go Toolchain Version Management -The Go toolchain version (`toolchain goX.Y.Z`) is pinned in `go.work` and in every module’s `go.mod`. Two CI workflow files also hardcode it: `.github/workflows/checks.yaml` (`go-version-input`) and `.github/workflows/sonarcloud.yml` (`go-version`). +**`go.work` is the single source of truth for the Go toolchain version.** Its `toolchain goX.Y.Z` directive controls which Go version is used for workspace builds and CI. Individual `go.mod` files intentionally have **no `toolchain` directive** — the workspace handles it, and omitting it avoids imposing a specific toolchain on downstream consumers of our published modules (sdk, protocol/go, lib/*). + +CI workflows read the Go version from `go.work` via `go-version-file: go.work`, so there are no hardcoded version strings in YAML files. **Do not update the Go toolchain version by hand in feature PRs.** Instead: - Toolchain bumps are automated by the `go-version-update` workflow (`.github/workflows/go-version-update.yaml`), which fires automatically when `govulncheck` fails on `main` or a `release/**` branch. It uses the `opentdf-automation[bot]` account and the `chore/bump-go-toolchain` branch. - To trigger a bump manually: `gh workflow run go-version-update.yaml --field base_branch=main` - To preview what a bump would change without modifying files: `.github/scripts/bump-go-version.sh --dry-run` +- **Patch bumps** (e.g. 1.25.8 → 1.25.9) only modify `go.work` — one file. +- **Minor-version upgrades** (e.g. 1.25 → 1.26) also update the `go` directive in `go.work` and all `go.mod` files. This is triggered when the current minor falls out of Go’s two-release support window. -**Minor-version upgrades** (e.g. 1.25 → 1.26) are handled by the same workflow when the current minor falls out of Go’s two-release support window. The `go` directive in every `go.mod` is updated to `X.Y.0` (minimum), and the `toolchain` directive is set to the latest patch. +**Do not add `toolchain` directives to go.mod files.** If `go mod tidy` or another tool adds one, remove it with `go mod edit -toolchain=none path/to/go.mod`. -If `govulncheck` is failing locally because you are on an older toolchain, update your local Go installation rather than editing these files directly. +If `govulncheck` is failing locally because you are on an older toolchain, update your local Go installation rather than editing version files directly. ## Security & Configuration Tips diff --git a/examples/go.mod b/examples/go.mod index 9da02faf7f..6525994e8e 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/examples go 1.25.0 -toolchain go1.25.8 - require ( connectrpc.com/connect v1.19.1 github.com/opentdf/platform/lib/ocrypto v0.10.0 diff --git a/lib/fixtures/go.mod b/lib/fixtures/go.mod index caf99bf130..fe5cb09211 100644 --- a/lib/fixtures/go.mod +++ b/lib/fixtures/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/lib/fixtures go 1.25.0 -toolchain go1.25.8 - require github.com/Nerzal/gocloak/v13 v13.9.0 require ( diff --git a/lib/flattening/go.mod b/lib/flattening/go.mod index 3551c144f8..40e10995f9 100644 --- a/lib/flattening/go.mod +++ b/lib/flattening/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/lib/flattening go 1.25.0 -toolchain go1.25.8 - require github.com/stretchr/testify v1.10.0 require ( diff --git a/lib/identifier/go.mod b/lib/identifier/go.mod index cabc7b781b..2d734ef6fe 100644 --- a/lib/identifier/go.mod +++ b/lib/identifier/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/lib/identifier go 1.25.0 -toolchain go1.25.8 - require github.com/stretchr/testify v1.10.0 require ( diff --git a/lib/ocrypto/go.mod b/lib/ocrypto/go.mod index 8328aa0e4c..309f3c4591 100644 --- a/lib/ocrypto/go.mod +++ b/lib/ocrypto/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/lib/ocrypto go 1.25.0 -toolchain go1.25.8 - require ( github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.45.0 diff --git a/protocol/go/go.mod b/protocol/go/go.mod index 03c0ad01c8..554c209411 100644 --- a/protocol/go/go.mod +++ b/protocol/go/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/protocol/go go 1.25.0 -toolchain go1.25.8 - require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.1-20240508200655-46a4cf4ba109.1 connectrpc.com/connect v1.19.1 diff --git a/sdk/go.mod b/sdk/go.mod index 8d048b098b..c615235d88 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/sdk go 1.25.0 -toolchain go1.25.8 - require ( connectrpc.com/connect v1.19.1 github.com/Masterminds/semver/v3 v3.4.0 diff --git a/service/go.mod b/service/go.mod index ac0042f7b5..6db42fe153 100644 --- a/service/go.mod +++ b/service/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/service go 1.25.0 -toolchain go1.25.8 - require ( buf.build/go/protovalidate v1.0.0 connectrpc.com/connect v1.19.1 diff --git a/test/integration/go.mod b/test/integration/go.mod index ce75fddc3a..c6be706666 100644 --- a/test/integration/go.mod +++ b/test/integration/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/test/integration go 1.25.0 -toolchain go1.25.8 - replace ( github.com/opentdf/platform/lib/fixtures => ../../lib/fixtures github.com/opentdf/platform/lib/ocrypto => ../../lib/ocrypto diff --git a/tests-bdd/go.mod b/tests-bdd/go.mod index 31c91c1b72..0700cf345a 100644 --- a/tests-bdd/go.mod +++ b/tests-bdd/go.mod @@ -2,8 +2,6 @@ module github.com/opentdf/platform/tests-bdd go 1.25.5 -toolchain go1.25.8 - require ( github.com/cucumber/godog v0.15.0 github.com/google/uuid v1.6.0