From d529ed4097152470ee42442d2448c9a096476e92 Mon Sep 17 00:00:00 2001 From: Linh Chu Date: Thu, 9 Apr 2026 21:44:08 +0100 Subject: [PATCH] [CNSL-1934] Add automated pending deploy branch management Introduces two workflows to manage the release process: 1. pending-deploy-pr.yml (workflow_dispatch trigger): - Finds the latest automation/pending-deploy-YYYYMMDD-hhmmss branch - Creates a PR to merge it into main - Triggers a pending deploy check 2. pending-deploy-check.yml (pull_request and workflow_dispatch trigger): - Validates pending deploy PRs before merge - Checks that Managed-service-commit-SHA trailers reference deployed commits - Blocks merge until all changes are confirmed deployed in managed-service - Posts PR comments detailing any undeployed commits This ensures SDK releases only reference deployed CC API changes. Co-Authored-By: roachdev-claude --- .github/workflows/pending-deploy-check.yml | 92 +++++ .github/workflows/pending-deploy-pr.yml | 83 +++++ CHANGELOG.md | 5 + Makefile | 5 + scripts/lib/pending-deploy-helpers-test.sh | 375 +++++++++++++++++++++ scripts/lib/pending-deploy-helpers.sh | 255 ++++++++++++++ scripts/lib/validation-test.sh | 111 ++++++ scripts/lib/validation.sh | 19 ++ scripts/pending-deploy-check.sh | 61 ++++ scripts/pending-deploy-pr.sh | 46 +++ scripts/post-failure-comment.sh | 126 +++++++ scripts/run-tests.sh | 36 ++ scripts/test-helpers.sh | 70 ++++ 13 files changed, 1284 insertions(+) create mode 100644 .github/workflows/pending-deploy-check.yml create mode 100644 .github/workflows/pending-deploy-pr.yml create mode 100755 scripts/lib/pending-deploy-helpers-test.sh create mode 100644 scripts/lib/pending-deploy-helpers.sh create mode 100755 scripts/lib/validation-test.sh create mode 100755 scripts/pending-deploy-check.sh create mode 100755 scripts/pending-deploy-pr.sh create mode 100755 scripts/post-failure-comment.sh create mode 100755 scripts/run-tests.sh create mode 100755 scripts/test-helpers.sh diff --git a/.github/workflows/pending-deploy-check.yml b/.github/workflows/pending-deploy-check.yml new file mode 100644 index 00000000..408f683a --- /dev/null +++ b/.github/workflows/pending-deploy-check.yml @@ -0,0 +1,92 @@ +name: Pending Deploy Check + +# This workflow validates that pending deploy PRs are safe to merge. +# +# Trigger: Pull requests to main from automation/pending-deploy-* branches +# Purpose: Verify all commits in the PR have been deployed in managed-service +# +# Why: SDK changes are generated from managed-service OpenAPI specs. We must ensure +# the corresponding managed-service changes have been deployed before merging the SDK changes, +# otherwise the SDK could reference unreleased API features. +# +# Flow: +# 1. For each commit in the PR, extract the Managed-service-commit-SHA trailer +# 2. Check if that SHA is in the latest managed-service release tag +# 3. If any commits are not yet deployed, post a detailed comment and fail the PR + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + branch: + description: 'Pending deploy branch to check' + required: true + type: string + pr_url: + description: 'PR URL to comment on if check fails' + required: true + type: string + +permissions: + contents: read # Checkout repository and read commit history + pull-requests: write # Post failure comments on PRs + +jobs: + pending-deploy-check: + runs-on: ubuntu-latest + if: | + (github.event_name == 'pull_request' && startsWith(github.head_ref, 'automation/pending-deploy-')) || + (github.event_name == 'workflow_dispatch' && startsWith(inputs.branch, 'automation/pending-deploy-')) + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Validate workflow input + if: github.event_name == 'workflow_dispatch' + id: validate-input + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + source scripts/lib/actions-helpers.sh + + # Extract PR number from URL + PR_NUMBER=$(echo "${{ inputs.pr_url }}" | grep -oE '[0-9]+$') + set_output pr_number "$PR_NUMBER" + + # Validate the PR's head branch matches the input branch + PR_HEAD_BRANCH=$(gh pr view "$PR_NUMBER" --json headRefName --jq '.headRefName') + if [ "$PR_HEAD_BRANCH" != "${{ inputs.branch }}" ]; then + log_error "PR #$PR_NUMBER head branch ($PR_HEAD_BRANCH) does not match input branch (${{ inputs.branch }})" + exit 1 + fi + + - name: Run deployment check + id: check-deployment + env: + PENDING_DEPLOY_BRANCH: ${{ inputs.branch || github.head_ref }} + MANAGED_SERVICE_TOKEN: ${{ secrets.MANAGED_SERVICE_TOKEN }} + run: scripts/pending-deploy-check.sh + + - name: Post failure comment and fail + if: steps.check-deployment.outputs.has_issues == 'true' + env: + PR_NUMBER: ${{ steps.validate-input.outputs.pr_number || github.event.pull_request.number }} + GITHUB_TOKEN: ${{ github.token }} + run: | + scripts/post-failure-comment.sh + source scripts/lib/actions-helpers.sh + log_error "Deployment check failed - see PR comment for details" + exit 1 + + - name: Summary + if: always() + run: | + source scripts/lib/actions-helpers.sh + if [ "${{ steps.check-deployment.outputs.has_issues }}" != "true" ]; then + log_info "All commits in pending deploy branch are deployed in managed-service" + else + log_info "Some commits are not deployed or potentially not deployed" + fi diff --git a/.github/workflows/pending-deploy-pr.yml b/.github/workflows/pending-deploy-pr.yml new file mode 100644 index 00000000..871f00aa --- /dev/null +++ b/.github/workflows/pending-deploy-pr.yml @@ -0,0 +1,83 @@ +name: Pending Deploy PR + +# This workflow creates PRs to apply pending deploy changes to main. +# +# Trigger: workflow_dispatch - called by TeamCity when managed-service is deployed +# Purpose: Automatically create a PR to merge the latest automation/pending-deploy-* branch into main +# +# Flow: +# 1. Find the latest automation/pending-deploy-YYYYMMDD-HHMMSS branch +# 2. Check if it has commits not in main +# 3. Create a PR if one doesn't already exist + +on: + workflow_dispatch: + inputs: + timestamp: + description: 'Deployment timestamp' + required: true + type: string + commit_sha: + description: 'Deployed commit SHA' + required: true + type: string + +permissions: + contents: read # Checkout repository and read branches/tags + pull-requests: write # Create and update PRs + actions: write # Trigger pending deploy check workflow + +jobs: + pending-deploy-pr: + runs-on: ubuntu-latest + outputs: + branch: ${{ steps.create-pr.outputs.branch }} + pr_url: ${{ steps.create-pr.outputs.pr_url }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 # Need full history to compare branches and commits + + - name: Run pending deploy PR workflow + id: create-pr + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: scripts/pending-deploy-pr.sh + + # GitHub Actions doesn't automatically trigger workflows when a PR is created using GITHUB_TOKEN + # (to prevent recursive workflow triggers). Since we need deployment validation to run, + # we explicitly dispatch the check workflow here. + - name: Trigger pending deploy check + if: steps.create-pr.outputs.pr_url != '' && steps.create-pr.outputs.branch != '' + id: trigger-check + env: + GH_TOKEN: ${{ github.token }} + run: | + source scripts/lib/actions-helpers.sh + if ! gh workflow run pending-deploy-check.yml \ + --repo ${{ github.repository }} \ + --field branch="${{ steps.create-pr.outputs.branch }}" \ + --field pr_url="${{ steps.create-pr.outputs.pr_url }}"; then + log_error "Failed to trigger pending-deploy-check workflow. You may need to manually trigger the check for ${{ steps.create-pr.outputs.pr_url }}" + else + log_info "Successfully triggered pending-deploy-check workflow" + fi + + - name: Summary + if: always() + run: | + source scripts/lib/actions-helpers.sh + if [ -z "${{ steps.create-pr.outputs.branch }}" ]; then + log_info "No pending deploy branches found" + elif [ "${{ steps.create-pr.outputs.has_new_commits }}" != "true" ]; then + log_info "Pending deploy branch has no new commits" + elif [ -n "${{ steps.create-pr.outputs.pr_url }}" ]; then + log_info "PR created or updated for pending deploy branch" + if [ "${{ steps.trigger-check.outcome }}" = "success" ]; then + log_info "Deployment check workflow triggered successfully" + elif [ "${{ steps.trigger-check.outcome }}" = "failure" ]; then + log_info "Failed to trigger deployment check workflow" + fi + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ec78d0..95d8a191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Automated pending deploy branch management with two GitHub Actions workflows: + - `pending-deploy-pr.yml`: Creates PRs from pending deploy branches to main (triggered via + workflow_dispatch) and triggers a pending deploy check + - `pending-deploy-check.yml`: Validates that SDK commits reference deployed managed-service changes + before allowing merge - Automated release workflow that creates release PRs when `automation/pending-deploy-*` branches are merged to main, updating the version number in all relevant files - Added pending deploy branch management to release workflow to ensure automated diff --git a/Makefile b/Makefile index ff08288f..b9fadb62 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,11 @@ add-boilerplate: validate: go run main.go +# Run bash helper script tests +.PHONY: test-scripts +test-scripts: + ./scripts/run-tests.sh + default: generate-openapi-client validate # build-tool is a helper that builds $TOOL_PKG in a temp directory so that it diff --git a/scripts/lib/pending-deploy-helpers-test.sh b/scripts/lib/pending-deploy-helpers-test.sh new file mode 100755 index 00000000..c79db618 --- /dev/null +++ b/scripts/lib/pending-deploy-helpers-test.sh @@ -0,0 +1,375 @@ +#!/usr/bin/env bash +# Tests for pending-deploy-helpers.sh functions +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." +source ./test-helpers.sh +source ./lib/actions-helpers.sh +source ./lib/validation.sh +source ./lib/pending-deploy-helpers.sh + +# Create a temporary directory for test artifacts +TEST_DIR=$(mktemp -d) +cd "$TEST_DIR" + +cleanup() { + cd - > /dev/null + rm -rf "$TEST_DIR" +} +trap cleanup EXIT + +# Mock set_output to avoid writing to real GITHUB_OUTPUT +set_output() { + echo "set_output: $1=$2" +} + +# --- check_deployment_status tests --- + +test_check_deployment_status_missing_sha() { + ( + export MANAGED_SERVICE_TOKEN="token" + local output + output=$(check_deployment_status "" "release-2024-01-01-1" 2>&1) || true + echo "$output" | check_contains "missing_parameters" + ) +} +expect_success "check_deployment_status: missing sha parameter outputs missing_parameters" test_check_deployment_status_missing_sha + +test_check_deployment_status_missing_tag() { + ( + export MANAGED_SERVICE_TOKEN="token" + local output + output=$(check_deployment_status "abc123" "" 2>&1) || true + echo "$output" | check_contains "missing_parameters" + ) +} +expect_success "check_deployment_status: missing tag parameter outputs missing_parameters" test_check_deployment_status_missing_tag + +test_check_deployment_status_missing_token() { + ( + unset MANAGED_SERVICE_TOKEN + local output + output=$(check_deployment_status "abc123" "release-2024-01-01-1" 2>&1) || true + echo "$output" | check_contains "missing_token" + ) +} +expect_success "check_deployment_status: missing token outputs missing_token" test_check_deployment_status_missing_token + +test_check_deployment_status_identical() { + ( + export MANAGED_SERVICE_TOKEN="token" + # Mock gh command to return "identical" status (after jq extraction) + gh() { + if [[ "$*" == *"compare"* ]] && [[ "$*" == *"--jq"* ]]; then + echo "identical" + fi + } + export -f gh + check_deployment_status "abc123" "release-2024-01-01-1" + ) +} +expect_success "check_deployment_status: identical status returns 0" test_check_deployment_status_identical + +test_check_deployment_status_behind() { + ( + export MANAGED_SERVICE_TOKEN="token" + # Mock gh command to return "behind" status (after jq extraction) + gh() { + if [[ "$*" == *"compare"* ]] && [[ "$*" == *"--jq"* ]]; then + echo "behind" + fi + } + export -f gh + check_deployment_status "abc123" "release-2024-01-01-1" + ) +} +expect_success "check_deployment_status: behind status returns 0" test_check_deployment_status_behind + +test_check_deployment_status_ahead() { + ( + export MANAGED_SERVICE_TOKEN="token" + # Mock gh command to return "ahead" status (after jq extraction) + gh() { + if [[ "$*" == *"compare"* ]] && [[ "$*" == *"--jq"* ]]; then + echo "ahead" + fi + } + export -f gh + check_deployment_status "abc123" "release-2024-01-01-1" 2>&1 + ) +} +expect_failure "check_deployment_status: ahead status returns 1" test_check_deployment_status_ahead + +test_check_deployment_status_diverged() { + ( + export MANAGED_SERVICE_TOKEN="token" + # Mock gh command to return "diverged" status (after jq extraction) + gh() { + if [[ "$*" == *"compare"* ]] && [[ "$*" == *"--jq"* ]]; then + echo "diverged" + fi + } + export -f gh + local output exit_code + output=$(check_deployment_status "abc123" "release-2024-01-01-1" 2>&1) || exit_code=$? + [[ "$exit_code" -eq 2 ]] && echo "$output" | check_contains "diverged" + ) +} +expect_success "check_deployment_status: diverged status returns 2 and outputs status" test_check_deployment_status_diverged + +test_check_deployment_status_api_failure() { + ( + export MANAGED_SERVICE_TOKEN="token" + # Mock gh command to fail + gh() { + if [[ "$*" == *"compare"* ]]; then + echo "API error" >&2 + return 1 + fi + } + export -f gh + local output exit_code + output=$(check_deployment_status "abc123" "release-2024-01-01-1" 2>&1) || exit_code=$? + [[ "$exit_code" -eq 2 ]] && echo "$output" | check_contains "unknown" + ) +} +expect_success "check_deployment_status: API failure returns 2 and outputs unknown" test_check_deployment_status_api_failure + +# --- find_latest_branch tests --- + +test_find_latest_branch_no_branches() { + ( + # Mock git to return no matching branches + git() { + if [[ "$*" == "branch --remotes" ]]; then + echo " origin/main" + echo " origin/feature-branch" + else + command git "$@" + fi + } + export -f git + + find_latest_branch 2>&1 | check_contains "No pending deploy branches" + ) +} +expect_success "find_latest_branch: no matching branches found" test_find_latest_branch_no_branches + +test_find_latest_branch_single_branch() { + ( + # Mock git to return one pending deploy branch + git() { + if [[ "$*" == "branch --remotes" ]]; then + echo " origin/main" + echo " origin/automation/pending-deploy-20240501-120000" + else + command git "$@" + fi + } + export -f git + + local output + output=$(find_latest_branch 2>&1) + echo "$output" | check_contains "automation/pending-deploy-20240501-120000" + ) +} +expect_success "find_latest_branch: single branch found" test_find_latest_branch_single_branch + +test_find_latest_branch_multiple_branches_picks_latest() { + ( + # Mock git to return multiple pending deploy branches + git() { + if [[ "$*" == "branch --remotes" ]]; then + echo " origin/main" + echo " origin/automation/pending-deploy-20240501-120000" + echo " origin/automation/pending-deploy-20240502-140000" + echo " origin/automation/pending-deploy-20240501-150000" + else + command git "$@" + fi + } + export -f git + + local output + output=$(find_latest_branch 2>&1) + # Should pick the latest timestamp: 20240502-140000 + echo "$output" | check_contains "automation/pending-deploy-20240502-140000" + ) +} +expect_success "find_latest_branch: picks latest from multiple branches" test_find_latest_branch_multiple_branches_picks_latest + +# --- check_all_commits tests --- + +test_check_all_commits_missing_trailer() { + ( + export MANAGED_SERVICE_TOKEN="token" + + # Create commits.txt with one commit + echo "abc123|Add new feature" > commits.txt + + # Mock git log to return empty trailer + git() { + if [[ "$*" == *"trailers"* ]]; then + echo "" + else + command git "$@" + fi + } + export -f git + + check_all_commits "release-2024-01-01-1" + + # Check that commit was added to missing_trailer.txt + grep --quiet "abc123|Add new feature" missing_trailer.txt + ) +} +expect_success "check_all_commits: commit without trailer goes to missing_trailer.txt" test_check_all_commits_missing_trailer + +test_check_all_commits_deployed() { + ( + export MANAGED_SERVICE_TOKEN="token" + + # Create commits.txt with one commit + echo "abc123|Add new feature" > commits.txt + + # Mock git log to return a trailer + git() { + if [[ "$*" == *"trailers"* ]]; then + echo "def456" + else + command git "$@" + fi + } + export -f git + + # Mock check_deployment_status to return 0 (deployed) + check_deployment_status() { + return 0 + } + export -f check_deployment_status + + check_all_commits "release-2024-01-01-1" + + # Check that commit was NOT added to any error file + [[ ! -s not_deployed.txt ]] && [[ ! -s unexpected_status.txt ]] + ) +} +expect_success "check_all_commits: deployed commit not in error files" test_check_all_commits_deployed + +test_check_all_commits_not_deployed() { + ( + export MANAGED_SERVICE_TOKEN="token" + + # Create commits.txt with one commit + echo "abc123|Add new feature" > commits.txt + + # Mock git log to return a trailer + git() { + if [[ "$*" == *"trailers"* ]]; then + echo "def456" + else + command git "$@" + fi + } + export -f git + + # Mock check_deployment_status to return 1 (not deployed) + check_deployment_status() { + return 1 + } + export -f check_deployment_status + + check_all_commits "release-2024-01-01-1" + + # Check that commit was added to not_deployed.txt + grep --quiet "abc123|Add new feature|def456" not_deployed.txt + ) +} +expect_success "check_all_commits: not deployed commit goes to not_deployed.txt" test_check_all_commits_not_deployed + +test_check_all_commits_unexpected_status() { + ( + export MANAGED_SERVICE_TOKEN="token" + + # Create commits.txt with one commit + echo "abc123|Add new feature" > commits.txt + + # Mock git log to return a trailer + git() { + if [[ "$*" == *"trailers"* ]]; then + echo "def456" + else + command git "$@" + fi + } + export -f git + + # Mock check_deployment_status to return 2 (unexpected) with output + check_deployment_status() { + echo "diverged" + return 2 + } + export -f check_deployment_status + + check_all_commits "release-2024-01-01-1" 2>&1 + + # Check that commit was added to unexpected_status.txt with status + grep --quiet "abc123|Add new feature|def456|diverged" unexpected_status.txt + ) +} +expect_success "check_all_commits: unexpected status goes to unexpected_status.txt" test_check_all_commits_unexpected_status + +test_check_all_commits_mixed_statuses() { + ( + export MANAGED_SERVICE_TOKEN="token" + + # Create commits.txt with multiple commits + cat > commits.txt <<'EOF' +commit1|First commit +commit2|Second commit +commit3|Third commit +commit4|Fourth commit +EOF + + # Mock git log to return different trailers + git() { + if [[ "$*" == *"trailers"* ]]; then + local sha="$4" + case "$sha" in + commit1) echo "" ;; # No trailer + commit2) echo "ms-sha-2" ;; + commit3) echo "ms-sha-3" ;; + commit4) echo "ms-sha-4" ;; + esac + else + command git "$@" + fi + } + export -f git + + # Mock check_deployment_status to return different statuses + check_deployment_status() { + local ms_sha="$1" + case "$ms_sha" in + ms-sha-2) return 0 ;; # Deployed + ms-sha-3) return 1 ;; # Not deployed + ms-sha-4) echo "diverged"; return 2 ;; # Unexpected + esac + } + export -f check_deployment_status + + check_all_commits "release-2024-01-01-1" 2>&1 + + # Verify categorization + grep --quiet "commit1|First commit" missing_trailer.txt && \ + grep --quiet "commit3|Third commit|ms-sha-3" not_deployed.txt && \ + grep --quiet "commit4|Fourth commit|ms-sha-4|diverged" unexpected_status.txt && \ + ! grep --quiet "commit2" missing_trailer.txt && \ + ! grep --quiet "commit2" not_deployed.txt && \ + ! grep --quiet "commit2" unexpected_status.txt + ) +} +expect_success "check_all_commits: correctly categorizes mixed commit statuses" test_check_all_commits_mixed_statuses + +print_results diff --git a/scripts/lib/pending-deploy-helpers.sh b/scripts/lib/pending-deploy-helpers.sh new file mode 100644 index 00000000..b696323e --- /dev/null +++ b/scripts/lib/pending-deploy-helpers.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# Shared utility functions for pending deploy workflows + +# Get the latest managed-service release tag +# Exports: LATEST_RELEASE_TAG +# Returns: 0 on success, 1 on failure +get_latest_release_tag() { + check_required_env "MANAGED_SERVICE_TOKEN" || return 1 + + export GH_TOKEN="$MANAGED_SERVICE_TOKEN" + + log_info "Fetching release tags from managed-service repository" + + # Fetch all release tags matching release-YYYY-MM-DD-N pattern + # The GitHub API returns results in pages (30 items per page). Without --paginate, we'd only get + # the first page. With --paginate, gh automatically fetches all pages for us. We need all tags + # because the API doesn't support sorting by date, so we must fetch everything and sort ourselves. + local all_tags + all_tags=$(gh api repos/cockroachlabs/managed-service/tags --paginate --jq '.[].name' | grep --extended-regexp '^release-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]+$') + + if [[ -z "${all_tags:-}" ]]; then + log_error "No release tags found in managed-service repository" + return 1 + fi + + # Sort and get the latest (tags sort lexicographically in chronological order) + local sorted_tags + sorted_tags=$(echo "$all_tags" | sort --reverse) + LATEST_RELEASE_TAG=$(echo "$sorted_tags" | head --lines=1) + + log_info "Latest release tag: $LATEST_RELEASE_TAG" + export LATEST_RELEASE_TAG + return 0 +} + +# Check if a managed-service commit SHA is deployed as of the latest release tag +# +# Args: $1 - managed-service commit SHA +# $2 - latest release tag +# Returns: 0 if deployed, 1 if not deployed, 2 if uncertain/unexpected +# Outputs to stdout: Status string when deployment status is uncertain +check_deployment_status() { + local ms_sha="$1" + local latest_tag="$2" + + if [[ -z "${ms_sha:-}" || -z "${latest_tag:-}" ]]; then + log_error "Both ms_sha and latest_tag are required" + echo "missing_parameters" + return 2 + fi + + if ! check_required_env "MANAGED_SERVICE_TOKEN"; then + echo "missing_token" + return 2 + fi + + export GH_TOKEN="$MANAGED_SERVICE_TOKEN" + + # Compare the latest release tag with the commit SHA + # Status can be: identical, ahead, behind, or diverged + local compare_status + if ! compare_status=$(gh api "repos/cockroachlabs/managed-service/compare/${latest_tag}...${ms_sha}" --jq '.status' 2>&1); then + log_error "Failed to compare commit with release tag: $compare_status" + echo "unknown" + return 2 + fi + + case "$compare_status" in + identical|behind) + # SHA has been deployed + return 0 + ;; + ahead) + # SHA is ahead of the latest release - not deployed yet + return 1 + ;; + *) + # Unexpected status (e.g., diverged or other) + echo "$compare_status" + log_error "Unexpected comparison status: $compare_status" + return 2 + ;; + esac +} + +# Get commits that are in the pending deploy branch but not in main +get_commits() { + local branch="$1" + + log_info "Getting commits from $branch not in main" + + # Get commits that are in the pending deploy branch but not in main + # Format: SHA|subject + git log origin/main..origin/"$branch" --format="%H|%s" > commits.txt + + if [[ ! -s commits.txt ]]; then + log_info "No commits found in $branch that are not in main" + set_output has_issues "false" + exit 0 + fi + + log_info "Commits to check:" + cat commits.txt +} + +# Check deployment status of all commits +# Creates files categorizing commits by deployment status: +# - not_deployed.txt: Commits not yet deployed +# - missing_trailer.txt: Commits missing managed-service SHA trailer +# - unexpected_status.txt: Commits with unexpected deployment status +check_all_commits() { + local latest_tag="$1" + + touch not_deployed.txt + touch missing_trailer.txt + touch unexpected_status.txt + + # Process each commit + while IFS='|' read -r sha subject; do + log_info "Checking commit $sha: $subject" + + # Extract managed-service commit SHA from git commit message trailer + # Trailers are key-value pairs at the end of commit messages, format: + # Managed-service-commit-SHA: + # This links SDK commits back to the managed-service commit that triggered them + local ms_sha + ms_sha=$(git log --max-count=1 --format='%(trailers:key=Managed-service-commit-SHA,valueonly)' "$sha") + + if [[ -z "$ms_sha" ]]; then + log_info " No Managed-service-commit-SHA trailer found" + echo "$sha|$subject" >> missing_trailer.txt + continue + fi + + log_info " Found managed-service SHA: $ms_sha" + + # Check deployment status + # Return codes: 0=deployed, 1=not deployed, 2=uncertain/unexpected + # On uncertain status (return 2), the function outputs status details to stdout + # Note: We use || status=$? to capture non-zero exit codes without triggering set -e + local output status=0 + output=$(check_deployment_status "$ms_sha" "$latest_tag") || status=$? + + if [[ $status -eq 0 ]]; then + log_info " Deployed" + elif [[ $status -eq 1 ]]; then + log_info " Not deployed yet" + echo "$sha|$subject|$ms_sha" >> not_deployed.txt + else + log_error " Unexpected status: $output" + echo "$sha|$subject|$ms_sha|$output" >> unexpected_status.txt + fi + done < commits.txt +} + +# Find the latest pending deploy branch +find_latest_branch() { + log_info "Looking for pending deploy branches" + + # Find all remote branches matching automation/pending-deploy-YYYYMMDD-HHMMSS + # The timestamp format ensures lexicographic sorting matches chronological ordering + # Example: automation/pending-deploy-20250506-143022 (May 6, 2025 at 14:30:22) + local all_branches + all_branches=$(git branch --remotes) + + local branches + branches=$(echo "$all_branches" | grep --extended-regexp 'origin/automation/pending-deploy-[0-9]{8}-[0-9]{6}$' || true) + + if [[ -z "$branches" ]]; then + log_info "No pending deploy branches found on origin" + set_output branch "" + return 0 + fi + + # Find the latest branch (sort --reverse gives newest first due to timestamp format) + local latest + latest=$(echo "$branches" | sed 's|^[[:space:]]*origin/||' | sort --reverse | head --lines=1) + + if [[ -z "$latest" ]]; then + log_error "Failed to parse pending deploy branches" + exit 1 + fi + + log_info "Latest pending deploy branch: $latest" + set_output branch "$latest" + export BRANCH="$latest" +} + +# Check if the branch has commits not in main +check_branch_has_new_commits() { + local branch="$1" + + if [[ -z "$branch" ]]; then + return 0 + fi + + log_info "Checking for commits in $branch not in main" + + # Count commits in branch but not in origin/main + local commit_count + commit_count=$(git rev-list --count origin/main..origin/"$branch") + + if [[ "$commit_count" -eq 0 ]]; then + log_info "No new commits in $branch" + set_output has_new_commits "false" + return 0 + fi + + log_info "Found $commit_count commit(s) in $branch not in main" + set_output has_new_commits "true" + export HAS_NEW_COMMITS="true" +} + +# Create or check for existing PR +create_pr_if_not_exists() { + local branch="$1" + local has_new_commits="$2" + + if [[ -z "$branch" ]] || [[ "$has_new_commits" != "true" ]]; then + return 0 + fi + + log_info "Checking if PR already exists for $branch" + + # Check if PR already exists + local existing_pr_url + existing_pr_url=$(gh pr list --head "$branch" --base main --json url --jq '.[0].url' || echo "") + + if [[ -n "$existing_pr_url" ]] && [[ "$existing_pr_url" != "null" ]]; then + log_info "PR already exists: $existing_pr_url" + set_output pr_url "$existing_pr_url" + return 0 + fi + + log_info "Creating new PR for $branch" + + # Create new PR + local pr_body + pr_body="This PR represents commits from \`$branch\` that are pending deployment. These commits were generated in response to Cockroach Cloud API changes in the managed-service repository." + + local pr_url + pr_url=$(gh pr create \ + --head "$branch" \ + --base main \ + --title "Applying pending deploy changes: $branch" \ + --body "$pr_body") + + if [[ -z "$pr_url" ]]; then + log_error "Failed to create PR" + exit 1 + fi + + log_info "Created PR: $pr_url" + set_output pr_url "$pr_url" +} diff --git a/scripts/lib/validation-test.sh b/scripts/lib/validation-test.sh new file mode 100755 index 00000000..fa79e362 --- /dev/null +++ b/scripts/lib/validation-test.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Tests for validation.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." +source ./test-helpers.sh +source ./lib/actions-helpers.sh +source ./lib/validation.sh + +# --- check_required_commands tests --- + +test_check_required_commands_all_found() { + check_required_commands bash echo grep +} +expect_success "check_required_commands: all commands found" test_check_required_commands_all_found + +test_check_required_commands_one_missing() { + check_required_commands bash nonexistent_cmd_xyz grep +} +expect_failure "check_required_commands: fails when command missing" test_check_required_commands_one_missing + +test_check_required_commands_multiple_missing() { + local output + output=$(check_required_commands bash fake_cmd_1 fake_cmd_2 grep 2>&1) || true + # Should report missing commands + echo "$output" | check_contains "fake_cmd_1" + echo "$output" | check_contains "fake_cmd_2" + # Should NOT report found commands + ! echo "$output" | check_contains "grep" +} +expect_success "check_required_commands: reports multiple missing" test_check_required_commands_multiple_missing + +test_check_required_commands_error_message() { + local output + output=$(check_required_commands nonexistent_xyz 2>&1) || true + echo "$output" | check_contains "::error::" + echo "$output" | check_contains "not found in PATH" + echo "$output" | check_contains "nonexistent_xyz" +} +expect_success "check_required_commands: error message format" test_check_required_commands_error_message + +test_check_required_commands_empty() { + check_required_commands +} +expect_success "check_required_commands: handles no arguments" test_check_required_commands_empty + +# --- check_required_env tests --- + +test_check_required_env_all_set() { + ( + export TEST_VAR_1="value1" + export TEST_VAR_2="value2" + export TEST_VAR_3="value3" + check_required_env TEST_VAR_1 TEST_VAR_2 TEST_VAR_3 + ) +} +expect_success "check_required_env: all env vars set" test_check_required_env_all_set + +test_check_required_env_one_missing() { + ( + export TEST_VAR_1="value1" + unset TEST_VAR_2 + export TEST_VAR_3="value3" + check_required_env TEST_VAR_1 TEST_VAR_2 TEST_VAR_3 + ) +} +expect_failure "check_required_env: fails when env var missing" test_check_required_env_one_missing + +test_check_required_env_multiple_missing() { + ( + export TEST_VAR_1="value1" + unset TEST_VAR_2 + unset TEST_VAR_3 + local output + output=$(check_required_env TEST_VAR_1 TEST_VAR_2 TEST_VAR_3 2>&1) || true + # Should report missing env vars + echo "$output" | check_contains "TEST_VAR_2" + echo "$output" | check_contains "TEST_VAR_3" + # Should NOT report set env vars + ! echo "$output" | check_contains "TEST_VAR_1" + ) +} +expect_success "check_required_env: reports multiple missing" test_check_required_env_multiple_missing + +test_check_required_env_error_message() { + ( + unset NONEXISTENT_VAR_XYZ + local output + output=$(check_required_env NONEXISTENT_VAR_XYZ 2>&1) || true + echo "$output" | check_contains "::error::" + echo "$output" | check_contains "not set" + echo "$output" | check_contains "NONEXISTENT_VAR_XYZ" + ) +} +expect_success "check_required_env: error message format" test_check_required_env_error_message + +test_check_required_env_empty_value() { + ( + export TEST_VAR_EMPTY="" + check_required_env TEST_VAR_EMPTY + ) +} +expect_failure "check_required_env: fails when env var is empty string" test_check_required_env_empty_value + +test_check_required_env_empty() { + check_required_env +} +expect_success "check_required_env: handles no arguments" test_check_required_env_empty + +print_results diff --git a/scripts/lib/validation.sh b/scripts/lib/validation.sh index 8e45c779..f54203e8 100644 --- a/scripts/lib/validation.sh +++ b/scripts/lib/validation.sh @@ -19,3 +19,22 @@ check_required_commands() { return 0 } + +# Check if required environment variables are set +# Usage: check_required_env "VAR1" "VAR2" "VAR3" +check_required_env() { + local missing_vars=() + + for var in "$@"; do + if [[ -z "${!var:-}" ]]; then + missing_vars+=("$var") + fi + done + + if [[ ${#missing_vars[@]} -gt 0 ]]; then + log_error "Required environment variable(s) not set: ${missing_vars[*]}" + return 1 + fi + + return 0 +} diff --git a/scripts/pending-deploy-check.sh b/scripts/pending-deploy-check.sh new file mode 100755 index 00000000..ff3729a4 --- /dev/null +++ b/scripts/pending-deploy-check.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Check deployment status of commits in a pending deploy branch. +# +# This script verifies that all commits in a pending deploy branch +# have been deployed to managed-service by checking their commit trailers +# against the latest release tag. +# +# Required environment variables: +# PENDING_DEPLOY_BRANCH: The head branch name +# MANAGED_SERVICE_TOKEN: GitHub token with managed-service read access +# GITHUB_OUTPUT: Path to GitHub Actions output file +# +# Outputs: +# Sets has_issues=true/false in GITHUB_OUTPUT +# Creates result files: not_deployed.txt, missing_trailer.txt, unexpected_status.txt + +# Get the script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source helper functions +source "$SCRIPT_DIR/lib/actions-helpers.sh" +source "$SCRIPT_DIR/lib/validation.sh" +source "$SCRIPT_DIR/lib/pending-deploy-helpers.sh" + +# Check for required commands +check_required_commands "gh" "git" || exit 1 + +# Main execution +main() { + log_info "=== Starting pending deploy check ===" + + check_required_env "PENDING_DEPLOY_BRANCH" "MANAGED_SERVICE_TOKEN" "GITHUB_OUTPUT" || exit 1 + + # Get list of commits in pending deploy branch not yet in main + get_commits "$PENDING_DEPLOY_BRANCH" + + # Fetch the latest managed-service release tag for comparison + if ! get_latest_release_tag; then + log_error "Failed to get latest release tag" + exit 1 + fi + + # Verify each commit's deployment status and categorize results + check_all_commits "$LATEST_RELEASE_TAG" + + # Check if any of the result files have content (indicating issues found) + # These files are used by post-failure-comment.sh to format the PR comment + if [[ -s not_deployed.txt ]] || [[ -s missing_trailer.txt ]] || [[ -s unexpected_status.txt ]]; then + log_error "Found commits that are not deployed or potentially not deployed" + set_output has_issues "true" + else + log_info "All commits are deployed" + set_output has_issues "false" + fi + + log_info "=== Pending deploy check completed ===" +} + +main diff --git a/scripts/pending-deploy-pr.sh b/scripts/pending-deploy-pr.sh new file mode 100755 index 00000000..0666436f --- /dev/null +++ b/scripts/pending-deploy-pr.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Create or update a PR for the latest pending deploy branch. +# +# This script finds the latest pending deploy branch and creates a PR +# to merge it into main if it has commits that are not yet in main. +# +# Required environment variables: +# GITHUB_TOKEN: GitHub token for creating PRs +# GITHUB_REPOSITORY: Repository in owner/repo format +# GITHUB_OUTPUT: Path to GitHub Actions output file + +# Get the script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source helper functions +source "$SCRIPT_DIR/lib/actions-helpers.sh" +source "$SCRIPT_DIR/lib/validation.sh" +source "$SCRIPT_DIR/lib/pending-deploy-helpers.sh" + +# Check for required commands +check_required_commands "gh" "git" || exit 1 + +# Main execution +main() { + log_info "=== Starting pending deploy PR workflow ===" + + check_required_env "GITHUB_TOKEN" "GITHUB_REPOSITORY" "GITHUB_OUTPUT" || exit 1 + + # Set GH_TOKEN for gh CLI commands + export GH_TOKEN="$GITHUB_TOKEN" + + # Find the most recent automation/pending-deploy-* branch + find_latest_branch + + if [[ -n "${BRANCH:-}" ]]; then + # Check if the branch has commits not yet in main + check_branch_has_new_commits "$BRANCH" + create_pr_if_not_exists "$BRANCH" "${HAS_NEW_COMMITS:-false}" + fi + + log_info "=== Pending deploy PR workflow completed ===" +} + +main diff --git a/scripts/post-failure-comment.sh b/scripts/post-failure-comment.sh new file mode 100755 index 00000000..8710d6d5 --- /dev/null +++ b/scripts/post-failure-comment.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Post a PR comment with deployment check failure details. +# +# This script formats and posts a comment to a GitHub PR explaining +# which commits in a pending deploy branch have not been deployed yet. +# +# Required environment variables: +# PR_NUMBER: Pull request number +# GITHUB_TOKEN: GitHub token for posting PR comments +# +# Required files (created by pending-deploy-check.sh): +# not_deployed.txt: Commits that are definitely not deployed +# missing_trailer.txt: Commits missing the managed-service SHA trailer +# unexpected_status.txt: Commits with unexpected deployment status + +# Get the script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source helper functions +source "$SCRIPT_DIR/lib/actions-helpers.sh" +source "$SCRIPT_DIR/lib/validation.sh" + +# Cleanup function for temporary files +cleanup() { + rm -f comment.md +} +trap cleanup EXIT + +# Check for required commands +check_required_commands "gh" || exit 1 + +# Validate required environment variables +check_required_env "PR_NUMBER" "GITHUB_TOKEN" || exit 1 + +# Set GH_TOKEN for gh CLI commands +export GH_TOKEN="$GITHUB_TOKEN" + +log_info "Formatting deployment check failure comment for PR #$PR_NUMBER" + +# Build comment body +cat > comment.md <<'EOF' +## Pre-Deploy Check Failed + +This PR contains commits that are not yet deployed or potentially not deployed in managed-service. + +EOF + +# Add not deployed section +if [[ -s not_deployed.txt ]]; then + cat >> comment.md <<'EOF' +### Not Deployed Commits + +These commits reference managed-service SHAs that are definitively not deployed: + +EOF + + while IFS='|' read -r sha subject ms_sha; do + cat >> comment.md <> comment.md <<'EOF' +### Potentially Not Deployed Commits + +EOF + + # Missing trailers section + if [[ -s missing_trailer.txt ]]; then + cat >> comment.md <<'EOF' +#### Missing Trailers + +These commits lack a managed-service SHA trailer: + +EOF + + while IFS='|' read -r sha subject; do + cat >> comment.md <> comment.md <<'EOF' +#### Unexpected Deploy Status + +These commits have unexpected deployment status: + +EOF + + while IFS='|' read -r sha subject ms_sha output; do + cat >> comment.md <> comment.md <<'EOF' + +--- + +**Action Required:** This PR is blocked from merging until all commits are confirmed deployed in managed-service. + +- For commits with missing trailers: Add the `Managed-service-commit-SHA:` trailer to the commit message +- For not deployed commits: Remove commit from pending deploy branch and wait for the corresponding managed-service release +- This check will re-run automatically when the PR is updated +EOF + +# Post comment +gh pr comment "$PR_NUMBER" --body-file comment.md + +log_info "Posted deployment failure comment to PR #$PR_NUMBER" diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 00000000..8823e6eb --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Run all bash script tests +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "Running all bash script tests..." +echo "" + +TOTAL_PASS=0 +TOTAL_FAIL=0 + +for test_file in lib/*-test.sh; do + if [ -f "$test_file" ]; then + echo "Running $test_file..." + if output=$(./"$test_file" 2>&1); then + echo "$output" + # Extract pass/fail counts from "Results: X passed, Y failed" + if [[ "$output" =~ ([0-9]+)\ passed,\ ([0-9]+)\ failed ]]; then + TOTAL_PASS=$((TOTAL_PASS + ${BASH_REMATCH[1]})) + TOTAL_FAIL=$((TOTAL_FAIL + ${BASH_REMATCH[2]})) + fi + else + echo "$output" + TOTAL_FAIL=$((TOTAL_FAIL + 1)) + fi + echo "" + fi +done + +echo "========================================" +echo "TOTAL: $TOTAL_PASS passed, $TOTAL_FAIL failed" +echo "========================================" + +[ "$TOTAL_FAIL" -eq 0 ] || exit 1 diff --git a/scripts/test-helpers.sh b/scripts/test-helpers.sh new file mode 100755 index 00000000..90207ebb --- /dev/null +++ b/scripts/test-helpers.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Minimal test helpers for bash script tests. + +PASS=0 +FAIL=0 + +# Assert that input contains a given substring. +# Usage: check_contains "expected" "$file" (reads file) +# cmd | check_contains "expected" (reads stdin) +check_contains() { + if [ $# -ge 2 ]; then + grep --quiet --fixed-strings -- "$1" "$2" + else + grep --quiet --fixed-strings -- "$1" + fi +} + +_run_test() { + local name="$1" + local expected_exit="$2" + local expected_output="$3" + shift 3 + + local output exit_code + output=$("$@" 2>&1) && exit_code=0 || exit_code=$? + + if [ "$expected_exit" = "nonzero" ]; then + if [ "$exit_code" -eq 0 ]; then + echo "FAIL: $name — expected non-zero exit, got 0" + echo " output: $output" + FAIL=$((FAIL + 1)) + return + fi + elif [ "$exit_code" -ne "$expected_exit" ]; then + echo "FAIL: $name — expected exit $expected_exit, got $exit_code" + echo " output: $output" + FAIL=$((FAIL + 1)) + return + fi + + if [ -n "$expected_output" ] && ! printf '%s\n' "$output" | check_contains "$expected_output"; then + echo "FAIL: $name — expected output containing: $expected_output" + echo " actual: $output" + FAIL=$((FAIL + 1)) + return + fi + + echo "PASS: $name" + PASS=$((PASS + 1)) +} + +# expect_success "test name" command [args...] +# Asserts the command exits 0. +expect_success() { + local name="$1"; shift + _run_test "$name" 0 "" "$@" +} + +# expect_failure "test name" command [args...] +# Asserts the command exits non-zero. +expect_failure() { + local name="$1"; shift + _run_test "$name" "nonzero" "" "$@" +} + +print_results() { + echo "" + echo "Results: $PASS passed, $FAIL failed" + [ "$FAIL" -eq 0 ] || exit 1 +}