Ctrl K
ComunidadGuías…

action.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
name: 'cfzt preview tunnel'
description: >-
  Bring up or tear down a Cloudflare Zero Trust preview tunnel with cfzt (zt) via GitHub Deployments.
author: casablanque-code
branding:
  icon: cloud-lightning
  color: orange

inputs:
  mode:
    description: "'up' to create the tunnel, 'down' to tear it down."
    required: true
  name:
    description: >-
      Tunnel name. Also becomes the hostname (<name>.<domain>) and the
      cache/deployment key — use the *same* name on the "up" and "down"
      call for a given preview (e.g. "pr-${{ github.event.number }}").
    required: true
  domain:
    description: 'Cloudflare zone the tunnel hostname is created under.'
    required: true
  cloudflare-api-token:
    description: >-
      Cloudflare API token with Account/Cloudflare Tunnel/Edit,
      Zone/DNS/Edit, and Account/Access: Apps and Policies/Edit.
    required: true
  cloudflare-account-id:
    description: 'Cloudflare account ID.'
    required: true
  port:
    description: 'Local port to expose. Required for mode=up unless docker is true.'
    required: false
    default: ''
  docker:
    description: 'Auto-detect the port from a Docker container named `name`. mode=up only.'
    required: false
    default: 'false'
  public:
    description: >-
      No Zero Trust Access login — anyone with the URL can view it.
      Mutually exclusive with allow. mode=up only.
    required: false
    default: 'false'
  allow:
    description: >-
      Newline-separated emails to restrict access to via Cloudflare Access.
      Mutually exclusive with public. mode=up only.
    required: false
    default: ''
  tcp:
    description: 'Force TCP/http2 instead of QUIC. mode=up only.'
    required: false
    default: 'false'
  protocol:
    description: 'cloudflared protocol: auto, quic, or http2. mode=up only.'
    required: false
    default: 'auto'
  cloudflared-version:
    description: >-
      cloudflared release tag to install (e.g. '2026.7.0'), skipping
      the version check on PATH. Leave empty to always install
      whatever the "latest" GitHub release currently is — simplest,
      but the exact binary a given commit runs against can then change
      between one workflow run and the next. Pin this for reproducible
      CI.
    required: false
    default: ''
  cfzt-version:
    description: >-
      cfzt (zt) release tag to install (e.g. 'v0.9.0'). Leave empty to
      install main's install.sh at whatever it currently resolves to
      "latest" as — same reproducibility caveat as cloudflared-version.
    required: false
    default: ''
  force:
    description: >-
      Replace a pre-existing DNS record for the hostname that a previous
      cfzt run didn't create. mode=up only.
    required: false
    default: 'false'
  environment:
    description: 'GitHub Deployments environment name. Defaults to `name`.'
    required: false
    default: ''
  create-deployment:
    description: 'Record this up/down in the GitHub Deployments UI.'
    required: false
    default: 'true'
  github-token:
    description: 'Token used for the Deployments API. Needs the `deployments: write` permission.'
    required: false
    default: ${{ github.token }}

outputs:
  url:
    description: 'https:// URL of the tunnel. Set for mode=up.'
    value: ${{ steps.hostname.outputs.url }}
  hostname:
    description: 'Bare hostname of the tunnel, e.g. pr-142.example.com.'
    value: ${{ steps.hostname.outputs.hostname }}

