docs(agents): document internal workspace bundling #88
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: DEPLOY — Deploy Branch | |
| on: | |
| push: | |
| branches: | |
| - "deploy/**" | |
| workflow_call: | |
| inputs: | |
| environment: | |
| description: "Target — Environment (used for Environment-scoped vars/secrets)" | |
| required: true | |
| type: string | |
| component: | |
| description: "Target — Component to deploy (triggers the matching webhook list)" | |
| required: true | |
| type: string | |
| confirm: | |
| description: "Confirm — Choose the exact deploy action (mobile-friendly)" | |
| required: true | |
| type: string | |
| workflow_dispatch: | |
| inputs: | |
| environment: | |
| description: "Target — Environment (used for Environment-scoped vars/secrets)" | |
| required: true | |
| type: choice | |
| default: production | |
| options: | |
| - production | |
| - preview | |
| component: | |
| description: "Target — Component to deploy (triggers the matching webhook list)" | |
| required: true | |
| type: choice | |
| options: | |
| - ui | |
| - server | |
| - website | |
| - docs | |
| confirm: | |
| description: "Confirm — Choose the exact deploy action (mobile-friendly)" | |
| required: true | |
| type: choice | |
| options: | |
| - deploy production ui | |
| - deploy production server | |
| - deploy production website | |
| - deploy production docs | |
| - deploy preview ui | |
| - deploy preview server | |
| - deploy preview website | |
| - deploy preview docs | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: deploy-${{ startsWith(github.ref_name, 'deploy/') && github.ref_name || format('{0}/{1}', inputs.environment, inputs.component) }} | |
| cancel-in-progress: false | |
| jobs: | |
| release_actor_guard: | |
| name: Release actor guard | |
| permissions: | |
| contents: read | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Authorize deploy/* push actor | |
| if: github.event_name == 'push' | |
| run: | | |
| set -euo pipefail | |
| actor="${GITHUB_ACTOR:-}" | |
| # Deploy branches are promoted by the release GitHub App. We only allow | |
| # push-triggered deploys from that bot actor (and the repo owner as an escape hatch). | |
| case "$actor" in | |
| happier-release-bot[[]bot[]]|happier-releases-bot[[]bot[]]|leeroybrun) ;; | |
| *) | |
| echo "Refusing push-triggered deploy from unauthorized actor: '${actor}'." >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Checkout | |
| if: github.event_name != 'push' | |
| uses: actions/checkout@v4 | |
| - name: Authorize release actor | |
| if: github.event_name != 'push' | |
| uses: ./.github/actions/release-actor-guard | |
| with: | |
| team_slug: release-admins | |
| app_id: ${{ secrets.RELEASE_BOT_APP_ID }} | |
| private_key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} | |
| deploy: | |
| needs: [release_actor_guard] | |
| runs-on: ubuntu-latest | |
| # Deploy branches are promoted from release workflows. Those pushes may be authored by bots, | |
| # and we prefer deploying via explicit workflow_call (to avoid relying on push-trigger semantics). | |
| if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' || startsWith(github.ref_name, 'deploy/production/') || startsWith(github.ref_name, 'deploy/preview/')) && needs.release_actor_guard.result == 'success' }} | |
| environment: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.environment || (startsWith(github.ref_name, 'deploy/production/') && 'production' || 'preview') }} | |
| steps: | |
| - name: Enforce trusted refs for manual dispatch | |
| run: | | |
| set -euo pipefail | |
| if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then | |
| exit 0 | |
| fi | |
| case "${GITHUB_REF_NAME}" in | |
| dev|main) ;; | |
| *) | |
| echo "Refusing workflow_dispatch from untrusted ref '${GITHUB_REF_NAME}'. Use dev or main." >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Resolve target (env + component) | |
| id: target | |
| env: | |
| REF_NAME: ${{ github.ref_name }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| INPUT_ENV: ${{ inputs.environment }} | |
| INPUT_COMPONENT: ${{ inputs.component }} | |
| INPUT_CONFIRM: ${{ inputs.confirm }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$EVENT_NAME" = "workflow_dispatch" ] || [ "$EVENT_NAME" = "workflow_call" ]; then | |
| env_name="$INPUT_ENV" | |
| component="$INPUT_COMPONENT" | |
| expected="deploy ${env_name} ${component}" | |
| if [ "$INPUT_CONFIRM" != "$expected" ]; then | |
| echo "Confirmation mismatch." >&2 | |
| echo "Expected: $expected" >&2 | |
| echo "Got: $INPUT_CONFIRM" >&2 | |
| exit 1 | |
| fi | |
| else | |
| # Expected: deploy/<env>/<component> | |
| IFS='/' read -r pfx env_name component rest <<<"$REF_NAME" | |
| if [ "$pfx" != "deploy" ] || [ -z "${env_name:-}" ] || [ -z "${component:-}" ]; then | |
| echo "Unexpected ref name: $REF_NAME" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| case "$env_name" in | |
| production|preview) ;; | |
| *) | |
| echo "Unsupported environment: $env_name" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| case "$component" in | |
| ui|server|website|docs) ;; | |
| *) | |
| echo "Unsupported component: $component" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| echo "environment=$env_name" >> "$GITHUB_OUTPUT" | |
| echo "component=$component" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "## Deploy trigger" | |
| echo "" | |
| echo "- ref: \`$REF_NAME\`" | |
| echo "- environment: \`$env_name\`" | |
| echo "- component: \`$component\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Trigger deploy webhook(s) | |
| env: | |
| CF_WEBHOOK_DEPLOY_CLIENT_ID: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_ID }} | |
| CF_WEBHOOK_DEPLOY_CLIENT_SECRET: ${{ secrets.CF_WEBHOOK_DEPLOY_CLIENT_SECRET }} | |
| DEPLOY_WEBHOOK_URL: ${{ vars.DEPLOY_WEBHOOK_URL != '' && vars.DEPLOY_WEBHOOK_URL || secrets.DEPLOY_WEBHOOK_URL }} | |
| GH_TOKEN: ${{ github.token }} | |
| HAPPIER_UI_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_UI_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_UI_DEPLOY_WEBHOOKS || secrets.HAPPIER_UI_DEPLOY_WEBHOOKS }} | |
| HAPPIER_WEBSITE_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_WEBSITE_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_WEBSITE_DEPLOY_WEBHOOKS || secrets.HAPPIER_WEBSITE_DEPLOY_WEBHOOKS }} | |
| HAPPIER_DOCS_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_DOCS_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_DOCS_DEPLOY_WEBHOOKS || secrets.HAPPIER_DOCS_DEPLOY_WEBHOOKS }} | |
| HAPPIER_SERVER_API_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_SERVER_API_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_SERVER_API_DEPLOY_WEBHOOKS || secrets.HAPPIER_SERVER_API_DEPLOY_WEBHOOKS }} | |
| HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS: ${{ vars.HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS != '' && vars.HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS || secrets.HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS }} | |
| COMPONENT: ${{ steps.target.outputs.component }} | |
| ENVIRONMENT: ${{ steps.target.outputs.environment }} | |
| REF_NAME: ${{ github.ref_name }} | |
| REPOSITORY: ${{ github.repository }} | |
| SHA: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| has_hooks() { | |
| local hooks="$1" | |
| if [ -z "${hooks:-}" ]; then | |
| return 1 | |
| fi | |
| while IFS= read -r line; do | |
| line="$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" | |
| [ -z "${line}" ] && continue | |
| [ "${line#\#}" != "${line}" ] && continue | |
| return 0 | |
| done <<< "${hooks}" | |
| return 1 | |
| } | |
| require_hooks_or_skip() { | |
| local label="$1" | |
| local hooks="$2" | |
| if has_hooks "${hooks}"; then | |
| return 0 | |
| fi | |
| if [ "${ENVIRONMENT}" = "production" ]; then | |
| echo "Missing deploy webhook list for '${label}' in production; refusing to silently skip." >&2 | |
| exit 1 | |
| fi | |
| echo "::notice::No deploy webhook list configured for '${label}' in environment '${ENVIRONMENT}'. Skipping deploy trigger." | |
| echo "No deploy webhook list configured for '${label}' in environment '${ENVIRONMENT}'. Skipping." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| } | |
| # Decide whether this deploy can do anything before requiring secrets. | |
| case "$COMPONENT" in | |
| ui) | |
| require_hooks_or_skip "ui" "${HAPPIER_UI_DEPLOY_WEBHOOKS:-}" | |
| ;; | |
| website) | |
| require_hooks_or_skip "website" "${HAPPIER_WEBSITE_DEPLOY_WEBHOOKS:-}" | |
| ;; | |
| docs) | |
| require_hooks_or_skip "docs" "${HAPPIER_DOCS_DEPLOY_WEBHOOKS:-}" | |
| ;; | |
| server) | |
| if has_hooks "${HAPPIER_SERVER_API_DEPLOY_WEBHOOKS:-}" && has_hooks "${HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS:-}"; then | |
| : # ok | |
| else | |
| if [ "${ENVIRONMENT}" = "production" ]; then | |
| echo "Missing server deploy webhook list(s) in production; refusing to silently skip." >&2 | |
| echo "server api configured: $(has_hooks "${HAPPIER_SERVER_API_DEPLOY_WEBHOOKS:-}" && echo yes || echo no)" >&2 | |
| echo "server worker configured: $(has_hooks "${HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS:-}" && echo yes || echo no)" >&2 | |
| exit 1 | |
| fi | |
| echo "::notice::No server deploy webhook list(s) configured for environment '${ENVIRONMENT}'. Skipping deploy trigger." | |
| echo "No server deploy webhook list(s) configured for environment '${ENVIRONMENT}'. Skipping." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| ;; | |
| *) | |
| echo "Unsupported component: $COMPONENT" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| if [ -z "${CF_WEBHOOK_DEPLOY_CLIENT_ID:-}" ] || [ -z "${CF_WEBHOOK_DEPLOY_CLIENT_SECRET:-}" ]; then | |
| echo "Missing Cloudflare Access service token secrets (CF_WEBHOOK_DEPLOY_CLIENT_ID/CF_WEBHOOK_DEPLOY_CLIENT_SECRET)." >&2 | |
| exit 1 | |
| fi | |
| if [ -z "${DEPLOY_WEBHOOK_URL:-}" ]; then | |
| echo "Missing DEPLOY_WEBHOOK_URL (expected a base URL like 'https://ci.example.com/api/deploy/')." >&2 | |
| exit 1 | |
| fi | |
| DEPLOY_WEBHOOK_URL="${DEPLOY_WEBHOOK_URL%/}" | |
| if ! [[ "${DEPLOY_WEBHOOK_URL}" =~ ^https?:// ]]; then | |
| echo "DEPLOY_WEBHOOK_URL must start with http:// or https:// (got: ${DEPLOY_WEBHOOK_URL})." >&2 | |
| exit 1 | |
| fi | |
| # Dokploy's deploy webhook is designed to be called by a Git provider webhook. | |
| # If we call it directly from CI, we need to include a minimal push payload | |
| # and headers so it can extract the branch and validate "Branch Not Match". | |
| BRANCH_NAME="${REF_NAME:-}" | |
| if [ -z "${BRANCH_NAME:-}" ] || [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] || [ "${GITHUB_EVENT_NAME:-}" = "workflow_call" ]; then | |
| BRANCH_NAME="deploy/${ENVIRONMENT}/${COMPONENT}" | |
| fi | |
| REF="refs/heads/${BRANCH_NAME}" | |
| AFTER_SHA="${SHA}" | |
| if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] || [ "${GITHUB_EVENT_NAME:-}" = "workflow_call" ]; then | |
| echo "Resolving deploy branch SHA for ${GITHUB_EVENT_NAME:-}..." | |
| AFTER_SHA="$(gh api "repos/${REPOSITORY}/git/ref/heads/${BRANCH_NAME}" --jq '.object.sha')" | |
| fi | |
| echo "Dokploy webhook target: ref=${REF} after=${AFTER_SHA}" | |
| trigger_hooks() { | |
| local label="$1" | |
| local hooks="$2" | |
| if [ -z "${hooks:-}" ]; then | |
| echo "No webhook URLs configured for '${label}' in environment '${{ steps.target.outputs.environment }}'." >&2 | |
| return 1 | |
| fi | |
| echo "Triggering deploy hook(s) for '${label}'..." | |
| local triggered=0 | |
| while IFS= read -r hook; do | |
| hook="$(echo "$hook" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" | |
| if [ -z "$hook" ] || [ "${hook#\#}" != "$hook" ]; then | |
| continue | |
| fi | |
| local url="" | |
| if [[ "$hook" =~ ^https?:// ]]; then | |
| url="$hook" | |
| else | |
| url="${DEPLOY_WEBHOOK_URL}/${hook}" | |
| fi | |
| local delivery_id="" | |
| delivery_id="$(python3 -c 'import uuid; print(str(uuid.uuid4()))')" | |
| local payload="" | |
| payload="$(REF="$REF" SHA="$AFTER_SHA" REPOSITORY="$REPOSITORY" python3 -c 'import json, os; ref=os.environ.get("REF", ""); sha=os.environ.get("SHA", ""); repo=os.environ.get("REPOSITORY", ""); owner, name = ((repo.split("/", 1) + [""])[:2] if repo else ("", "")); print(json.dumps({"ref": ref, "before": "0"*40, "after": sha, "repository": {"full_name": repo, "name": name, "owner": {"name": owner}, "default_branch": "main"}}))')" | |
| triggered=$((triggered + 1)) | |
| # Avoid leaking full hook URLs if operators configure full URLs that may embed secrets. | |
| echo "POST deploy hook (${label}) #${triggered}" | |
| echo "Request headers: Content-Type, X-GitHub-Event, X-GitHub-Delivery, CF-Access-Client-Id, CF-Access-Client-Secret" | |
| echo "Request body: ${payload}" | |
| resp_file="$(mktemp)" | |
| hdr_file="$(mktemp)" | |
| http_code="$(curl -sS -X POST \ | |
| --retry 3 \ | |
| --retry-all-errors \ | |
| --connect-timeout 10 \ | |
| --max-time 60 \ | |
| -H "CF-Access-Client-Id: ${CF_WEBHOOK_DEPLOY_CLIENT_ID}" \ | |
| -H "CF-Access-Client-Secret: ${CF_WEBHOOK_DEPLOY_CLIENT_SECRET}" \ | |
| -H "Content-Type: application/json" \ | |
| -H "X-GitHub-Event: push" \ | |
| -H "X-GitHub-Delivery: ${delivery_id}" \ | |
| --data "${payload}" \ | |
| -D "${hdr_file}" \ | |
| -o "${resp_file}" \ | |
| -w "%{http_code}" \ | |
| "${url}")" | |
| echo "Response status: ${http_code}" | |
| if [ -s "${hdr_file}" ]; then | |
| echo "Response headers:" | |
| cat "${hdr_file}" | |
| else | |
| echo "Response headers: <empty>" | |
| fi | |
| if [ -s "${resp_file}" ]; then | |
| echo "Response body:" | |
| cat "${resp_file}" | |
| else | |
| echo "Response body: <empty>" | |
| fi | |
| rm -f "${hdr_file}" "${resp_file}" | |
| if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then | |
| echo "Non-2xx response from deploy webhook for '${label}'." >&2 | |
| return 1 | |
| fi | |
| done <<< "$hooks" | |
| if [ "$triggered" -eq 0 ]; then | |
| echo "No valid webhook IDs/URLs found for '${label}' (empty/comment-only list)." >&2 | |
| return 1 | |
| fi | |
| echo "Triggered ${triggered} webhook(s) for '${label}'." | |
| } | |
| case "$COMPONENT" in | |
| ui) | |
| trigger_hooks "ui" "${HAPPIER_UI_DEPLOY_WEBHOOKS:-}" | |
| ;; | |
| website) | |
| trigger_hooks "website" "${HAPPIER_WEBSITE_DEPLOY_WEBHOOKS:-}" | |
| ;; | |
| docs) | |
| trigger_hooks "docs" "${HAPPIER_DOCS_DEPLOY_WEBHOOKS:-}" | |
| ;; | |
| server) | |
| # Enforce order: API first, then worker. | |
| trigger_hooks "server api" "${HAPPIER_SERVER_API_DEPLOY_WEBHOOKS:-}" | |
| trigger_hooks "server worker" "${HAPPIER_SERVER_WORKER_DEPLOY_WEBHOOKS:-}" | |
| ;; | |
| *) | |
| echo "Unsupported component: $COMPONENT" >&2 | |
| exit 1 | |
| ;; | |
| esac |