# This action will trigger when # 1. when the workflow is manually triggered # 2. ./scripts/deploy_pr.sh is run locally # 3. when a PR is updated name: Deploy PR on: push: branches-ignore: - main workflow_dispatch: inputs: experiments: description: "Experiments to enable" required: false type: string default: "*" build: description: "Force new build" required: false type: boolean default: false deploy: description: "Force new deployment" required: false type: boolean default: false env: REPO: ghcr.io/coder/coder-preview permissions: contents: read packages: write pull-requests: write # needed for commenting on PRs jobs: check_pr: runs-on: ubuntu-latest outputs: PR_OPEN: ${{ steps.check_pr.outputs.pr_open }} steps: - name: Checkout uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Check if PR is open id: check_pr run: | set -euo pipefail pr_open=true if [[ "$(gh pr view --json state | jq -r '.state')" != "OPEN" ]]; then echo "PR doesn't exist or is closed." pr_open=false fi echo "pr_open=$pr_open" >> $GITHUB_OUTPUT env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} get_info: needs: check_pr if: ${{ needs.check_pr.outputs.PR_OPEN == 'true' }} outputs: PR_NUMBER: ${{ steps.pr_info.outputs.PR_NUMBER }} PR_TITLE: ${{ steps.pr_info.outputs.PR_TITLE }} PR_URL: ${{ steps.pr_info.outputs.PR_URL }} CODER_BASE_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_BASE_IMAGE_TAG }} CODER_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_IMAGE_TAG }} NEW: ${{ steps.check_deployment.outputs.NEW }} BUILD: ${{ steps.build_conditionals.outputs.first_or_force_build == 'true' || steps.build_conditionals.outputs.automatic_rebuild == 'true' }} runs-on: "ubuntu-latest" steps: - name: Checkout uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 - name: Get PR number, title, and branch name id: pr_info run: | set -euo pipefail PR_NUMBER=$(gh pr view --json number | jq -r '.number') PR_TITLE=$(gh pr view --json title | jq -r '.title') PR_URL=$(gh pr view --json url | jq -r '.url') echo "PR_URL=$PR_URL" >> $GITHUB_OUTPUT echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT echo "PR_TITLE=$PR_TITLE" >> $GITHUB_OUTPUT env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set required tags id: set_tags run: | set -euo pipefail echo "CODER_BASE_IMAGE_TAG=$CODER_BASE_IMAGE_TAG" >> $GITHUB_OUTPUT echo "CODER_IMAGE_TAG=$CODER_IMAGE_TAG" >> $GITHUB_OUTPUT env: CODER_BASE_IMAGE_TAG: ghcr.io/coder/coder-preview-base:pr${{ steps.pr_info.outputs.PR_NUMBER }} CODER_IMAGE_TAG: ghcr.io/coder/coder-preview:pr${{ steps.pr_info.outputs.PR_NUMBER }} - name: Set up kubeconfig run: | set -euo pipefail mkdir -p ~/.kube echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config chmod 644 ~/.kube/config export KUBECONFIG=~/.kube/config - name: Check if the helm deployment already exists id: check_deployment run: | set -euo pipefail if helm status "pr${{ steps.pr_info.outputs.PR_NUMBER }}" --namespace "pr${{ steps.pr_info.outputs.PR_NUMBER }}" > /dev/null 2>&1; then echo "Deployment already exists. Skipping deployment." NEW=false else echo "Deployment doesn't exist." NEW=true fi echo "NEW=$NEW" >> $GITHUB_OUTPUT - name: Check changed files uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: base: ${{ github.ref }} filters: | all: - "**" ignored: - "docs/**" - "README.md" - "examples/web-server/**" - "examples/monitoring/**" - "examples/lima/**" - ".github/**" - "offlinedocs/**" - ".devcontainer/**" - "helm/**" - "*[^g][^o][^.][^s][^u][^m]*" - "*[^g][^o][^.][^m][^o][^d]*" - "*[^M][^a][^k][^e][^f][^i][^l][^e]*" - "scripts/**/*[^D][^o][^c][^k][^e][^r][^f][^i][^l][^e]*" - "scripts/**/*[^D][^o][^c][^k][^e][^r][^f][^i][^l][^e][.][b][^a][^s][^e]*" - name: Print number of changed files run: | set -euo pipefail echo "Total number of changed files: ${{ steps.filter.outputs.all_count }}" echo "Number of ignored files: ${{ steps.filter.outputs.ignored_count }}" - name: Build conditionals id: build_conditionals run: | set -euo pipefail # build if the workflow is manually triggered and the deployment doesn't exist (first build or force rebuild) echo "first_or_force_build=${{ (github.event_name == 'workflow_dispatch' && steps.check_deployment.outputs.NEW == 'true') || github.event.inputs.build == 'true' }}" >> $GITHUB_OUTPUT # build if the deployment alreday exist and there are changes in the files that we care about (automatic updates) echo "automatic_rebuild=${{ steps.check_deployment.outputs.NEW == 'false' && steps.filter.outputs.all_count > steps.filter.outputs.ignored_count }}" >> $GITHUB_OUTPUT comment-pr: needs: get_info if: needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true' runs-on: "ubuntu-latest" steps: - name: Find Comment uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0 id: fc with: issue-number: ${{ needs.get_info.outputs.PR_NUMBER }} comment-author: "github-actions[bot]" body-includes: ":rocket:" direction: last - name: Comment on PR id: comment_id uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 with: comment-id: ${{ steps.fc.outputs.comment-id }} issue-number: ${{ needs.get_info.outputs.PR_NUMBER }} edit-mode: replace body: | --- :rocket: Deploying PR ${{ needs.get_info.outputs.PR_NUMBER }} ... --- reactions: eyes reactions-edit-mode: replace build: needs: get_info # Run build job only if there are changes in the files that we care about or if the workflow is manually triggered with --build flag if: needs.get_info.outputs.BUILD == 'true' runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} # This concurrency only cancels build jobs if a new build is triggred. It will avoid cancelling the current deployemtn in case of docs chnages. concurrency: group: build-${{ github.workflow }}-${{ github.ref }}-${{ needs.get_info.outputs.BUILD }} cancel-in-progress: true env: DOCKER_CLI_EXPERIMENTAL: "enabled" CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }} steps: - name: Checkout uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 - name: Setup Node uses: ./.github/actions/setup-node - name: Setup Go uses: ./.github/actions/setup-go - name: Setup sqlc uses: ./.github/actions/setup-sqlc - name: GHCR Login uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Linux amd64 Docker image run: | set -euo pipefail go mod download make gen/mark-fresh export DOCKER_IMAGE_NO_PREREQUISITES=true version="$(./scripts/version.sh)" export CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")" make -j build/coder_linux_amd64 ./scripts/build_docker.sh \ --arch amd64 \ --target ${{ env.CODER_IMAGE_TAG }} \ --version $version \ --push \ build/coder_linux_amd64 deploy: needs: [build, get_info] # Run deploy job only if build job was successful or skipped if: | always() && (needs.build.result == 'success' || needs.build.result == 'skipped') && (needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true') runs-on: "ubuntu-latest" env: CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }} PR_NUMBER: ${{ needs.get_info.outputs.PR_NUMBER }} PR_TITLE: ${{ needs.get_info.outputs.PR_TITLE }} PR_URL: ${{ needs.get_info.outputs.PR_URL }} PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" steps: - name: Set up kubeconfig run: | set -euo pipefail mkdir -p ~/.kube echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config chmod 644 ~/.kube/config export KUBECONFIG=~/.kube/config - name: Check if image exists run: | set -euo pipefail foundTag=$( gh api /orgs/coder/packages/container/coder-preview/versions | jq -r --arg tag "pr${{ env.PR_NUMBER }}" '.[] | select(.metadata.container.tags == [$tag]) | .metadata.container.tags[0]' ) if [ -z "$foundTag" ]; then echo "Image not found" echo "${{ env.CODER_IMAGE_TAG }} not found in ghcr.io/coder/coder-preview" exit 1 else echo "Image found" echo "$foundTag tag found in ghcr.io/coder/coder-preview" fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Add DNS record to Cloudflare if: needs.get_info.outputs.NEW == 'true' run: | curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records" \ -H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \ -H "Content-Type:application/json" \ --data '{"type":"CNAME","name":"*.${{ env.PR_HOSTNAME }}","content":"${{ env.PR_HOSTNAME }}","ttl":1,"proxied":false}' - name: Create PR namespace if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | set -euo pipefail # try to delete the namespace, but don't fail if it doesn't exist kubectl delete namespace "pr${{ env.PR_NUMBER }}" || true kubectl create namespace "pr${{ env.PR_NUMBER }}" - name: Checkout uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Check and Create Certificate if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | # Using kubectl to check if a Certificate resource already exists # we are doing this to avoid letsenrypt rate limits if ! kubectl get certificate pr${{ env.PR_NUMBER }}-tls -n pr-deployment-certs > /dev/null 2>&1; then echo "Certificate doesn't exist. Creating a new one." envsubst < ./.github/pr-deployments/certificate.yaml | kubectl apply -f - else echo "Certificate exists. Skipping certificate creation." fi echo "Copy certificate from pr-deployment-certs to pr${{ env.PR_NUMBER }} namespace" until kubectl get secret pr${{ env.PR_NUMBER }}-tls -n pr-deployment-certs &> /dev/null do echo "Waiting for secret pr${{ env.PR_NUMBER }}-tls to be created..." sleep 5 done ( kubectl get secret pr${{ env.PR_NUMBER }}-tls -n pr-deployment-certs -o json | jq 'del(.metadata.namespace,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.selfLink,.metadata.uid,.metadata.managedFields)' | kubectl -n pr${{ env.PR_NUMBER }} apply -f - ) - name: Set up PostgreSQL database if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | helm repo add bitnami https://charts.bitnami.com/bitnami helm install coder-db bitnami/postgresql \ --namespace pr${{ env.PR_NUMBER }} \ --set auth.username=coder \ --set auth.password=coder \ --set auth.database=coder \ --set persistence.size=10Gi kubectl create secret generic coder-db-url -n pr${{ env.PR_NUMBER }} \ --from-literal=url="postgres://coder:coder@coder-db-postgresql.pr${{ env.PR_NUMBER }}.svc.cluster.local:5432/coder?sslmode=disable" - name: Create a service account, role, and rolebinding for the PR namespace if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | set -euo pipefail # Create service account, role, rolebinding envsubst < ./.github/pr-deployments/rbac.yaml | kubectl apply -f - - name: Create values.yaml env: EXPERIMENTS: ${{ github.event.inputs.experiments }} PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID }} PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET }} run: | set -euo pipefail envsubst < ./.github/pr-deployments/values.yaml > ./pr-deploy-values.yaml - name: Install/Upgrade Helm chart run: | set -euo pipefail helm dependency update --skip-refresh ./helm/coder helm upgrade --install "pr${{ env.PR_NUMBER }}" ./helm/coder \ --namespace "pr${{ env.PR_NUMBER }}" \ --values ./pr-deploy-values.yaml \ --force - name: Install coder-logstream-kube if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | helm repo add coder-logstream-kube https://helm.coder.com/logstream-kube helm upgrade --install coder-logstream-kube coder-logstream-kube/coder-logstream-kube \ --namespace "pr${{ env.PR_NUMBER }}" \ --set url="https://${{ env.PR_HOSTNAME }}" - name: Get Coder binary if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | set -euo pipefail DEST="${HOME}/coder" URL="https://${{ env.PR_HOSTNAME }}/bin/coder-linux-amd64" mkdir -p "$(dirname ${DEST})" COUNT=0 until $(curl --output /dev/null --silent --head --fail "$URL"); do printf '.' sleep 5 COUNT=$((COUNT+1)) if [ $COUNT -ge 60 ]; then echo "Timed out waiting for URL to be available" exit 1 fi done curl -fsSL "$URL" -o "${DEST}" chmod +x "${DEST}" "${DEST}" version mv "${DEST}" /usr/local/bin/coder - name: Create first user, template and workspace if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' id: setup_deployment run: | set -euo pipefail # Create first user # create a masked random password 12 characters long password=$(openssl rand -base64 16 | tr -d "=+/" | cut -c1-12) # add mask so that the password is not printed to the logs echo "::add-mask::$password" echo "password=$password" >> $GITHUB_OUTPUT coder login \ --first-user-username coder \ --first-user-email pr${{ env.PR_NUMBER }}@coder.com \ --first-user-password $password \ --first-user-trial \ --use-token-as-session \ https://${{ env.PR_HOSTNAME }} # Create template cd ./.github/pr-deployments/template coder templates push -y --variable namespace=pr${{ env.PR_NUMBER }} kubernetes # Create workspace coder create --template="kubernetes" kube --parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y coder stop kube -y - name: Send Slack notification if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | curl -s -o /dev/null -X POST -H 'Content-type: application/json' \ -d \ '{ "pr_number": "'"${{ env.PR_NUMBER }}"'", "pr_url": "'"${{ env.PR_URL }}"'", "pr_title": "'"${{ env.PR_TITLE }}"'", "pr_access_url": "'"https://${{ env.PR_HOSTNAME }}"'", "pr_username": "'"test"'", "pr_email": "'"pr${{ env.PR_NUMBER }}@coder.com"'", "pr_password": "'"${{ steps.setup_deployment.outputs.password }}"'", "pr_actor": "'"${{ github.actor }}"'" }' \ ${{ secrets.PR_DEPLOYMENTS_SLACK_WEBHOOK }} echo "Slack notification sent" - name: Find Comment uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0 id: fc with: issue-number: ${{ env.PR_NUMBER }} comment-author: "github-actions[bot]" body-includes: ":rocket:" direction: last - name: Comment on PR uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 env: STATUS: ${{ needs.get_info.outputs.NEW == 'true' && 'Created' || 'Updated' }} with: issue-number: ${{ env.PR_NUMBER }} edit-mode: replace comment-id: ${{ steps.fc.outputs.comment-id }} body: | --- :heavy_check_mark: PR ${{ env.PR_NUMBER }} ${{ env.STATUS }} successfully. :rocket: Access the credentials [here](${{ secrets.PR_DEPLOYMENTS_SLACK_CHANNEL_URL }}). --- cc: @${{ github.actor }} reactions: rocket reactions-edit-mode: replace