From 3372da594b506a1bbad2f41817a0ed1d4ca8a9ac Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 26 Mar 2026 17:16:39 +0000 Subject: [PATCH] fix: Vault-gated deployment promotion via Woodpecker environments (#755) Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/disinto | 97 +++++++++++++++++++++++++++++++++++++++ lib/ci-helpers.sh | 32 +++++++++++++ vault/vault-run-action.sh | 46 +++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/bin/disinto b/bin/disinto index 18d7f5d..873b055 100755 --- a/bin/disinto +++ b/bin/disinto @@ -260,6 +260,20 @@ services: networks: - disinto-net + # Staging deployment slot — activated by Woodpecker staging pipeline (#755). + # Profile-gated: only starts when explicitly targeted by deploy commands. + # Customize image/ports/volumes for your project after init. + staging: + image: alpine:3 + profiles: ["staging"] + security_opt: + - apparmor=unconfined + environment: + DEPLOY_ENV: staging + networks: + - disinto-net + command: ["echo", "staging slot — replace with project image"] + volumes: forgejo-data: woodpecker-data: @@ -307,6 +321,86 @@ generate_agent_docker() { fi } +# Generate template .woodpecker/ deployment pipeline configs in a project repo. +# Creates staging.yml and production.yml alongside the project's existing CI config. +# These pipelines trigger on Woodpecker's deployment event with environment filters. +generate_deploy_pipelines() { + local repo_root="$1" project_name="$2" + local wp_dir="${repo_root}/.woodpecker" + + mkdir -p "$wp_dir" + + # Skip if deploy pipelines already exist + if [ -f "${wp_dir}/staging.yml" ] && [ -f "${wp_dir}/production.yml" ]; then + echo "Deploy: .woodpecker/{staging,production}.yml (already exist)" + return + fi + + if [ ! -f "${wp_dir}/staging.yml" ]; then + cat > "${wp_dir}/staging.yml" <<'STAGINGEOF' +# .woodpecker/staging.yml — Staging deployment pipeline +# Triggered by vault-runner via Woodpecker promote API. +# Human approves promotion in vault → vault-runner calls promote → this runs. + +when: + event: deployment + environment: staging + +steps: + - name: deploy-staging + image: docker:27 + commands: + - echo "Deploying to staging environment..." + - echo "Pipeline ${CI_PIPELINE_NUMBER} promoted from CI #${CI_PIPELINE_PARENT}" + # Pull the image built by CI and deploy to staging + # Customize these commands for your project: + # - docker compose -f docker-compose.yml --profile staging up -d + - echo "Staging deployment complete" + + - name: verify-staging + image: alpine:3 + commands: + - echo "Verifying staging deployment..." + # Add health checks, smoke tests, or integration tests here: + # - curl -sf http://staging:8080/health || exit 1 + - echo "Staging verification complete" +STAGINGEOF + echo "Created: ${wp_dir}/staging.yml" + fi + + if [ ! -f "${wp_dir}/production.yml" ]; then + cat > "${wp_dir}/production.yml" <<'PRODUCTIONEOF' +# .woodpecker/production.yml — Production deployment pipeline +# Triggered by vault-runner via Woodpecker promote API. +# Human approves promotion in vault → vault-runner calls promote → this runs. + +when: + event: deployment + environment: production + +steps: + - name: deploy-production + image: docker:27 + commands: + - echo "Deploying to production environment..." + - echo "Pipeline ${CI_PIPELINE_NUMBER} promoted from staging" + # Pull the verified image and deploy to production + # Customize these commands for your project: + # - docker compose -f docker-compose.yml up -d + - echo "Production deployment complete" + + - name: verify-production + image: alpine:3 + commands: + - echo "Verifying production deployment..." + # Add production health checks here: + # - curl -sf http://production:8080/health || exit 1 + - echo "Production verification complete" +PRODUCTIONEOF + echo "Created: ${wp_dir}/production.yml" + fi +} + # Check whether compose mode is active (docker-compose.yml exists). is_compose_mode() { [ -f "${FACTORY_ROOT}/docker-compose.yml" ] @@ -1233,6 +1327,9 @@ p.write_text(text) # Generate VISION.md template generate_vision "$repo_root" "$project_name" + # Generate template deployment pipeline configs in project repo + generate_deploy_pipelines "$repo_root" "$project_name" + # Install cron jobs install_cron "$project_name" "$toml_path" "$auto_yes" "$bare" diff --git a/lib/ci-helpers.sh b/lib/ci-helpers.sh index fda0071..23ebce7 100644 --- a/lib/ci-helpers.sh +++ b/lib/ci-helpers.sh @@ -235,3 +235,35 @@ classify_pipeline_failure() { echo "code" return 1 } + +# ci_promote +# Calls the Woodpecker promote API to trigger a deployment pipeline. +# The promote endpoint creates a new pipeline with event=deployment and +# deploy_to=, which fires pipelines filtered on that environment. +# Requires: WOODPECKER_TOKEN, WOODPECKER_SERVER (from env.sh) +# Returns 0 on success, 1 on failure. Prints the new pipeline number on success. +ci_promote() { + local repo_id="$1" pipeline_num="$2" environment="$3" + + if [ -z "$repo_id" ] || [ -z "$pipeline_num" ] || [ -z "$environment" ]; then + echo "Usage: ci_promote " >&2 + return 1 + fi + + local resp new_num + resp=$(woodpecker_api "/repos/${repo_id}/pipelines/${pipeline_num}" \ + -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "event=deployment&deploy_to=${environment}" 2>/dev/null) || { + echo "ERROR: promote API call failed" >&2 + return 1 + } + + new_num=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null) + if [ -z "$new_num" ]; then + echo "ERROR: promote returned no pipeline number" >&2 + return 1 + fi + + echo "$new_num" +} diff --git a/vault/vault-run-action.sh b/vault/vault-run-action.sh index c5f8ae5..169af65 100755 --- a/vault/vault-run-action.sh +++ b/vault/vault-run-action.sh @@ -71,6 +71,52 @@ case "$ACTION_TYPE" in fi ;; + promote) + # Promote a Woodpecker pipeline to a deployment environment (staging/production). + # Payload: {"repo_id": N, "pipeline": N, "environment": "staging"|"production"} + PROMOTE_REPO_ID=$(echo "$PAYLOAD" | jq -r '.repo_id // ""') + PROMOTE_PIPELINE=$(echo "$PAYLOAD" | jq -r '.pipeline // ""') + PROMOTE_ENV=$(echo "$PAYLOAD" | jq -r '.environment // ""') + + if [ -z "$PROMOTE_REPO_ID" ] || [ -z "$PROMOTE_PIPELINE" ] || [ -z "$PROMOTE_ENV" ]; then + log "ERROR: ${ACTION_ID} promote missing repo_id, pipeline, or environment" + FIRE_EXIT=1 + else + # Validate environment is staging or production + case "$PROMOTE_ENV" in + staging|production) ;; + *) + log "ERROR: ${ACTION_ID} promote invalid environment '${PROMOTE_ENV}' (must be staging or production)" + FIRE_EXIT=1 + ;; + esac + + if [ "$FIRE_EXIT" -eq 0 ]; then + WP_SERVER="${WOODPECKER_SERVER:-http://woodpecker:8000}" + WP_TOKEN="${WOODPECKER_TOKEN:-}" + + if [ -z "$WP_TOKEN" ]; then + log "ERROR: ${ACTION_ID} promote requires WOODPECKER_TOKEN" + FIRE_EXIT=1 + else + PROMOTE_RESP=$(curl -sf -X POST \ + -H "Authorization: Bearer ${WP_TOKEN}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "event=deployment&deploy_to=${PROMOTE_ENV}" \ + "${WP_SERVER}/api/repos/${PROMOTE_REPO_ID}/pipelines/${PROMOTE_PIPELINE}" 2>/dev/null) || PROMOTE_RESP="" + + NEW_PIPELINE=$(printf '%s' "$PROMOTE_RESP" | jq -r '.number // empty' 2>/dev/null) + if [ -n "$NEW_PIPELINE" ]; then + log "${ACTION_ID}: promoted pipeline ${PROMOTE_PIPELINE} to ${PROMOTE_ENV} -> new pipeline #${NEW_PIPELINE}" + else + log "ERROR: ${ACTION_ID} promote API failed (repo_id=${PROMOTE_REPO_ID} pipeline=${PROMOTE_PIPELINE} env=${PROMOTE_ENV})" + FIRE_EXIT=1 + fi + fi + fi + fi + ;; + blog-post|social-post|email-blast|pricing-change|dns-change|stripe-charge) HANDLER="${VAULT_DIR}/handlers/${ACTION_TYPE}.sh" if [ -x "$HANDLER" ]; then