ci: add k8s stack PR preview via labeled deploy + Cloudflare Tunnel #29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: K8s Stack Preview | |
| # Deploys the full Nebari platform stack (Keycloak + nic-operator + Envoy | |
| # Gateway, via nebari-dev/action-nebari-sandbox) plus this PR's chart into | |
| # an ephemeral kind 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 *.<vars.PREVIEW_DOMAIN> 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. | |
| # | |
| # The domain is a repo Variable (Settings -> Secrets and variables -> | |
| # Actions -> Variables -> PREVIEW_DOMAIN, currently openteams.app), not | |
| # hardcoded, so it can be repointed without editing this file. Hostnames | |
| # built from it stay single-level (pr-<n>-data-science-pack.<domain>, | |
| # never pr-<n>.data-science-pack.<domain>) deliberately: Cloudflare's | |
| # free Universal SSL only auto-covers the zone apex plus one wildcard | |
| # level (<domain> + *.<domain>); 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 PREVIEW_DOMAIN zone, and Zero Trust/Access all live in ONE | |
| # Cloudflare account (OpenTeams Account) — not the account behind | |
| # CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID, which docs.yml uses for | |
| # Pages: | |
| # - Variable PREVIEW_DOMAIN (repo Settings -> Actions -> Variables): | |
| # the zone name, e.g. openteams.app. | |
| # - That zone in the Cloudflare account, with an Access self-hosted | |
| # application for `*.<PREVIEW_DOMAIN>`, GitHub as identity provider, | |
| # policy scoped to this org (nebari-dev). Note this wildcard covers | |
| # ANY single-label subdomain of the zone, not just previews — fine | |
| # as long as the zone 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 -> the PREVIEW_DOMAIN zone. | |
| # Account Resources: Include -> Specific account -> OpenTeams Account. | |
| # | |
| # 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: kind nodes share the runner's Docker daemon | |
| # rather than being hardware-isolated, and kind's default CNI (kindnet) | |
| # 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: ${{ vars.PREVIEW_DOMAIN }} | |
| 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 | |
| # v0.32.0+ required: the sandbox's kind cluster uses containerd's | |
| # config v4 format, which `kind load` on older CLI releases can't | |
| # parse ("ERROR: unknown containerd config version: 4"). | |
| - name: Install kind | |
| uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 | |
| with: | |
| version: v0.33.0 | |
| install_only: true | |
| - name: Provision sandbox (kind + full NIC platform stack) | |
| id: sandbox | |
| uses: nebari-dev/action-nebari-sandbox@9ac369ebf87ac2ae217504dcbf824c77f70e429a # v3.0.0 | |
| with: | |
| cluster-name: pr-preview-${{ github.event.pull_request.number }} | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 | |
| # Every run gets a fresh runner (empty Docker daemon), so a plain | |
| # `docker build` re-runs every layer -- including the apt/pixi | |
| # installs -- from scratch every time. GitHub Actions cache (type=gha) | |
| # persists layers across runs so an unchanged pixi.lock/pixi.toml | |
| # reuses the previous run's install instead of redoing it. | |
| - name: Build hub image from this PR | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 | |
| with: | |
| context: images/ | |
| target: jupyterhub | |
| tags: nebari-data-science-pack-jupyterhub:preview | |
| load: true | |
| cache-from: type=gha,scope=k8s-preview-jupyterhub | |
| cache-to: type=gha,scope=k8s-preview-jupyterhub,mode=max | |
| - name: Side-load hub image into the sandbox cluster | |
| run: kind load docker-image nebari-data-science-pack-jupyterhub:preview --name ${{ 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=true \ | |
| --set nebariapp.hostname="pr-${{ github.event.pull_request.number }}-data-science-pack.${{ env.PREVIEW_DOMAIN }}" \ | |
| --set jupyterhub.custom.external-url="pr-${{ github.event.pull_request.number }}-data-science-pack.${{ 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 | |
| - 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" | |
| sleep 3 | |
| # jhub-apps runs as a managed service subprocess inside the hub pod, | |
| # not its own Deployment, so a crash there doesn't fail `helm --wait` | |
| # or the rollout checks above -- it only shows up as a 502 on | |
| # /services/japps/* once someone hits it. Hit its root path directly | |
| # (bypassing Cloudflare, straight to CHP) so a crashed/never-bound | |
| # uvicorn process shows up here instead of only from a live login. | |
| - name: Smoke-test jhub-apps service | |
| if: always() | |
| env: | |
| KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} | |
| run: | | |
| echo "--- GET /services/japps/ ---" | |
| curl -sS -o /tmp/japps-root.html -w 'HTTP %{http_code}\n' http://localhost:8000/services/japps/ || true | |
| head -c 2000 /tmp/japps-root.html || true | |
| echo | |
| echo "--- japps process in hub pod ---" | |
| kubectl -n pr-preview exec deploy/hub -- ps aux | grep -i "uvicorn\|japps" || true | |
| - name: Dump hub logs (jhub-apps startup) | |
| if: always() | |
| env: | |
| KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} | |
| run: kubectl -n pr-preview logs deployment/hub --tail=500 | |
| # 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. Temporarily | |
| # unconditional (not gated to failure()) while actively debugging the | |
| # nebariapp/Keycloak auth path live instead of iterating full CI runs. | |
| - name: Debug via tmate SSH | |
| if: always() | |
| uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24 | |
| timeout-minutes: 45 | |
| env: | |
| KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} | |
| with: | |
| limit-access-to-actor: true | |
| - 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 }}-data-science-pack.${{ 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 }}-data-science-pack.${{ env.PREVIEW_DOMAIN }} | |
| run: | | |
| zone_id=$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=${PREVIEW_DOMAIN}" \ | |
| -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 ${PREVIEW_DOMAIN}" | |
| 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-<n>-data-science-pack.<domain>) 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}" | |
| # Runs once the tunnel step above ends (timeout or a manual cancel), | |
| # so this captures anything logged in response to real traffic during | |
| # the tunnel's lifetime -- unlike the earlier startup-time log dump. | |
| - name: Dump hub logs after tunnel closes | |
| if: always() | |
| env: | |
| KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} | |
| run: kubectl -n pr-preview logs deployment/hub --tail=1000 || true | |
| - 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 | |
| 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. |