Wikven writes a self-contained dist/ directory of static files, with no server or database behind it. Host it anywhere that serves static files.

What a build writes

Everything the site needs is in dist/, and nothing in it is fetched from anywhere else at view time:

dist/
├── index.html               the main page, served at the site root
├── Getting_Started.html     one file per page
├── Guide/
│   └── Setup.html           a page whose title has a slash becomes a directory
├── File:Logo.png.html       file description pages
├── assets/                  everything the build generates, together
│   ├── site.styles.css      MediaWiki:Common.css and the skin's own CSS
│   ├── startup-static.js    the module loader, with its network fetch removed
│   ├── modules-static.js    every module the pages need, bundled
│   ├── img-1a2b3c4d5e6f.png every image, from a page or from CSS
│   └── webfonts.css         only with WikvenBundleWebfonts set
├── fonts/                   the webfont files themselves, likewise
├── pagefind/                the search index
└── citizen/                 a complete copy of the site, per extra skin

Two things about that layout are worth knowing before you point a host at it. Links between pages are relative, so the site works at a domain root and in a subdirectory alike, and even from the filesystem. But anything addressed from the site root rather than from the page — the search bundle most visibly — has to be told the base path; that is the note under GitHub Pages below. And each extra skin's directory is a complete copy, so a three-skin site is roughly three times the files: see Skins.

GitHub Pages

This workflow rebuilds and publishes the site on every push. It bakes the source with the Wikven composite action, then uploads dist/ to Pages.

name: Deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
  pages: write
  id-token: write
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deploy.outputs.page_url }}
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10  # v6.0.3
        with:
          # Whole history, so each page is dated at the commit that last changed
          # its source file. A shallow checkout knows only the latest commit.
          fetch-depth: 0
      - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d  # v6.0.0
      - uses: chaotic-ground/wikven/actions/bake@v1.3.0
        with:
          source: src
          output: dist
      - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9  # v5.0.0
        with:
          path: dist
      - id: deploy
        uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128  # v5.0.0

The action is pinned to a released version — @v1.3.0 is the current one — and no image is named on purpose: an action left to its default runs the image of the release it was cut from, so the two cannot fall out of step. To pin the image by hand as well, add image: ghcr.io/chaotic-ground/wikven:1.3.0; the git tag carries the v and the image tag does not. To try a change before it is released, name a dated nightly-YYYY-MM-DD pre-release from the releases page in both places: a nightly tag names a commit of main and its image was built from that same commit, so naming both from one keeps them together.

Enable Pages for the repository under Settings → Pages → Build and deployment → Source: GitHub Actions. This documentation site is published this way.

A GitHub project page is served from a subdirectory (username.github.io/repo/). Page-to-page links keep working because Wikven writes them relative, but anything that needs an absolute path must include that base path: set search's SifterSearchBundlePath to /repo/pagefind/ (this site uses /wikven/pagefind/). A user/organization page (served at the domain root) needs no such setting.

Any static host

Because dist/ is just files, you can serve it from any static host (Netlify, Cloudflare Pages, an object store like S3, or a plain web server). Build the site in your CI or locally, then point the host at the dist/ directory, or upload its contents to the server's document root.

What each host does with the addresses

A page is a file. Wikven writes Getting_Started.html rather than Getting_Started/index.html, and every link a page carries is relative to where that file sits. So any host that serves files serves the site, with nothing to configure.

Some hosts also answer the address without the extension, so that /Getting_Started reaches the same page as /Getting_Started.html. GitHub Pages, GitLab Pages and Cloudflare Pages do; Codeberg Pages, an object store and a plain web server do not. The site works either way, because the links Wikven writes carry the extension; it matters only for an address somebody types or shares by hand.

Netlify is the one to turn something off on. Its Pretty URLs, on by default, forward /Getting_Started to /Getting_Started/ and rewrite /Getting_Started.html to the same address. Wikven writes no Getting_Started/ directory, so a page served there sits one directory deeper than its own links expect, and its stylesheets and pictures resolve to nothing. It is under Project configuration → Build & deploy → Post processing.

Cloudflare Pages hides a broken link. Where a site has no 404.html, it answers an address the site does not have by serving the front page with a 200, taking the site for a single-page application. A stale or mistyped link then looks like a working one.

Whether you need an error page depends on the host, then: some show their own, and Cloudflare Pages shows yours or your front page. Name a page 404 in the source tree and the build writes dist/404.html, which GitHub Pages, GitLab Pages and Codeberg Pages serve for an address that is not there. Put __NOINDEX__ on it — a sitemap is an invitation to index, and an error page is not a page to invite anyone to.

