From 1584f1aed763dd63dfb9dfbe6952fe6c31a7de7a Mon Sep 17 00:00:00 2001 From: nichenqin Date: Thu, 7 May 2026 14:15:55 +0800 Subject: [PATCH] chore: sync reference deploy wrapper --- .github/workflows/ci.yml | 2 +- README.md | 117 +++++++- action.yml | 79 ++++- scripts/resolve-control-plane.sh | 111 +++++++ scripts/run-deploy.sh | 501 ++++++++++++++++++++++++++++++- 5 files changed, 788 insertions(+), 22 deletions(-) create mode 100755 scripts/resolve-control-plane.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f6b89a..045ac0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - name: Validate wrapper scripts - run: bash -n scripts/install-appaloft.sh scripts/run-deploy.sh + run: bash -n scripts/install-appaloft.sh scripts/run-deploy.sh scripts/resolve-control-plane.sh - name: Validate dry-run preview mapping env: diff --git a/README.md b/README.md index 5be5e06..82eda6d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Appaloft Deploy Action -Install the Appaloft CLI in GitHub Actions and run the repository deployment workflow. +Run Appaloft deployments from GitHub Actions. -This action is a thin wrapper around the released `appaloft` binary. It does not create a hosted -control plane, does not add a new deployment command, and does not read Appaloft project, resource, -server, credential, or secret identity from committed `appaloft.yml`. +The default mode is a thin wrapper around the released `appaloft` binary for pure SSH deployments. +Self-hosted server API mode is available for repositories that already have their project, +environment, resource, and deployment target registered in an Appaloft server. In both modes, the +action does not read Appaloft project, resource, server, credential, or secret identity from +committed `appaloft.yml`. ## Basic Deploy @@ -73,6 +75,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + pull-requests: write environment: name: preview-pr-${{ github.event.pull_request.number }} url: ${{ steps.deploy.outputs.preview-url }} @@ -91,9 +94,16 @@ jobs: preview-domain-template: pr-${{ github.event.pull_request.number }}.preview.example.com preview-tls-mode: disabled require-preview-url: true + pr-comment: true + github-token: ${{ github.token }} ssh-host: ${{ secrets.APPALOFT_SSH_HOST }} ssh-user: ${{ secrets.APPALOFT_SSH_USER }} ssh-private-key: ${{ secrets.APPALOFT_SSH_PRIVATE_KEY }} + environment-variables: | + HOST=0.0.0.0 + PORT=3000 + secret-variables: | + DATABASE_URL=ci-env:DATABASE_URL ``` The default example skips fork pull requests before deployment credentials are exposed. Fork @@ -121,6 +131,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + pull-requests: write steps: - uses: actions/checkout@v4 @@ -131,6 +142,8 @@ jobs: config: appaloft.preview.yml preview: pull-request preview-id: pr-${{ github.event.pull_request.number }} + pr-comment: true + github-token: ${{ github.token }} ssh-host: ${{ secrets.APPALOFT_SSH_HOST }} ssh-user: ${{ secrets.APPALOFT_SSH_USER }} ssh-private-key: ${{ secrets.APPALOFT_SSH_PRIVATE_KEY }} @@ -140,6 +153,75 @@ Cleanup is idempotent. It stops preview-owned runtime state when present, remove desired state, unlinks preview source identity, and preserves production deployments and ordinary deployment history. +## Self-Hosted Server API Mode + +Use this mode when a self-hosted Appaloft server owns deployment state and the repository should +only trigger a deployment through the server API. The resource profile must already exist in the +server; this first slice does not apply `appaloft.yml`, upload a source archive, create resources, +or run preview cleanup. + +```yaml +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + environment: + name: production + url: ${{ steps.deploy.outputs.console-url }} + steps: + - uses: appaloft/deploy-action@v1 + id: deploy + with: + control-plane-mode: self-hosted + control-plane-url: https://console.example.com + appaloft-token: ${{ secrets.APPALOFT_TOKEN }} + project-id: ${{ secrets.APPALOFT_PROJECT_ID }} + environment-id: ${{ secrets.APPALOFT_ENVIRONMENT_ID }} + resource-id: ${{ secrets.APPALOFT_RESOURCE_ID }} + server-id: ${{ secrets.APPALOFT_SERVER_ID }} +``` + +Server API mode performs a lightweight compatibility check against `/api/version`, derives a safe +source fingerprint from GitHub repository context and config path, and calls +`POST /api/action/deployments/from-source-link`. When trusted ids are supplied, the server can use +them to bootstrap a missing source link before later runs omit ids. When ids are omitted, the server +resolves project, environment, resource, and target from existing source-link state. It does not +install or invoke the Appaloft CLI, open SSH, or read or write SSH-server PGlite state. + +For `command: preview-cleanup`, server API mode derives the preview-scoped source fingerprint from +the trusted `preview` and `preview-id` inputs and calls `POST /api/deployments/cleanup-preview`. +Cleanup context is resolved from source-link state; project/resource/server ids are not accepted for +server-mode preview cleanup. + +Server API mode writes the console URL and deployment id to the GitHub step summary when GitHub +provides `GITHUB_STEP_SUMMARY`. For cleanup it writes the console URL and cleanup status. Workflows +can also use the `console-url` output for environment URLs or PR comments. + +When `pr-comment: true`, the action posts or updates one stable pull request comment with the +preview URL, console URL, deployment id, or cleanup status that is available for the selected mode. +The workflow must pass `github-token: ${{ github.token }}` and grant `pull-requests: write` or +`issues: write`. This is entrypoint feedback only; product-grade GitHub App comments/checks remain +control-plane features. + +The control-plane connection policy can live in `appaloft.yml`: + +```yaml +controlPlane: + mode: self-hosted + url: https://console.example.com +``` + +Explicit action inputs override config values. Project, environment, resource, server, token, SSH, +and database identity still come from trusted workflow inputs, variables, secrets, existing +source-link state, or the Appaloft server, not from committed config. + ## Inputs | Input | Default | Purpose | @@ -157,15 +239,24 @@ deployment history. | `server-provider` | `generic-ssh` | Server provider key. | | `server-proxy-kind` | empty | Server proxy kind such as `traefik` or `caddy`. | | `state-backend` | empty | Explicit state backend. SSH targets default to `ssh-pglite`. | +| `environment-variables` | empty | Newline-separated values passed as repeated CLI `--env` flags in pure SSH CLI mode. | +| `secret-variables` | empty | Newline-separated values passed as repeated CLI `--secret` flags in pure SSH CLI mode. Prefer `ci-env:` references over raw secret values. | | `preview` | empty | Use `pull-request` for PR preview deploy or cleanup. | | `preview-id` | empty | Trusted preview scope, for example `pr-123`. Required for pull request previews. | | `preview-domain-template` | empty | Trusted preview hostname for deploy, for example `pr-123.preview.example.com`. | | `preview-tls-mode` | empty | Preview TLS mode for `preview-domain-template`. | | `require-preview-url` | `false` | Fail deploy if no public preview URL can be resolved. | -| `control-plane-mode` | `none` | Reserved for future Cloud/self-hosted control-plane mode. | -| `control-plane-url` | empty | Reserved for future control-plane endpoint. | -| `appaloft-token` | empty | Reserved for future control-plane token. | +| `pr-comment` | `false` | Post or update one pull request comment with preview, deployment, cleanup, and console feedback. | +| `github-token` | empty | GitHub token used only when `pr-comment` is true. | +| `control-plane-mode` | empty | Use `none` for pure SSH CLI mode or `self-hosted` for server API mode. When empty, `controlPlane.mode` from config may select the mode; otherwise the effective default is `none`. | +| `control-plane-url` | empty | Self-hosted Appaloft server endpoint for server API mode. When empty, `controlPlane.url` from config may supply the endpoint. | +| `appaloft-token` | empty | Optional bearer token for server API mode. | | `use-oidc` | `false` | Reserved for future GitHub OIDC exchange. | +| `project-id` | empty | Optional trusted project id for server API mode. When supplied with environment/resource/server ids, the server may bootstrap a missing source link. When omitted with the other ids, the server resolves context from source-link state. | +| `environment-id` | empty | Optional trusted environment id for server API mode. Required only when any explicit deployment id is supplied. | +| `resource-id` | empty | Optional trusted resource id for server API mode. Required only when any explicit deployment id is supplied. | +| `server-id` | empty | Optional trusted deployment target id for server API mode. Required only when any explicit deployment id is supplied. | +| `destination-id` | empty | Optional trusted destination id for server API mode. | ## Outputs @@ -175,6 +266,9 @@ deployment history. | `appaloft-target` | Selected release target. | | `preview-id` | Preview id when preview mode is selected. | | `preview-url` | Public preview URL when Appaloft resolves one during deploy. | +| `deployment-id` | Deployment id accepted by Appaloft. | +| `console-url` | Self-hosted Appaloft console URL used by server API mode. | +| `preview-cleanup-status` | Cleanup status returned by server API mode for `command: preview-cleanup`. | ## Security Notes @@ -184,8 +278,13 @@ deployment history. selectors into `appaloft.yml`. - The action defaults SSH deployments to server-owned `ssh-pglite` state when `ssh-host` is set and no control plane is selected. -- Control-plane inputs are reserved until the Appaloft CLI handshake is active; non-`none` values - fail before mutation. +- `control-plane-mode: self-hosted` does not accept SSH keys or `state-backend`; the action calls + the Appaloft server API and leaves state ownership with the server. +- In self-hosted server API mode, `command: preview-cleanup` accepts only source/config and trusted + preview scope inputs. Deployment target ids are intentionally ignored/rejected because cleanup + resolves from server-owned source-link state. +- `pr-comment` requires explicit workflow permission and token wiring. The action updates the same + marker comment for the PR instead of creating a new comment on each run. ## Product-Grade Previews diff --git a/action.yml b/action.yml index 9a54f2d..b50c31d 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: Appaloft Deploy -description: Install the Appaloft CLI and run the repository deployment workflow. +description: Run an Appaloft deployment from GitHub Actions through pure SSH CLI mode or self-hosted server API mode. inputs: command: @@ -54,6 +54,14 @@ inputs: description: Appaloft state backend. Defaults to ssh-pglite when ssh-host is supplied. required: false default: "" + environment-variables: + description: Newline-separated environment variables passed as repeated CLI --env values in pure CLI mode. + required: false + default: "" + secret-variables: + description: Newline-separated secret variables passed as repeated CLI --secret values in pure CLI mode. + required: false + default: "" preview: description: Preview mode. The first accepted value is pull-request. required: false @@ -74,22 +82,50 @@ inputs: description: Fail when no public preview URL is resolved. required: false default: "false" + pr-comment: + description: Post or update a pull request comment with preview, deployment, cleanup, and console links. + required: false + default: "false" + github-token: + description: "GitHub token used only when pr-comment is true. The workflow must grant pull-requests: write or issues: write." + required: false + default: "" control-plane-mode: - description: Future control-plane mode. Only none is accepted by this wrapper baseline. + description: Control-plane mode. Use none for pure SSH CLI mode or self-hosted for server API mode. required: false - default: "none" + default: "" control-plane-url: - description: Future control-plane endpoint. + description: Self-hosted Appaloft server endpoint for server API mode. required: false default: "" appaloft-token: - description: Future control-plane token. + description: Optional bearer token for self-hosted Appaloft server API mode. required: false default: "" use-oidc: - description: Future GitHub OIDC exchange toggle. + description: Future GitHub OIDC exchange toggle. Not yet supported. required: false default: "false" + project-id: + description: Trusted Appaloft project id for self-hosted server API mode. + required: false + default: "" + environment-id: + description: Trusted Appaloft environment id for self-hosted server API mode. + required: false + default: "" + resource-id: + description: Trusted Appaloft resource id for self-hosted server API mode. + required: false + default: "" + server-id: + description: Trusted Appaloft deployment target id for self-hosted server API mode. + required: false + default: "" + destination-id: + description: Optional trusted Appaloft destination id for self-hosted server API mode. + required: false + default: "" outputs: appaloft-version: @@ -104,11 +140,29 @@ outputs: preview-url: description: Preview URL resolved by the CLI from generated/default access or custom preview route intent. value: ${{ steps.deploy.outputs.preview-url }} + deployment-id: + description: Deployment id accepted by Appaloft. + value: ${{ steps.deploy.outputs.deployment-id }} + console-url: + description: Self-hosted Appaloft console URL used by server API mode. + value: ${{ steps.deploy.outputs.console-url }} + preview-cleanup-status: + description: Preview cleanup status when command is preview-cleanup. + value: ${{ steps.deploy.outputs.preview-cleanup-status }} runs: using: composite steps: + - id: resolve + shell: bash + env: + INPUT_CONFIG: ${{ inputs.config }} + INPUT_CONTROL_PLANE_MODE: ${{ inputs.control-plane-mode }} + INPUT_CONTROL_PLANE_URL: ${{ inputs.control-plane-url }} + run: bash "$GITHUB_ACTION_PATH/scripts/resolve-control-plane.sh" + - id: install + if: ${{ steps.resolve.outputs.control-plane-mode == 'none' }} shell: bash env: INPUT_VERSION: ${{ inputs.version }} @@ -131,13 +185,22 @@ runs: INPUT_SERVER_PROVIDER: ${{ inputs.server-provider }} INPUT_SERVER_PROXY_KIND: ${{ inputs.server-proxy-kind }} INPUT_STATE_BACKEND: ${{ inputs.state-backend }} + INPUT_ENVIRONMENT_VARIABLES: ${{ inputs.environment-variables }} + INPUT_SECRET_VARIABLES: ${{ inputs.secret-variables }} INPUT_PREVIEW: ${{ inputs.preview }} INPUT_PREVIEW_ID: ${{ inputs.preview-id }} INPUT_PREVIEW_DOMAIN_TEMPLATE: ${{ inputs.preview-domain-template }} INPUT_PREVIEW_TLS_MODE: ${{ inputs.preview-tls-mode }} INPUT_REQUIRE_PREVIEW_URL: ${{ inputs.require-preview-url }} - INPUT_CONTROL_PLANE_MODE: ${{ inputs.control-plane-mode }} - INPUT_CONTROL_PLANE_URL: ${{ inputs.control-plane-url }} + INPUT_PR_COMMENT: ${{ inputs.pr-comment }} + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} + INPUT_CONTROL_PLANE_MODE: ${{ steps.resolve.outputs.control-plane-mode }} + INPUT_CONTROL_PLANE_URL: ${{ steps.resolve.outputs.control-plane-url }} INPUT_APPALOFT_TOKEN: ${{ inputs.appaloft-token }} INPUT_USE_OIDC: ${{ inputs.use-oidc }} + INPUT_PROJECT_ID: ${{ inputs.project-id }} + INPUT_ENVIRONMENT_ID: ${{ inputs.environment-id }} + INPUT_RESOURCE_ID: ${{ inputs.resource-id }} + INPUT_SERVER_ID: ${{ inputs.server-id }} + INPUT_DESTINATION_ID: ${{ inputs.destination-id }} run: $GITHUB_ACTION_PATH/scripts/run-deploy.sh diff --git a/scripts/resolve-control-plane.sh b/scripts/resolve-control-plane.sh new file mode 100755 index 0000000..939506a --- /dev/null +++ b/scripts/resolve-control-plane.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +trim_quotes() { + local value="$1" + value="${value%%#*}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + printf '%s' "$value" +} + +read_yaml_control_plane_value() { + local file="$1" + local key="$2" + awk -v key="$key" ' + /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } + /^[^[:space:]][^:]*:/ && $0 !~ /^controlPlane:[[:space:]]*$/ { in_block = 0 } + /^controlPlane:[[:space:]]*$/ { in_block = 1; next } + in_block == 1 { + pattern = "^[[:space:]]+" key ":[[:space:]]*" + if ($0 ~ pattern) { + sub(pattern, "", $0) + print $0 + exit + } + } + ' "$file" +} + +read_json_control_plane_value() { + local file="$1" + local key="$2" + node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const key = process.argv[2]; + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + const value = parsed && parsed.controlPlane && parsed.controlPlane[key]; + if (typeof value === "string") process.stdout.write(value); + ' "$file" "$key" +} + +read_control_plane_value() { + local file="$1" + local key="$2" + if [ ! -f "$file" ]; then + return 0 + fi + + normalized_file="$(printf '%s' "$file" | tr '[:upper:]' '[:lower:]')" + case "$normalized_file" in + *.json) + read_json_control_plane_value "$file" "$key" + ;; + *) + trim_quotes "$(read_yaml_control_plane_value "$file" "$key")" + ;; + esac +} + +normalize_url() { + local value="$1" + value="${value%/}" + printf '%s' "$value" +} + +config_path="${INPUT_CONFIG:-}" +if [ -z "$config_path" ] && [ -f "appaloft.yml" ]; then + config_path="appaloft.yml" +fi + +input_mode="${INPUT_CONTROL_PLANE_MODE:-}" +mode="$input_mode" +url="${INPUT_CONTROL_PLANE_URL:-}" + +if [ -n "$config_path" ] && [ -f "$config_path" ]; then + config_mode="$(read_control_plane_value "$config_path" mode)" + config_url="$(read_control_plane_value "$config_path" url)" + + if [ -z "$mode" ] && [ -n "$config_mode" ]; then + mode="$config_mode" + fi + + if [ -z "$url" ] && [ -n "$config_url" ] && { { [ -z "$input_mode" ] && [ -n "$config_mode" ]; } || [ "$input_mode" = "self-hosted" ] || [ "$input_mode" = "cloud" ]; }; then + url="$config_url" + fi +fi + +if [ -z "$mode" ]; then + mode="none" +fi + +if [ -n "$url" ]; then + url="$(normalize_url "$url")" +fi + +{ + echo "control-plane-mode=$mode" + echo "control-plane-url=$url" +} >> "${GITHUB_OUTPUT:-/dev/null}" + +if [ -n "${APPALOFT_DEPLOY_ACTION_RESOLVE_OUTPUT:-}" ]; then + { + echo "control-plane-mode=$mode" + echo "control-plane-url=$url" + } > "$APPALOFT_DEPLOY_ACTION_RESOLVE_OUTPUT" +fi diff --git a/scripts/run-deploy.sh b/scripts/run-deploy.sh index 9e87297..ff5f7dc 100755 --- a/scripts/run-deploy.sh +++ b/scripts/run-deploy.sh @@ -28,6 +28,348 @@ append_option() { fi } +append_multiline_option() { + local name="$1" + local value="$2" + local line + + if [ -z "$value" ]; then + return + fi + + while IFS= read -r line || [ -n "$line" ]; do + if [ -n "$line" ]; then + argv+=("$name" "$line") + fi + done <<< "$value" +} + +json_escape() { + local value="$1" + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + value=${value//$'\t'/\\t} + printf '%s' "$value" +} + +normalized_url() { + local value="$1" + value="${value%/}" + printf '%s' "$value" +} + +trim_config_value() { + local value="$1" + value="${value%%#*}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + printf '%s' "$value" +} + +read_yaml_block_value() { + local file="$1" + local block="$2" + local key="$3" + awk -v block="$block" -v key="$key" ' + /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } + /^[^[:space:]][^:]*:/ { + expected = "^" block ":[[:space:]]*$" + in_block = ($0 ~ expected) + next + } + in_block == 1 { + pattern = "^[[:space:]]+" key ":[[:space:]]*" + if ($0 ~ pattern) { + sub(pattern, "", $0) + print $0 + exit + } + } + ' "$file" +} + +read_json_block_value() { + local file="$1" + local block="$2" + local key="$3" + node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const block = process.argv[2]; + const key = process.argv[3]; + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + const value = parsed && parsed[block] && parsed[block][key]; + if (typeof value === "string") process.stdout.write(value); + ' "$file" "$block" "$key" +} + +read_config_block_value() { + local file="$1" + local block="$2" + local key="$3" + if [ ! -f "$file" ]; then + return 0 + fi + + normalized_file="$(printf '%s' "$file" | tr '[:upper:]' '[:lower:]')" + case "$normalized_file" in + *.json) + read_json_block_value "$file" "$block" "$key" + ;; + *) + trim_config_value "$(read_yaml_block_value "$file" "$block" "$key")" + ;; + esac +} + +read_control_plane_value() { + read_config_block_value "$1" controlPlane "$2" +} + +read_source_value() { + read_config_block_value "$1" source "$2" +} + +source_fingerprint_for_action() { + local source_locator="$1" + local selected_config_path="$2" + local base_directory="$3" + local selected_preview_id="${4:-}" + + node -e ' + const sourceLocator = process.argv[1] || "."; + const configPath = process.argv[2] || "appaloft.yml"; + const baseDirectoryInput = process.argv[3] || "."; + const previewId = process.argv[4] || ""; + const env = process.env; + function normalizePathSeparators(value) { + return String(value || "").trim().replaceAll("\\\\", "/").replace(/\/+/g, "/"); + } + function stripWorkspacePrefix(value, workspaceRoot) { + const normalized = normalizePathSeparators(value); + const root = workspaceRoot ? normalizePathSeparators(workspaceRoot).replace(/\/+$/, "") : ""; + if (root && normalized === root) return "."; + if (root && normalized.startsWith(`${root}/`)) return normalized.slice(root.length + 1); + return normalized; + } + function normalizeSafeRelativePath(value, fallback, workspaceRoot) { + const stripped = stripWorkspacePrefix(value || fallback, workspaceRoot) + .replace(/^\.\//, "") + .replace(/\/+$/, ""); + if (!stripped || stripped === ".") return fallback; + if (stripped.startsWith("/")) return fallback; + return stripped; + } + function stripGitSuffix(value) { + return value.replace(/\.git$/i, ""); + } + function normalizeRepositoryLocator(locator) { + const raw = stripGitSuffix(String(locator || "").trim().replace(/\/+$/, "")); + const sshMatch = /^git@([^:]+):(.+)$/.exec(raw); + if (sshMatch) { + const host = (sshMatch[1] || "unknown").toLowerCase(); + const path = stripGitSuffix(sshMatch[2] || "").replace(/^\/+/, ""); + return `${host}/${path.toLowerCase()}`; + } + try { + const url = new URL(raw); + const host = url.host.toLowerCase(); + const path = stripGitSuffix(url.pathname.replace(/^\/+/, "").replace(/\/+$/, "")); + return `${host}/${host === "github.com" ? path.toLowerCase() : path}`; + } catch { + return raw.toLowerCase(); + } + } + function normalizeBranch(branch) { + return String(branch || "").trim().replace(/^refs\/heads\//, ""); + } + function pullRequestNumberFromPreviewId(value) { + const normalized = String(value || "").trim().toLowerCase().replace(/^preview-/, ""); + if (/^\d+$/.test(normalized)) return normalized; + const match = /^pr-(\d+)$/.exec(normalized); + return match ? match[1] : ""; + } + function scopeKey() { + const explicitPreviewNumber = pullRequestNumberFromPreviewId(previewId); + if (explicitPreviewNumber) return `preview:pr:${explicitPreviewNumber}`; + const pullRequestMatch = /^refs\/pull\/(\d+)\/(?:merge|head)$/.exec(env.GITHUB_REF || ""); + if (pullRequestMatch) return `preview:pr:${pullRequestMatch[1]}`; + if (env.GITHUB_HEAD_REF) return `preview:branch:${normalizeBranch(env.GITHUB_HEAD_REF)}`; + if ((env.GITHUB_REF || "").startsWith("refs/heads/")) { + return `branch:${normalizeBranch(env.GITHUB_REF)}`; + } + return "default"; + } + const provider = env.GITHUB_REPOSITORY ? "github" : "local"; + const repositoryLocator = env.GITHUB_REPOSITORY + ? `https://github.com/${env.GITHUB_REPOSITORY}` + : sourceLocator; + const repository = env.GITHUB_REPOSITORY_ID + ? `provider-repository:${env.GITHUB_REPOSITORY_ID}` + : normalizeRepositoryLocator(repositoryLocator); + const workspaceRoot = env.GITHUB_WORKSPACE || process.cwd(); + const keyParts = [ + "source-fingerprint:v1", + scopeKey(), + provider, + repository, + normalizeSafeRelativePath(baseDirectoryInput, ".", workspaceRoot), + normalizeSafeRelativePath(configPath, "appaloft.yml", workspaceRoot), + ]; + process.stdout.write(keyParts.map(encodeURIComponent).join(":")); + ' "$source_locator" "$selected_config_path" "$base_directory" "$selected_preview_id" +} + +require_input() { + local name="$1" + local value="$2" + if [ -z "$value" ]; then + error "$name is required for self-hosted control-plane mode" + exit 1 + fi +} + +append_auth_header() { + if [ -n "$appaloft_token" ]; then + curl_args+=("-H" "Authorization: Bearer ${appaloft_token}") + fi +} + +append_step_summary() { + if [ -z "${GITHUB_STEP_SUMMARY:-}" ]; then + return 0 + fi + + { + if [ "$wrapper_command" = "preview-cleanup" ]; then + printf '### Appaloft preview cleanup\n\n' + else + printf '### Appaloft deployment\n\n' + fi + printf -- '- Console: %s\n' "$control_plane_url" + if [ -n "${deployment_id:-}" ]; then + printf -- '- Deployment: `%s`\n' "$deployment_id" + fi + if [ -n "${cleanup_status:-}" ]; then + printf -- '- Cleanup status: `%s`\n' "$cleanup_status" + fi + } >> "$GITHUB_STEP_SUMMARY" +} + +pull_request_number_from_context() { + local normalized_preview_id + normalized_preview_id="$(printf '%s' "$preview_id" | tr '[:upper:]' '[:lower:]')" + normalized_preview_id="${normalized_preview_id#preview-}" + normalized_preview_id="${normalized_preview_id#pr-}" + case "$normalized_preview_id" in + ''|*[!0-9]*) + ;; + *) + printf '%s' "$normalized_preview_id" + return 0 + ;; + esac + + case "${GITHUB_REF:-}" in + refs/pull/*/merge|refs/pull/*/head) + local without_prefix="${GITHUB_REF#refs/pull/}" + printf '%s' "${without_prefix%%/*}" + return 0 + ;; + esac + + printf '' +} + +github_api_url() { + local path="$1" + printf '%s%s' "${GITHUB_API_URL:-https://api.github.com}" "$path" +} + +build_pr_comment_body() { + node -e ' + const marker = process.argv[1]; + const command = process.argv[2]; + const previewId = process.argv[3]; + const consoleUrl = process.argv[4]; + const previewUrl = process.argv[5]; + const deploymentId = process.argv[6]; + const cleanupStatus = process.argv[7]; + const lines = [marker, "", command === "preview-cleanup" ? "### Appaloft preview cleanup" : "### Appaloft deployment", ""]; + if (previewId) lines.push(`- Preview: \`${previewId}\``); + if (previewUrl) lines.push(`- Preview URL: ${previewUrl}`); + if (consoleUrl) lines.push(`- Console: ${consoleUrl}`); + if (deploymentId) lines.push(`- Deployment: \`${deploymentId}\``); + if (cleanupStatus) lines.push(`- Cleanup status: \`${cleanupStatus}\``); + process.stdout.write(JSON.stringify({ body: `${lines.join("\n")}\n` })); + ' "$1" "$wrapper_command" "$preview_id" "${control_plane_url:-}" "${preview_url:-}" "${deployment_id:-}" "${cleanup_status:-}" +} + +maybe_publish_pr_comment() { + if ! truthy "$pr_comment"; then + return 0 + fi + + [ -n "${GITHUB_REPOSITORY:-}" ] || { error "pr-comment requires GITHUB_REPOSITORY"; exit 1; } + github_token="${input_github_token:-${GITHUB_TOKEN:-}}" + [ -n "$github_token" ] || { error "pr-comment requires github-token or GITHUB_TOKEN"; exit 1; } + + pr_number="$(pull_request_number_from_context)" + [ -n "$pr_number" ] || { error "pr-comment requires preview-id like pr-123 or a pull_request GitHub ref"; exit 1; } + + comment_marker="" + comments_path="/repos/${GITHUB_REPOSITORY}/issues/${pr_number}/comments" + + if truthy "${APPALOFT_DEPLOY_ACTION_DRY_RUN:-false}"; then + if [ -n "${APPALOFT_DEPLOY_ACTION_ARGV_PATH:-}" ]; then + printf 'COMMENT %s\n' "$(github_api_url "$comments_path")" >> "$APPALOFT_DEPLOY_ACTION_ARGV_PATH" + else + printf 'COMMENT %s\n' "$(github_api_url "$comments_path")" + fi + return 0 + fi + + comment_payload="$(build_pr_comment_body "$comment_marker")" + comments_response="$(curl -fsS \ + -H "Authorization: Bearer ${github_token}" \ + -H "Accept: application/vnd.github+json" \ + "$(github_api_url "${comments_path}?per_page=100")")" + comment_id="$(COMMENT_MARKER="$comment_marker" node -e ' + const fs = require("fs"); + const comments = JSON.parse(fs.readFileSync(0, "utf8")); + const marker = process.env.COMMENT_MARKER; + const match = Array.isArray(comments) + ? comments.find((comment) => typeof comment.body === "string" && comment.body.includes(marker)) + : undefined; + if (match && match.id !== undefined) process.stdout.write(String(match.id)); + ' </dev/null + else + curl -fsS -X POST \ + -H "Authorization: Bearer ${github_token}" \ + -H "Accept: application/vnd.github+json" \ + -H "Content-Type: application/json" \ + --data "$comment_payload" \ + "$(github_api_url "$comments_path")" >/dev/null + fi +} + cleanup_key_file() { if [ -n "${generated_key_file:-}" ]; then rm -f "$generated_key_file" @@ -43,19 +385,50 @@ appaloft_bin="${APPALOFT_BIN:-appaloft}" wrapper_command="${INPUT_COMMAND:-deploy}" source_locator="${INPUT_SOURCE:-.}" config_path="${INPUT_CONFIG:-}" -control_plane_mode="${INPUT_CONTROL_PLANE_MODE:-none}" +input_control_plane_mode="${INPUT_CONTROL_PLANE_MODE:-}" +control_plane_mode="$input_control_plane_mode" control_plane_url="${INPUT_CONTROL_PLANE_URL:-}" appaloft_token="${INPUT_APPALOFT_TOKEN:-}" use_oidc="${INPUT_USE_OIDC:-false}" ssh_private_key="${INPUT_SSH_PRIVATE_KEY:-}" ssh_private_key_file="${INPUT_SSH_PRIVATE_KEY_FILE:-}" state_backend="${INPUT_STATE_BACKEND:-}" +environment_variables="${INPUT_ENVIRONMENT_VARIABLES:-}" +secret_variables="${INPUT_SECRET_VARIABLES:-}" preview="${INPUT_PREVIEW:-}" preview_id="${INPUT_PREVIEW_ID:-}" preview_domain_template="${INPUT_PREVIEW_DOMAIN_TEMPLATE:-}" preview_tls_mode="${INPUT_PREVIEW_TLS_MODE:-}" require_preview_url="${INPUT_REQUIRE_PREVIEW_URL:-false}" +pr_comment="${INPUT_PR_COMMENT:-false}" +input_github_token="${INPUT_GITHUB_TOKEN:-}" preview_output_file="" +project_id="${INPUT_PROJECT_ID:-}" +environment_id="${INPUT_ENVIRONMENT_ID:-}" +resource_id="${INPUT_RESOURCE_ID:-}" +server_id="${INPUT_SERVER_ID:-}" +destination_id="${INPUT_DESTINATION_ID:-}" + +selected_config_path="$config_path" +if [ -z "$selected_config_path" ] && [ -f "appaloft.yml" ]; then + selected_config_path="appaloft.yml" +fi + +if [ -n "$selected_config_path" ] && [ -f "$selected_config_path" ]; then + config_control_plane_mode="$(read_control_plane_value "$selected_config_path" mode)" + config_control_plane_url="$(read_control_plane_value "$selected_config_path" url)" + if [ -z "$control_plane_mode" ] && [ -n "$config_control_plane_mode" ]; then + control_plane_mode="$config_control_plane_mode" + fi + if [ -z "$control_plane_url" ] && [ -n "$config_control_plane_url" ] && { { [ -z "$input_control_plane_mode" ] && [ -n "$config_control_plane_mode" ]; } || [ "$input_control_plane_mode" = "self-hosted" ] || [ "$input_control_plane_mode" = "cloud" ]; }; then + control_plane_url="$config_control_plane_url" + fi + config_source_base_directory="$(read_source_value "$selected_config_path" baseDirectory)" +fi + +if [ -z "$control_plane_mode" ]; then + control_plane_mode="none" +fi case "$wrapper_command" in ""|deploy) @@ -72,14 +445,26 @@ esac case "$control_plane_mode" in ""|none) ;; + self-hosted) + control_plane_mode="self-hosted" + ;; + cloud|auto) + error "control-plane-mode=${control_plane_mode} is not supported by this deploy-action release" + exit 1 + ;; *) - error "control-plane-mode=${control_plane_mode} is reserved until CLI control-plane handshakes are active" + error "Unsupported control-plane-mode: ${control_plane_mode}" exit 1 ;; esac -if [ -n "$control_plane_url" ] || [ -n "$appaloft_token" ] || truthy "$use_oidc"; then - error "control-plane-url, appaloft-token, and use-oidc are reserved until control-plane mode is active" +if [ "$control_plane_mode" = "none" ] && { [ -n "$control_plane_url" ] || [ -n "$appaloft_token" ] || truthy "$use_oidc"; }; then + error "control-plane-url, appaloft-token, and use-oidc require control-plane-mode=self-hosted" + exit 1 +fi + +if truthy "$use_oidc"; then + error "use-oidc is reserved until GitHub OIDC token exchange is active" exit 1 fi @@ -103,6 +488,110 @@ if [ -n "$preview" ] && [ "$preview" != "pull-request" ]; then exit 1 fi +if [ "$control_plane_mode" = "self-hosted" ]; then + require_input "control-plane-url" "$control_plane_url" + has_explicit_deployment_context=false + has_any_explicit_context=false + if [ -n "$project_id" ] || [ -n "$environment_id" ] || [ -n "$resource_id" ] || [ -n "$server_id" ] || [ -n "$destination_id" ]; then + has_any_explicit_context=true + fi + if [ "$wrapper_command" = "deploy" ] && $has_any_explicit_context; then + has_explicit_deployment_context=true + require_input "project-id" "$project_id" + require_input "environment-id" "$environment_id" + require_input "resource-id" "$resource_id" + require_input "server-id" "$server_id" + fi + + if [ "$wrapper_command" = "preview-cleanup" ] && $has_any_explicit_context; then + error "self-hosted preview-cleanup resolves context from source-link state and must not receive project/resource/server ids" + exit 1 + fi + + if [ -n "${INPUT_SSH_HOST:-}" ] || [ -n "${INPUT_SSH_USER:-}" ] || [ -n "${INPUT_SSH_PORT:-}" ] || [ -n "$ssh_private_key" ] || [ -n "$ssh_private_key_file" ] || [ -n "$state_backend" ]; then + error "self-hosted control-plane mode must not receive ssh-* inputs or state-backend" + exit 1 + fi + + if [ "$wrapper_command" = "deploy" ] && { [ "$source_locator" != "." ] || [ -n "${INPUT_RUNTIME_NAME:-}" ] || [ -n "$preview" ] || [ -n "$preview_domain_template" ] || [ -n "$preview_tls_mode" ] || truthy "$require_preview_url"; }; then + error "self-hosted control-plane mode deploys an existing Appaloft resource profile; config, source, runtime-name, and preview inputs are not applied in this slice" + exit 1 + fi + + if [ "$wrapper_command" = "preview-cleanup" ] && { [ -n "${INPUT_RUNTIME_NAME:-}" ] || [ -n "$preview_domain_template" ] || [ -n "$preview_tls_mode" ] || truthy "$require_preview_url"; }; then + error "self-hosted preview-cleanup accepts source, config, preview, and preview-id only" + exit 1 + fi + + control_plane_url="$(normalized_url "$control_plane_url")" + curl_args=("-fsS") + append_auth_header + source_fingerprint="$(source_fingerprint_for_action "$source_locator" "${selected_config_path:-appaloft.yml}" "${config_source_base_directory:-.}" "$preview_id")" + + if truthy "${APPALOFT_DEPLOY_ACTION_DRY_RUN:-false}"; then + if [ -n "${APPALOFT_DEPLOY_ACTION_ARGV_PATH:-}" ]; then + { + printf 'GET %s/api/version\n' "$control_plane_url" + if [ "$wrapper_command" = "preview-cleanup" ]; then + printf 'POST %s/api/deployments/cleanup-preview\n' "$control_plane_url" + else + printf 'POST %s/api/action/deployments/from-source-link\n' "$control_plane_url" + fi + } > "$APPALOFT_DEPLOY_ACTION_ARGV_PATH" + else + printf 'GET %s/api/version\n' "$control_plane_url" + if [ "$wrapper_command" = "preview-cleanup" ]; then + printf 'POST %s/api/deployments/cleanup-preview\n' "$control_plane_url" + else + printf 'POST %s/api/action/deployments/from-source-link\n' "$control_plane_url" + fi + fi + else + version_response="$(curl "${curl_args[@]}" "$control_plane_url/api/version")" + if [[ "$version_response" != *'"apiVersion":"v1"'* && "$version_response" != *'"apiVersion": "v1"'* ]]; then + error "self-hosted control-plane handshake failed: expected apiVersion v1" + exit 1 + fi + + payload="{\"sourceFingerprint\":\"$(json_escape "$source_fingerprint")\"" + if [ "$wrapper_command" = "deploy" ] && $has_explicit_deployment_context; then + payload="${payload},\"projectId\":\"$(json_escape "$project_id")\",\"environmentId\":\"$(json_escape "$environment_id")\",\"resourceId\":\"$(json_escape "$resource_id")\",\"serverId\":\"$(json_escape "$server_id")\"" + if [ -n "$destination_id" ]; then + payload="${payload},\"destinationId\":\"$(json_escape "$destination_id")\"" + fi + fi + payload="${payload}}" + + if [ "$wrapper_command" = "preview-cleanup" ]; then + cleanup_endpoint="$control_plane_url/api/deployments/cleanup-preview" + cleanup_response="$(curl "${curl_args[@]}" -X POST "$cleanup_endpoint" -H "Content-Type: application/json" --data "$payload")" + cleanup_status="$(printf '%s\n' "$cleanup_response" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" + if [ -z "$cleanup_status" ]; then + error "self-hosted control-plane preview cleanup response did not include status" + exit 1 + fi + echo "preview-cleanup-status=$cleanup_status" >> "${GITHUB_OUTPUT:-/dev/null}" + else + deploy_endpoint="$control_plane_url/api/action/deployments/from-source-link" + deploy_response="$(curl "${curl_args[@]}" -X POST "$deploy_endpoint" -H "Content-Type: application/json" --data "$payload")" + deployment_id="$(printf '%s\n' "$deploy_response" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" + if [ -z "$deployment_id" ]; then + error "self-hosted control-plane deploy response did not include deployment id" + exit 1 + fi + echo "deployment-id=$deployment_id" >> "${GITHUB_OUTPUT:-/dev/null}" + fi + fi + + if [ "$wrapper_command" = "preview-cleanup" ] && [ -n "$preview_id" ]; then + echo "preview-id=$preview_id" >> "${GITHUB_OUTPUT:-/dev/null}" + fi + echo "console-url=$control_plane_url" >> "${GITHUB_OUTPUT:-/dev/null}" + append_step_summary + maybe_publish_pr_comment + exit 0 +fi + if [ -n "${INPUT_SSH_HOST:-}" ] && [ -z "$state_backend" ]; then state_backend="ssh-pglite" fi @@ -144,6 +633,8 @@ append_option "--server-provider" "${INPUT_SERVER_PROVIDER:-generic-ssh}" append_option "--server-proxy-kind" "${INPUT_SERVER_PROXY_KIND:-}" append_option "--server-ssh-private-key-file" "$ssh_private_key_file" append_option "--state-backend" "$state_backend" +append_multiline_option "--env" "$environment_variables" +append_multiline_option "--secret" "$secret_variables" append_option "--preview" "$preview" append_option "--preview-id" "$preview_id" @@ -199,3 +690,5 @@ fi if [ -n "$preview_url" ]; then echo "preview-url=$preview_url" >> "${GITHUB_OUTPUT:-/dev/null}" fi + +maybe_publish_pr_comment