From 68d53131f9c9fc2f19a04addef3d20f23b4fcdf6 Mon Sep 17 00:00:00 2001 From: JNH <22553869+nthh@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:57:12 -0700 Subject: [PATCH 1/4] feat: staging deploy workflow via `deploy` PR label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CI workflow that deploys PRs to staging.remolt.dev when a maintainer adds the `deploy` label. Uses the existing Cloudflare tunnel — just adds/removes an ingress rule at deploy/teardown time. - k8s/staging/ kustomize overlay (reuses base manifests, overrides namespace to remolt-staging + lower resource limits) - .github/workflows/staging.yml with deploy + teardown jobs - Race condition guard: fails if another PR already has the label - Posts staging URL / teardown confirmation as PR comments Prerequisites (one-time manual setup): - GitHub OAuth app for staging.remolt.dev - GitHub environment `staging` with OAuth secrets - Cloudflare DNS CNAME for staging.remolt.dev Co-Authored-By: Claude Opus 4.6 --- .github/workflows/staging.yml | 190 +++++++++++++++++++++++++++++++++ k8s/staging/kustomization.yaml | 15 +++ k8s/staging/server-patch.yaml | 26 +++++ 3 files changed, 231 insertions(+) create mode 100644 .github/workflows/staging.yml create mode 100644 k8s/staging/kustomization.yaml create mode 100644 k8s/staging/server-patch.yaml diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml new file mode 100644 index 0000000..542a19e --- /dev/null +++ b/.github/workflows/staging.yml @@ -0,0 +1,190 @@ +name: Staging Deploy + +on: + pull_request: + types: [labeled, unlabeled, closed] + +env: + REGISTRY: ghcr.io + NAMESPACE: nthh + +permissions: + contents: read + packages: write + pull-requests: write + +jobs: + # ---------- DEPLOY ---------- + deploy-staging: + if: >- + github.event.action == 'labeled' && + github.event.label.name == 'deploy' + runs-on: ubuntu-latest + environment: staging + + steps: + # Ensure only one PR uses staging at a time + - name: Check for conflicting staging deploys + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DEPLOY_PRS=$(gh pr list --repo ${{ github.repository }} --label deploy --state open --json number --jq 'length') + if [ "$DEPLOY_PRS" -gt 1 ]; then + echo "::error::Another PR already has the 'deploy' label. Remove it first." + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + # Build server image from PR branch + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + build-args: | + COMMIT_SHA=${{ github.event.pull_request.head.sha }} + tags: | + ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/remolt-server:staging-${{ github.event.pull_request.number }} + cache-from: type=gha,scope=staging-server + cache-to: type=gha,mode=max,scope=staging-server + + # Deploy to K8s + - uses: azure/setup-kubectl@v4 + + - run: | + echo "${{ secrets.KUBECONFIG_BASE64 }}" | base64 -d > /tmp/kubeconfig + echo "KUBECONFIG=/tmp/kubeconfig" >> "$GITHUB_ENV" + + # Create namespace and auth secret (idempotent, not managed by kustomize) + - name: Create namespace and secrets + run: | + kubectl create namespace remolt-staging --dry-run=client -o yaml | kubectl apply -f - + kubectl -n remolt-staging create secret generic remolt-auth \ + --from-literal=GITHUB_CLIENT_ID=${{ secrets.STAGING_GITHUB_CLIENT_ID }} \ + --from-literal=GITHUB_CLIENT_SECRET=${{ secrets.STAGING_GITHUB_CLIENT_SECRET }} \ + --from-literal=COOKIE_SECRET=${{ secrets.STAGING_COOKIE_SECRET }} \ + --dry-run=client -o yaml | kubectl apply -f - + + # Apply staging kustomization (RBAC, server deployment, network policy) + - name: Apply staging manifests + run: kubectl kustomize --load-restrictor=LoadRestrictionsNone k8s/staging/ | kubectl apply -f - + + # Set the image to the one we just built + - name: Deploy server image + run: | + kubectl -n remolt-staging set image deployment/remolt-server \ + server=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/remolt-server:staging-${{ github.event.pull_request.number }} + kubectl -n remolt-staging rollout status deployment/remolt-server --timeout=300s + + # Add staging ingress rule to cloudflared config (same tunnel, new hostname) + - name: Configure tunnel routing + run: | + kubectl -n remolt get configmap cloudflared-config -o jsonpath='{.data.config\.yaml}' > /tmp/cf-config.yaml + + if ! grep -q 'staging.remolt.dev' /tmp/cf-config.yaml; then + python3 -c " + import yaml + with open('/tmp/cf-config.yaml') as f: + cfg = yaml.safe_load(f) + staging_rule = { + 'hostname': 'staging.remolt.dev', + 'service': 'http://remolt-server.remolt-staging.svc.cluster.local:8080' + } + # Insert before the catch-all rule (last entry) + cfg['ingress'].insert(-1, staging_rule) + with open('/tmp/cf-config.yaml', 'w') as f: + yaml.dump(cfg, f, default_flow_style=False) + " + kubectl -n remolt create configmap cloudflared-config \ + --from-file=config.yaml=/tmp/cf-config.yaml \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl -n remolt rollout restart deployment/cloudflared + kubectl -n remolt rollout status deployment/cloudflared --timeout=120s + fi + + # Post staging URL on the PR + - name: Comment staging URL + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: [ + '## Staging deployed', + '', + 'https://staging.remolt.dev', + '', + `Commit: \`${context.payload.pull_request.head.sha.slice(0, 7)}\``, + `Image: \`remolt-server:staging-${context.payload.pull_request.number}\``, + ].join('\n'), + }); + + # ---------- TEARDOWN ---------- + teardown-staging: + if: >- + (github.event.action == 'unlabeled' && github.event.label.name == 'deploy') || + (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'deploy')) + runs-on: ubuntu-latest + environment: staging + + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-kubectl@v4 + + - run: | + echo "${{ secrets.KUBECONFIG_BASE64 }}" | base64 -d > /tmp/kubeconfig + echo "KUBECONFIG=/tmp/kubeconfig" >> "$GITHUB_ENV" + + # Delete sandbox pods first + - name: Clean up sandbox pods + run: | + kubectl -n remolt-staging delete pods -l remolt.managed=true --ignore-not-found=true + + # Delete staging resources (but not namespace or secrets) + - name: Delete staging deployment + run: | + kubectl kustomize --load-restrictor=LoadRestrictionsNone k8s/staging/ | kubectl delete --ignore-not-found=true -f - + + # Remove staging ingress rule from cloudflared config + - name: Remove tunnel routing + run: | + kubectl -n remolt get configmap cloudflared-config -o jsonpath='{.data.config\.yaml}' > /tmp/cf-config.yaml + + if grep -q 'staging.remolt.dev' /tmp/cf-config.yaml; then + python3 -c " + import yaml + with open('/tmp/cf-config.yaml') as f: + cfg = yaml.safe_load(f) + cfg['ingress'] = [r for r in cfg['ingress'] if r.get('hostname') != 'staging.remolt.dev'] + with open('/tmp/cf-config.yaml', 'w') as f: + yaml.dump(cfg, f, default_flow_style=False) + " + kubectl -n remolt create configmap cloudflared-config \ + --from-file=config.yaml=/tmp/cf-config.yaml \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl -n remolt rollout restart deployment/cloudflared + kubectl -n remolt rollout status deployment/cloudflared --timeout=120s + fi + + - name: Comment teardown + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: '## Staging torn down\n\nThe staging environment has been cleaned up.', + }); diff --git a/k8s/staging/kustomization.yaml b/k8s/staging/kustomization.yaml new file mode 100644 index 0000000..2ff5369 --- /dev/null +++ b/k8s/staging/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../rbac.yaml + - ../server.yaml + - ../network-policy.yaml + +namespace: remolt-staging + +patches: + - path: server-patch.yaml + target: + kind: Deployment + name: remolt-server diff --git a/k8s/staging/server-patch.yaml b/k8s/staging/server-patch.yaml new file mode 100644 index 0000000..1db9c64 --- /dev/null +++ b/k8s/staging/server-patch.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: remolt-server +spec: + template: + spec: + containers: + - name: server + env: + - name: REMOLT_SANDBOX_IMAGE + value: ghcr.io/nthh/remolt-sandbox:latest + - name: REMOLT_MAX_SESSIONS + value: "2" + - name: REMOLT_MAX_IDLE_SECONDS + value: "1800" + - name: REMOLT_WARM_POOL + value: "0" + - name: REMOLT_SANDBOX_BANDWIDTH + value: "100mbit" + - name: REMOLT_ALLOWED_ORIGINS + value: "https://staging.remolt.dev" + - name: REMOLT_NAMESPACE + value: "remolt-staging" + - name: REMOLT_MAX_USER_SESSIONS + value: "1" From 9c4ec1047bcdbecee2ef2e7606f12023d8187635 Mon Sep 17 00:00:00 2001 From: JNH <22553869+nthh@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:06:32 -0700 Subject: [PATCH 2/4] fix: use pre-provisioned K8s secrets instead of GitHub env secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth secrets (remolt-auth) are stored directly in the cluster, same as production. No GitHub environment or STAGING_* secrets needed — just the existing KUBECONFIG_BASE64 repo secret. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/staging.yml | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 542a19e..a3e0932 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -20,7 +20,7 @@ jobs: github.event.action == 'labeled' && github.event.label.name == 'deploy' runs-on: ubuntu-latest - environment: staging + # Uses repo-level KUBECONFIG_BASE64 secret; OAuth secrets are pre-provisioned in cluster steps: # Ensure only one PR uses staging at a time @@ -65,15 +65,9 @@ jobs: echo "${{ secrets.KUBECONFIG_BASE64 }}" | base64 -d > /tmp/kubeconfig echo "KUBECONFIG=/tmp/kubeconfig" >> "$GITHUB_ENV" - # Create namespace and auth secret (idempotent, not managed by kustomize) - - name: Create namespace and secrets - run: | - kubectl create namespace remolt-staging --dry-run=client -o yaml | kubectl apply -f - - kubectl -n remolt-staging create secret generic remolt-auth \ - --from-literal=GITHUB_CLIENT_ID=${{ secrets.STAGING_GITHUB_CLIENT_ID }} \ - --from-literal=GITHUB_CLIENT_SECRET=${{ secrets.STAGING_GITHUB_CLIENT_SECRET }} \ - --from-literal=COOKIE_SECRET=${{ secrets.STAGING_COOKIE_SECRET }} \ - --dry-run=client -o yaml | kubectl apply -f - + # Ensure namespace exists (secret remolt-auth is pre-provisioned in cluster) + - name: Create namespace + run: kubectl create namespace remolt-staging --dry-run=client -o yaml | kubectl apply -f - # Apply staging kustomization (RBAC, server deployment, network policy) - name: Apply staging manifests @@ -137,7 +131,7 @@ jobs: (github.event.action == 'unlabeled' && github.event.label.name == 'deploy') || (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'deploy')) runs-on: ubuntu-latest - environment: staging + # Uses repo-level KUBECONFIG_BASE64 secret; OAuth secrets are pre-provisioned in cluster steps: - uses: actions/checkout@v4 From 08d1365af8121b40f0588b96c7b749d10c44aee6 Mon Sep 17 00:00:00 2001 From: JNH <22553869+nthh@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:08:15 -0700 Subject: [PATCH 3/4] security: use pull_request_target to protect kubeconfig secret pull_request_target runs the workflow from main, not the PR branch. This prevents a malicious PR from modifying staging.yml to exfiltrate the KUBECONFIG_BASE64 secret. The PR code is still checked out for building the Docker image. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/staging.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index a3e0932..83d2153 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -1,7 +1,9 @@ name: Staging Deploy on: - pull_request: + # pull_request_target runs workflow from main branch, not the PR branch. + # This prevents a malicious PR from modifying this workflow to exfiltrate secrets. + pull_request_target: types: [labeled, unlabeled, closed] env: From 374b159d84e53f2cc1cc52a8540605e04f4df0bd Mon Sep 17 00:00:00 2001 From: JNH <22553869+nthh@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:09:44 -0700 Subject: [PATCH 4/4] docs: add staging env example and gitignore .env.staging Co-Authored-By: Claude Opus 4.6 --- .env.example | 6 ++++++ .env.staging.example | 16 ++++++++++++++++ .gitignore | 1 + 3 files changed, 23 insertions(+) create mode 100644 .env.staging.example diff --git a/.env.example b/.env.example index 086df43..753be2f 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,9 @@ GITHUB_CLIENT_SECRET= # Cookie signing key (generate with: openssl rand -hex 32) COOKIE_SECRET= + +# Copy this file to .env for local dev, or .env.staging for staging. +# Load staging config with: cp .env.staging .env +# +# For staging (staging.remolt.dev), create a separate GitHub OAuth app +# with callback URL: https://staging.remolt.dev/auth/callback diff --git a/.env.staging.example b/.env.staging.example new file mode 100644 index 0000000..341ec0d --- /dev/null +++ b/.env.staging.example @@ -0,0 +1,16 @@ +# Staging: staging.remolt.dev +# Create a GitHub OAuth app with callback URL: https://staging.remolt.dev/auth/callback +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Cookie signing key (generate with: openssl rand -hex 32) +COOKIE_SECRET= + +# Apply to cluster with: +# source .env.staging +# kubectl create namespace remolt-staging --dry-run=client -o yaml | kubectl apply -f - +# kubectl -n remolt-staging create secret generic remolt-auth \ +# --from-literal=GITHUB_CLIENT_ID=$GITHUB_CLIENT_ID \ +# --from-literal=GITHUB_CLIENT_SECRET=$GITHUB_CLIENT_SECRET \ +# --from-literal=COOKIE_SECRET=$COOKIE_SECRET \ +# --dry-run=client -o yaml | kubectl apply -f - diff --git a/.gitignore b/.gitignore index 836ee9b..001d55a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,6 @@ __pycache__/ # Secrets .env +.env.staging *.pem kubeconfig.yaml