On this page

Web hosting: the release declaration

The .wisent-release.json shape for a web product

A web product declares one platform whose key is web. runner_platform names the host that builds it. The quality gate and the build both call stado web; the gate is declared "kind": "format", so the pre-push check runs it as the product's formatting gate (its own format, type and lint scripts), and the stage map names the one tarball the unit is installed from.

{
  "schema_version": 1,
  "product": "preferences-landing",
  "releases": true,
  "version_source": { "kind": "json", "path": "package.json", "pointer": "/version" },
  "platforms": {
    "web": {
      "runner_platform": "darwin-arm64",
      "quality": [
        { "name": "web-quality", "kind": "format", "argv": ["stado", "web", "quality"] }
      ],
      "build": { "argv": ["stado", "web", "build"] },
      "stage": {
        "dist/preferences-landing-web.tar.gz": "preferences-landing-web.tar.gz",
        "dist/preferences-landing-web.tar.gz.sha256": "preferences-landing-web.tar.gz.sha256"
      }
    }
  },
  "promotion": { "channels": ["candidate", "stable"], "reconcile": false }
}

stado web quality reads the checkout the release worker prepared (WISENT_SOURCE_DIR), installs the locked dependency tree, and runs the product's own typecheck and lint scripts when it declares them. A project with a tsconfig.json must declare typecheck: its build checks types, so a gate without the check passed commits the build then refused. Such a project is refused before the install with stado web quality: <product> has a tsconfig.json, so its build checks types, but its package.json declares no typecheck script for this gate to run first; declare "typecheck": "tsc --noEmit" in <package.json>. stado web build runs the product's build script and stages a tarball holding .next, public, package.json, the production node_modules, and a generated launcher that executes the product's start script on the port the unit passes it. A product whose build needs a credential names it in the platform's secret_env as VAR: "role#field"; the vault decides which item plays the role.

A product whose web application is not at the repository root — a frontend/ beside a backend — names that directory with --package on both steps: stado web quality --package frontend and stado web build --package frontend. Its package.json, install, checks, build and staged tree are that directory's, and --root is read relative to it; the artifact's product name and source revision stay the repository's. A path that leaves the checkout, or a directory the checkout does not have, is refused by name.

A dependency the package reaches outside its checkout is answered from a release input the manifest declares under inputs, on both steps. A private Git dependency fetched over SSH from GitHub takes --git-input INPUT=OWNER/REPOSITORY.git, where the input is a Git bundle: both GitHub spellings of that repository resolve to the bundle through a Git config only this build reads. A file: dependency on a sibling repository takes --link-input PATH=INPUT[/SUBPATH]: PATH, relative to the package, becomes a link to that directory of the input, for example --link-input ../echo-web/packages/onboarding-web=onboarding-web/packages/onboarding-web. An input the manifest does not declare for this platform is refused by its variable name, a link outside the release work area or over an existing file is refused, and the link the quality step made is accepted again by the build.

Before npm ci, both steps read every private GitHub dependency the lockfile pins over SSH. One a --git-input answers needs no credential: the step says the declared release inputs answer every one and installs from the bundles. One no input answers is fetched over HTTPS with a GITHUB_TOKEN the platform declares under secret_env; with no token, the step is refused before npm runs, naming each unanswered repository URL, with the two ways to answer it (a Git bundle input, or the token). GitHub's Git origin is read from one declaration in Stado, tooling/inputs/github.json. tests/release/inputs/web-git-input.mjs runs stado web quality on a product answered by its input, checks the installed file, and on a product with one unanswered pin checks the refusal names only that pin and that nothing was installed.

Publish that bundle from the canonical dependency checkout through Stado:

stado release catalog pin-input "$PRODUCT_CHECKOUT" \
  --name "$INPUT_NAME" --source "$DEPENDENCY_CHECKOUT" \
  --revision "$LOCKED_COMMIT" --git-bundle --json

The command exports the resolved commit and its reachable history, not other branches or tags. The bundle advertises that exact commit as HEAD, which npm requires even for a dependency pinned by commit. Stado creates temporary bare Git metadata under the checkout's build directory and reads objects from the canonical repository; it makes no clone or second checkout and changes no source reference, index or file. Failed export or metadata cleanup refuses publication and names the failed operation. --git-bundle cannot be combined with --cargo or --path.

The object is published create-only under its SHA-256 before the product manifest is changed. The saved input has extract: false and a <input-name>.bundle mount. The JSON receipt reports source_commit, uri, sha256, bytes and git_bundle. A failed upload leaves the manifest unchanged; a failed manifest write reports its path. Configure the same --git-input INPUT=OWNER/REPOSITORY.git argument on both web steps, and republish when the lockfile selects another commit. Git bundle bytes may differ between exports; consumers verify the recorded digest and commit.

Stado Desktop's Releases → Publish an immutable build input offers Export Git bundle for web dependencies on the selected API host. It uses the same operation, reports the same receipt and preserves its refusal. Neither a publication receipt nor a successful Git read qualifies the web application's build or graphical behavior.

tests/release/inputs/git-bundle-input.mjs runs real publication and readback against an isolated local Stado object store, installs the pinned package with npm offline, compares an installed file to that commit, and checks conflicting options and missing source/revision refusals without changing the manifest or source references:

