Testing and Deployment

Continuous Integration and Continuous Deployment


Learning Objectives

  • you know of continuous integration and continuous deployment.

Continuous integration and continuous deployment are practices that help developers automate the process of building, testing, and deploying software. Here, we look into both, using GitHub Actions as an example.

Note that you will not be expected to set up continuous integration and continuous deployment for the overarching project. This part is included to give you an idea of what continuous integration and continuous deployment are and how they can be used in practice.

Continuous integration

Continuous integration is a practice where developers integrate their code to a central repository whenever they have finished working on a small increment to the software, often multiple times a day. Continuous integration is typically accompanied by automated testing — whenever code is added to the repository, automated tests are run to verify the new code does not introduce breaking changes.

GitHub is an example of a central repository that can be used for continuous integration. GitHub allows running Actions that can include e.g. running the automated tests whenever the content of the repository is changed.

To add continuous integration to our walking skeleton, we would first need to upload it to a GitHub repository. Then, we would add a GitHub action that builds the project and runs the tests whenever new code is added to the repository.

As the walking skeleton is already created with Docker, we can simply add a GitHub action that runs the tests with Docker.

For this, we would create a folder .github in the root of the repository, and inside it, a folder workflows. Inside the workflows folder, we would create a file ci-cd.yml with the following content — the filename reflects that we will soon look into continuous deployment as well:

name: CI/CD
on:
  push:
    branches:
      - main

jobs:
  run-e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Start application
        run: docker compose up --build -d

      - name: Run tests
        run: docker compose run --rm --entrypoint=npx e2e-tests playwright test

The above workflow runs the end-to-end tests with Playwright whenever new code is added to the repository. The “job” is called “run-e2e-tests” and it consists of three steps: first, the code is copied from the repository to the machine running the actions, then, the application is started with Docker (the -d flag asks to start the project as a daemon, i.e., in the background), and finally, the tests are run with Playwright.

Already with the above, we have continuous integration almost set up for the walking skeleton. There is a small adjustment that must be made, however.

By default, the .env.development file from the client folder is not added to GitHub, and thus, the client would not have the PUBLIC_API_URL environment variable that it requires. To fix this, we would modify the .gitignore file in the client folder to remove the block for .env.*. This way, when the client folder is added to GitHub, the configuration file is added there as well — the modified .gitignore file would look e.g. as follows.

node_modules

# Output
.output
.vercel
/.svelte-kit
/build

# OS
.DS_Store
Thumbs.db

# Env
.env

# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

Now, when project (including the client folder) is added to GitHub, the .env.development file is added there as well, and the tests should run successfully.

After all of the parts are in place, whenever new code is added to the repository, the tests are run. The results of the tests are shown in the Actions tab of the GitHub repository.

Continuous deployment

Continuous integration is often coupled with continuous deployment, which refers to the process of deploying software to production whenever new code that passes tests is added to the repository.

For continuous deployment, we would create a new job that would deploy the software to a production environment, if the “run-e2e-tests” job completes. For the deployment, as we’ve already used Deno Deploy, we will continue using it. Deno also provides a Deno Deploy GitHub actions workflow that we can build on.

Server for client-side application

As we have started to use the server responsible for the client-side application, we would also want that our deployment has a client-side server. For this, we would use the node-adapter package, which is a package that can be used to serve SvelteKit applications on the server-side. Taking it into use would involve modifying the package.json of the project, adding the line "@sveltejs/adapter-node": "^5.0.0" to the dependencies of the project, and running deno install --allow-scripts.

Then, we would modify the file svelte.config.js to use the adapter, changing the first line to the following.

import adapter from '@sveltejs/adapter-node';

Now, the client-side application would be built as an application that would be served by the node-adapter package.

Deploying the client-side application

To deploy the client-side application on Deno Deploy, we would need a Deno Deploy project for it. When deploying with GitHub actions, we would create the project in Deno Deploy dashboard, linking the project to the GitHub repository, and selecting “Just link the repo, I’ll set up GitHub Actions myself”. Then, once the project is created, we copy the name and set up the workflow in the GitHub repository.

To deploy the client-side application to Deno Deploy, we would have a job similar to the following:

  deploy-client:
    needs: run-e2e-tests
    runs-on: ubuntu-latest

    # permissions for deno deploy
    permissions:
      id-token: write
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Deno
        uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x

      - name: Load dependencies
        run: deno install --allow-scripts
        working-directory: ./client

      - name: Build client
        run: deno run --env-file=.env.development build
        working-directory: ./client

      - name: Upload to Deno Deploy
        uses: denoland/deployctl@v1
        with:
          project: deno-deploy-client-project-name
          entrypoint: index.js
          root: ./client/build/

The above job executes if the “run-e2e-tests” job completes successfully. The job runs on an Ubuntu machine, and it has a few steps: first, the code is copied from the repository to the machine running the actions, then, Deno is installed, the dependencies are loaded, the client is built, and finally, the client is uploaded to Deno Deploy.

In the above, the environment file is .env.development. If the client is to be deployed to production, the environment file would be .env.production, or better yet, the environment variables would be set in the GitHub repository (Settings -> Security -> Secrets and variables -> Actions -> Environment secrets).

Together, the continuous integration and continuous deployment configuration would look e.g. as follows:

name: CI/CD
on:
  push:
    branches:
      - main
jobs:
  run-e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Start application
        run: docker compose up --build -d

      - name: Run tests
        run: docker compose run --rm --entrypoint=npx e2e-tests playwright test

  deploy-client:
    needs: run-e2e-tests
    runs-on: ubuntu-latest

    # permissions for deno deploy
    permissions:
      id-token: write
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Deno
        uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x

      - name: Load dependencies
        run: deno install --allow-scripts
        working-directory: ./client

      - name: Build client
        run: deno run --env-file=.env.development build
        working-directory: ./client

      - name: Upload to Deno Deploy
        uses: denoland/deployctl@v1
        with:
          project: deno-deploy-client-project-name
          entrypoint: index.js
          root: ./client/build/

With the above in place, given that the Deno Deploy project exists and the project has been linked to a GitHub project, we would see something like Figure 1 in the GitHub Actions tab.

Fig 1. GitHub Actions shows that the run-e2e-tests and the deploy-client job have finished.

Fig 1. GitHub Actions shows that the run-e2e-tests and the deploy-client job have finished.

Deploying the server-side application and next steps

The next step would be to set up the server-side deployment, which would be similar to the client-side deployment.

We would need a Deno Deploy project for the server-side application, which we would set up in the Deno Deploy dashboard similar to the client-side application. The project would be linked to the same GitHub repository.

The job would be similar to the client-side deployment, with the difference that the entrypoint would be “app.js” and the root would be ”./server”. In addition, there would be no need to build the server-side application or to load the dependencies.

For the server-side application, the environment variables used to connect to a database — if one exists — and other variables such as JWT_SECRET would be set in the project settings in Deno Deploy, under “Environment variables”.

Once the server-side deployment would be set up, one possible next step would be to set up database migrations. As we have used Flyway, it would be sensible to use the official Flyway GitHub Action to run the migrations. The Flyway GitHub Action would be added as a new job, while the environment variables for the Flyway GitHub Action would be set in the GitHub repository settings, under “Secrets”.