diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c5410..93ab11f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,25 @@ jobs: grep -q '^preview-id=pr-1$' "$tmp/github-output" grep -q '^console-url=https://console.example.com$' "$tmp/github-output" + - name: Validate dry-run self-hosted server config deploy + env: + APPALOFT_DEPLOY_ACTION_DRY_RUN: "true" + INPUT_CONFIG: appaloft.yml + INPUT_CONTROL_PLANE_MODE: self-hosted + INPUT_CONTROL_PLANE_URL: https://console.example.com/ + INPUT_SERVER_CONFIG_DEPLOY: "true" + run: | + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + export GITHUB_OUTPUT="$tmp/github-output" + export APPALOFT_DEPLOY_ACTION_ARGV_PATH="$tmp/argv" + + bash scripts/run-deploy.sh + + grep -q '^GET https://console.example.com/api/version$' "$tmp/argv" + grep -q '^POST https://console.example.com/api/action/deployments/from-config-package$' "$tmp/argv" + grep -q '^console-url=https://console.example.com$' "$tmp/github-output" + - name: Opt-in exact-version install smoke if: ${{ vars.APPALOFT_INSTALL_SMOKE_VERSION != '' }} env: diff --git a/README.md b/README.md index 18602c1..b8597d7 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: appaloft/deploy-action@main + - uses: appaloft/deploy-action@v1 with: - version: v0.9.2 + version: v0.9.0 config: appaloft.yml ssh-host: ${{ secrets.APPALOFT_SSH_HOST }} ssh-user: ${{ secrets.APPALOFT_SSH_USER }} @@ -84,10 +84,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} - - uses: appaloft/deploy-action@main + - uses: appaloft/deploy-action@v1 id: deploy with: - version: v0.9.2 + version: v0.9.0 config: appaloft.preview.yml preview: pull-request preview-id: pr-${{ github.event.pull_request.number }} @@ -135,10 +135,10 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: appaloft/deploy-action@main + - uses: appaloft/deploy-action@v1 with: command: preview-cleanup - version: v0.9.2 + version: v0.9.0 config: appaloft.preview.yml preview: pull-request preview-id: pr-${{ github.event.pull_request.number }} @@ -176,7 +176,7 @@ jobs: name: production url: ${{ steps.deploy.outputs.console-url }} steps: - - uses: appaloft/deploy-action@main + - uses: appaloft/deploy-action@v1 id: deploy with: control-plane-mode: self-hosted @@ -195,6 +195,24 @@ them to bootstrap a missing source link before later runs omit ids. When ids are 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. +`server-config-deploy: true` selects the next self-hosted server config workflow. In that mode the +action feature-gates server support through `/api/version` before source package handoff and then +calls `POST /api/action/deployments/from-config-package`. A server that does not advertise source +package and server-side config bootstrap support fails before package upload or state mutation. This +mode is for compatible `0.9.x` self-hosted servers; leave it unset for the existing source-link +trigger mode. + +```yaml +- 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 }} + server-config-deploy: true + config: appaloft.yml +``` + For `preview: pull-request`, server API mode derives a preview-scoped source fingerprint and calls the same deployment endpoint. It writes `preview-id`, `deployment-id`, `deployment-url`, and `console-url` outputs, but it does not apply `preview-domain-template`, `preview-tls-mode`, @@ -202,7 +220,7 @@ the same deployment endpoint. It writes `preview-id`, `deployment-id`, `deployme mode. ```yaml -- uses: appaloft/deploy-action@main +- uses: appaloft/deploy-action@v1 id: deploy with: control-plane-mode: self-hosted @@ -253,7 +271,7 @@ source-link state, or the Appaloft server, not from committed config. | Input | Default | Purpose | | --- | --- | --- | | `command` | `deploy` | `deploy` or `preview-cleanup`. | -| `version` | `latest` | Appaloft CLI release tag such as `v0.9.2`. | +| `version` | `latest` | Appaloft CLI release tag such as `v0.9.0`. | | `config` | empty | Optional Appaloft config path. If omitted, `appaloft.yml` is used only when present. | | `source` | `.` | Source path or locator passed to the CLI. | | `runtime-name` | empty | Trusted runtime name override for deploy. | @@ -278,6 +296,7 @@ source-link state, or the Appaloft server, not from committed config. | `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. | +| `server-config-deploy` | `false` | Experimental self-hosted mode that calls `POST /api/action/deployments/from-config-package` after the server advertises source package and server-side config bootstrap support. | | `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. | @@ -312,6 +331,8 @@ source-link state, or the Appaloft server, not from committed config. rejected until the server owns that policy. `command: preview-cleanup` accepts only source/config and trusted preview scope inputs. Deployment target ids are intentionally ignored/rejected for cleanup because cleanup resolves from server-owned source-link state. +- `server-config-deploy` requires explicit self-hosted server support. The action fails before + source package handoff when the server handshake does not advertise the required capability. - `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. Comment API failures are warnings so they do not mask a successful deployment. diff --git a/action.yml b/action.yml index 89e6c5e..fb0aa81 100644 --- a/action.yml +++ b/action.yml @@ -7,7 +7,7 @@ inputs: required: false default: deploy version: - description: Appaloft CLI release tag, for example v0.9.2. Use latest for the latest stable release. + description: Appaloft CLI release tag, for example v0.9.0. Use latest for the latest stable release. required: false default: latest config: @@ -106,6 +106,10 @@ inputs: description: Future GitHub OIDC exchange toggle. Not yet supported. required: false default: "false" + server-config-deploy: + description: Experimental self-hosted server config deploy mode. Requires server-side source package and config bootstrap support. + required: false + default: "false" project-id: description: Trusted Appaloft project id for self-hosted server API mode. required: false @@ -201,6 +205,7 @@ runs: INPUT_CONTROL_PLANE_URL: ${{ steps.resolve.outputs.control-plane-url }} INPUT_APPALOFT_TOKEN: ${{ inputs.appaloft-token }} INPUT_USE_OIDC: ${{ inputs.use-oidc }} + INPUT_SERVER_CONFIG_DEPLOY: ${{ inputs.server-config-deploy }} INPUT_PROJECT_ID: ${{ inputs.project-id }} INPUT_ENVIRONMENT_ID: ${{ inputs.environment-id }} INPUT_RESOURCE_ID: ${{ inputs.resource-id }} diff --git a/scripts/run-deploy.sh b/scripts/run-deploy.sh index 21addb0..af2ad78 100755 --- a/scripts/run-deploy.sh +++ b/scripts/run-deploy.sh @@ -273,6 +273,78 @@ console_href_url() { esac } +version_supports_action_server_config_deploy() { + node -e ' + const fs = require("fs"); + const input = fs.readFileSync(0, "utf8"); + const parsed = JSON.parse(input); + const features = parsed && typeof parsed.features === "object" && parsed.features + ? parsed.features + : {}; + const supported = + parsed.actionServerConfigDeploy === true || + features.actionServerConfigDeploy === true || + ( + (features.sourcePackage === true || features.sourcePackages === true) && + features.serverSideConfigBootstrap === true + ); + process.exit(supported ? 0 : 1); + ' +} + +source_package_payload_for_action() { + local source_fingerprint="$1" + local selected_config="$2" + local source_root="$3" + local payload + + payload="{\"sourceFingerprint\":\"$(json_escape "$source_fingerprint")\",\"configPath\":\"$(json_escape "$selected_config")\",\"sourceRoot\":\"$(json_escape "$source_root")\",\"sourcePackage\":{\"transport\":\"server-github-fetch\",\"sourceFingerprint\":\"$(json_escape "$source_fingerprint")\",\"configPath\":\"$(json_escape "$selected_config")\",\"sourceRoot\":\"$(json_escape "$source_root")\"" + if [ -n "${GITHUB_SHA:-}" ]; then + payload="${payload},\"revision\":\"$(json_escape "$GITHUB_SHA")\"" + fi + if [ -n "${GITHUB_REPOSITORY:-}" ]; then + payload="${payload},\"repositoryFullName\":\"$(json_escape "$GITHUB_REPOSITORY")\"" + fi + if [ -n "${GITHUB_REPOSITORY_ID:-}" ]; then + payload="${payload},\"repositoryId\":\"$(json_escape "$GITHUB_REPOSITORY_ID")\"" + fi + payload="${payload}}" + if [ -n "$project_id" ] || [ -n "$environment_id" ] || [ -n "$resource_id" ] || [ -n "$server_id" ] || [ -n "$destination_id" ] || [ -n "${GITHUB_REPOSITORY:-}" ] || [ -n "${GITHUB_REPOSITORY_ID:-}" ] || [ -n "${GITHUB_REF:-}" ] || [ -n "${GITHUB_SHA:-}" ]; then + payload="${payload},\"trustedContext\":{" + local separator="" + if [ -n "$project_id" ]; then payload="${payload}${separator}\"projectId\":\"$(json_escape "$project_id")\""; separator=","; fi + if [ -n "$environment_id" ]; then payload="${payload}${separator}\"environmentId\":\"$(json_escape "$environment_id")\""; separator=","; fi + if [ -n "$resource_id" ]; then payload="${payload}${separator}\"resourceId\":\"$(json_escape "$resource_id")\""; separator=","; fi + if [ -n "$server_id" ]; then payload="${payload}${separator}\"serverId\":\"$(json_escape "$server_id")\""; separator=","; fi + if [ -n "$destination_id" ]; then payload="${payload}${separator}\"destinationId\":\"$(json_escape "$destination_id")\""; separator=","; fi + if [ -n "${GITHUB_REPOSITORY:-}" ]; then payload="${payload}${separator}\"repositoryFullName\":\"$(json_escape "$GITHUB_REPOSITORY")\""; separator=","; fi + if [ -n "${GITHUB_REPOSITORY_ID:-}" ]; then payload="${payload}${separator}\"repositoryId\":\"$(json_escape "$GITHUB_REPOSITORY_ID")\""; separator=","; fi + if [ -n "${GITHUB_REF:-}" ]; then payload="${payload}${separator}\"ref\":\"$(json_escape "$GITHUB_REF")\""; separator=","; fi + if [ -n "${GITHUB_SHA:-}" ]; then payload="${payload}${separator}\"revision\":\"$(json_escape "$GITHUB_SHA")\""; fi + payload="${payload}}" + fi + if [ "$preview" = "pull-request" ]; then + payload="${payload},\"preview\":{\"kind\":\"pull-request\",\"previewId\":\"$(json_escape "$preview_id")\"" + local pr_number + pr_number="$(pull_request_number_from_context)" + if [ -n "$pr_number" ]; then + payload="${payload},\"pullRequestNumber\":${pr_number}" + fi + if [ -n "${GITHUB_SHA:-}" ]; then + payload="${payload},\"headSha\":\"$(json_escape "$GITHUB_SHA")\"" + fi + if [ -n "${GITHUB_BASE_REF:-}" ]; then + payload="${payload},\"baseRef\":\"$(json_escape "$GITHUB_BASE_REF")\"" + fi + if [ -n "${GITHUB_HEAD_REF:-}" ]; then + payload="${payload},\"headRef\":\"$(json_escape "$GITHUB_HEAD_REF")\"" + fi + payload="${payload}}" + fi + payload="${payload}}" + printf '%s' "$payload" +} + append_step_summary() { if [ -z "${GITHUB_STEP_SUMMARY:-}" ]; then return 0 @@ -444,6 +516,7 @@ 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}" +server_config_deploy="${INPUT_SERVER_CONFIG_DEPLOY:-false}" ssh_private_key="${INPUT_SSH_PRIVATE_KEY:-}" ssh_private_key_file="${INPUT_SSH_PRIVATE_KEY_FILE:-}" state_backend="${INPUT_STATE_BACKEND:-}" @@ -522,6 +595,11 @@ if truthy "$use_oidc"; then exit 1 fi +if truthy "$server_config_deploy" && { [ "$control_plane_mode" != "self-hosted" ] || [ "$wrapper_command" != "deploy" ]; }; then + error "server-config-deploy requires control-plane-mode=self-hosted and command=deploy" + exit 1 +fi + if [ -n "$ssh_private_key" ] && [ -n "$ssh_private_key_file" ]; then error "ssh-private-key and ssh-private-key-file are mutually exclusive" exit 1 @@ -567,11 +645,16 @@ if [ "$control_plane_mode" = "self-hosted" ]; then exit 1 fi - if [ "$wrapper_command" = "deploy" ] && { [ "$source_locator" != "." ] || [ -n "${INPUT_RUNTIME_NAME:-}" ] || [ -n "$preview_domain_template" ] || [ -n "$preview_tls_mode" ] || truthy "$require_preview_url" || [ -n "$environment_variables" ] || [ -n "$secret_variables" ]; }; then + if [ "$wrapper_command" = "deploy" ] && ! truthy "$server_config_deploy" && { [ "$source_locator" != "." ] || [ -n "${INPUT_RUNTIME_NAME:-}" ] || [ -n "$preview_domain_template" ] || [ -n "$preview_tls_mode" ] || truthy "$require_preview_url" || [ -n "$environment_variables" ] || [ -n "$secret_variables" ]; }; then error "self-hosted control-plane mode deploys an existing Appaloft resource profile; source, runtime/profile, environment, secret, and preview route inputs are not applied in this slice" exit 1 fi + if truthy "$server_config_deploy" && { [ -n "${INPUT_RUNTIME_NAME:-}" ] || [ -n "$preview_domain_template" ] || [ -n "$preview_tls_mode" ] || truthy "$require_preview_url" || [ -n "$environment_variables" ] || [ -n "$secret_variables" ]; }; then + error "server-config-deploy hands source/config to the self-hosted server and does not accept runner-side profile, env, secret, or preview route inputs" + 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 @@ -588,6 +671,8 @@ if [ "$control_plane_mode" = "self-hosted" ]; 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" + elif truthy "$server_config_deploy"; then + printf 'POST %s/api/action/deployments/from-config-package\n' "$control_plane_url" else printf 'POST %s/api/action/deployments/from-source-link\n' "$control_plane_url" fi @@ -596,6 +681,8 @@ if [ "$control_plane_mode" = "self-hosted" ]; 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" + elif truthy "$server_config_deploy"; then + printf 'POST %s/api/action/deployments/from-config-package\n' "$control_plane_url" else printf 'POST %s/api/action/deployments/from-source-link\n' "$control_plane_url" fi @@ -607,6 +694,11 @@ if [ "$control_plane_mode" = "self-hosted" ]; then exit 1 fi + if truthy "$server_config_deploy" && ! printf '%s' "$version_response" | version_supports_action_server_config_deploy; then + error "self-hosted control-plane does not support Action Server Config Deploy; missing sourcePackage/serverSideConfigBootstrap feature" + 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")\"" @@ -626,7 +718,12 @@ if [ "$control_plane_mode" = "self-hosted" ]; then fi echo "preview-cleanup-status=$cleanup_status" >> "${GITHUB_OUTPUT:-/dev/null}" else - deploy_endpoint="$control_plane_url/api/action/deployments/from-source-link" + if truthy "$server_config_deploy"; then + deploy_endpoint="$control_plane_url/api/action/deployments/from-config-package" + payload="$(source_package_payload_for_action "$source_fingerprint" "${selected_config_path:-appaloft.yml}" "${config_source_base_directory:-.}")" + else + deploy_endpoint="$control_plane_url/api/action/deployments/from-source-link" + fi 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