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 (.) 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:" (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:) 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:" 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"