runs:
  using: composite
  steps:
    - name: Validate inputs
      shell: bash
      env:
        MODE: ${{ inputs.mode }}
        DOCKER: ${{ inputs.docker }}
        PORT: ${{ inputs.port }}
        CREATE_DEPLOYMENT: ${{ inputs.create-deployment }}
      run: |
        set -euo pipefail
        case "$MODE" in
          up|down) ;;
          *) echo "::error::mode must be 'up' or 'down', got '$MODE'"; exit 1 ;;
        esac
        if [ "$MODE" = "up" ] && [ "$DOCKER" != "true" ] && [ -z "$PORT" ]; then
          echo "::error::port is required for mode=up unless docker is true"
          exit 1
        fi
        # jq is used unconditionally (config write, up/down argument
        # building); gh only when the Deployments API steps actually run.
        # Failing fast here with a clear message beats the composite
        # action dying several steps in with a bare "command not found"
        # on a self-hosted runner that doesn't have these preinstalled —
        # both ship by default on GitHub-hosted Ubuntu runners.
        command -v jq >/dev/null 2>&1 || {
          echo "::error::jq is required (not found on PATH) — install it or use a runner image that includes it"
          exit 1
        }
        if [ "$CREATE_DEPLOYMENT" = "true" ]; then
          command -v gh >/dev/null 2>&1 || {
            echo "::error::gh (GitHub CLI) is required for create-deployment (not found on PATH) — install it, or set create-deployment: 'false' to skip the Deployments UI integration"
            exit 1
          }
        fi

    - name: Compute hostname
      id: hostname
      shell: bash
      env:
        NAME: ${{ inputs.name }}
        DOMAIN: ${{ inputs.domain }}
        MODE: ${{ inputs.mode }}
      run: |
        set -euo pipefail
        hostname="${NAME}.${DOMAIN}"
        echo "hostname=${hostname}" >> "$GITHUB_OUTPUT"
        if [ "$MODE" = "up" ]; then
          echo "url=https://${hostname}" >> "$GITHUB_OUTPUT"
        fi

    - name: Resolve deployment environment name
      id: env-name
      shell: bash
      env:
        ENVIRONMENT: ${{ inputs.environment }}
        NAME: ${{ inputs.name }}
      run: |
        set -euo pipefail
        echo "environment=${ENVIRONMENT:-$NAME}" >> "$GITHUB_OUTPUT"

    - name: Install cloudflared
      shell: bash
      env:
        CLOUDFLARED_VERSION: ${{ inputs.cloudflared-version }}
      run: |
        set -euo pipefail
        if command -v cloudflared >/dev/null 2>&1; then
          echo "cloudflared already on PATH: $(cloudflared --version)"
          exit 0
        fi
        arch="$(uname -m)"
        case "$arch" in
          x86_64) asset=cloudflared-linux-amd64 ;;
          aarch64|arm64) asset=cloudflared-linux-arm64 ;;
          *) echo "::error::unsupported architecture for cloudflared: $arch"; exit 1 ;;
        esac
        if [ -n "$CLOUDFLARED_VERSION" ]; then
          url="https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${asset}"
        else
          echo "::warning::cloudflared-version not set — installing whatever 'latest' currently is. Pin cloudflared-version for reproducible runs."
          url="https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}"
        fi
        curl -fsSL "$url" -o /usr/local/bin/cloudflared
        chmod +x /usr/local/bin/cloudflared
        cloudflared --version

    - name: Install cfzt
      shell: bash
      env:
        CFZT_VERSION: ${{ inputs.cfzt-version }}
      run: |
        set -euo pipefail
        if command -v zt >/dev/null 2>&1; then
          echo "zt already on PATH: $(zt version)"
          exit 0
        fi
        if [ -n "$CFZT_VERSION" ]; then
          # Pin install.sh itself to the same tag, not just the binary it
          # installs — main's install.sh can change independently of any
          # released zt version, so fetching it from main would still
          # leave the install *process* unpinned even with ZT_VERSION set.
          curl -fsSL "https://raw.githubusercontent.com/casablanque-code/cfzt/${CFZT_VERSION}/install.sh" \
            | ZT_VERSION="$CFZT_VERSION" bash
        else
          echo "::warning::cfzt-version not set — installing whatever main's install.sh currently resolves 'latest' to. Pin cfzt-version for reproducible runs."
          curl -fsSL https://raw.githubusercontent.com/casablanque-code/cfzt/main/install.sh | bash
        fi

    - name: Write cfzt config
      id: write-config
      shell: bash
      env:
        CF_API_TOKEN: ${{ inputs.cloudflare-api-token }}
        CF_ACCOUNT_ID: ${{ inputs.cloudflare-account-id }}
        DOMAIN: ${{ inputs.domain }}
      run: |
        set -euo pipefail
        if [ -e "${HOME}/.zt-config.json" ]; then
          echo "created=false" >> "$GITHUB_OUTPUT"
        else
          echo "created=true" >> "$GITHUB_OUTPUT"
        fi
        jq -n \
          --arg api_token "$CF_API_TOKEN" \
          --arg account_id "$CF_ACCOUNT_ID" \
          --arg domain "$DOMAIN" \
          '{api_token: $api_token, account_id: $account_id, domain: $domain}' \
          > "${HOME}/.zt-config.json"
        chmod 600 "${HOME}/.zt-config.json"

    - name: zt up
      id: zt-up
      if: inputs.mode == 'up'
      shell: bash
      env:
        NAME: ${{ inputs.name }}
        PORT: ${{ inputs.port }}
        DOCKER: ${{ inputs.docker }}
        PUBLIC: ${{ inputs.public }}
        ALLOW: ${{ inputs.allow }}
        TCP: ${{ inputs.tcp }}
        PROTOCOL: ${{ inputs.protocol }}
        FORCE: ${{ inputs.force }}
      run: |
        set -euo pipefail
        args=(up "$NAME")
        if [ "$DOCKER" = "true" ]; then
          args+=(--docker)
          [ -n "$PORT" ] && args+=("$PORT")
        else
          args+=("$PORT")
        fi
        [ "$PUBLIC" = "true" ] && args+=(--public)
        while IFS= read -r email; do
          [ -n "$email" ] && args+=(--allow "$email")
        done <<< "$ALLOW"
        [ "$TCP" = "true" ] && args+=(--tcp)
        [ -n "$PROTOCOL" ] && [ "$PROTOCOL" != "auto" ] && args+=(--protocol "$PROTOCOL")
        [ "$FORCE" = "true" ] && args+=(--force)
        zt "${args[@]}"

    - name: Record GitHub deployment
      id: record-deployment
      if: inputs.mode == 'up' && inputs.create-deployment == 'true'
      shell: bash
      env:
        GH_TOKEN: ${{ inputs.github-token }}
        REPO: ${{ github.repository }}
        REF: ${{ github.sha }}
        NAME: ${{ inputs.name }}
        ENVIRONMENT: ${{ steps.env-name.outputs.environment }}
        URL: ${{ steps.hostname.outputs.url }}
      run: |
        set -euo pipefail
        # required_contexts: [] matters here — without it the Deployments
        # API defers to the repo's required status checks and can refuse
        # to create the deployment before they've all passed, which isn't
        # what we want for a preview tunnel that IS one of the checks.
        # task is set to "cfzt:<name>" (rather than left as the default
        # "deploy") so the "down" step can identify exactly which
        # deployments belong to this preview, independent of environment
        # or sha. environment alone isn't enough: it can be reused across
        # multiple previews (e.g. several PRs all passed `environment:
        # preview`), and matching by sha doesn't work either since down
        # runs on pull_request:closed with a different sha than whichever
        # up call(s) preceded it. name is the one value the action
        # requires to stay identical between up and down, so it's the
        # only safe join key.
        payload=$(jq -n \
          --arg ref "$REF" \
          --arg environment "$ENVIRONMENT" \
          --arg description "cfzt preview: $URL" \
          --arg task "cfzt:${NAME}" \
          '{ref: $ref, task: $task, environment: $environment, description: $description, auto_merge: false, required_contexts: []}')
        deployment_id=$(gh api "repos/${REPO}/deployments" --method POST --input - <<< "$payload" --jq '.id')

        status_payload=$(jq -n \
          --arg environment "$ENVIRONMENT" \
          --arg environment_url "$URL" \
          '{state: "success", environment: $environment, environment_url: $environment_url, description: "tunnel live"}')
        gh api "repos/${REPO}/deployments/${deployment_id}/statuses" --method POST --input - <<< "$status_payload"

    - name: Clean up tunnel on deployment-recording failure
      # zt up creating a live tunnel while the GitHub Deployment never
      # gets recorded leaves Cloudflare state that nothing in the
      # workflow knows to tear down later — the corresponding "down" run
      # (keyed off task=cfzt:<name>) will never find a deployment to
      # react to, and there's no other trigger for it. If the up half
      # succeeded but recording it failed, undo the tunnel immediately
      # rather than leaving an orphaned preview live indefinitely.
      # always() is required here: the default step condition skips this
      # step entirely once any prior step has failed, which would also
      # skip this cleanup — always() plus the explicit outcome checks
      # narrow it back down to exactly the up-succeeded/record-failed case.
      if: >-
        always() && inputs.mode == 'up' && inputs.create-deployment == 'true' &&
        steps.zt-up.outcome == 'success' && steps.record-deployment.outcome == 'failure'
      shell: bash
      env:
        NAME: ${{ inputs.name }}
      run: |
        set -euo pipefail
        echo "::warning::GitHub deployment recording failed after zt up succeeded — tearing the tunnel back down"
        zt down "$NAME" --remote

    - name: zt down
      if: inputs.mode == 'down'
      shell: bash
      env:
        NAME: ${{ inputs.name }}
      run: zt down "$NAME" --remote

    - name: Mark GitHub deployment inactive
      if: inputs.mode == 'down' && inputs.create-deployment == 'true'
      shell: bash
      env:
        GH_TOKEN: ${{ inputs.github-token }}
        REPO: ${{ github.repository }}
        NAME: ${{ inputs.name }}
        ENVIRONMENT: ${{ steps.env-name.outputs.environment }}
      run: |
        set -euo pipefail
        # Filter by task, not just environment: environment can be reused
        # across multiple unrelated previews (custom `environment:` input
        # set to the same value for several PRs), and the environment
        # query param alone would then mark every one of them inactive.
        # task="cfzt:<name>" was set at creation time in the "up" step and
        # is unique per preview, so re-check it client-side after the
        # environment-scoped list call.
        task="cfzt:${NAME}"
        ids=$(gh api "repos/${REPO}/deployments?environment=${ENVIRONMENT}" \
          --jq --arg task "$task" '.[] | select(.task == $task) | .id')
        for id in $ids; do
          gh api "repos/${REPO}/deployments/${id}/statuses" \
            --method POST \
            -f "state=inactive" \
            -f "environment=${ENVIRONMENT}"
        done

    - name: Remove cfzt config
      # Only removes the file this run itself created (write-config's
      # created=true) — never a config that already existed on the
      # runner before this action touched it, e.g. a self-hosted runner
      # with its own persistent ~/.zt-config.json. always() so this
      # still runs after an earlier step failure; on GitHub-hosted
      # ephemeral runners the API token dying with the VM makes this
      # mostly redundant, but on self-hosted/persistent runners a
      # Cloudflare API token left sitting in a world-readable-by-owner
      # file after the job ends is unnecessary exposure.
      if: always() && steps.write-config.outputs.created == 'true'
      shell: bash
      run: rm -f "${HOME}/.zt-config.json"