Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 315 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
name: Deploy to Staging

on:
# Manual trigger with options
workflow_dispatch:
inputs:
image_tag:
description: 'OpenHands image tag to deploy'
required: true
default: 'main'
environment:
description: 'Environment to deploy'
required: true
type: choice
options:
- both
- pathroute
- subdomain
default: 'both'
skip_secrets:
description: 'Skip applying secrets (use existing)'
type: boolean
default: false
dry_run:
description: 'Dry run (template only, no deploy)'
type: boolean
default: false

env:
GCP_PROJECT: staging-092324
GCP_ZONE: us-central1
GCP_CLUSTER: staging-core-application

jobs:
deploy-pathroute:
name: Deploy to staging-pathroute
if: ${{ inputs.environment == 'both' || inputs.environment == 'pathroute' }}
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
env:
NAMESPACE: openhands-pathroute
HELM_RELEASE: openhands-pathroute
ENV_DIR: envs/staging-pathroute

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install SOPS
run: |
curl -L "https://github.com/mozilla/sops/releases/download/v3.9.1/sops-v3.9.1.linux.amd64" -o sops
chmod +x sops
sudo mv sops /usr/local/bin/sops
sops --version

- name: Install Helm
uses: azure/setup-helm@v3
with:
version: 'latest'

- name: Authenticate with Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SERVICE_KEY }}

- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@v2

- name: Install gke-gcloud-auth-plugin
run: |
gcloud components install gke-gcloud-auth-plugin

- name: Configure kubectl
run: |
gcloud container clusters get-credentials ${{ env.GCP_CLUSTER }} \
--zone ${{ env.GCP_ZONE }} \
--project ${{ env.GCP_PROJECT }}

- name: Create namespace if not exists
if: ${{ !inputs.dry_run }}
run: |
kubectl create namespace ${{ env.NAMESPACE }} --dry-run=client -o yaml | kubectl apply -f -

- name: Decrypt and apply secrets
if: ${{ !inputs.skip_secrets && !inputs.dry_run }}
run: |
SECRETS_DIR="${{ env.ENV_DIR }}/secrets"

