Build and deployment
Assignment
Martina Hostettler wants to show the app at the general meeting, and the members should be able to try it out on their own afterwards.
As a board member, I want to show the app publicly at a fixed address, so that the members can try it out at the meeting and afterwards.
The association doesn't have a server yet. The volunteer also wants no change to go online whose tests fail. The acceptance criteria are:
- The app is publicly reachable at a fixed address.
- It runs without the course API, with data to try out.
- Every change on
maingoes online by itself once the tests pass.
Learning goals
After this chapter you can
- explain the build of an SPA and its output,
- set up the base path and a fallback page for GitHub Pages,
- use environment variables and modes of Vite,
- use a stand-in for the API for a demo,
- write a GitHub Actions workflow that tests, builds and publishes.
The build
Section titled “The build”npm run build turns the sources into the folder dist with index.html and the files in assets, whose names carry a hash, as in chapter 2.3. In the running case app, the team area sits in files of its own that only load when someone opens it. npm run preview shows the build locally. According to the Vite documentation, that's only for checking and isn't a server for production. dist gets served by a host for static sites.
The base path
Section titled “The base path”A project page on GitHub Pages lives at https://name.github.io/repo/, so in a subfolder. According to its documentation, Vite then sets base to /repo/, and the router takes over the path with import.meta.env.BASE_URL, as chapter 6.1 showed. Instead of in vite.config.js, you can also pass the path when building, with vite build --base /repo/.
A fallback page for GitHub Pages
Section titled “A fallback page for GitHub Pages”GitHub Pages only knows the files in dist. When someone opens /repo/items/6 directly, there's no such file. According to the GitHub Docs, Pages then shows the file 404.html, if a project has one. If it's a copy of index.html, the app starts there, and the router shows the page for the address. A small Vite plugin creates the copy with every build.
// GitHub Pages shows 404.html for every address it does not know. As a copy of index.html it starts// the app there, and the router shows the page of the address.function spaFallback() { let outDir return { name: 'spa-fallback', apply: 'build', configResolved(config) { outDir = resolve(config.root, config.build.outDir) }, closeBundle() { copyFileSync(join(outDir, 'index.html'), join(outDir, '404.html')) }, }}
export default defineConfig({ // The project page lives at https://name.github.io/lorraine-commons/ base: '/lorraine-commons/', plugins: [vue(), vueDevTools(), spaFallback()],Other providers like Netlify or Vercel send every address to index.html with rules of their own, and their documentation describes them.
Environment variables and modes
Section titled “Environment variables and modes”According to the Vite documentation, Vite reads variables from .env, from .env.local and from a file for the mode, such as .env.demo. The file for the mode wins over the general files, and variables that are already set in the shell at startup win over all files. npm run dev runs in the mode development, npm run build in the mode production, and --mode demo picks a mode of your own. Only variables that start with VITE_ make it into the code in the browser, and they're readable in the built files afterwards. So an API key or a password never belongs in such a variable.
A stand-in for the API for the demo
Section titled “A stand-in for the API for the demo”The demo of the running case app runs in the mode demo. Its file sets two variables.
# The demo on the course site runs without the course API and inside a folder of the site.# npm run demo and npm run build:demo read this file through the mode demo.VITE_API=memoryVITE_HISTORY=hashVITE_API=memory gives the stores the stand-in for the API from chapter 8.2, which keeps the catalog, the login and the requests in the browser's memory. VITE_HISTORY=hash makes the router keep the path behind #.
// The demo lives in a folder of the course site, whose 404 page belongs to the course. With the// address after #, the server only ever sees the folder, see .env.demo.const history = import.meta.env.VITE_HISTORY === 'hash' ? createWebHashHistory(import.meta.env.BASE_URL) : createWebHistory(import.meta.env.BASE_URL)The reason for hash mode is where the demo lives. It sits in a folder of the course site, and a site on GitHub Pages has only one 404.html, which belongs to the course here. With the hash, the browser only asks the server for the folder of the demo, and every address also works on a reload. The demo also shares its origin with the course site, and with it the browser's storage. That's why favorites from the examples of chapter 7.4 show up in the demo too. The same goes for all project pages of one account on GitHub Pages, which live at the same address name.github.io. So keys in localStorage need a name that belongs to the app.
Another kind of stand-in intercepts requests in the network instead of in the code. Mock Service Worker installs a service worker for this, and the app keeps calling fetch. The running case app doesn't need it, because its stores already get the API through an adapter.
A GitHub Actions workflow
Section titled “A GitHub Actions workflow”A workflow describes in a file below .github/workflows what GitHub should do after an event like a push. The example in the Vite documentation and the GitHub Docs use the actions configure-pages, upload-pages-artifact and deploy-pages for this.
name: Deploy
on: push: branches: [main] workflow_dispatch:
permissions: contents: read pages: write id-token: write
# One deployment at a time, a newer push waits for the one that runsconcurrency: group: pages cancel-in-progress: false
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: lts/* cache: npm - run: npm ci # A failing test stops the workflow before anything goes online - run: npm test - run: npm run build - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: dist
deploy: needs: build runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1The job build tests and builds, and the job deploy only publishes after it. If a test fails, build ends and deploy never starts. To be allowed to publish, the workflow needs the permissions pages: write and id-token: write, and under "Settings" and "Pages" the source is set to "GitHub Actions". As in the course repo, the actions are pinned to a commit here, and the comment names the version. According to the GitHub Docs, a site on Pages is public even when the repository is private.
The course's demo
Section titled “The course's demo”The course site publishes the demo itself. The script scripts/build_demo.mjs in the course repo builds the running case app in the mode demo in both languages into the folder of the course site, and the course's workflow publishes it together with the pages. The demo of the running case app runs without a server, and the login and the requests last until you close the tab.
In your project
Section titled “In your project”The minimum scope asks for a public deployment. Set the base path, create 404.html if your app uses history mode, and write the workflow as soon as the first page exists, not just before handing in. Describe in the README how the app is built and published, and put one line for every environment variable in .env.example, without secrets.
In code
Section titled “In code”Work with the running case app in the folder case/08-architecture/en of the course repo. Write down what you expect before you run anything.
- Build the app with
npm run buildand opendist/index.html. Which path do the references to the files start with? - Build it once more with
npx vite build --base /lorraine-commons/. What changes indist/index.html? - Start the demo with
npm run demoand open a thing. What does the address look like, and why? - Write
VITE_API=memoryinto a file.env.localand startnpm run dev. What changes, and what stays the same? Delete the file again afterwards.
Exercises
Section titled “Exercises”The exercises get a small app with a router ready for GitHub Pages. You'll find the project in the folder exercises/08-architecture/deployment/en of the course repo. The tests of the first two levels build the project into a temporary folder, which takes a few seconds.
npm installnpm testExercise Getting started
The app should run at https://name.github.io/lorraine-commons/. Set the base path in vite.config.js and check the paths in dist/index.html after npm run build.
Solution
export default defineConfig({ // The project page lives at https://name.github.io/lorraine-commons/ base: '/lorraine-commons/', plugins: [vue(), vueDevTools(), spaFallback()],The router reads the path with import.meta.env.BASE_URL and needs no change of its own.
Exercise Going further
A direct link to /lorraine-commons/items/6 ends up on the error page of GitHub Pages. Write a plugin in vite.config.js that copies dist/index.html to dist/404.html after every build.
Solution
// GitHub Pages shows 404.html for every address it does not know. As a copy of index.html it starts// the app there, and the router shows the page of the address.function spaFallback() { let outDir return { name: 'spa-fallback', apply: 'build', configResolved(config) { outDir = resolve(config.root, config.build.outDir) }, closeBundle() { copyFileSync(join(outDir, 'index.html'), join(outDir, '404.html')) }, }}apply: 'build' limits the plugin to the build. configResolved remembers the output folder, even when someone changes it with --outDir.
Exercise Challenge
Write the workflow .github/workflows/deploy.yml. It starts on every push to main, installs with npm ci, tests with npm test, builds with npm run build, uploads dist and publishes to GitHub Pages in a job of its own.
Solution
The solution is above, in the section on the workflow. It only runs once the project lives in a repository of its own on GitHub and "GitHub Actions" is chosen as the source under "Settings" and "Pages".
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
| The published app shows a blank page. | base is missing or doesn't match the name of the repository. |
| A direct link to a page shows the error page of GitHub Pages. | Ship 404.html as a copy of index.html. |
An API key sits in a variable starting with VITE_. | Everything with VITE_ ends up readable in the code in the browser. Secrets belong on a server. |
| The workflow publishes although tests fail. | Run npm test before the build, and publish in a job that waits for the build. |
| The workflow fails because permissions are missing. | Set pages: write and id-token: write and choose "GitHub Actions" as the source of Pages. |
An AI assistant suggests Vue CLI or vite preview as the server for publishing. | According to its documentation, Vue CLI is in maintenance mode, and vite preview isn't a server for production. Build with vite build and serve from a host for static sites. |
Self-check
Section titled “Self-check”Self-check
- Why does a project page on GitHub Pages need a base path?
- What is
404.htmlfor, and why is a copy ofindex.htmlenough? - Which file does
vite build --mode demoread, and which variables make it into the browser? - Why does the workflow publish nothing when a test fails?
Answers
- It lives in a subfolder. Without a base path, the browser looks for the files of the app from the root of the domain.
- Pages shows it for every unknown address. As a copy of
index.html, the app starts there, and the router shows the page for the address. .env.demo, plus.envand.env.local. Only variables that start withVITE_make it into the browser.- The tests run in the job
build. If one fails, the job ends, and the jobdeploywaits for it in vain.
Assignment
The demo runs at the general meeting, and members try it out at home afterwards. In the meantime, the new volunteer has asked an AI assistant for a list of the overdue loans for the team. The code looks reasonable, but the lists stay empty, and the console reports errors. The next chapter shows how you check code like that.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.