diff --git a/.github/workflows/pending-deploy-check.yml b/.github/workflows/pending-deploy-check.yml new file mode 100644 index 0000000..733d1d7 --- /dev/null +++ b/.github/workflows/pending-deploy-check.yml @@ -0,0 +1,61 @@ +name: Pending Deploy Check + +# This workflow validates that pending deploy PRs are safe to merge. +# +# Trigger: Pull requests to main from 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] + +permissions: + contents: read + pull-requests: write + +jobs: + pending-deploy-check: + runs-on: ubuntu-latest + if: startsWith(github.head_ref, 'pending-deploy-') + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Run deployment check + id: check-deployment + env: + GITHUB_HEAD_REF: ${{ 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: ${{ github.event.pull_request.number }} + GITHUB_TOKEN: ${{ github.token }} + run: | + scripts/post-failure-comment.sh + source scripts/lib/logging.sh + log_error "Deployment check failed - see PR comment for details" + exit 1 + + - name: Summary + if: always() + run: | + source scripts/lib/logging.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 \ No newline at end of file diff --git a/.github/workflows/pending-deploy-pr.yml b/.github/workflows/pending-deploy-pr.yml new file mode 100644 index 0000000..a5aab7a --- /dev/null +++ b/.github/workflows/pending-deploy-pr.yml @@ -0,0 +1,58 @@ +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 pending-deploy-* branch into main +# +# Flow: +# 1. Find the latest 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 + +# Required permissions: +# - contents:read for checking out code and reading branches/tags +# - pull-requests:write for creating/updating PRs +permissions: + contents: read + pull-requests: write + +jobs: + pending-deploy-pr: + runs-on: ubuntu-latest + 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 + + - name: Summary + if: always() + run: | + source scripts/lib/logging.sh + if [ -z "${{ steps.create-pr.outputs.branch }}" ]; then + log_info "No pending deploy branches found" + elif [ "${{ steps.create-pr.outputs.has_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" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d84e8..d972f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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) + - `pending-deploy-check.yml`: Validates that SDK commits reference deployed managed-service changes + before allowing merge + ## [7.1.0] - 2026-04-14 ### Added diff --git a/scripts/lib/logging.sh b/scripts/lib/logging.sh new file mode 100755 index 0000000..a316072 --- /dev/null +++ b/scripts/lib/logging.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Logging functions for GitHub Actions workflows + +# Output an informational message to stdout +log_info() { + local message="$1" + echo "$message" +} + +# Output an error message using GitHub Actions workflow command format +# https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message +log_error() { + local message="$1" + echo "::error::$message" >&2 +} + +# Output a warning message using GitHub Actions workflow command format +log_warning() { + local message="$1" + echo "::warning::$message" >&2 +} + +# Output a notice message using GitHub Actions workflow command format +log_notice() { + local message="$1" + echo "::notice::$message" +} diff --git a/scripts/lib/release-helpers.sh b/scripts/lib/release-helpers.sh new file mode 100755 index 0000000..7f9ce44 --- /dev/null +++ b/scripts/lib/release-helpers.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Shared utility functions for pending deploy workflows + +# Verify logging functions are available +if ! command -v log_error &> /dev/null; then + echo "Error: logging.sh must be sourced before release-helpers.sh" >&2 + exit 1 +fi + +# Get the latest managed-service release tag +# Exports: LATEST_RELEASE_TAG +# Returns: 0 on success, 1 on failure +get_latest_release_tag() { + if [[ -z "${MANAGED_SERVICE_TOKEN:-}" ]]; then + log_error "MANAGED_SERVICE_TOKEN environment variable is not set" + return 1 + fi + + 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 -E '^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) + LATEST_RELEASE_TAG=$(echo "$all_tags" | sort -r | head -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 [[ -z "${MANAGED_SERVICE_TOKEN:-}" ]]; then + log_error "MANAGED_SERVICE_TOKEN environment variable is not set" + 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 +} diff --git a/scripts/lib/validation.sh b/scripts/lib/validation.sh new file mode 100755 index 0000000..8e45c77 --- /dev/null +++ b/scripts/lib/validation.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Validation helper functions + +# Check if required commands are available in PATH +# Usage: check_required_commands "cmd1" "cmd2" "cmd3" +check_required_commands() { + local missing_commands=() + + for cmd in "$@"; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing_commands+=("$cmd") + fi + done + + if [[ ${#missing_commands[@]} -gt 0 ]]; then + log_error "Required command(s) not found in PATH: ${missing_commands[*]}" + return 1 + fi + + return 0 +} diff --git a/scripts/pending-deploy-check.sh b/scripts/pending-deploy-check.sh new file mode 100755 index 0000000..b25ed54 --- /dev/null +++ b/scripts/pending-deploy-check.sh @@ -0,0 +1,143 @@ +#!/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: +# GITHUB_HEAD_REF: 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/logging.sh" +source "$SCRIPT_DIR/lib/validation.sh" +source "$SCRIPT_DIR/lib/release-helpers.sh" + +# Check for required commands +check_required_commands "gh" || exit 1 + +# 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" + echo "has_issues=false" >> "$GITHUB_OUTPUT" + 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 -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 + local output + output=$(check_deployment_status "$ms_sha" "$latest_tag") + local 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 +} + +# Main execution +main() { + log_info "=== Starting pending deploy check ===" + + if [[ -z "${GITHUB_HEAD_REF:-}" ]]; then + log_error "GITHUB_HEAD_REF environment variable is not set" + exit 1 + fi + + if [[ -z "${MANAGED_SERVICE_TOKEN:-}" ]]; then + log_error "MANAGED_SERVICE_TOKEN environment variable is not set" + exit 1 + fi + + if [[ -z "${GITHUB_OUTPUT:-}" ]]; then + log_error "GITHUB_OUTPUT environment variable is not set" + exit 1 + fi + + # Get list of commits in pending deploy branch not yet in main + get_commits "$GITHUB_HEAD_REF" + + # Fetch the latest managed-service release tag for comparison + if ! get_latest_release_tag; then + 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" + echo "has_issues=true" >> "$GITHUB_OUTPUT" + else + log_info "All commits are deployed" + echo "has_issues=false" >> "$GITHUB_OUTPUT" + 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 0000000..c851bc6 --- /dev/null +++ b/scripts/pending-deploy-pr.sh @@ -0,0 +1,159 @@ +#!/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/logging.sh" +source "$SCRIPT_DIR/lib/validation.sh" + +# Check for required commands +check_required_commands "gh" || exit 1 + +# Find the latest pending deploy branch +find_latest_branch() { + log_info "Looking for pending deploy branches" + + # Find all remote branches matching pending-deploy-YYYYMMDD-HHMMSS + # The timestamp format ensures lexicographic sorting matches chronological ordering + # Example: pending-deploy-20250506-143022 (May 6, 2025 at 14:30:22) + local all_branches + all_branches=$(git branch -r) + + local branches + branches=$(echo "$all_branches" | grep -E 'origin/pending-deploy-[0-9]{8}-[0-9]{6}$' || true) + + if [[ -z "$branches" ]]; then + log_info "No pending deploy branches found on origin" + echo "branch=" >> "$GITHUB_OUTPUT" + return 0 + fi + + # Find the latest branch (sort -r gives newest first due to timestamp format) + local latest + latest=$(echo "$branches" | sed 's|^[[:space:]]*origin/||' | sort -r | head -1) + + if [[ -z "$latest" ]]; then + log_error "Failed to parse pending deploy branches" + exit 1 + fi + + log_info "Latest pending deploy branch: $latest" + echo "branch=$latest" >> "$GITHUB_OUTPUT" + export BRANCH="$latest" +} + +# Check if the branch has commits not in main +check_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" + echo "has_commits=false" >> "$GITHUB_OUTPUT" + return 0 + fi + + log_info "Found $commit_count commit(s) in $branch not in main" + echo "has_commits=true" >> "$GITHUB_OUTPUT" + export HAS_COMMITS="true" +} + +# Create or check for existing PR +create_pr_if_not_exists() { + local branch="$1" + local has_commits="$2" + + if [[ -z "$branch" ]] || [[ "$has_commits" != "true" ]]; then + return 0 + fi + + export GH_TOKEN="$GITHUB_TOKEN" + + log_info "Checking if PR already exists for $branch" + + # Check if PR already exists + local existing_pr + existing_pr=$(gh pr list --head "$branch" --base main --json number --jq '.[0].number' || echo "") + + if [[ -n "$existing_pr" ]]; then + log_info "PR already exists: #$existing_pr" + return 0 + fi + + log_info "Creating new PR for $branch" + + # Create new PR + local pr_body + pr_body=$(printf "%s\n\n%s" \ + "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." \ + "This PR is automatically managed by the pending-deploy-pr workflow.") + + 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" + echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT" +} + +# Main execution +main() { + log_info "=== Starting pending deploy PR workflow ===" + + if [[ -z "${GITHUB_TOKEN:-}" ]]; then + log_error "GITHUB_TOKEN environment variable is not set" + exit 1 + fi + + if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then + log_error "GITHUB_REPOSITORY environment variable is not set" + exit 1 + fi + + if [[ -z "${GITHUB_OUTPUT:-}" ]]; then + log_error "GITHUB_OUTPUT environment variable is not set" + exit 1 + fi + + # Find the most recent pending-deploy-* branch + find_latest_branch + + if [[ -n "${BRANCH:-}" ]]; then + # Check if the branch has commits not yet in main + check_commits "$BRANCH" + create_pr_if_not_exists "$BRANCH" "${HAS_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 0000000..08ce9a2 --- /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/logging.sh" +source "$SCRIPT_DIR/lib/validation.sh" + +# Check for required commands +check_required_commands "gh" || exit 1 + +# Validate required environment variables +if [[ -z "${PR_NUMBER:-}" ]]; then + log_error "PR_NUMBER environment variable is not set" + exit 1 +fi + +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + log_error "GITHUB_TOKEN environment variable is not set" + exit 1 +fi + +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 status; 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 +export GH_TOKEN="$GITHUB_TOKEN" +gh pr comment "$PR_NUMBER" --body-file comment.md + +log_info "Posted deployment failure comment to PR #$PR_NUMBER"