Being in the sitemap is not the same as being in an index, and no build can put a site in one. Search engines is what to read next.

Preview locally

Open dist/index.html directly, or serve the directory so links and assets resolve exactly as they will in production. Wikven has a built-in serve command for this.

The standalone binary serves dist/ on http://localhost:8080, and takes --listen for another address:

wikven serve

The image runs the same command; publish the port, and have the build output in the mounted dist/ already:

docker run --rm -p 8080:8080 \
  -v "$(pwd)/dist:/workspace/dist" \
  ghcr.io/chaotic-ground/wikven serve

Any static server works too, for example cd dist && python3 -m http.server.

Dates, authors and reproducible builds

Each page's "last edited" line names the commit that last changed that page's source file, and credits that commit's author. The wiki a build fills has no history of its own — every page is written in one import pass — so the repository's history is where those two facts come from.

That history has to be reachable. The bake action reads it on the runner and hands it to the build, but a shallow checkout knows only the last commit, which is why the workflow above checks out with fetch-depth: 0. Without the full history nothing fails: every page is simply dated at the commit being built and credited to nobody. The action says so in the log when it happens, and that line is the only warning you get.

The rest follows from the same idea. A build runs with its clock frozen at SOURCE_DATE_EPOCH — the commit's own date, when the action sets it — so two builds of one commit produce byte-identical output, down to the timestamps in the pages. It means a redeploy of unchanged content changes nothing on the host, and a diff between two builds only ever shows what you actually edited. Wikven's own continuous integration asserts it by baking this site twice and comparing the results.

Keeping pinned sources current

The pins that make those builds reproducible are the ones nothing moves for you. An exact WikvenRepositories pin stays exact until you change it, and no bot will change it: .wikven.yaml is Wikven's own file, and Dependabot only watches the ecosystems it recognises. A site can therefore sit years behind on a skin with every check green.

Which way out of that you take is yours to choose, and the two are not the same bargain:

  • Keep the pin exact and move it deliberately. A commit, or a tarball with its sha256, builds the same site tomorrow as it does today, and every change to what you ship is a diff someone approved. What it costs is time-to-fix: a security release sits upstream until the pin is moved.
  • Point the pin at something that moves. A reference naming a release branch — REL1_46, say — takes whatever that branch holds when the build runs, so a fix arrives at the next bake with nothing to merge. What it costs is reproducibility: two bakes of one source tree can differ, and nothing in the tree records what changed between them.

The rest of this section is about making the first cheap enough to prefer.

updatecli fills that hole, and it fills it by never having to recognise your file: you tell it where the pin is. A manifest names a source (where the current version is published) and a target (which key of which file holds the pin); applying it writes the source's value into the target and opens a pull request if that changed anything. Run it on a schedule in continuous integration and a pin that has fallen behind becomes a pull request you can review. Here is a whole manifest for one pin, a repository following a release tag:

---
name: "build(deps): bump Citizen"
pipelineid: citizen
scms:
  default:
    kind: github
    spec:
      owner: yourname
      repository: some-repository
      branch: main
      user: github-actions[bot]
      email: 41898282+github-actions[bot]@users.noreply.github.com
actions:
  default:
    kind: github/pullrequest
    scmid: default
sources:
  citizen:
    kind: githubrelease
    spec:
      owner: StarCitizenTools
      repository: mediawiki-skins-Citizen
      versionfilter:
        kind: semver
targets:
  citizen:
    name: 'build(deps): bump Citizen to {{ source "citizen" }}'
    kind: yaml
    scmid: default
    sourceid: citizen
    spec:
      file: .wikven.yaml
      key: $.config.WikvenRepositories.Citizen.reference

A commit that follows a branch is the same manifest with the source swapped: kind: gitcommit, given the repository's URL and the branch to track, writing to a key that ends in commit instead of reference. A tarball and its sha256 are not covered here: the two keys have to move together, and the archive names Special:ExtensionDistributor hands out carry a build hash that no version feed publishes, so a source has nothing to read. Declare anything you want watched as a repository. And there is no shortcut past writing one: updatecli's autodiscovery crawls the ecosystems it knows (npm, cargo, dockerfile, maven, ...), which a bespoke configuration file will never be, so a hand-written manifest is the whole story for this file.

This site is the working example. Its manifests are in updatecli/updatecli.d/ (one release tag per reference pin, one branch head per commit pin), and a weekly workflow applies them, opening one pull request per pipeline. Upstream's own documentation covers everything a manifest can do beyond the two shapes above.