if [[ -d "$SECRETS_DIR" ]]; then
echo "Applying secrets from $SECRETS_DIR"
for file in "$SECRETS_DIR"/*.yaml; do
# Skip .gitkeep or non-existent files
[[ -e "$file" ]] || continue
[[ "$(basename "$file")" == ".gitkeep" ]] && continue

echo "Decrypting and applying: $file"
sops --decrypt "$file" | kubectl apply -n ${{ env.NAMESPACE }} -f -
done
echo "All secrets applied successfully"
else
echo "No secrets directory found at $SECRETS_DIR"
fi

- name: Update Helm dependencies
run: |
helm dependency update charts/openhands

- name: Helm template (dry run)
if: ${{ inputs.dry_run }}
run: |
helm template ${{ env.HELM_RELEASE }} charts/openhands \
--namespace ${{ env.NAMESPACE }} \
--values ${{ env.ENV_DIR }}/values.yaml \
--set image.tag=${{ inputs.image_tag }} \
--debug

- name: Deploy with Helm
if: ${{ !inputs.dry_run }}
run: |
# Check current release status
if helm status ${{ env.HELM_RELEASE }} -n ${{ env.NAMESPACE }} &>/dev/null; then
status=$(helm status ${{ env.HELM_RELEASE }} -n ${{ env.NAMESPACE }} -o json | jq -r '.info.status')
if [[ "$status" != "deployed" ]]; then
echo "Found release in non-deployed state ($status). Attempting rollback..."
helm rollback ${{ env.HELM_RELEASE }} -n ${{ env.NAMESPACE }} || true
fi
fi

helm upgrade --install \
--wait \
--timeout 10m \
${{ env.HELM_RELEASE }} \
charts/openhands \
--namespace ${{ env.NAMESPACE }} \
--values ${{ env.ENV_DIR }}/values.yaml \
--set image.tag=${{ inputs.image_tag }} \
--debug

- name: Get deployment info
if: ${{ !inputs.dry_run }}
id: deployment_info
run: |
echo "## Deployment Summary (pathroute)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Environment:** staging-pathroute" >> $GITHUB_STEP_SUMMARY
echo "- **Namespace:** ${{ env.NAMESPACE }}" >> $GITHUB_STEP_SUMMARY
echo "- **Image Tag:** ${{ inputs.image_tag }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY

# Get ingress hostname
hostname=$(kubectl get ing -n ${{ env.NAMESPACE }} -o jsonpath='{.items[0].spec.rules[0].host}' 2>/dev/null || echo "N/A")
echo "- **Hostname:** https://$hostname" >> $GITHUB_STEP_SUMMARY
echo "hostname=$hostname" >> $GITHUB_OUTPUT

echo "" >> $GITHUB_STEP_SUMMARY
echo "### Pods Status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
kubectl get pods -n ${{ env.NAMESPACE }} -l app.kubernetes.io/instance=${{ env.HELM_RELEASE }} >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY

- name: Verify deployment health
if: ${{ !inputs.dry_run }}
run: |
echo "Waiting for deployment to stabilize..."
kubectl rollout status deployment -n ${{ env.NAMESPACE }} -l app.kubernetes.io/instance=${{ env.HELM_RELEASE }} --timeout=5m || true

echo ""
echo "Current pod status:"
kubectl get pods -n ${{ env.NAMESPACE }} -l app.kubernetes.io/instance=${{ env.HELM_RELEASE }}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical - Data Structure: Jobs deploy-pathroute and deploy-subdomain are 99% identical (150+ lines duplicated). This is exactly the wrong data structure.

The right way: Use a matrix strategy:

jobs:
  deploy:
    strategy:
      matrix:
        env:
          - name: pathroute
            namespace: openhands-pathroute
            helm_release: openhands-pathroute
            env_dir: envs/staging-pathroute
          - name: subdomain
            namespace: openhands-subdomain
            helm_release: openhands-subdomain
            env_dir: envs/staging-subdomain
    env:
      NAMESPACE: ${{ matrix.env.namespace }}
      HELM_RELEASE: ${{ matrix.env.helm_release }}
      ENV_DIR: ${{ matrix.env.env_dir }}
    # ... rest of steps (once)

Then add conditional matrix execution:

if: ${{ inputs.environment == 'both' || inputs.environment == matrix.env.name }}

This eliminates 150 lines of duplication and makes adding new environments trivial.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed - Refactored to use matrix strategy. The workflow now uses:

strategy:
  matrix:
    env:
      - name: pathroute
        namespace: openhands-pathroute
        ...
      - name: subdomain
        namespace: openhands-subdomain
        ...
if: ${{ inputs.environment == 'both' || inputs.environment == matrix.env.name }}

This eliminated 150+ lines of duplication. See commit 4dd4352.

This comment was written by an AI assistant (OpenHands).


outputs:
hostname: ${{ steps.deployment_info.outputs.hostname }}

deploy-subdomain:
name: Deploy to staging-subdomain
if: ${{ inputs.environment == 'both' || inputs.environment == 'subdomain' }}
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
env:
NAMESPACE: openhands-subdomain
HELM_RELEASE: openhands-subdomain
ENV_DIR: envs/staging-subdomain

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install SOPS
run: |
curl -L "https://github.com/mozilla/sops/releases/download/v3.9.1/sops-v3.9.1.linux.amd64" -o sops
chmod +x sops
sudo mv sops /usr/local/bin/sops
sops --version

- name: Install Helm
uses: azure/setup-helm@v3
with:
version: 'latest'

- name: Authenticate with Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SERVICE_KEY }}

- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@v2

- name: Install gke-gcloud-auth-plugin
run: |
gcloud components install gke-gcloud-auth-plugin

- name: Configure kubectl
run: |
gcloud container clusters get-credentials ${{ env.GCP_CLUSTER }} \
--zone ${{ env.GCP_ZONE }} \
--project ${{ env.GCP_PROJECT }}

- name: Create namespace if not exists
if: ${{ !inputs.dry_run }}
run: |
kubectl create namespace ${{ env.NAMESPACE }} --dry-run=client -o yaml | kubectl apply -f -

- name: Decrypt and apply secrets
if: ${{ !inputs.skip_secrets && !inputs.dry_run }}
run: |
SECRETS_DIR="${{ env.ENV_DIR }}/secrets"

if [[ -d "$SECRETS_DIR" ]]; then
echo "Applying secrets from $SECRETS_DIR"
for file in "$SECRETS_DIR"/*.yaml; do
# Skip .gitkeep or non-existent files
[[ -e "$file" ]] || continue
[[ "$(basename "$file")" == ".gitkeep" ]] && continue

echo "Decrypting and applying: $file"
sops --decrypt "$file" | kubectl apply -n ${{ env.NAMESPACE }} -f -
done
echo "All secrets applied successfully"
else
echo "No secrets directory found at $SECRETS_DIR"
fi

- name: Update Helm dependencies
run: |
helm dependency update charts/openhands

- name: Helm template (dry run)
if: ${{ inputs.dry_run }}
run: |
helm template ${{ env.HELM_RELEASE }} charts/openhands \
--namespace ${{ env.NAMESPACE }} \
--values ${{ env.ENV_DIR }}/values.yaml \
--set image.tag=${{ inputs.image_tag }} \
--debug

- name: Deploy with Helm
if: ${{ !inputs.dry_run }}
run: |
# Check current release status
if helm status ${{ env.HELM_RELEASE }} -n ${{ env.NAMESPACE }} &>/dev/null; then
status=$(helm status ${{ env.HELM_RELEASE }} -n ${{ env.NAMESPACE }} -o json | jq -r '.info.status')
if [[ "$status" != "deployed" ]]; then
echo "Found release in non-deployed state ($status). Attempting rollback..."
helm rollback ${{ env.HELM_RELEASE }} -n ${{ env.NAMESPACE }} || true
fi
fi

helm upgrade --install \
--wait \
--timeout 10m \
${{ env.HELM_RELEASE }} \
charts/openhands \
--namespace ${{ env.NAMESPACE }} \
--values ${{ env.ENV_DIR }}/values.yaml \
--set image.tag=${{ inputs.image_tag }} \
--debug

- name: Get deployment info
if: ${{ !inputs.dry_run }}
id: deployment_info
run: |
echo "## Deployment Summary (subdomain)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Environment:** staging-subdomain" >> $GITHUB_STEP_SUMMARY
echo "- **Namespace:** ${{ env.NAMESPACE }}" >> $GITHUB_STEP_SUMMARY
echo "- **Image Tag:** ${{ inputs.image_tag }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY

# Get ingress hostname
hostname=$(kubectl get ing -n ${{ env.NAMESPACE }} -o jsonpath='{.items[0].spec.rules[0].host}' 2>/dev/null || echo "N/A")
echo "- **Hostname:** https://$hostname" >> $GITHUB_STEP_SUMMARY
echo "hostname=$hostname" >> $GITHUB_OUTPUT

echo "" >> $GITHUB_STEP_SUMMARY
echo "### Pods Status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
kubectl get pods -n ${{ env.NAMESPACE }} -l app.kubernetes.io/instance=${{ env.HELM_RELEASE }} >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY

- name: Verify deployment health
if: ${{ !inputs.dry_run }}
run: |
echo "Waiting for deployment to stabilize..."
kubectl rollout status deployment -n ${{ env.NAMESPACE }} -l app.kubernetes.io/instance=${{ env.HELM_RELEASE }} --timeout=5m || true

echo ""
echo "Current pod status:"
kubectl get pods -n ${{ env.NAMESPACE }} -l app.kubernetes.io/instance=${{ env.HELM_RELEASE }}

outputs:
hostname: ${{ steps.deployment_info.outputs.hostname }}
17 changes: 17 additions & 0 deletions .sops.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# SOPS configuration for OpenHands-Cloud
# This file tells SOPS which encryption keys to use for different file patterns
creation_rules:
# Staging path-route environment secrets - use GCP KMS
- path_regex: envs/staging-pathroute/.*secrets.*\.yaml$
gcp_kms: projects/global-432717/locations/global/keyRings/sops-key-ring/cryptoKeys/sops-key
encrypted_regex: "^(data|stringData|config)$"

# Staging subdomain environment secrets - use GCP KMS
- path_regex: envs/staging-subdomain/.*secrets.*\.yaml$
gcp_kms: projects/global-432717/locations/global/keyRings/sops-key-ring/cryptoKeys/sops-key
encrypted_regex: "^(data|stringData|config)$"

# Production environment secrets (future use)
- path_regex: envs/production/.*secrets.*\.yaml$
gcp_kms: projects/global-432717/locations/global/keyRings/sops-key-ring/cryptoKeys/sops-key
encrypted_regex: "^(data|stringData|config)$"
Loading
Loading