Static sites
Any repository that builds a static site, or a browser app that is nothing but files, can ship it to your own hosting account with one reviewed toolkit. It is the same promise as the rest of CLANKER.NET: it runs in your repository, with your credentials, and we hold none of them.
The toolkit does four things and nothing else:
| Step | What it does |
|---|---|
validate | Checks your contract before anything is installed or any credential is read. |
seal | After your own build and checks pass, records a digest of every file, bound to the commit, the environment and the contract. |
credentials | Loads the hosting token from your stored configuration, masks it, and never writes it to an output. |
publish | Checks every target and seal before the first upload, uploads the sealed files without rebuilding, then runs your smoke checks. |
It does not create projects, attach domains, touch DNS or apply infrastructure. A declared project that does not exist stops the release instead of being created. Domain and DNS changes stay in a separate step that you confirm by hand.
Five minutes
1. Say what is published. A static block in the repository’s one config file,
replicator.config.ts, reviewed with the rest of your release workflow:
export default replicator.app({
name: "my-site",
state: { accountId: "<32 hex digits>" },
stages: { production: {} },
static: {
adapter: "vite",
buildCommands: ["npm run build"],
apiBoundary: "None: browser-only artifact.",
environments: {
production: { baseUrl: "https://example.com" },
preview: {},
},
targets: [
{
key: "site",
outputDirectory: "dist",
artifactName: "site-dist",
smokeCommand: 'node scripts/smoke.mjs "$STATIC_DEPLOY_URL"',
environments: {
production: { project: "my-site", productionBranch: "main" },
preview: { project: "my-site", productionBranch: "main" },
},
},
],
},
run(ctx) {
new replicator.StaticSite("site", {
accountId: "<32 hex digits>",
project: "my-site",
productionBranch: "main",
domains: ["example.com"],
});
},
});
The toolkit reads a small contract file; there is none to keep. The engine writes it from the
block, adding what it already knows: the app’s name, the state account, the credential
variable (REPLICATOR_STATE_TOKEN), the toolkit’s version and the pinned upload tool. The
release ref defaults to the first target’s production branch.
replicator infra static-contract --config replicator.config.ts --out contract.json
It exits 3 when the config has no static block, and refuses a static block in an app with no
state.accountId. The contract is rebuilt in every job from the same config, so the bytes a seal is
bound to are the bytes publish checks.
The three generic example contracts shipped with the toolkit (packages/replicator/deploy/examples/)
show a plain Vite site, a site plus a separate app bundle, and a site whose app
shares one artifact with the public site. They name placeholder projects and
domains: the toolkit holds no repository’s contract. The contract your
repository builds from its static block has the same shape, and your Pages projects and domains
are declared beside it in the same replicator.config.ts (the StaticSite component).
adapternames how your build runs (vite,astroorastro-expo); the toolkit never runs a build command from this file.buildCommandsdocuments what your own workflow runs.- Every project is declared in your repository’s replicator config; publishing never creates one, so a missing project stops the release.
smokeCommandis reviewed code from your repository, run withSTATIC_DEPLOY_URLandSTATIC_DEPLOY_TARGET. Never fill it from a dispatch input or from pull-request text.outputDirectorymust stay inside your repository.- Each run reads absolute origins from
STATIC_SITE_BASE_URL(andSTATIC_SITE_ORIGIN/STATIC_SITE_APP_ORIGINwhen the contract requires them). The 1.x fallback variables, bare hostnames, URL prefixes and a target’sidwere removed in 2.0.0; a contract that names one is refused.
2. Call the four steps from your release workflow. Build and check with your
own commands, then build the contract (the job needs the engine: setup-replicator) and call the steps:
- uses: clankerlabs/CLANKERNET/.github/actions/static-site@<40-character commit>
with: { operation: validate, config: contract.json }
# … your build and checks …
- uses: clankerlabs/CLANKERNET/.github/actions/static-site@<40-character commit>
with: { operation: seal, config: contract.json }
- uses: clankerlabs/CLANKERNET/.github/actions/static-site@<40-character commit>
with: { operation: credentials, config: contract.json }
- uses: clankerlabs/CLANKERNET/.github/actions/static-site@<40-character commit>
with: { operation: publish, config: contract.json }
REPLICATOR_STATE_TOKEN is the one secret the workflow needs: the token that
opens your state also publishes the sites, and the account id beside it names
where the projects live; both come from state.accountId in the config, so they are written once.
The workflow then needs REPLICATOR_STATE_TOKEN as its one secret and no
other tooling is installed for the credentials step. The sites themselves,
their domains and their storage can be declared in the repository’s
environments file.
Take a project’s name from your environments file. When the repository’s environments file owns a project, the contract does not have to repeat its name. Write the name of the output that carries it instead:
"production": { "project": { "fromOutput": "publicProject" }, "productionBranch": "main" }
and pass the file the deploy wrote (replicator infra deploy --outputs-out outputs.json) as the step’s outputs input. validate and seal only check
the shape, so they run without it; publish reads the name from the file and
then runs the usual checks on that project. Renaming a project in the
environments file is then a change in one place. A release that deploys the
environment first and publishes second (the release pipeline)
does this for you.
3. Pin it. The reference is a full 40-character commit, never a branch or a
moving tag, so a release of yours is always built by a toolkit you reviewed.
The contract’s toolkitVersion must match the toolkit’s version, so a mismatch
fails validate before any credential is read.
What it protects
- Nothing is rebuilt at publish time. The seal binds the bytes that passed your checks to this commit, this environment, this canonical address and this exact contract; publishing refuses anything else.
- Pull requests never publish. A preview is a deliberate, branch-scoped upload; production needs the declared release ref.
- All or nothing at the start. Every target and every seal is checked before the first byte is uploaded, and smoke checks run only after all uploads (an app and its site are compared once both are updated).
- A receipt for every release (
static-site-receipt.json): the resource identifiers, the immutable addresses and the previous deployment of each target. - No automatic rollback. After a partial multi-project upload, read the receipt and redeploy the previous commit, or pick the recorded previous deployments through the path that owns the resource. Keep the previous toolkit pin, contract and workflow together as one rollback unit.
Reaching the toolkit
Nothing is copied into your repository. Use the action from a checkout of this repository at a pinned commit:
- Check ours out at a pinned commit next to yours (for example into
.factory/, with a read token if the repository is private) and use the action from that checkout:uses: ./.factory/.github/actions/static-site. The action finds the package relative to itself and exports its path asSTATIC_DEPLOY_TOOLKITfor your own shell adapters. This is the same pattern the release pipeline uses for the engine. - Reference the action directly at a pinned commit, as above, when your repository may use actions from the one that hosts the toolkit.
- Call the reusable release workflow (
replicator-release.yml), which does both for you.
Limits
- One kind of hosting target today (a static-site project with a production branch). The target shape is where another kind would be added; nothing else in the contract changes.
- The toolkit needs Node 22 and no packages.