From 97f5153f0ffa86d7dbc8acadfe62898daf1cc8bc Mon Sep 17 00:00:00 2001 From: nichenqin Date: Wed, 6 May 2026 23:00:28 +0800 Subject: [PATCH] chore: sync reference wrapper --- .github/workflows/ci.yml | 61 +++++++ README.md | 245 +++++++++++++++++----------- action.yml | 211 ++++++++++-------------- deploy.sh | 4 - scripts/install-appaloft.sh | 245 ++++++++++++---------------- scripts/run-deploy.sh | 275 ++++++++++++++++++++------------ test/run.sh | 308 ------------------------------------ 7 files changed, 576 insertions(+), 773 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100755 deploy.sh delete mode 100755 test/run.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f6b89a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Validate wrapper scripts + run: bash -n scripts/install-appaloft.sh scripts/run-deploy.sh + + - name: Validate dry-run preview mapping + env: + APPALOFT_BIN: /opt/appaloft/appaloft + APPALOFT_DEPLOY_ACTION_DRY_RUN: "true" + INPUT_CONFIG: appaloft.preview.yml + INPUT_SOURCE: "." + INPUT_SSH_HOST: 203.0.113.10 + INPUT_PREVIEW: pull-request + INPUT_PREVIEW_ID: pr-1 + INPUT_PREVIEW_DOMAIN_TEMPLATE: pr-1.preview.example.com + INPUT_PREVIEW_TLS_MODE: disabled + INPUT_REQUIRE_PREVIEW_URL: "true" + run: | + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + export RUNNER_TEMP="$tmp" + export GITHUB_OUTPUT="$tmp/github-output" + export APPALOFT_DEPLOY_ACTION_ARGV_PATH="$tmp/argv" + + bash scripts/run-deploy.sh + + grep -q -- "--preview-output-file" "$tmp/argv" + grep -q '^preview-id=pr-1$' "$tmp/github-output" + grep -q '^preview-url=http://pr-1.preview.example.com$' "$tmp/github-output" + + - name: Opt-in exact-version install smoke + if: ${{ vars.APPALOFT_INSTALL_SMOKE_VERSION != '' }} + env: + INPUT_VERSION: ${{ vars.APPALOFT_INSTALL_SMOKE_VERSION }} + GITHUB_TOKEN: ${{ github.token }} + run: | + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + export RUNNER_TEMP="$tmp" + export GITHUB_OUTPUT="$tmp/github-output" + + bash scripts/install-appaloft.sh + + appaloft_bin="$(sed -n 's/^appaloft-bin=//p' "$tmp/github-output" | head -n 1)" + test -n "$appaloft_bin" + test -x "$appaloft_bin" diff --git a/README.md b/README.md index a5ad1e8..5be5e06 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,194 @@ -# deploy-action +# Appaloft Deploy Action -Deploy a repository with Appaloft from GitHub Actions. +Install the Appaloft CLI in GitHub Actions and run the repository deployment workflow. + +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`. + +## Basic Deploy ```yaml +name: Deploy + +on: + push: + branches: [main] + jobs: deploy: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 - - uses: appaloft/deploy-action@v0 + + - uses: appaloft/deploy-action@v1 with: - version: latest + version: v0.9.0 config: appaloft.yml - ssh-host: ${{ vars.APPALOFT_SSH_HOST }} - ssh-user: deploy + ssh-host: ${{ secrets.APPALOFT_SSH_HOST }} + ssh-user: ${{ secrets.APPALOFT_SSH_USER }} ssh-private-key: ${{ secrets.APPALOFT_SSH_PRIVATE_KEY }} - env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} ``` -## Repository Config +Pin `version` to an Appaloft CLI release for production workflows. `version: latest` is useful for +quick experiments, but it trades repeatability for convenience. -Keep deployment intent in `appaloft.yml`, but do not commit raw secret values or trusted hosted -control-plane ids. +Minimal `appaloft.yml`: ```yaml runtime: - build: npm run build - start: npm run start - + strategy: workspace-commands + buildCommand: bun install && bun run build + startCommand: bun run start network: - port: 3000 + internalPort: 3000 +``` + +Application secrets should be mapped by the workflow and referenced from config, not committed as +values: +```yaml secrets: DATABASE_URL: from: ci-env:DATABASE_URL ``` -GitHub Actions supplies application secrets through normal workflow environment variables: +## Pull Request Preview + +Action-only pull request previews require a workflow file. The action does not install a webhook or +make GitHub run previews on its own. ```yaml -env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} -``` +name: Appaloft Preview + +on: + pull_request: + types: [opened, reopened, synchronize] -## Remote State Default +jobs: + preview: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + environment: + name: preview-pr-${{ github.event.pull_request.number }} + url: ${{ steps.deploy.outputs.preview-url }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} -This action is a thin, checked binary wrapper around the Appaloft CLI: + - uses: appaloft/deploy-action@v1 + id: deploy + with: + version: v0.9.0 + config: appaloft.preview.yml + preview: pull-request + preview-id: pr-${{ github.event.pull_request.number }} + preview-domain-template: pr-${{ github.event.pull_request.number }}.preview.example.com + preview-tls-mode: disabled + require-preview-url: true + ssh-host: ${{ secrets.APPALOFT_SSH_HOST }} + ssh-user: ${{ secrets.APPALOFT_SSH_USER }} + ssh-private-key: ${{ secrets.APPALOFT_SSH_PRIVATE_KEY }} +``` -- downloads an Appaloft CLI release asset from GitHub Releases; -- verifies the asset with `checksums.txt` before adding it to `PATH`; -- writes `ssh-private-key` to a temporary `0600` file and passes only the file path to the CLI; -- invokes `appaloft deploy` with the same config-file flow used by the CLI; -- defaults Appaloft's own state to `ssh-pglite` when `ssh-host` is provided. +The default example skips fork pull requests before deployment credentials are exposed. Fork +previews need an explicit reduced-credential policy. -The GitHub runner is not the durable Appaloft state store in the default SSH path. Application -secrets such as `DATABASE_URL` are separate from Appaloft's own state and should be provided through -GitHub Actions `secrets` plus `ci-env:` references in `appaloft.yml`. +Use `appaloft.preview.yml` when the root config is production-oriented. Preview route intent should +come from generated/default access, this trusted `preview-domain-template`, or an explicitly +selected preview config file. Production `access.domains[]` should not be reinterpreted as pull +request preview hostnames. -## No Config +## Preview Cleanup -If `config` is omitted and `appaloft.yml` does not exist, the action does not pass `--config`. -Deployment can still run from direct action inputs and CLI detection: +Add a separate close-event workflow so preview runtime and route state are cleaned when the pull +request closes: ```yaml -- uses: appaloft/deploy-action@v0 - with: - source: . - ssh-host: ${{ vars.APPALOFT_SSH_HOST }} - ssh-user: deploy - ssh-private-key: ${{ secrets.APPALOFT_SSH_PRIVATE_KEY }} -``` +name: Appaloft Preview Cleanup -If `appaloft.yml` exists, the action passes `--config appaloft.yml`. A config file without -`access.domains[]` does not bind a custom domain; provider-local TLS diagnostics can still run for -the provider default route. +on: + pull_request: + types: [closed] -## Hosted Or Self-Hosted Control Plane - -The first public path does not require `APPALOFT_PROJECT_ID`. Trusted ids are advanced overrides for -a hosted Appaloft service or self-hosted control plane: +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 -```yaml -- uses: appaloft/deploy-action@v0 - with: - project-id: ${{ vars.APPALOFT_PROJECT_ID }} - server-id: ${{ vars.APPALOFT_SERVER_ID }} - environment-id: ${{ vars.APPALOFT_ENVIRONMENT_ID }} - resource-id: ${{ vars.APPALOFT_RESOURCE_ID }} + - uses: appaloft/deploy-action@v1 + with: + command: preview-cleanup + version: v0.9.0 + config: appaloft.preview.yml + preview: pull-request + preview-id: pr-${{ github.event.pull_request.number }} + ssh-host: ${{ secrets.APPALOFT_SSH_HOST }} + ssh-user: ${{ secrets.APPALOFT_SSH_USER }} + ssh-private-key: ${{ secrets.APPALOFT_SSH_PRIVATE_KEY }} ``` +Cleanup is idempotent. It stops preview-owned runtime state when present, removes preview route +desired state, unlinks preview source identity, and preserves production deployments and ordinary +deployment history. + ## Inputs -| Input | Default | Description | +| Input | Default | Purpose | | --- | --- | --- | -| `version` | `latest` | Appaloft CLI GitHub Release tag, or `latest`. | -| `config` | `appaloft.yml` | Path to `appaloft.yml`. The default is passed only when the file exists. | -| `source` | `.` | Local path, git source, image source, or remote source to deploy. | -| `method` | | Deployment method override. | -| `ssh-host` | | SSH host for remote Appaloft state and deployment execution. | -| `ssh-user` | | SSH username for the target server. | -| `ssh-port` | | SSH port. The CLI defaults to `22` when omitted. | -| `ssh-private-key` | | SSH private key material from a GitHub secret. | -| `ssh-private-key-file` | | Path to an SSH private key file already present on the runner. | -| `server-proxy-kind` | | Edge proxy kind, for example `traefik`, `caddy`, or `none`. | -| `state-backend` | `ssh-pglite` when `ssh-host` is set | Appaloft state backend override. | -| `args` | | Additional Appaloft CLI arguments appended after translated inputs. | -| `project-id` | | Advanced trusted project id override. | -| `server-id` | | Advanced trusted server id override. | -| `destination-id` | | Advanced trusted destination id override. | -| `environment-id` | | Advanced trusted environment id override. | -| `resource-id` | | Advanced trusted resource id override. | -| `resource-name` | | Resource name to create or reuse when `resource-id` is not supplied. | -| `resource-kind` | | Resource kind to create when `resource-id` is not supplied. | -| `resource-description` | | Resource description to create when `resource-id` is not supplied. | -| `install` | | Install command override. | -| `build` | | Build command override. | -| `start` | | Start command override. | -| `publish-dir` | | Static publish directory override. | -| `port` | | Application port override. | -| `health-path` | | Health check path override. | -| `app-log-lines` | `3` | Number of application log lines to print after deployment. | - -Legacy `target-*` and `path-or-source` inputs remain accepted as aliases for older workflows. - -## Release Model - -This repo is released only when the GitHub Actions wrapper changes. CLI changes ship from the main -Appaloft repo as GitHub Release assets. Workflows using `version: latest` pick up the newest CLI -release without requiring a deploy-action repo release. - -## License - -Apache-2.0. +| `command` | `deploy` | `deploy` or `preview-cleanup`. | +| `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. | +| `ssh-host` | empty | SSH target host for pure SSH deployments. | +| `ssh-user` | empty | SSH username. | +| `ssh-port` | empty | SSH port. | +| `ssh-private-key` | empty | SSH private key value, written to a temp file before invoking Appaloft. | +| `ssh-private-key-file` | empty | Existing runner-local private key path. Mutually exclusive with `ssh-private-key`. | +| `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`. | +| `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. | +| `use-oidc` | `false` | Reserved for future GitHub OIDC exchange. | + +## Outputs + +| Output | Purpose | +| --- | --- | +| `appaloft-version` | Installed CLI version. | +| `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. | + +## Security Notes + +- `ssh-private-key` is written to a runner temp file with mode `0600`; raw key material is not + passed as a command-line argument. +- Do not commit SSH keys, tokens, database URLs, production secret values, or Appaloft identity + 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. + +## Product-Grade Previews + +This action supports workflow-file previews. Product-grade previews with GitHub App webhooks, +preview policy, comments/checks, cleanup retries, quotas, audit, and managed domain lifecycle are +future Appaloft Cloud or self-hosted control-plane features. diff --git a/action.yml b/action.yml index bd446d3..9a54f2d 100644 --- a/action.yml +++ b/action.yml @@ -1,186 +1,143 @@ -name: Deploy With Appaloft -description: Deploy a repository with the Appaloft CLI from GitHub Actions. -author: Appaloft - -branding: - icon: upload-cloud - color: blue +name: Appaloft Deploy +description: Install the Appaloft CLI and run the repository deployment workflow. inputs: + command: + description: Wrapper command to run. Use deploy for deployments or preview-cleanup for pull request cleanup. + required: false + default: deploy version: - description: Appaloft CLI version to install. Use latest to install the latest GitHub Release. + description: Appaloft CLI release tag, for example v0.9.0. Use latest for the latest stable release. required: false default: latest config: - description: Path to appaloft.yml. The default is used only when the file exists. + description: Optional Appaloft config path. When omitted, appaloft.yml is used only if it exists. required: false - default: appaloft.yml + default: "" source: - description: Local path, git source, image source, or remote source to deploy. + description: Source path or locator passed to appaloft deploy. required: false default: "." - method: - description: Deployment method override. + runtime-name: + description: Trusted runtime name override. required: false + default: "" ssh-host: - description: SSH server host for remote Appaloft state and deployment execution. + description: SSH target host for pure SSH deployments. required: false + default: "" ssh-user: - description: SSH username for the target server. + description: SSH username. required: false + default: "" ssh-port: - description: SSH server port. The CLI defaults to 22 when omitted. + description: SSH port. required: false + default: "" ssh-private-key: - description: SSH private key material from a GitHub secret. + description: SSH private key value. Written to a runner temp file before invoking Appaloft. required: false + default: "" ssh-private-key-file: - description: Path to an SSH private key file already present on the runner. + description: Existing runner-local SSH private key file. + required: false + default: "" + server-provider: + description: Server provider key. required: false + default: "generic-ssh" server-proxy-kind: - description: Edge proxy kind for the target server, for example traefik, caddy, or none. + description: Server proxy kind. required: false + default: "" state-backend: - description: Appaloft state backend. Defaults to ssh-pglite when ssh-host is provided. - required: false - args: - description: Additional Appaloft CLI arguments appended after translated inputs. - required: false - project-id: - description: Advanced trusted project id override for hosted or self-hosted control-plane deployments. - required: false - server-id: - description: Advanced trusted server id override for hosted or self-hosted control-plane deployments. - required: false - destination-id: - description: Advanced trusted destination id override. - required: false - environment-id: - description: Advanced trusted environment id override for hosted or self-hosted control-plane deployments. - required: false - resource-id: - description: Advanced trusted resource id override for hosted or self-hosted control-plane deployments. - required: false - resource-name: - description: Resource name to create or reuse when resource-id is not supplied. - required: false - resource-kind: - description: Resource kind to create when resource-id is not supplied. - required: false - resource-description: - description: Resource description to create when resource-id is not supplied. - required: false - install: - description: Install command override. - required: false - build: - description: Build command override. - required: false - start: - description: Start command override. - required: false - publish-dir: - description: Static publish directory override. - required: false - port: - description: Application port override. - required: false - health-path: - description: Health check path override. - required: false - app-log-lines: - description: Number of application log lines to print after deployment. - required: false - default: "3" - path-or-source: - description: Legacy alias for source. + description: Appaloft state backend. Defaults to ssh-pglite when ssh-host is supplied. required: false - target-host: - description: Legacy alias for ssh-host. + default: "" + preview: + description: Preview mode. The first accepted value is pull-request. required: false - target-port: - description: Legacy alias for ssh-port. + default: "" + preview-id: + description: Trusted preview scope, for example pr-123. required: false - target-proxy-kind: - description: Legacy alias for server-proxy-kind. + default: "" + preview-domain-template: + description: Trusted preview hostname template rendered by the workflow or action. required: false - target-ssh-username: - description: Legacy alias for ssh-user. + default: "" + preview-tls-mode: + description: Preview TLS mode for preview-domain-template. required: false - target-private-key: - description: Legacy alias for ssh-private-key. + default: "" + require-preview-url: + description: Fail when no public preview URL is resolved. required: false - target-private-key-file: - description: Legacy alias for ssh-private-key-file. + default: "false" + control-plane-mode: + description: Future control-plane mode. Only none is accepted by this wrapper baseline. required: false - target-name: - description: Legacy server name input. + default: "none" + control-plane-url: + description: Future control-plane endpoint. required: false - target-provider: - description: Legacy server provider input. + default: "" + appaloft-token: + description: Future control-plane token. required: false - target-ssh-public-key: - description: Legacy SSH public key metadata input. + default: "" + use-oidc: + description: Future GitHub OIDC exchange toggle. required: false + default: "false" outputs: appaloft-version: - description: Installed Appaloft CLI release tag. + description: Installed Appaloft CLI version. value: ${{ steps.install.outputs.appaloft-version }} appaloft-target: - description: Installed Appaloft CLI release target. + description: Installed Appaloft release target. value: ${{ steps.install.outputs.appaloft-target }} - appaloft-bin-dir: - description: Directory added to PATH for the installed Appaloft CLI. - value: ${{ steps.install.outputs.appaloft-bin-dir }} + preview-id: + description: Preview id when preview mode is selected. + value: ${{ steps.deploy.outputs.preview-id }} + preview-url: + description: Preview URL resolved by the CLI from generated/default access or custom preview route intent. + value: ${{ steps.deploy.outputs.preview-url }} runs: using: composite steps: - - name: Install Appaloft CLI - id: install + - id: install shell: bash - run: '"$GITHUB_ACTION_PATH/scripts/install-appaloft.sh" "$INPUT_VERSION"' env: INPUT_VERSION: ${{ inputs.version }} + GITHUB_TOKEN: ${{ github.token }} + run: $GITHUB_ACTION_PATH/scripts/install-appaloft.sh - - name: Deploy + - id: deploy shell: bash - run: '"$GITHUB_ACTION_PATH/scripts/run-deploy.sh"' env: + APPALOFT_BIN: ${{ steps.install.outputs.appaloft-bin }} + INPUT_COMMAND: ${{ inputs.command }} INPUT_CONFIG: ${{ inputs.config }} INPUT_SOURCE: ${{ inputs.source }} - INPUT_PATH_OR_SOURCE: ${{ inputs.path-or-source }} - INPUT_METHOD: ${{ inputs.method }} + INPUT_RUNTIME_NAME: ${{ inputs.runtime-name }} INPUT_SSH_HOST: ${{ inputs.ssh-host }} INPUT_SSH_USER: ${{ inputs.ssh-user }} INPUT_SSH_PORT: ${{ inputs.ssh-port }} INPUT_SSH_PRIVATE_KEY: ${{ inputs.ssh-private-key }} INPUT_SSH_PRIVATE_KEY_FILE: ${{ inputs.ssh-private-key-file }} + INPUT_SERVER_PROVIDER: ${{ inputs.server-provider }} INPUT_SERVER_PROXY_KIND: ${{ inputs.server-proxy-kind }} INPUT_STATE_BACKEND: ${{ inputs.state-backend }} - INPUT_ARGS: ${{ inputs.args }} - INPUT_PROJECT_ID: ${{ inputs.project-id }} - INPUT_SERVER_ID: ${{ inputs.server-id }} - INPUT_DESTINATION_ID: ${{ inputs.destination-id }} - INPUT_ENVIRONMENT_ID: ${{ inputs.environment-id }} - INPUT_RESOURCE_ID: ${{ inputs.resource-id }} - INPUT_RESOURCE_NAME: ${{ inputs.resource-name }} - INPUT_RESOURCE_KIND: ${{ inputs.resource-kind }} - INPUT_RESOURCE_DESCRIPTION: ${{ inputs.resource-description }} - INPUT_INSTALL: ${{ inputs.install }} - INPUT_BUILD: ${{ inputs.build }} - INPUT_START: ${{ inputs.start }} - INPUT_PUBLISH_DIR: ${{ inputs.publish-dir }} - INPUT_PORT: ${{ inputs.port }} - INPUT_HEALTH_PATH: ${{ inputs.health-path }} - INPUT_APP_LOG_LINES: ${{ inputs.app-log-lines }} - INPUT_TARGET_HOST: ${{ inputs.target-host }} - INPUT_TARGET_PORT: ${{ inputs.target-port }} - INPUT_TARGET_PROXY_KIND: ${{ inputs.target-proxy-kind }} - INPUT_TARGET_SSH_USERNAME: ${{ inputs.target-ssh-username }} - INPUT_TARGET_PRIVATE_KEY: ${{ inputs.target-private-key }} - INPUT_TARGET_PRIVATE_KEY_FILE: ${{ inputs.target-private-key-file }} - INPUT_TARGET_NAME: ${{ inputs.target-name }} - INPUT_TARGET_PROVIDER: ${{ inputs.target-provider }} - INPUT_TARGET_SSH_PUBLIC_KEY: ${{ inputs.target-ssh-public-key }} + 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_APPALOFT_TOKEN: ${{ inputs.appaloft-token }} + INPUT_USE_OIDC: ${{ inputs.use-oidc }} + run: $GITHUB_ACTION_PATH/scripts/run-deploy.sh diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 7777b03..0000000 --- a/deploy.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -exec "$(dirname "$0")/scripts/run-deploy.sh" diff --git a/scripts/install-appaloft.sh b/scripts/install-appaloft.sh index 711239e..343a73c 100755 --- a/scripts/install-appaloft.sh +++ b/scripts/install-appaloft.sh @@ -1,177 +1,142 @@ #!/usr/bin/env bash set -euo pipefail -fail() { - echo "appaloft deploy-action: $*" >&2 - exit 1 -} - -has_command() { - command -v "$1" >/dev/null 2>&1 -} - -json_tag_name() { - sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 -} - -sha256_file() { - local file="$1" - - if has_command sha256sum; then - sha256sum "$file" | awk '{print $1}' - return - fi - - if has_command shasum; then - shasum -a 256 "$file" | awk '{print $1}' - return - fi +repository="appaloft/appaloft" +version="${INPUT_VERSION:-latest}" +runner_temp="${RUNNER_TEMP:-/tmp}" +install_root="${runner_temp%/}/appaloft-deploy-action" +mkdir -p "$install_root" - fail "sha256sum or shasum is required to verify Appaloft release assets" +error() { + echo "::error::$*" >&2 } detect_target() { - local os="${RUNNER_OS:-}" - local machine - machine="$(uname -m)" - - if [[ -z "$os" ]]; then - case "$(uname -s)" in - Darwin) os="macOS" ;; - Linux) os="Linux" ;; - MINGW* | MSYS* | CYGWIN*) os="Windows" ;; - *) fail "unsupported runner OS: $(uname -s)" ;; - esac - fi - - case "$machine" in - x86_64 | amd64) machine="x64" ;; - arm64 | aarch64) machine="arm64" ;; - *) fail "unsupported runner architecture: $machine" ;; - esac + local os + local arch + os="$(uname -s)" + arch="$(uname -m)" case "$os" in - Linux) echo "linux-${machine}-gnu" ;; - macOS) echo "darwin-${machine}" ;; - Windows) echo "win32-${machine}" ;; - *) fail "unsupported runner OS: $os" ;; + Darwin) + os="darwin" + ;; + Linux) + os="linux" + ;; + *) + error "Unsupported runner OS: $os" + exit 1 + ;; esac -} - -download() { - local url="$1" - local output="$2" - - if [[ -n "${GITHUB_TOKEN:-}" ]]; then - curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$url" -o "$output" - else - curl -fsSL "$url" -o "$output" - fi -} -resolve_latest_tag() { - local repo="$1" - local release_dir="$2" - local latest_json + case "$arch" in + arm64|aarch64) + arch="arm64" + ;; + x86_64|amd64) + arch="x64" + ;; + *) + error "Unsupported runner architecture: $arch" + exit 1 + ;; + esac - if [[ -n "$release_dir" ]]; then - latest_json="${release_dir}/latest.json" - [[ -f "$latest_json" ]] || fail "latest.json is required when APPALOFT_ACTION_RELEASE_DIR is used with version latest" - json_tag_name <"$latest_json" + if [ "$os" = "linux" ]; then + if ldd --version 2>&1 | grep -qi musl; then + printf '%s-%s-musl\n' "$os" "$arch" + else + printf '%s-%s-gnu\n' "$os" "$arch" + fi return fi - local api_url="https://api.github.com/repos/${repo}/releases/latest" - if [[ -n "${GITHUB_TOKEN:-}" ]]; then - curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$api_url" | json_tag_name - else - curl -fsSL "$api_url" | json_tag_name - fi + printf '%s-%s\n' "$os" "$arch" } -version="${1:-${INPUT_VERSION:-latest}}" -repo="${APPALOFT_ACTION_REPOSITORY:-appaloft/appaloft}" -release_dir="${APPALOFT_ACTION_RELEASE_DIR:-}" -target="${APPALOFT_ACTION_TARGET:-$(detect_target)}" -runner_temp="${RUNNER_TEMP:-/tmp}" -work_dir="${runner_temp}/appaloft-deploy-action/install" +resolve_latest_version() { + local headers=() + if [ -n "${GITHUB_TOKEN:-}" ]; then + headers=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi -mkdir -p "$work_dir" + curl -fsSL "${headers[@]}" "https://api.github.com/repos/${repository}/releases/latest" | + sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | + head -n 1 +} -if [[ "$version" == "latest" ]]; then - tag="$(resolve_latest_tag "$repo" "$release_dir")" - [[ -n "$tag" ]] || fail "could not resolve latest Appaloft release" -else - tag="$version" +if [ "$version" = "latest" ]; then + version="$(resolve_latest_version)" fi -if [[ "$tag" != v* ]]; then - tag="v${tag}" +if [ -z "$version" ]; then + error "Unable to resolve Appaloft release version" + exit 1 fi -case "$target" in - win32-*) extension="zip" ;; - *) extension="tar.gz" ;; +case "$version" in + v*) + version_tag="$version" + version_number="${version#v}" + ;; + *) + version_tag="v${version}" + version_number="$version" + ;; esac -asset_name="appaloft-${tag}-${target}.${extension}" -archive_path="${work_dir}/${asset_name}" -checksums_path="${work_dir}/checksums.txt" +target="$(detect_target)" +archive_name="appaloft-v${version_number}-${target}.tar.gz" +release_base_url="https://github.com/${repository}/releases/download/${version_tag}" +archive_path="${install_root}/${archive_name}" +checksums_path="${install_root}/checksums.txt" +extract_dir="${install_root}/appaloft-v${version_number}-${target}" -if [[ -n "$release_dir" ]]; then - [[ -f "${release_dir}/${asset_name}" ]] || fail "release asset not found: ${release_dir}/${asset_name}" - [[ -f "${release_dir}/checksums.txt" ]] || fail "checksums.txt not found in ${release_dir}" - cp "${release_dir}/${asset_name}" "$archive_path" - cp "${release_dir}/checksums.txt" "$checksums_path" -else - base_url="https://github.com/${repo}/releases/download/${tag}" - download "${base_url}/${asset_name}" "$archive_path" - download "${base_url}/checksums.txt" "$checksums_path" -fi +curl -fsSL "${release_base_url}/${archive_name}" -o "$archive_path" +curl -fsSL "${release_base_url}/checksums.txt" -o "$checksums_path" -expected_checksum="$(awk -v file="$asset_name" '$2 == file {print $1}' "$checksums_path" | head -n 1)" -[[ -n "$expected_checksum" ]] || fail "checksum for ${asset_name} was not found in checksums.txt" +expected_checksum="$( + awk -v asset="$archive_name" '$2 == asset { print $1 }' "$checksums_path" +)" -actual_checksum="$(sha256_file "$archive_path")" -if [[ "$actual_checksum" != "$expected_checksum" ]]; then - fail "checksum mismatch for ${asset_name}: expected ${expected_checksum}, got ${actual_checksum}" +if [ -z "$expected_checksum" ]; then + error "checksums.txt does not contain ${archive_name}" + exit 1 fi -extract_dir="${work_dir}/${tag}-${target}/extract" -bin_dir="${work_dir}/${tag}-${target}/bin" -rm -rf "$extract_dir" "$bin_dir" -mkdir -p "$extract_dir" "$bin_dir" - -case "$extension" in - zip) - has_command unzip || fail "unzip is required to extract ${asset_name}" - unzip -q "$archive_path" -d "$extract_dir" - binary_path="$(find "$extract_dir" -type f \( -name appaloft -o -name appaloft.exe \) | head -n 1)" - binary_name="appaloft.exe" - ;; - tar.gz) - tar -xzf "$archive_path" -C "$extract_dir" - binary_path="$(find "$extract_dir" -type f -name appaloft | head -n 1)" - binary_name="appaloft" - ;; - *) fail "unsupported archive extension: ${extension}" ;; -esac +if command -v sha256sum >/dev/null 2>&1; then + actual_checksum="$(sha256sum "$archive_path" | awk '{ print $1 }')" +else + actual_checksum="$(shasum -a 256 "$archive_path" | awk '{ print $1 }')" +fi -[[ -n "${binary_path:-}" ]] || fail "Appaloft CLI binary was not found in ${asset_name}" +if [ "$actual_checksum" != "$expected_checksum" ]; then + error "Checksum mismatch for ${archive_name}" + exit 1 +fi -cp "$binary_path" "${bin_dir}/${binary_name}" -chmod +x "${bin_dir}/${binary_name}" +rm -rf "$extract_dir" +mkdir -p "$extract_dir" +tar -xzf "$archive_path" -C "$extract_dir" -if [[ -n "${GITHUB_PATH:-}" ]]; then - echo "$bin_dir" >>"$GITHUB_PATH" +appaloft_bin="$(find "$extract_dir" -type f -name appaloft -print | head -n 1)" +if [ -z "$appaloft_bin" ]; then + error "Extracted archive did not contain an appaloft binary" + exit 1 fi -if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - { - echo "appaloft-version=${tag}" - echo "appaloft-target=${target}" - echo "appaloft-bin-dir=${bin_dir}" - } >>"$GITHUB_OUTPUT" +chmod +x "$appaloft_bin" +appaloft_bin_dir="$(dirname "$appaloft_bin")" + +if [ -n "${GITHUB_PATH:-}" ]; then + echo "$appaloft_bin_dir" >> "$GITHUB_PATH" fi -echo "Installed Appaloft CLI ${tag} for ${target}" +{ + echo "appaloft-bin=$appaloft_bin" + echo "appaloft-version=$version_tag" + echo "appaloft-target=$target" +} >> "${GITHUB_OUTPUT:-/dev/null}" + +echo "Installed Appaloft ${version_tag} for ${target}" diff --git a/scripts/run-deploy.sh b/scripts/run-deploy.sh index d127761..9e87297 100755 --- a/scripts/run-deploy.sh +++ b/scripts/run-deploy.sh @@ -1,128 +1,201 @@ #!/usr/bin/env bash set -euo pipefail -add_arg() { - local option="$1" - local value="${2:-}" +error() { + echo "::error::$*" >&2 +} - if [[ -n "$value" ]]; then - args+=("$option" "$value") - fi +truthy() { + case "${1:-}" in + true|TRUE|True|1|yes|YES|Yes) + return 0 + ;; + *) + return 1 + ;; + esac } -first_non_empty() { - local value +append_arg() { + argv+=("$1") +} - for value in "$@"; do - if [[ -n "$value" ]]; then - echo "$value" - return - fi - done +append_option() { + local name="$1" + local value="$2" + if [ -n "$value" ]; then + argv+=("$name" "$value") + fi } -cleanup() { - if [[ -n "${materialized_ssh_private_key_file:-}" ]]; then - rm -f "$materialized_ssh_private_key_file" +cleanup_key_file() { + if [ -n "${generated_key_file:-}" ]; then + rm -f "$generated_key_file" + fi + if [ -n "${generated_preview_output_file:-}" ]; then + rm -f "$generated_preview_output_file" fi } -trap cleanup EXIT -runner_temp="${RUNNER_TEMP:-/tmp}" -source_value="$(first_non_empty "${INPUT_SOURCE:-}" "${INPUT_PATH_OR_SOURCE:-}" ".")" -config_value="${INPUT_CONFIG:-appaloft.yml}" +trap cleanup_key_file EXIT + +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}" +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:-}" +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}" +preview_output_file="" + +case "$wrapper_command" in + ""|deploy) + wrapper_command="deploy" + ;; + preview-cleanup) + ;; + *) + error "Unsupported deploy-action command: $wrapper_command" + exit 1 + ;; +esac + +case "$control_plane_mode" in + ""|none) + ;; + *) + error "control-plane-mode=${control_plane_mode} is reserved until CLI control-plane handshakes are active" + 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" + 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 +fi -args=(deploy "$source_value") +if [ "$preview" = "pull-request" ] && [ -z "$preview_id" ]; then + error "preview-id is required when preview=pull-request" + exit 1 +fi -if [[ -n "$config_value" ]]; then - if [[ -f "$config_value" ]]; then - add_arg "--config" "$config_value" - elif [[ "$config_value" != "appaloft.yml" ]]; then - add_arg "--config" "$config_value" - fi +if [ "$wrapper_command" = "preview-cleanup" ] && [ "$preview" != "pull-request" ]; then + error "preview-cleanup requires preview=pull-request" + exit 1 fi -add_arg "--method" "${INPUT_METHOD:-}" -add_arg "--project" "${INPUT_PROJECT_ID:-}" -add_arg "--server" "${INPUT_SERVER_ID:-}" -add_arg "--destination" "${INPUT_DESTINATION_ID:-}" -add_arg "--environment" "${INPUT_ENVIRONMENT_ID:-}" -add_arg "--resource" "${INPUT_RESOURCE_ID:-}" -add_arg "--resource-name" "${INPUT_RESOURCE_NAME:-}" -add_arg "--resource-kind" "${INPUT_RESOURCE_KIND:-}" -add_arg "--resource-description" "${INPUT_RESOURCE_DESCRIPTION:-}" -add_arg "--install" "${INPUT_INSTALL:-}" -add_arg "--build" "${INPUT_BUILD:-}" -add_arg "--start" "${INPUT_START:-}" -add_arg "--publish-dir" "${INPUT_PUBLISH_DIR:-}" -add_arg "--port" "${INPUT_PORT:-}" -add_arg "--health-path" "${INPUT_HEALTH_PATH:-}" -add_arg "--app-log-lines" "${INPUT_APP_LOG_LINES:-3}" - -ssh_host="$(first_non_empty "${INPUT_SSH_HOST:-}" "${INPUT_TARGET_HOST:-}")" -ssh_user="$(first_non_empty "${INPUT_SSH_USER:-}" "${INPUT_TARGET_SSH_USERNAME:-}")" -ssh_port="$(first_non_empty "${INPUT_SSH_PORT:-}" "${INPUT_TARGET_PORT:-}")" -ssh_private_key="$(first_non_empty "${INPUT_SSH_PRIVATE_KEY:-}" "${INPUT_TARGET_PRIVATE_KEY:-}")" -ssh_private_key_file="$(first_non_empty "${INPUT_SSH_PRIVATE_KEY_FILE:-}" "${INPUT_TARGET_PRIVATE_KEY_FILE:-}")" -server_proxy_kind="$(first_non_empty "${INPUT_SERVER_PROXY_KIND:-}" "${INPUT_TARGET_PROXY_KIND:-}")" -server_name="${INPUT_TARGET_NAME:-}" -server_provider="${INPUT_TARGET_PROVIDER:-}" -ssh_public_key="${INPUT_TARGET_SSH_PUBLIC_KEY:-}" -state_backend="${INPUT_STATE_BACKEND:-}" +if [ -n "$preview" ] && [ "$preview" != "pull-request" ]; then + error "Unsupported preview mode: $preview" + exit 1 +fi -materialized_ssh_private_key_file="" -if [[ -z "$ssh_private_key_file" && -n "$ssh_private_key" ]]; then - mkdir -p "${runner_temp}/appaloft-deploy-action" - materialized_ssh_private_key_file="${runner_temp}/appaloft-deploy-action/ssh-key-${$}" - umask 077 - printf '%s\n' "$ssh_private_key" >"$materialized_ssh_private_key_file" - chmod 600 "$materialized_ssh_private_key_file" - ssh_private_key_file="$materialized_ssh_private_key_file" -fi - -target_requested=false -for target_value in \ - "$ssh_host" \ - "$ssh_user" \ - "$ssh_port" \ - "$ssh_private_key_file" \ - "$server_proxy_kind" \ - "$server_name" \ - "$server_provider" \ - "$ssh_public_key"; do - if [[ -n "$target_value" ]]; then - target_requested=true - break - fi -done +if [ -n "${INPUT_SSH_HOST:-}" ] && [ -z "$state_backend" ]; then + state_backend="ssh-pglite" +fi + +if [ -n "$ssh_private_key" ]; then + generated_key_file="$(mktemp "${RUNNER_TEMP:-/tmp}/appaloft-ssh-key.XXXXXX")" + printf '%s\n' "$ssh_private_key" > "$generated_key_file" + chmod 600 "$generated_key_file" + ssh_private_key_file="$generated_key_file" +fi + +if [ -n "$preview" ] && [ "$wrapper_command" = "deploy" ]; then + generated_preview_output_file="$(mktemp "${RUNNER_TEMP:-/tmp}/appaloft-preview-output.XXXXXX")" + preview_output_file="$generated_preview_output_file" +fi + +case "$wrapper_command" in + deploy) + argv=("$appaloft_bin" "deploy" "$source_locator") + ;; + preview-cleanup) + argv=("$appaloft_bin" "preview" "cleanup" "$source_locator") + ;; +esac + +if [ -n "$config_path" ]; then + append_option "--config" "$config_path" +elif [ -f "appaloft.yml" ]; then + append_option "--config" "appaloft.yml" +fi -if [[ "$target_requested" == "true" ]]; then - add_arg "--server-name" "$server_name" - add_arg "--server-host" "$ssh_host" +if [ "$wrapper_command" = "deploy" ]; then + append_option "--runtime-name" "${INPUT_RUNTIME_NAME:-}" +fi +append_option "--server-host" "${INPUT_SSH_HOST:-}" +append_option "--server-ssh-username" "${INPUT_SSH_USER:-}" +append_option "--server-port" "${INPUT_SSH_PORT:-}" +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_option "--preview" "$preview" +append_option "--preview-id" "$preview_id" + +if [ "$wrapper_command" = "deploy" ]; then + append_option "--preview-domain-template" "$preview_domain_template" + append_option "--preview-tls-mode" "$preview_tls_mode" + append_option "--preview-output-file" "$preview_output_file" +fi + +if [ "$wrapper_command" = "deploy" ] && truthy "$require_preview_url"; then + append_arg "--require-preview-url" +fi - if [[ -n "$server_provider" ]]; then - add_arg "--server-provider" "$server_provider" - elif [[ -n "$ssh_host" ]]; then - add_arg "--server-provider" "generic-ssh" +if truthy "${APPALOFT_DEPLOY_ACTION_DRY_RUN:-false}"; then + if [ -n "${APPALOFT_DEPLOY_ACTION_ARGV_PATH:-}" ]; then + printf '%s\n' "${argv[@]}" > "$APPALOFT_DEPLOY_ACTION_ARGV_PATH" + else + printf '%q ' "${argv[@]}" + printf '\n' fi +else + "${argv[@]}" +fi - add_arg "--server-port" "$ssh_port" - add_arg "--server-proxy-kind" "$server_proxy_kind" - add_arg "--server-ssh-username" "$ssh_user" - add_arg "--server-ssh-public-key" "$ssh_public_key" - add_arg "--server-ssh-private-key-file" "$ssh_private_key_file" +preview_url="" +if [ -n "$preview_output_file" ] && [ -f "$preview_output_file" ]; then + while IFS='=' read -r key value; do + case "$key" in + preview-id) + if [ -z "$preview_id" ]; then + preview_id="$value" + fi + ;; + preview-url) + preview_url="$value" + ;; + esac + done < "$preview_output_file" fi -if [[ -n "$state_backend" ]]; then - add_arg "--state-backend" "$state_backend" -elif [[ -n "$ssh_host" ]]; then - add_arg "--state-backend" "ssh-pglite" +if [ -n "$preview_id" ]; then + echo "preview-id=$preview_id" >> "${GITHUB_OUTPUT:-/dev/null}" fi -if [[ -n "${INPUT_ARGS:-}" ]]; then - read -r -a extra_args <<<"${INPUT_ARGS}" - args+=("${extra_args[@]}") +if [ -z "$preview_url" ] && [ -n "$preview_domain_template" ]; then + if [ "$preview_tls_mode" = "disabled" ]; then + preview_url="http://${preview_domain_template}" + else + preview_url="https://${preview_domain_template}" + fi fi -echo "Running appaloft ${args[*]}" -appaloft "${args[@]}" +if [ -n "$preview_url" ]; then + echo "preview-url=$preview_url" >> "${GITHUB_OUTPUT:-/dev/null}" +fi diff --git a/test/run.sh b/test/run.sh deleted file mode 100755 index df621ce..0000000 --- a/test/run.sh +++ /dev/null @@ -1,308 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -tmp_root="${TMPDIR:-/tmp}/appaloft-deploy-action-tests" -rm -rf "$tmp_root" -mkdir -p "$tmp_root" - -failures=0 - -log() { - printf '%s\n' "$*" -} - -fail() { - log "not ok - $1" - failures=$((failures + 1)) -} - -pass() { - log "ok - $1" -} - -assert_file_exists() { - local path="$1" - local message="$2" - [[ -f "$path" ]] || { - fail "$message" - return 1 - } -} - -assert_contains() { - local haystack="$1" - local needle="$2" - local message="$3" - [[ "$haystack" == *"$needle"* ]] || { - fail "$message" - return 1 - } -} - -assert_not_contains() { - local haystack="$1" - local needle="$2" - local message="$3" - [[ "$haystack" != *"$needle"* ]] || { - fail "$message" - return 1 - } -} - -file_sha256() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$1" | awk '{print $1}' - else - shasum -a 256 "$1" | awk '{print $1}' - fi -} - -file_mode() { - if stat -c '%a' "$1" >/dev/null 2>&1; then - stat -c '%a' "$1" - else - stat -f '%Lp' "$1" - fi -} - -create_release_fixture() { - local root="$1" - local version="$2" - local target="$3" - local archive_base="appaloft-${version}-${target}" - local bundle_dir="$root/${archive_base}" - mkdir -p "$bundle_dir" - cat >"$bundle_dir/appaloft" <<'SH' -#!/usr/bin/env bash -printf 'appaloft fixture %s\n' "$*" -SH - chmod +x "$bundle_dir/appaloft" - (cd "$root" && tar -czf "${archive_base}.tar.gz" "$archive_base") - rm -rf "$bundle_dir" - local archive="${root}/${archive_base}.tar.gz" - printf '%s %s\n' "$(file_sha256 "$archive")" "$(basename "$archive")" >"$root/checksums.txt" -} - -run_fake_appaloft() { - local workdir="$1" - local argv_file="$2" - local mode_file="$3" - local bin_dir="$workdir/bin" - mkdir -p "$bin_dir" - cat >"$bin_dir/appaloft" <"$argv_file" -printf '\n' >>"$argv_file" -key_file="" -previous="" -for arg in "\$@"; do - if [[ "\$previous" == "--server-ssh-private-key-file" ]]; then - key_file="\$arg" - fi - previous="\$arg" -done -if [[ -n "\$key_file" && -f "\$key_file" ]]; then - if stat -c '%a' "\$key_file" >/dev/null 2>&1; then - stat -c '%a' "\$key_file" >"$mode_file" - else - stat -f '%Lp' "\$key_file" >"$mode_file" - fi - printf '%s' "\$key_file" >"$workdir/key-path" -fi -SH - chmod +x "$bin_dir/appaloft" - printf '%s' "$bin_dir" -} - -test_action_metadata_contract() { - local name="[QUICK-DEPLOY-ENTRY-011] action metadata is a direct verified binary wrapper" - local metadata - metadata="$(cat "$repo_root/action.yml")" - - assert_contains "$metadata" "scripts/install-appaloft.sh" "$name missing install script" || return - assert_contains "$metadata" "scripts/run-deploy.sh" "$name missing deploy script" || return - assert_contains "$metadata" "ssh-host:" "$name missing ssh-host input" || return - assert_contains "$metadata" "ssh-private-key:" "$name missing ssh-private-key input" || return - assert_contains "$metadata" "state-backend:" "$name missing state-backend input" || return - assert_not_contains "$metadata" "appaloft/setup-appaloft" "$name must not depend on setup-appaloft" || return - - pass "$name" -} - -test_install_verifies_checksum() { - local name="[CONFIG-FILE-ENTRY-009] install verifies checksum before adding CLI to PATH" - local workdir="$tmp_root/install-ok" - local release_dir="$workdir/release" - local github_path="$workdir/github-path" - mkdir -p "$release_dir" - create_release_fixture "$release_dir" "v0.1.0" "linux-x64-gnu" - - APPALOFT_ACTION_RELEASE_DIR="$release_dir" \ - APPALOFT_ACTION_TARGET="linux-x64-gnu" \ - RUNNER_TEMP="$workdir/runner" \ - GITHUB_PATH="$github_path" \ - "$repo_root/scripts/install-appaloft.sh" "v0.1.0" >/dev/null - - local installed_dir - installed_dir="$(tail -n 1 "$github_path")" - assert_file_exists "$installed_dir/appaloft" "$name did not install appaloft" || return - "$installed_dir/appaloft" doctor >/dev/null - - pass "$name" -} - -test_install_rejects_checksum_mismatch() { - local name="[CONFIG-FILE-ENTRY-009] install rejects checksum mismatch" - local workdir="$tmp_root/install-bad" - local release_dir="$workdir/release" - mkdir -p "$release_dir" - create_release_fixture "$release_dir" "v0.1.0" "linux-x64-gnu" - printf '0000000000000000000000000000000000000000000000000000000000000000 appaloft-v0.1.0-linux-x64-gnu.tar.gz\n' >"$release_dir/checksums.txt" - - if APPALOFT_ACTION_RELEASE_DIR="$release_dir" \ - APPALOFT_ACTION_TARGET="linux-x64-gnu" \ - RUNNER_TEMP="$workdir/runner" \ - GITHUB_PATH="$workdir/github-path" \ - "$repo_root/scripts/install-appaloft.sh" "v0.1.0" >/dev/null 2>"$workdir/error.log"; then - fail "$name accepted a mismatched checksum" - return - fi - - assert_contains "$(cat "$workdir/error.log")" "checksum" "$name did not report checksum" || return - pass "$name" -} - -test_latest_version_resolution() { - local name="[CONFIG-FILE-ENTRY-011] latest resolves stable release tag" - local workdir="$tmp_root/latest" - local release_dir="$workdir/release" - local github_path="$workdir/github-path" - mkdir -p "$release_dir" - create_release_fixture "$release_dir" "v0.2.0" "linux-x64-gnu" - printf '{"tag_name":"v0.2.0","prerelease":false,"draft":false}\n' >"$release_dir/latest.json" - - APPALOFT_ACTION_RELEASE_DIR="$release_dir" \ - APPALOFT_ACTION_TARGET="linux-x64-gnu" \ - RUNNER_TEMP="$workdir/runner" \ - GITHUB_PATH="$github_path" \ - "$repo_root/scripts/install-appaloft.sh" "latest" >/dev/null - - assert_contains "$(tail -n 1 "$github_path")" "v0.2.0" "$name did not install latest fixture" || return - pass "$name" -} - -test_ssh_private_key_mapping() { - local name="[CONFIG-FILE-ENTRY-010] deploy maps SSH private key through temp file only" - local workdir="$tmp_root/ssh-key" - local argv_file="$workdir/argv" - local mode_file="$workdir/key-mode" - mkdir -p "$workdir" - local fake_path - fake_path="$(run_fake_appaloft "$workdir" "$argv_file" "$mode_file")" - - PATH="$fake_path:$PATH" \ - RUNNER_TEMP="$workdir/runner" \ - INPUT_SOURCE="." \ - INPUT_CONFIG="appaloft.yml" \ - INPUT_SSH_HOST="107.173.15.220" \ - INPUT_SSH_USER="deploy" \ - INPUT_SSH_PRIVATE_KEY=$'-----BEGIN KEY-----\nsecret-private-key\n-----END KEY-----' \ - "$repo_root/scripts/run-deploy.sh" - - local argv - argv="$(cat "$argv_file")" - assert_contains "$argv" "--server-host 107.173.15.220" "$name missing server host" || return - assert_contains "$argv" "--server-ssh-private-key-file" "$name missing key file flag" || return - assert_not_contains "$argv" "secret-private-key" "$name leaked private key in argv" || return - assert_contains "$(cat "$mode_file")" "600" "$name did not chmod key file to 600" || return - - local key_path - key_path="$(cat "$workdir/key-path")" - [[ ! -e "$key_path" ]] || { - fail "$name did not remove temp key" - return - } - - pass "$name" -} - -test_no_config_mode() { - local name="[CONFIG-FILE-ENTRY-012] no-config deploy omits --config and keeps SSH remote-state path" - local workdir="$tmp_root/no-config" - local argv_file="$workdir/argv" - local mode_file="$workdir/key-mode" - mkdir -p "$workdir/workspace" - local fake_path - fake_path="$(run_fake_appaloft "$workdir" "$argv_file" "$mode_file")" - - (cd "$workdir/workspace" && PATH="$fake_path:$PATH" \ - RUNNER_TEMP="$workdir/runner" \ - INPUT_SOURCE="." \ - INPUT_CONFIG="appaloft.yml" \ - INPUT_SSH_HOST="107.173.15.220" \ - "$repo_root/scripts/run-deploy.sh") - - local argv - argv="$(cat "$argv_file")" - assert_contains "$argv" "deploy ." "$name missing deploy source" || return - assert_not_contains "$argv" "--config" "$name passed missing default config" || return - assert_contains "$argv" "--state-backend ssh-pglite" "$name did not make ssh-pglite explicit" || return - - pass "$name" -} - -test_config_without_domain() { - local name="[CONFIG-FILE-ENTRY-013] config without domains does not add custom route inputs" - local workdir="$tmp_root/config-no-domain" - local argv_file="$workdir/argv" - local mode_file="$workdir/key-mode" - mkdir -p "$workdir/workspace" - cat >"$workdir/workspace/appaloft.yml" <<'YAML' -runtime: - strategy: static - publishDirectory: dist -network: - internalPort: 80 -YAML - local fake_path - fake_path="$(run_fake_appaloft "$workdir" "$argv_file" "$mode_file")" - - (cd "$workdir/workspace" && PATH="$fake_path:$PATH" \ - RUNNER_TEMP="$workdir/runner" \ - INPUT_SOURCE="." \ - INPUT_CONFIG="appaloft.yml" \ - INPUT_SSH_HOST="107.173.15.220" \ - "$repo_root/scripts/run-deploy.sh") - - local argv - argv="$(cat "$argv_file")" - assert_contains "$argv" "--config appaloft.yml" "$name missing config flag" || return - assert_not_contains "$argv" "access.domains" "$name invented domain input" || return - assert_not_contains "$argv" "--domain" "$name invented domain flag" || return - - pass "$name" -} - -tests=( - test_action_metadata_contract - test_install_verifies_checksum - test_install_rejects_checksum_mismatch - test_latest_version_resolution - test_ssh_private_key_mapping - test_no_config_mode - test_config_without_domain -) - -for test_name in "${tests[@]}"; do - if ! "$test_name"; then - : - fi -done - -if [[ "$failures" -gt 0 ]]; then - log "$failures test(s) failed" - exit 1 -fi - -log "${#tests[@]} test(s) passed"