diff --git a/.github/workflows/k8s-preview-tunnel-smoketest.yaml b/.github/workflows/k8s-preview-tunnel-smoketest.yaml
new file mode 100644
index 0000000..be92bb7
--- /dev/null
+++ b/.github/workflows/k8s-preview-tunnel-smoketest.yaml
@@ -0,0 +1,174 @@
+name: K8s Preview Tunnel Smoketest
+# Manual, on-demand check of the Cloudflare Tunnel + DNS + Access plumbing
+# that k8s-preview.yaml depends on — without paying for a full k3d +
+# platform-stack + helm deploy on every iteration. Serves a static
+# index.html instead of JupyterHub; same tunnel/DNS create+configure+
+# delete API calls as the real workflow, same secrets. Run this first
+# when validating the Cloudflare-side setup (token scopes, Access
+# application, GitHub identity provider); once a visit to the printed
+# URL round-trips through GitHub SSO successfully, k8s-preview.yaml's
+# tunnel plumbing is known-good and any remaining issue is in the
+# k3d/helm/chart side, not Cloudflare.
+
+on:
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'PR number to post the smoketest URL to (optional; skips the comment if blank)'
+ required: false
+
+env:
+ PREVIEW_DOMAIN: github.fyi
+ CLOUDFLARED_VERSION: "2026.7.3"
+ CLOUDFLARED_SHA256: "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17"
+
+jobs:
+ smoketest:
+ name: Tunnel smoketest
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - name: Serve a trivial static page
+ run: |
+ mkdir -p /tmp/preview-test
+ cat > /tmp/preview-test/index.html <<'EOF'
+
+
Tunnel smoketest OK
+ EOF
+ python3 -m http.server 8000 --directory /tmp/preview-test \
+ > /tmp/http-server.log 2>&1 &
+
+ - name: Install cloudflared
+ run: |
+ curl -fsSL -o /tmp/cloudflared \
+ "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
+ echo "${CLOUDFLARED_SHA256} /tmp/cloudflared" | sha256sum -c -
+ chmod +x /tmp/cloudflared
+
+ - name: Create Cloudflare Tunnel for this run
+ id: cf_tunnel
+ env:
+ CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }}
+ CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_TUNNEL_ACCOUNT_ID }}
+ PREVIEW_HOSTNAME: smoketest-${{ github.run_id }}.${{ env.PREVIEW_DOMAIN }}
+ run: |
+ tunnel_secret=$(openssl rand -base64 32)
+ echo "::add-mask::${tunnel_secret}"
+
+ tunnel_name="smoketest-${{ github.run_id }}"
+ create_resp=$(curl -sS -X POST \
+ "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg name "$tunnel_name" --arg secret "$tunnel_secret" \
+ '{name: $name, config_src: "cloudflare", tunnel_secret: $secret}')")
+ tunnel_id=$(jq -r '.result.id // empty' <<< "$create_resp")
+
+ # Same retry-safety as the real workflow: a retry reuses run_id,
+ # so reuse the existing tunnel by name on a 409 name conflict.
+ if [ -z "$tunnel_id" ]; then
+ echo "::warning::Tunnel create failed (likely a name conflict from a retry), looking up existing tunnel named ${tunnel_name}: $create_resp"
+ tunnel_id=$(curl -fsS "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel?name=${tunnel_name}&is_deleted=false" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" | jq -r '.result[0].id // empty')
+ fi
+ if [ -z "$tunnel_id" ]; then
+ echo "::error::Tunnel creation failed and no existing tunnel named ${tunnel_name} found: $create_resp"
+ exit 1
+ fi
+ echo "TUNNEL_ID=${tunnel_id}" >> "$GITHUB_ENV"
+
+ token_resp=$(curl -fsS \
+ "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}/token" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}")
+ tunnel_token=$(jq -r '.result' <<< "$token_resp")
+ echo "::add-mask::${tunnel_token}"
+ echo "TUNNEL_TOKEN=${tunnel_token}" >> "$GITHUB_ENV"
+
+ curl -fsS -X PUT \
+ "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}/configurations" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg host "$PREVIEW_HOSTNAME" \
+ '{config: {ingress: [{hostname: $host, service: "http://localhost:8000"}, {service: "http_status:404"}]}}')" \
+ > /dev/null
+
+ - name: Point DNS at the tunnel
+ id: cf_dns
+ env:
+ CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }}
+ PREVIEW_HOSTNAME: smoketest-${{ github.run_id }}.${{ env.PREVIEW_DOMAIN }}
+ run: |
+ zone_id=$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=github.fyi" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" | jq -r '.result[0].id')
+ if [ -z "$zone_id" ] || [ "$zone_id" = "null" ]; then
+ echo "::error::Could not resolve zone id for github.fyi"
+ exit 1
+ fi
+ echo "ZONE_ID=${zone_id}" >> "$GITHUB_ENV"
+
+ record_resp=$(curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg host "$PREVIEW_HOSTNAME" --arg target "${TUNNEL_ID}.cfargotunnel.com" \
+ '{type: "CNAME", name: $host, content: $target, proxied: true}')")
+ record_id=$(jq -r '.result.id' <<< "$record_resp")
+ if [ -z "$record_id" ] || [ "$record_id" = "null" ]; then
+ echo "::error::DNS record creation failed: $record_resp"
+ exit 1
+ fi
+ echo "DNS_RECORD_ID=${record_id}" >> "$GITHUB_ENV"
+
+ echo "## Smoketest URL" >> "$GITHUB_STEP_SUMMARY"
+ echo "https://${PREVIEW_HOSTNAME}" >> "$GITHUB_STEP_SUMMARY"
+ echo "Visiting it should challenge you with Cloudflare Access GitHub SSO," >> "$GITHUB_STEP_SUMMARY"
+ echo "then show 'Tunnel smoketest OK' once you're through." >> "$GITHUB_STEP_SUMMARY"
+ echo "Live for up to 15 minutes (this job's timeout)." >> "$GITHUB_STEP_SUMMARY"
+ echo "url=https://${PREVIEW_HOSTNAME}" >> "$GITHUB_OUTPUT"
+
+ - name: Compute deployment timestamps
+ id: timestamps
+ run: |
+ echo "deployed_at=$(date -u +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT"
+ echo "expires_at=$(date -u -d '+15 minutes' +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT"
+
+ - name: Comment smoketest link on PR
+ if: github.event.inputs.pr_number != ''
+ uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
+ with:
+ header: k8s-preview-smoketest
+ number_force: ${{ github.event.inputs.pr_number }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ message: |
+ **Tunnel smoketest** (static page, not the real stack):
+ ${{ steps.cf_dns.outputs.url }}
+
+ Deployed: ${{ steps.timestamps.outputs.deployed_at }} · Expires: ${{ steps.timestamps.outputs.expires_at }}
+
+ Sign-in via Cloudflare Access (GitHub SSO), then should show "Tunnel smoketest OK".
+
+ - name: Run tunnel until the job times out
+ run: /tmp/cloudflared tunnel --no-autoupdate run --token "${TUNNEL_TOKEN}"
+
+ - name: Delete DNS record
+ if: always()
+ env:
+ CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }}
+ run: |
+ [ -n "${ZONE_ID:-}" ] && [ -n "${DNS_RECORD_ID:-}" ] || exit 0
+ curl -fsS -X DELETE \
+ "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${DNS_RECORD_ID}" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" || true
+
+ - name: Delete Cloudflare Tunnel
+ if: always()
+ env:
+ CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }}
+ CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_TUNNEL_ACCOUNT_ID }}
+ run: |
+ [ -n "${TUNNEL_ID:-}" ] || exit 0
+ curl -fsS -X DELETE \
+ "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" || true
diff --git a/.github/workflows/k8s-preview.yaml b/.github/workflows/k8s-preview.yaml
new file mode 100644
index 0000000..3347767
--- /dev/null
+++ b/.github/workflows/k8s-preview.yaml
@@ -0,0 +1,367 @@
+name: K8s Stack Preview
+# Deploys the full Nebari platform stack (Keycloak + nic-operator + Envoy
+# Gateway, via nebari-dev/action-nebari-sandbox's `platform` profile) plus
+# this PR's chart into an ephemeral k3d cluster on the runner, then exposes
+# JupyterHub through a per-PR Cloudflare Tunnel behind Cloudflare Access
+# (GitHub-org SSO) so a reviewer can click a link and use it.
+#
+# This repo is public, so a plain shared secret posted in the PR comment
+# (a quick-tunnel URL, a basic-auth password) is readable by anyone who
+# opens the PR, not just intended reviewers. Access closes that gap by
+# authenticating the *person*, not a string in the comment: Cloudflare
+# challenges every request to *.github.fyi with a GitHub SSO login and
+# only lets it through to cloudflared if the signed-in account is a
+# member of this GitHub org. The PR comment only ever contains a URL.
+#
+# Hostnames are single-level (pr-.github.fyi, not pr-.dspack.github.fyi)
+# deliberately: Cloudflare's free Universal SSL only auto-covers the zone
+# apex plus one wildcard level (github.fyi + *.github.fyi); a second level
+# needs the paid Advanced Certificate Manager add-on, which this setup
+# doesn't use.
+#
+# Scope, deliberately: the tunnel points straight at the `proxy-public`
+# service (dummy-authenticator login, same as local Tilt dev). The chart
+# deploys with nebariapp.enabled=false — nebariapp.auth.enabled=true
+# was tried to additionally exercise the operator/Keycloak reconcile,
+# but 00-gateway-auth.py reads /etc/oauth/issuer-url unconditionally at
+# import time, before the operator's async client provisioning can ever
+# populate it, so the hub pod crash-loops. Not worth chasing for a
+# preview link; the operator/OIDC path stays untested here.
+#
+# The link only lives for the run's duration (bounded by timeout-minutes
+# below) — it is not a persistent per-PR environment. Each run creates its
+# own Cloudflare Tunnel + DNS record (so concurrent previews on different
+# PRs don't collide on the same route) and deletes both on cleanup.
+#
+# One-time setup this workflow assumes already exists (Cloudflare Zero
+# Trust dashboard, done by a repo admin, not scripted here). The tunnel,
+# the `github.fyi` zone, and Zero Trust/Access all live in ONE Cloudflare
+# account (aktechlabs) — not the account behind CLOUDFLARE_API_TOKEN /
+# CLOUDFLARE_ACCOUNT_ID, which docs.yml uses for Pages:
+# - Zone `github.fyi` in that account, with an Access self-hosted
+# application for `*.github.fyi`, GitHub as identity provider,
+# policy scoped to this org. Note this wildcard covers ANY
+# single-label subdomain of github.fyi, not just previews — fine as
+# long as github.fyi isn't also hosting unrelated services outside
+# this org's control.
+# - Secret CLOUDFLARE_TUNNEL_ACCOUNT_ID: that account's id (the
+# `cfd_tunnel` API is account-scoped; can't be derived from the
+# token alone).
+# - Secret CLOUDFLARE_TUNNEL_API_TOKEN: a custom token scoped to
+# EXACTLY three permissions, nothing broader:
+# * Account -> Cloudflare Tunnel -> Edit
+# * Zone -> Zone -> Read (to resolve the zone id by name)
+# * Zone -> DNS -> Edit (to create/delete the CNAME record)
+# Zone Resources: Include -> Specific zone -> github.fyi.
+# Account Resources: Include -> Specific account -> aktechlabs.
+#
+# Only runs when a maintainer/collaborator adds the `deploy-preview` label
+# (GitHub restricts who can label a PR) — arbitrary PR authors, including
+# from forks, cannot trigger this themselves. Even so, a fork PR still runs
+# attacker-authored code once labeled; the comment flags this so whoever
+# labels it is doing so knowingly.
+#
+# Residual risk not covered here: k3d pods share the runner's Docker daemon
+# rather than being hardware-isolated, and k3d's default CNI (flannel)
+# does not enforce NetworkPolicy, so a container escape or outbound abuse
+# from inside a spawned notebook pod is not blocked at the network layer.
+# Per-job GITHUB_TOKEN permissions are scoped to the minimum each job
+# needs so a compromised runner in the exposed 90-minute window can't use
+# an ambient token to touch other workflows or repo state.
+
+on:
+ pull_request:
+ types: [labeled, unlabeled, synchronize]
+
+concurrency:
+ group: k8s-preview-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+env:
+ PREVIEW_LABEL: deploy-preview
+ PREVIEW_DOMAIN: github.fyi
+ CLOUDFLARED_VERSION: "2026.7.3"
+ # sha256 of cloudflared-linux-amd64 for the pinned version above,
+ # computed from the official release asset at
+ # https://github.com/cloudflare/cloudflared/releases/tag/2026.7.3
+ CLOUDFLARED_SHA256: "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17"
+
+jobs:
+ deploy-preview:
+ if: contains(github.event.pull_request.labels.*.name, 'deploy-preview') && github.event.action != 'unlabeled'
+ name: Deploy preview
+ runs-on: ubuntu-24.04
+ timeout-minutes: 90
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - name: Provision sandbox (k3d + full NIC platform stack)
+ id: sandbox
+ uses: nebari-dev/action-nebari-sandbox@dbdb054c16efee3b29e26aee406c12fb9639bfd0 # v2.2.0
+ with:
+ profile: platform
+ cluster-name: pr-preview-${{ github.event.pull_request.number }}
+
+ - name: Build hub image from this PR
+ run: docker build --target jupyterhub -t nebari-data-science-pack-jupyterhub:preview images/
+
+ - name: Install k3d CLI (for image import)
+ run: |
+ curl -fsSL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | \
+ TAG=v5.8.3 bash
+
+ - name: Side-load hub image into the sandbox cluster
+ run: k3d image import nebari-data-science-pack-jupyterhub:preview -c ${{ steps.sandbox.outputs.cluster-name }}
+
+ # charts/ is gitignored (dependency .tgz files aren't committed), so a
+ # fresh checkout needs this before `helm upgrade --install` can find
+ # the jupyterhub subchart. Resolves against the version/digest already
+ # pinned in the committed Chart.lock, not a new or bumped dependency.
+ - name: Fetch chart dependencies
+ run: |
+ helm repo add jupyterhub https://hub.jupyter.org/helm-chart/
+ helm dependency build .
+
+ - name: Deploy chart
+ id: deploy
+ env:
+ KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }}
+ run: |
+ helm upgrade --install preview . \
+ --namespace pr-preview --create-namespace \
+ --set jupyterhub.hub.image.name=nebari-data-science-pack-jupyterhub \
+ --set jupyterhub.hub.image.tag=preview \
+ --set nebariapp.enabled=false \
+ --set jupyterhub.custom.external-url="pr-${{ github.event.pull_request.number }}.${{ env.PREVIEW_DOMAIN }}" \
+ --wait --timeout 5m
+
+ - name: Wait for hub + proxy
+ env:
+ KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }}
+ run: |
+ kubectl -n pr-preview rollout status deployment/hub --timeout=180s
+ kubectl -n pr-preview rollout status deployment/proxy --timeout=180s
+
+ # Cleanup deletes the whole cluster next, so this is the only chance
+ # to see why a pod/job didn't reach Ready if `helm --wait` timed out.
+ - name: Debug pod/job status on deploy failure
+ if: failure()
+ env:
+ KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }}
+ run: |
+ kubectl -n pr-preview get pods -o wide || true
+ kubectl -n pr-preview get jobs || true
+ kubectl -n pr-preview get events --sort-by=.lastTimestamp || true
+ for pod in $(kubectl -n pr-preview get pods -o name 2>/dev/null); do
+ echo "--- describe $pod ---"
+ kubectl -n pr-preview describe "$pod" || true
+ echo "--- logs $pod ---"
+ kubectl -n pr-preview logs "$pod" --all-containers --tail=100 || true
+ done
+
+ # Interactive SSH debug session into the live runner (cluster still
+ # up, KUBECONFIG still valid) instead of guessing blind from static
+ # logs. limit-access-to-actor restricts the SSH session to whoever
+ # triggered this run — required on a public repo. Bounded to 20min
+ # so a forgotten session doesn't eat the whole 90min job timeout.
+ - name: Debug via tmate SSH on deploy failure
+ if: failure()
+ uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24
+ timeout-minutes: 20
+ env:
+ KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }}
+ with:
+ limit-access-to-actor: true
+
+ - name: Port-forward JupyterHub proxy
+ env:
+ KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }}
+ run: |
+ kubectl -n pr-preview port-forward svc/proxy-public 8000:80 \
+ > /tmp/port-forward.log 2>&1 &
+ echo "PORT_FORWARD_PID=$!" >> "$GITHUB_ENV"
+
+ - name: Install cloudflared
+ run: |
+ curl -fsSL -o /tmp/cloudflared \
+ "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
+ echo "${CLOUDFLARED_SHA256} /tmp/cloudflared" | sha256sum -c -
+ chmod +x /tmp/cloudflared
+
+ # A per-run named Tunnel (not the anonymous quick-tunnel) so:
+ # (a) it can sit behind an Access application (quick tunnels have no
+ # account/zone attached, so no policy can be bound to them), and
+ # (b) each PR gets its own tunnel + hostname, so two PRs previewing
+ # at once don't share one route and cross-talk.
+ - name: Create Cloudflare Tunnel for this PR
+ id: cf_tunnel
+ env:
+ CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }}
+ CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_TUNNEL_ACCOUNT_ID }}
+ PREVIEW_HOSTNAME: pr-${{ github.event.pull_request.number }}.${{ env.PREVIEW_DOMAIN }}
+ run: |
+ tunnel_secret=$(openssl rand -base64 32)
+ echo "::add-mask::${tunnel_secret}"
+
+ tunnel_name="pr-${{ github.event.pull_request.number }}-${{ github.run_id }}"
+ create_resp=$(curl -sS -X POST \
+ "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg name "$tunnel_name" --arg secret "$tunnel_secret" \
+ '{name: $name, config_src: "cloudflare", tunnel_secret: $secret}')")
+ tunnel_id=$(jq -r '.result.id // empty' <<< "$create_resp")
+
+ # A GitHub Actions retry reuses the same run_id (only run_attempt
+ # changes), so a re-run after the first attempt already created
+ # this tunnel (and didn't get to clean it up) hits a 409 name
+ # conflict here. Reuse the existing tunnel by name instead of
+ # failing — it doesn't need the original tunnel_secret, just a
+ # fresh --token from the /token endpoint below.
+ if [ -z "$tunnel_id" ]; then
+ echo "::warning::Tunnel create failed (likely a name conflict from a retry), looking up existing tunnel named ${tunnel_name}: $create_resp"
+ tunnel_id=$(curl -fsS "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel?name=${tunnel_name}&is_deleted=false" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" | jq -r '.result[0].id // empty')
+ fi
+ if [ -z "$tunnel_id" ]; then
+ echo "::error::Tunnel creation failed and no existing tunnel named ${tunnel_name} found: $create_resp"
+ exit 1
+ fi
+ echo "tunnel_id=${tunnel_id}" >> "$GITHUB_OUTPUT"
+ echo "TUNNEL_ID=${tunnel_id}" >> "$GITHUB_ENV"
+
+ token_resp=$(curl -fsS \
+ "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}/token" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}")
+ tunnel_token=$(jq -r '.result' <<< "$token_resp")
+ echo "::add-mask::${tunnel_token}"
+ echo "TUNNEL_TOKEN=${tunnel_token}" >> "$GITHUB_ENV"
+
+ curl -fsS -X PUT \
+ "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}/configurations" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg host "$PREVIEW_HOSTNAME" \
+ '{config: {ingress: [{hostname: $host, service: "http://localhost:8000"}, {service: "http_status:404"}]}}')" \
+ > /dev/null
+
+ - name: Point DNS at the tunnel
+ id: cf_dns
+ env:
+ CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }}
+ PREVIEW_HOSTNAME: pr-${{ github.event.pull_request.number }}.${{ env.PREVIEW_DOMAIN }}
+ run: |
+ zone_id=$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=github.fyi" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" | jq -r '.result[0].id')
+ if [ -z "$zone_id" ] || [ "$zone_id" = "null" ]; then
+ echo "::error::Could not resolve zone id for github.fyi"
+ exit 1
+ fi
+ echo "ZONE_ID=${zone_id}" >> "$GITHUB_ENV"
+
+ record_resp=$(curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg host "$PREVIEW_HOSTNAME" --arg target "${TUNNEL_ID}.cfargotunnel.com" \
+ '{type: "CNAME", name: $host, content: $target, proxied: true}')")
+ record_id=$(jq -r '.result.id' <<< "$record_resp")
+ if [ -z "$record_id" ] || [ "$record_id" = "null" ]; then
+ echo "::error::DNS record creation failed: $record_resp"
+ exit 1
+ fi
+ echo "DNS_RECORD_ID=${record_id}" >> "$GITHUB_ENV"
+ echo "url=https://${PREVIEW_HOSTNAME}" >> "$GITHUB_OUTPUT"
+
+ # The URL itself (pr-.github.fyi) is identical on every run, so
+ # without a timestamp the sticky comment would post byte-identical
+ # text each redeploy and look like it never updated.
+ - name: Compute deployment timestamps
+ id: timestamps
+ run: |
+ echo "deployed_at=$(date -u +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT"
+ echo "expires_at=$(date -u -d '+90 minutes' +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT"
+
+ - name: Comment preview link on PR
+ uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
+ with:
+ header: k8s-preview
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ message: |
+ **K8s stack preview** for `${{ github.event.pull_request.head.ref }}`:
+ ${{ steps.cf_dns.outputs.url }}
+
+ Deployed: ${{ steps.timestamps.outputs.deployed_at }} · Expires: ${{ steps.timestamps.outputs.expires_at }}
+
+ ${{ github.event.pull_request.head.repo.fork && '⚠️ **This PR is from a fork** — the code running in this preview is not from a trusted maintainer branch.' || '' }}
+
+ You'll be asked to sign in via Cloudflare Access (GitHub SSO) before
+ reaching JupyterHub — only members of this GitHub org get through.
+ **Then JupyterHub login:** dummy authenticator, any username + any password.
+
+ This goes straight to JupyterHub's proxy; the operator-provisioned
+ NebariApp/OIDC route isn't deployed here (see workflow header comment
+ for why) — this preview doesn't exercise operator/Keycloak reconcile.
+
+ Live until the expiry time above, or until the `deploy-preview` label
+ is removed. Push a new commit or re-add the label to redeploy.
+
+ - name: Run tunnel until the job times out
+ run: /tmp/cloudflared tunnel --no-autoupdate run --token "${TUNNEL_TOKEN}"
+
+ - name: Delete DNS record
+ if: always()
+ env:
+ CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }}
+ run: |
+ [ -n "${ZONE_ID:-}" ] && [ -n "${DNS_RECORD_ID:-}" ] || exit 0
+ curl -fsS -X DELETE \
+ "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${DNS_RECORD_ID}" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" || true
+
+ - name: Delete Cloudflare Tunnel
+ if: always()
+ env:
+ CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }}
+ CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_TUNNEL_ACCOUNT_ID }}
+ run: |
+ [ -n "${TUNNEL_ID:-}" ] || exit 0
+ curl -fsS -X DELETE \
+ "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" || true
+
+ - name: Cleanup cluster
+ if: always()
+ run: k3d cluster delete ${{ steps.sandbox.outputs.cluster-name }} || true
+
+ cleanup-preview:
+ if: github.event.action == 'unlabeled' && github.event.label.name == 'deploy-preview'
+ name: Stop preview
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ actions: write
+ steps:
+ - name: Cancel the in-flight preview run for this PR
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ run_id=$(gh api "repos/${{ github.repository }}/actions/runs?event=pull_request&status=in_progress" \
+ --jq '.workflow_runs[] | select(.name == "K8s Stack Preview") | select(.pull_requests[]?.number == ${{ github.event.pull_request.number }}) | .id' \
+ | head -1)
+ if [ -n "$run_id" ]; then
+ gh run cancel "$run_id" --repo "${{ github.repository }}"
+ fi
+
+ - name: Comment that the preview stopped
+ uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
+ with:
+ header: k8s-preview
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ message: |
+ **K8s stack preview** stopped — the `deploy-preview` label was removed.
+
+ Add it again to redeploy.