STADO_BIN="$CANDIDATE_STADO" \
STADO_TEST_GIT_SOURCE="$DEPENDENCY_CHECKOUT" \
STADO_TEST_GIT_REVISION="$LOCKED_COMMIT" \
STADO_TEST_GIT_PACKAGE="$PACKAGE_NAME" \
STADO_TEST_GIT_PACKAGE_FILE="$SHIPPED_PACKAGE_FILE" \
node tests/release/inputs/git-bundle-input.mjs

Use a package whose installation needs no build or unavailable registry dependency. The offline consumer uses Git's local-path rewrite: a file:// URL asks Git for a repository directory rather than a bundle. This tests bundle consumption, not npm's separate GitHub archive-download optimization or the full web gate. Reports under .build/git-bundle-input/run-*/ retain source and binary identities, command output, npm logs, exit statuses and the observed result. An installed Stado without --git-bundle, or one that exports no HEAD, fails the journey; it is not a passing qualification of newer source. Desktop observation and web build qualification remain separate runs.

A build-time value that is not a credential goes in the platform's env instead, as a literal:

"env": { "NEXT_PUBLIC_SITE_URL": "https://content.wisent.ai" }

stado web build exports both maps into the install and the build. The split is not cosmetic. secret_env names a Skarbiec item and field, so a public constant kept there becomes a credential the fleet grants, syncs and audits for nothing, and its value stops being visible in the diff that changed it. echo-web's build refuses to run without NEXT_PUBLIC_SITE_URL, and that URL is on the public internet. One variable in both maps is refused: it would have two answers.

A static site is a web product too

Six of these sites are directories of files — byk-landing, handtohuman-landing, kronika-landing and transcript-lake-landing are index.html and a stylesheet at the top of the repository, jeden's site is its web directory, and tama-landing builds one into dist/. Vercel served them by copying the directory, and they are hosted here the same way rather than being rewritten as Node servers.

One rule decides which a product is: a product that declares a start script is a server, and a product that does not is a static site. Nothing else is consulted — not next.config.*, not a dependency on next, not which directories exist. start is what the launcher runs, so "is there a server to start" and "what does this product declare" have to be one question. A product with a build and no start is a site whose build writes its files; a product with neither is a site whose files are committed, and it needs no package.json at all.

The site root is what --root names in the build argv, and the checkout root when nothing names one. There is deliberately no probe order over public, site, dist and .: a probe order is a guess, and a build that guesses stages a directory nobody declared.

"platforms": {
  "web": {
    "runner_platform": "darwin-arm64",
    "quality": [{ "name": "web-quality", "kind": "format", "argv": ["stado", "web", "quality", "--root", "dist"] }],
    "build": { "argv": ["stado", "web", "build", "--root", "dist"] },
    "stage": {
      "dist/tama-landing-web.tar.gz": "tama-landing-web.tar.gz",
      "dist/tama-landing-web.tar.gz.sha256": "tama-landing-web.tar.gz.sha256",
      "dist/SOURCE_REVISION": "SOURCE_REVISION"
    }
  }
}

The artifact holds the site under one fixed directory, site/, plus the same bin/start-web launcher and a bin/serve-static.mjs server generated into the tarball. That server uses only node:http, node:fs and node:path: a global serve would be a dependency of the host rather than of the release, and npx serve would fetch from the npm registry the first time a unit started — a network call in the run path, resolving a version nobody pinned, on a host whose whole point is that it runs the bytes someone published. It resolves a request as an exact file, then <path>.html, then <path>/index.html, which is the cleanUrls behaviour these sites were served with, answers 404.html when the product ships one, refuses anything but GET and HEAD, and confines every resolved path to the site root.

The launcher is the same script with one line different, so the unit's contract does not change: the install root resolved from $0, PORT passed by the unit and refused when unset, WEB_ENV_FILE sourced with assignments exported, and the loopback address only. stado web deploy is unchanged — it installs a tarball and runs bin/start-web, and it cannot tell the two kinds apart. When the site root is the checkout root, .git, .github, .vercel, .next, node_modules, release/ and any .env* file are left out: they are not part of the site, and one of them is a credential.

A web platform beside a binary platform

A product can host a site and ship a binary from one repository. jeden is a Rust CLI whose documentation site is its web directory, and its .wisent-release.json declares a runtime contract — the binary, its launcher and the schema versions the rollout state machine reads.

That contract is declared once for the product, and it applies to the platforms it describes. A platform whose stage map contains both the runtime's binary and its launcher ships the runtime and is held to the contract; a platform whose stage map contains neither ships something else, like a site tarball, and the contract says nothing about it. A platform with exactly one of the two is still refused, because that is the half-staged case the check exists for: a rollout would install something the host cannot start. A runtime that no platform stages at all is refused too — it is a declaration nothing checks against the world, and the first rollout would be what discovered it.

stado release submit reads the same rule when it publishes: a platform that ships no runtime publishes no binary path, no launcher and no schema versions, rather than inheriting the product's and claiming a binary that exists in none of its own bytes. Rollout is unaffected either way — a rollout target names the platform it rolls out, in the product's rollout policy, so a web platform is only ever rolled out if someone declared it there.

Source: this website