action.yml
Rama main · réplica de lectura del repositorio original.
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"