Publish

What publish does

sklp run publish runs .sklp/pipelines/publish.yaml: it diffs the branch, picks the impacted services, and for each one builds the image daemonless, scans it, and pushes it. Pushing an image is where publish stops — rolling it out is the node's job.

sklp run publish                 # impacted services only
sklp run publish -resolve-only   # list what would ship, push nothing
sklp run publish -only web,core  # just these scopes
sklp run publish -force          # every service, whatever the diff
sklp run publish -base-ref HEAD~3  # widen the diff window
FlagEffect
-resolve-onlyprint the impacted services and changed paths, then exit
-only a,bonly the steps scoped to these names; wins over -force
-forceevery scoped step is in scope
-base-ref <ref>base of the diff (default HEAD~1); widen it when a merge was missed
-no-cacherebuild even if an identical build is cached

Only from a clean main

The pipeline guards itself:

kind: pipeline
name: publish
space: false
on:
  branches: [main]
  clean_tree: true

From any other branch, or with anything in git status --porcelain, the run is refused before the first build. An image built from uncommitted work matches no commit. Merge the PR, git pull main, then publish.

Impacted services

Each image is a kind: publish step with a scope:. A service ships when a changed file matches its scope; the rest are skipped.

scopes:
  core:
    - "apps/core/**"
    - "Dockerfile"
    - "go.work"
  web:
    - "apps/web/**"
    - "pnpm-lock.yaml"
    - "Dockerfile.web"

Run -resolve-only first when unsure; it honours -only and -force.

The publish step

steps:
  - name: core-image
    kind: publish
    platform: linux/amd64
    scope: [core]
    context: .
    dockerfile: Dockerfile
    image: rg.fr-par.scw.cloud/skalpai/core
    tag: "{{ publish_tag }}"
    secrets:
      - id: github_token
        env: GITHUB_TOKEN
FieldMeaning
imageregistry reference without the tag
tagliteral, $VAR, or {{ publish_tag }}
contextbuild context, relative to the repo root (required)
dockerfiledefaults to <context>/Dockerfile
platformlinux/amd64 or linux/arm64; defaults to the build host's
build_args--build-arg values, templated; non-secret (they land in history)
build_contextsextra named contexts (dir, docker-image://, target:)
secretsbuild secrets: {id, env} or {id, file}
scanfalse, or {severity, ignore_unfixed}; default HIGH/CRITICAL fixable
pushfalse builds without pushing

The builder is in-process (runc, no Docker daemon, no BuildKit). It supports FROM … AS, RUN (with --mount=type=secret), COPY, COPY --from, ENV, ARG, WORKDIR, USER, EXPOSE, ENTRYPOINT, CMD; anything else is rejected loudly.

Runs on the host

A pipeline with publish steps needs space: false. A rootless Space container cannot create the containers a build runs; inside a Space the failure looks like a corrupt cache when it is not.

Platform

The image carries a real platform. Without platform: it is the build host's, so a build on an arm64 Mac produces an arm64 image. Pin platform: linux/amd64 for amd64 nodes; foreign RUN steps go through the host's binfmt qemu handler.

build_args

    build_args:
      VITE_SKALPAI_API_KEY: "${VITE_SKALPAI_API_KEY}"
      VITE_SKALPAI_SAAS_URL: "${VITE_SKALPAI_SAAS_URL}"

Values are resolved from the run's env before reaching the builder. Anything sensitive goes in secrets:, never build_args:.

Image tags

tag:
  strategy: date-sha

The tag is computed once per run and exported as $SKLP_PUBLISH_TAG and {{ publish_tag }}:

StrategyShape
date-shaYYYYMMDD-HHMM-<short-sha>, e.g. 20260516-1856-bf44d9d
sha<short-sha>
dateYYYYMMDD-HHMM

The date is UTC build time, the sha is HEAD. Every service of one run shares the same tag.

Registries and credentials

Images are named <registry>/<project>/<service>, e.g. rg.fr-par.scw.cloud/skalpai/core. Where the push credential comes from depends on where the run executes, never both:

WhereSource
Dev machine~/.config/sklp/registries.yaml, written by sklp registry login
CI node (SKLP_CI=1)the pipeline registries: block, ${VAR} from secrets:
sklp registry login --provider scaleway     # rg.fr-par.scw.cloud, user nologin
sklp registry login --provider github       # ghcr.io, user oauth2
printf "$TOKEN" | sklp registry login --provider scaleway --password-stdin
sklp registry ls
sklp registry logout rg.fr-par.scw.cloud
registries:
  rg.fr-par.scw.cloud:
    username: nologin
    password: ${SCW_SECRET_KEY}

sklp never reads ~/.docker/config.json; docker login does nothing for it. A host with no credential fails the run before the first build. See sklp registry login.

One-off push

sklp image push builds and pushes a single Dockerfile outside any pipeline, with the same builder:

sklp image push --image rg.fr-par.scw.cloud/skalpai/core \
  --context . --dockerfile Dockerfile --platform linux/amd64 \
  --secret id=github_token,env=GITHUB_TOKEN --scan

Without --tag it uses YYYYMMDD-HHMM-<short-sha>. --build-arg NAME=value is repeatable.

From pushed image to running service

A push only puts an image in the registry. Rollout happens on the node:

# .sklp/image-watches.yaml
watches:
  - name: app-core
    service: core
    space: app-production
    image_repo: rg.fr-par.scw.cloud/skalpai/core
  1. On a push to the default branch, core reconciles the repo and makes the node's image watches match .sklp/image-watches.yaml.
  2. The watch sees the new tag in the registry and rolls the service on the node.
  3. The node starts the new generation next to the old one, cuts traffic over once it is Ready, and keeps the old one alive through a soak (60 s by default). A crash during the soak rolls back (ADR-013).
  4. The node keeps the rolled image across later deploy.yaml applies until Git changes that service's tag itself: it tracks Image and DeclaredImage and arbitrates with a three-way merge (ADR-018). A spec it just rolled back is reported rejected, and core records the roll as failed.

Pushed is not deployed

A green publish means the image is in the registry, nothing more. Before calling a release live, check that the node runs the new tag and that the roll survived its soak — in the Skalpai app or through the node. A watch that reports rolled with no soak outcome is acceptance, not success.