Pipeline templates
A cratly website is built by a pipeline, and that pipeline is the same everywhere: install, build, check what the build reported, publish the result. Only the last step differs — and it differs by hosting, not by website.
These templates hold that shared part once, so a site repository declares which hosting it publishes to instead of carrying a copy of how.
Including them
The templates are served as plain static files from cratly.io. Any GitLab instance can read them — a self-hosted one, gitlab.com, one behind a company firewall — without an account anywhere and without access to a container registry:
include:
- remote: 'https://cratly.io/ci/v1/build.yml'
- remote: 'https://cratly.io/ci/v1/deploy-s3.yml'
inputs:
bucket: example.com
prefix: wwwThat is deliberately not a CI/CD component. Components can only be included from the catalogue of the same GitLab instance, which would restrict these templates to projects on gitlab.com. A static URL has no such border.
/ci/v1/build.yml | Install, build, check the build report, store the site as an artifact. |
/ci/v1/deploy-s3.yml | Publish to S3-compatible object storage. |
/ci/v1/deploy-rsync-ssh.yml | Publish over SSH with rsync. |
/ci/v1/deploy-sftp.yml | Publish over SFTP, for hosts that offer nothing else. |
Every file documents its own inputs in its spec: header — the copy served at the URL above is the authoritative list.
Versions
The version is the path, not a git tag: /ci/v1/… keeps working as it is, and a change that would break an existing pipeline appears at /ci/v2/… instead. Pinning is therefore the default rather than something to remember.
A pipeline including v1 gets fixes and new optional inputs as they are published. A site that wants no movement at all can vendor the file into its own repository — it is MIT, and that is an intended use.
Stages
The jobs land in GitLab's predefined build and deploy stages, so a site that declares no stages: of its own works unchanged. One that does declare them must list both names, or override the stage input.
Keeping an editor's wait short
An editor saving a page waits for that build. Two things shorten the wait, and both are about installing less.
Declare what the site is built from — the adapter, VitePress, Vue — as dependencies, and anything that only serves whoever edits the repository's own code — a linter, its plugins, type definitions — as devDependencies. Then the build installs only the first group:
include:
- remote: 'https://cratly.io/ci/v1/build.yml'
inputs:
install_command: bun install --frozen-lockfile --productionRun those tools where that code is reviewed rather than on every page an editor saves. A job of your own, skipped on the branches the editor commits to:
lint:
stage: build
rules:
- if: $CI_COMMIT_BRANCH =~ /^cratly-editor\//
when: never
- if: $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^cratly-editor\//
when: never
- when: on_success
script:
- bun install --frozen-lockfile
- bun run lintNothing is given up by this: a change to the site's configuration or theme is still checked before it reaches the published branch. What changes is who waits for it.
Choosing a runner
A build an editor waits for is slow for two reasons that look identical from the outside: it waited for a runner, or it ran slowly. They have different remedies, and only one of them is unbounded.
Waiting
Every template takes runner_tags, and every one of them defaults to naming none — the project's usual runners, whatever those are.
include:
- remote: 'https://cratly.io/ci/v1/build.yml'
inputs:
runner_tags: [ cratly ]A fleet that serves everything an organisation builds serves a site build no faster than the container image being assembled next to it. Queueing has no upper limit: a site build takes seconds, and can wait minutes behind work that has nothing to do with any site. Runners tagged for site builds take them out of that competition, and because the builds are short, a small dedicated capacity goes a long way.
An empty list is not the same as a tag nobody has: it places no requirement at all, which is why it is the default. Setting a tag no runner carries has the opposite of the intended effect — the job waits for a runner that never appears.
Running
What a site build spends its time on is installing dependencies: thousands of small files, unpacked in parallel. That makes processor cores the thing to look at, and sequential disk throughput almost irrelevant. Two machines compared on the same job: three times fewer cores and a slower core each made the install five times slower, while writing half a gigabyte sequentially differed by barely a quarter.
Two levers matter more than the machine:
- A build image that already holds the packages. Where the image carries a warm package cache covering what sites install, the install stops being a download. The same install measured 137 seconds against a stale cache and 11 seconds against a current one. A cache is only worth having if something keeps it current: seed it from the versions the adapter currently publishes, not from versions written into a file.
- Installing only what the build needs — see Keeping an editor's wait short above.
Who may use a runner
A tag routes jobs; it does not restrict who may send them. Any project that can see a runner can name its tag. What restricts it is the runner's scope — registered for one project, for a group, or for a whole instance.
This is worth deciding deliberately, because a pipeline exists to run commands a repository asks for, and anyone who may edit .gitlab-ci.yml decides what those are. A runner therefore belongs on a machine whose other duties you would be comfortable exposing to that, and scoped to the projects that should reach it. Sharing a machine between CI and unrelated services makes the blast radius of one careless change much larger than the machine itself.
Secrets are variables, never inputs
Inputs are readable by anyone who can read the pipeline configuration. Everything that grants access therefore comes from masked CI/CD variables instead, and the templates fail with an explanatory message when one is missing rather than proceeding half-configured:
| Template | Variables |
|---|---|
build.yml | CRATLY_NPM_TOKEN — only when npm_scope names a private registry. |
deploy-s3.yml | S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY |
deploy-rsync-ssh.yml | SSH_PRIVATE_KEY, SSH_KNOWN_HOSTS |
deploy-sftp.yml | SSH_KNOWN_HOSTS, plus SSH_PRIVATE_KEY or SFTP_PASSWORD |
A masked variable must be a single line, which a PEM key is not. The SSH templates accept either form and base64-decode the key when it does not look like PEM.
Host keys
The SSH and SFTP templates refuse to run without SSH_KNOWN_HOSTS, and they do not offer a way to turn that off. StrictHostKeyChecking=no would make every publication trust whatever answers at that address; on a shared runner, that is a network the pipeline knows nothing about. Produce the value once with
ssh-keyscan -p 22 www.example.comand compare the fingerprint against what the host's operator publishes before storing it.
What a deploy can promise
Replacing a website is never one operation on the wire, so the question is only how long it can be seen half-replaced, and what the far end has to support to shorten that. The answer differs per protocol, and no strategy is available everywhere:
| Method | Visitors during a deploy | The target must provide |
|---|---|---|
deploy-s3 | Object by object; uploads run before deletions, so assets are in place before the pages referencing them change. | An S3 API. No directories, no rename, nothing to stage with. |
deploy-rsync-ssh, strategy: in-place (default) | Per file, but each file is renamed in from a staging copy (rsync --delay-updates), so a slow or failed upload never lands partially. Deletions run afterwards. | A shell, rsync, and a filesystem that can rename. |
deploy-rsync-ssh, strategy: release-symlink | The whole site swaps at once: the new copy is uploaded beside the live one, then a symlink is moved over the old one in a single operation. Past releases stay for rollback. | Symlinks, GNU coreutils (mv -T), and a document root you may repoint. |
deploy-sftp | Per file, no staging. The weakest of the four. | The SFTP subsystem, nothing more. |
release-symlink is the only one that swaps a whole site atomically, and it is not the default on purpose: shared hosting frequently fails at least one of its three requirements — a document root the provider maps for you cannot be repointed at a release directory, and some jailed SFTP servers reject SYMLINK outright. The default asks for rename and nothing else, which is the widest ground available here.
For a host where even that is too much, deploy-sftp is the floor. A visitor who reloads during those seconds may get a page from one version and an image from the other; nothing in the protocol can prevent it. Deploy when the site is quiet, and keep the upload small by letting delete prune rather than re-uploading everything.
Build reports
build.yml reads .cratly/build-report.json and fails merge-request pipelines when a link or image points at a file the build does not contain. On the default branch the same finding only warns: the deploy has to keep running, or a stale site would be the punishment for a broken link. The report is kept as an artifact even when the job fails — it is what the editor reads to show the reason to whoever wrote the page.
Set fail_on_content_problems: "false" to make it advisory everywhere.
Private package registries
Nothing in a cratly site needs a licensed package, and the templates assume the public registry. Where a site does use one — a commercial component library, say — set npm_scope and npm_registry, and provide the token as CRATLY_NPM_TOKEN.
The credentials are configured in the job rather than baked into a build image on purpose: an image carrying licensed packages could not be distributed, and GitLab replaces a job image's ENTRYPOINT, so an image cannot configure itself from the environment either.