blob: 7d9c0e14031d82586fbec972f0e0be5d2646024d [file] [view]
# Developer guide
OpenServerless is a complete serverless development environment.
It allows you to build full stack, scalable, cloud-native applications with backend and
frontend, using services like PostgreSQL (SQL), FerretDB (NoSQL) and Milvus (vector)
databases, Redis cache and S3-compatible object storage, with minimum effort. Check the
tutorial on <https://openserverless.apache.org> for a complete guide on how to use it.
**This guide focuses on how to develop the system itself, not on how to use it.**
## TL;DR
The short version, for the impatient. Everything below this section explains *why* each
step is what it is; this is the sequence that gets you to a running local build.
**1. Clone with submodules.** Most of the code is in submodules, so this is not optional:
```bash
git clone --recurse-submodules https://github.com/apache/openserverless
cd openserverless
```
**2. Build the CLI first.** Nothing else works until `./ops` exists:
```bash
task cli # compiles cli/ into ./ops
```
The task files it runs are the ones in [`oplugins/`](oplugins/) — plain YAML, editable in
place.
> [!WARNING]
> **`./ops` behaves differently depending on where you are.** Run it from this directory
> and it uses your local [`oplugins/`](oplugins/). Run it from anywhere else and it
> **downloads** the task files from GitHub and uses those instead — your local edits are
> silently ignored. Always check with `./ops -info` that `OPS_ROOT` points inside your
> working copy. See [How `ops` finds its tasks](#how-ops-finds-its-tasks).
**3. Know where the images come from.** [`oplugins/opsroot.json`](oplugins/opsroot.json)
holds, under `.config.images`, the pointer to every image that gets deployed. Read it
before you wonder why the cluster is running something other than what you built — see
[`opsroot.json`](#opsrootjson).
**4. Build only what you need, then load it into kind.** Each component has its own task,
and each one builds the image *and* saves it so kind can pick it up:
```bash
task operator # or: task openwhisk, task admin-api, task streamer, task runtimes
task opsroot # rewrite opsroot.json to point at what you just built
ops setup docker # create the kind cluster and load your local images into it
```
`task build` does all of them at once — useful the first time, rarely what you want after.
Nothing is pulled from a registry: the images are saved to disk and imported straight into
the kind node. See [The development loop, end to end](#the-development-loop-end-to-end).
**5. Pick the submodules you actually work on.** You generally do not need to build
everything. Work inside a single subrepo, rebuild just that component, redeploy — see
[Iterating on a single component](#iterating-on-a-single-component).
**6. To publish images, fork and push a tag.** CI builds are triggered by tags, and they
run in whichever repository received the tag — so the tag must land in *your* fork:
```bash
# fork the repo on GitHub, then, in the component's directory:
git remote set-url origin https://github.com/<your-org>/<the-fork>
task tag # create the timestamp tag (this deletes existing local tags)
task ci # push the tag → GitHub Actions builds and pushes the image
```
See [Publishing images](#publishing-images).
## Components
The key components, each one a separate git repository checked out as a subdirectory of
this one:
| Directory | Repository | Role |
|-----------------|-------------------------------------|------|
| `cli` | `apache/openserverless-cli` | the `ops` CLI: controls the operator and implements the development environment |
| `oplugins-op` | `apache/openserverless-operator` | the operator, installing all the components including the integrated services |
| `admin-api` | `apache/openserverless-admin-api` | the admin API, complementing the operator |
| `streamer` | `apache/openserverless-streamer` | streaming support for AI applications |
| `runtimes` | `apache/openserverless-runtimes` | the runtimes to run applications in Python/Node.js/Go/Java using the integrated services |
| `build` | `apache/openserverless-build` | the OpenWhisk fork: controller and invoker images |
| `devcontainer` | `apache/openserverless-devcontainer`| the development container image |
| `oplugins` | `apache/openserverless-task` | the task files (the "plugins") that `ops` executes |
| `testing` | — | the end-to-end test suite |
## Start here: build the CLI
Everything else in this guide goes through `ops`, so build it first. From the root of this
repository:
```
task cli
```
That compiles the Go sources in [`cli/`](cli/) and leaves the binary in the repository root
as `./ops`. Nothing else is needed to start — no prerequisites to install, no configuration
to write.
Now ask it where it is:
```
./ops -info
```
This prints the environment the CLI resolved for itself — which task files it will run,
where it caches things, which version it is. It is the first thing to check whenever
something behaves unexpectedly, and it is explained field by field in
[How `ops` finds its tasks](#how-ops-finds-its-tasks).
Then ask it what it can do:
```
./ops -t
```
**The first run does real work.** `ops` ships with no task files and no external tools, so
before it can list anything it:
1. **downloads the tasks** — a git clone of `$OPS_REPO`
(default `apache/openserverless-task`) into `~/.ops/$OPS_BRANCH/oplugins`, unless it
finds them locally first. You will see `Cloning tasks...` / `Tasks downloaded
successfully`. In this repository the checked-out [`oplugins/`](oplugins/) is used
instead, so nothing is downloaded.
2. **downloads the prerequisites** — the external binaries the tasks declare in
`prereq.yml`, cached under `~/.ops/<os>-<arch>/bin`: `kubectl`, `helm`, `kind`, `k3sup`,
`yq`, `rg`, `uv`, `bun`, `7zz`, `coreutils`. They are fetched once, on demand, and pinned
by version.
Expect the first `ops -t` to pause while that happens; later runs are immediate. When it
finishes you get the banner and the list of top-level tasks:
```
$ ./ops -t
Welcome to ops, the all-mighty, extensibile apache OPenServerless CLI Tool.
...
*****
Tasks
*****
Type ops <task> to see usage and subtasks.
-----------------------------------
OpenServerless Administration Tasks
-----------------------------------
admin Manage additional users in OpenServerless
config Manage the Apache OpenServerless configuration
setup Setup the Apache OpenServerless platform on multiple environments
debug Debug utilities for the Apache OpenServerless platform
cloud OpenServerless setup utilities for supported Deployment models on Cloud Providers
util Utilities
...
```
`ops <task>` with no arguments shows that task's own usage and subtasks, so you can explore
downwards from here.
If a download goes wrong, `ops -update` re-fetches the tasks and prerequisites, and
`ops -reset` clears `~/.ops` entirely so the next run starts from scratch.
## The CLI
The system is controlled by `ops`, a single self-contained Go binary. It is essentially a
**task executor**: it is based on [Task](https://taskfile.dev) but extends it in ways that
turn it into a complete portable scripting environment.
### It is Task, extended
1. **It downloads prerequisite binaries.** Task files declare what they need in a
`prereq.yml`; `ops` fetches and caches the missing binaries on first use (see
[`cli/prereq.go`](cli/prereq.go)). You do not have to install them yourself.
2. **It embeds tools.** `wsk`, `wskdeploy`, `jq`, `sh`, `awk`, `envsubst`, `task` and a set
of small purpose-built helpers are compiled into the binary itself (see
[`cli/_patches/`](cli/_patches/) and [`cli/tools/`](cli/tools/)). They are invoked with a
`-` prefix and need no download:
```
ops -wsk action list
ops -jq . file.json
ops -h # list all the embedded tools
```
The result is that a single binary gives you a complete, portable scripting environment —
the same on Linux, macOS and Windows.
### General syntax
```
ops -<tool> <args>... # run an embedded tool
ops <task> <args>... # run a task from the task files
```
Essential flags:
```
-h | -help list embedded tools
-v | -version current version (always mention it when asking for help)
-t | -tasks list top level tasks (downloads them if needed)
-i | -info show the CLI environment
-u | -update download the latest tasks and prerequisites
-c | -config manage the server configuration
-l | -login access the system
-reset clean the downloads (if nothing works, try this)
```
### How `ops` finds its tasks
When you run a task, `ops` locates its **root** — the directory holding `opsfile.yml` and
`opsroot.json` — in this order (see `locateOpsRoot` in
[`cli/prepare.go:171`](cli/prepare.go#L171)):
1. `$OPS_ROOT`, if set.
2. Searching **upwards** from the current directory for a folder containing both
`opsfile.yml` and `opsroot.json`.
3. A `oplugins` (or `olaris`) subfolder **of the current directory**. This is what happens
when you work in this repository: the checked-out [`oplugins/`](oplugins/) is used
directly.
4. `~/.ops/$OPS_BRANCH/oplugins`. If it is not there, `ops` **downloads** it with a git
clone from `$OPS_REPO` (default `http://github.com/apache/openserverless-task`) at the
current branch.
So in normal use `ops` self-provisions its task files; in development it picks up your
local `oplugins` instead. Use `ops -info` to know which values are actually in effect:
```
$ ops -info
OPS & OPS_CMD: /Users/me/Projects/openserverless/ops
OPS_VERSION: 0.9.0-2609031223.SNAPSHOT
OPS_BRANCH:
OPS_BIN: /Users/me/.ops/darwin-arm64/bin
OPS_TMP: /Users/me/.ops/tmp
OPS_HOME: /Users/me/.ops
OPS_ROOT: /Users/me/Projects/openserverless/oplugins
OPS_REPO: http://github.com/apache/openserverless-task
OPS_PWD: /Users/me/Projects/openserverless
OPS_TASKS: 7e523adb9af3f17ae221220e9c17bce31fd92090
OPS_ROOT_PLUGIN: /Users/me/Projects/openserverless
```
`OPS_ROOT` pointing inside your working copy is the sign that you are running against your
local task files.
## The configuration files
### `opsroot.json`
[`oplugins/opsroot.json`](oplugins/opsroot.json) is **the glue of the whole system**. It
declares which images to deploy and other information; `.config.images` is the part that
matters most during development:
```json
{
"config": {
"images": {
"operator": "docker.io/apache/openserverless-operator:0.9.0-incubating.26i08j16-snapshot",
"controller": "docker.io/apache/openserverless-wsk-controller:0.9.0-incubating.26i07r51-snapshot",
"invoker": "docker.io/apache/openserverless-wsk-invoker:0.9.0-incubating.26i07r51-snapshot",
"streamer": "docker.io/apache/openserverless-streamer:...",
"systemapi": "docker.io/apache/openserverless-admin-api:...",
"devcontainer":"docker.io/apache/openserverless-devcontainer:...",
"couchdb": "docker.io/apache/couchdb:2.3",
"redis": "docker.io/bitnamilegacy/redis:8.2.1",
"...": "... third-party service images ..."
},
"ops": { }
},
"version": "..."
}
```
#### `.config` is a source of environment variables
The `config` object is not read by the tasks as JSON. **It is flattened into environment
variables** that are then passed to every task. The rule (see `flatten` in
[`cli/config/config_map.go:156`](cli/config/config_map.go#L156)) is: walk the tree, join the
keys with `_`, uppercase the result. So
```
.config.images.operator → IMAGES_OPERATOR
```
and a task can simply use `$IMAGES_OPERATOR`. `ops -config -d` shows the whole set — see
[Inspecting and editing the configuration](#inspecting-and-editing-the-configuration-ops--config)
below.
This is why `opsroot.json` is the glue: changing an image there changes what every task
deploys, with no code change anywhere.
The flattening merges three sources, in increasing order of precedence
([`cli/main.go:460`](cli/main.go#L460)):
1. `opsroot.json` from the task root — the defaults shipped with the release;
2. `config.json` — the user's own configuration, written by `ops -config`;
3. the `opsroot.json` of any installed plugin, under its own key (a plugin whose name
collides with an existing key is ignored, with a warning).
#### Inspecting and editing the configuration: `ops -config`
`ops -config` is how you read and change that merged configuration without editing JSON by
hand ([`cli/config/config_tool.go`](cli/config/config_tool.go)).
**Dump every variable** — `-d` (or `--dump`) prints the whole flattened set, exactly as the
tasks will see it. This is the quickest way to answer "what is this task actually going to
use?":
```
$ ops -config -d
IMAGES_OPERATOR=docker.io/apache/openserverless-operator:0.9.0-incubating.26i08j16-snapshot
IMAGES_CONTROLLER=docker.io/apache/openserverless-wsk-controller:0.9.0-incubating.26i07r51-snapshot
OPERATOR_CONFIG_APIHOST=miniops.me
OPERATOR_COMPONENT_POSTGRES=true
POSTGRES_CONFIG_REPLICAS=1
...
```
> The dump includes the generated passwords and API keys (`SECRET_*`,
> `REGISTRY_CONFIG_SECRET_*`). Redact it before pasting into a bug report.
**Read one value** — pass a bare key, with no `=`. Several keys print several values:
```
$ ops -config IMAGES_OPERATOR
docker.io/apache/openserverless-operator:0.9.0-incubating.26i08j16-snapshot
```
**Set a value** — pass `KEY=VALUE`. Several pairs at once are fine:
```
ops -config OPERATOR_CONFIG_APIHOST=my.host.example
ops -config IMAGES_STREAMER=ghcr.io/me/openserverless-streamer:mytag POSTGRES_CONFIG_REPLICAS=3
```
**Remove a value** — `-r` (or `--remove`) with the keys to drop:
```
ops -config -r POSTGRES_CONFIG_REPLICAS
```
All writes go to `config.json` in `$OPS_HOME` (`~/.ops/config.json`), which is layer 2 of
the merge above — so a value you set there **overrides** `opsroot.json` and survives the
task files being re-downloaded.
That layering has two consequences worth knowing:
- **`-r` removes a key from `config.json` only.** It cannot delete anything that came from
`opsroot.json`; removing your override just lets the underlying default show through
again. Removing a key that only exists in `opsroot.json` fails with
`invalid key: '<KEY>' - key does not exist in config.json`.
- **You cannot blank a value.** `ops -config -h` claims that `KEY=""` disables an
`opsroot.json` value, but the parser rejects an empty right-hand side
([`cli/config/config_tool.go:161`](cli/config/config_tool.go#L161)) and the command fails
with `invalid key-value pair: "KEY="`. To neutralise an inherited value today, set it to
something the task treats as off (`false`, `0`) — or edit `oplugins/opsroot.json`
directly, as described under [Overriding a single image](#overriding-a-single-image).
#### `.version` is the required CLI version
The top-level `version` field is **the version of `ops` that these task files require**. On
every run the CLI compares its own version against it as semver
([`cli/prepare.go:129`](cli/prepare.go#L129)). If `ops` is older, it says so and updates
itself automatically:
```
Your ops version (0.9.0) is older than the required version (0.9.1).
```
In development your CLI version is usually not valid semver (e.g.
`0.9.0-2609031223.SNAPSHOT`), so the check is skipped with a warning and you are never
force-updated out of your own build.
### `runtimes.json`
[`oplugins/runtimes.json`](oplugins/runtimes.json) points to the runtime images to use. It
is **a standard OpenWhisk runtimes manifest** — the same format OpenWhisk itself consumes,
with `runtimes`, `blackboxes` and `description` at the top level:
```json
{
"runtimes": {
"nodejs": [
{
"kind": "nodejs:24",
"default": false,
"image": {
"prefix": "docker.io/apache",
"name": "openserverless-runtime-nodejs",
"tag": "v24-26i07r17-snapshot"
},
"deprecated": false,
"attached": { "attachmentName": "codefile", "attachmentType": "text/plain" }
}
],
"python": [ ], "go": [ ], "java": [ ]
},
"blackboxes": [ ],
"description": "..."
}
```
It is generated in the [`runtimes/`](runtimes/) repository and copied over by
`task sync-runtimes`. Because it is the standard format, the CLI also hands it to the
embedded `wsk` through `WSK_RUNTIMES_JSON` ([`cli/main.go:291`](cli/main.go#L291)).
## How to build
The system runs on Kubernetes and can be deployed in many environments — most notably k3s,
microk8s, and cloud offerings like Amazon EKS, Azure AKS and Google GKE. For development
you can use Docker, and OpenServerless will install `kind` for you.
Build everything locally with:
```
task build
```
This builds all the required images. It runs, in order (see
[`Taskfile.yml:231`](Taskfile.yml#L231)):
| Step | What it does |
|------|--------------|
| `gitinit` | initializes git in the subrepos if missing (tags are the build's currency, so a repo without git cannot be built) |
| `opslink` | builds `ops`, symlinks it into `~/.local/bin`, links `oplugins` into `~/.ops/$OPS_BRANCH` |
| `openwhisk` | builds the controller and invoker images |
| `operator` | builds the operator image |
| `runtimes` | builds `runtimes.json` |
| `streamer` | builds the streamer image |
| `admin-api` | builds the admin-api image |
| `opsroot` | **rewrites `oplugins/opsroot.json` with the tags just built** |
You compile `ops` first, then `ops` builds the system.
### Everything is driven by tags
**All the compilations are driven by the git tag** of each component repository. A build
does not take a version from a variable — it reads the tag out of git, tags the image with
it, and writes that same tag into `opsroot.json`. There are two kinds of tag, for two
different purposes.
**There is no way around this, not even the manual runs.** Starting a workflow by hand from
the GitHub Actions UI (`workflow_dispatch`) looks like it bypasses tagging, but under the
hood it does exactly what you would have done locally: the job runs `task tag` and
`task ci` on the runner, and the following step reads the tag back with
`git describe --tags --abbrev=0` to name the image
([`oplugins-op/.github/workflows/image.yml:54`](oplugins-op/.github/workflows/image.yml#L54)).
So **every image that exists corresponds to a tag** — there is no such thing as an untagged
build, and given an image tag you can always find the commit it was built from.
#### Development tags: the operator ref
The main build derives its tag from the **git ref of the operator**
([`Taskfile.yml:25`](Taskfile.yml#L25)):
```yaml
RELEASE:
sh: git -C oplugins-op rev-parse --short HEAD
OPERATOR_TAG: "{{.RELEASE}}"
```
Every other component inherits it — `STREAMER_TAG`, `ADMIN_API_TAG`, `OPENWHISK_TAG`,
`DEVCONTAINER_TAG` and `OPS_BRANCH` are all `{{.OPERATOR_TAG}}`. One short commit hash
labels the whole build, so a local build is always internally consistent.
**These tags are meant for private development.** They never leave your machine: the images
are saved locally (under `~/.ops/<os>-<arch>/images/kind/`) and loaded into `kind`.
#### Publication tags: the timestamp
To publish images you define a tag explicitly with `task tag`, in the component's own
directory. These are the tags meant to publish images, and they are covered in full in
[Publishing images](#publishing-images) below.
### Keeping `opsroot.json` in sync
Two tasks write `opsroot.json`, and it is worth knowing which one you are running:
- **`task opsroot`** (part of `task build`) writes the tags of the images **you just built
locally** — the operator-ref tags.
- **`task sync-opsroot`** writes the **latest published tag of each component**, read from
git:
```yaml
OPENWHISK_TAG:
sh: git -C build for-each-ref --sort=-creatordate --count=1 --format='%(refname:short)' refs/tags
```
Both keep a pristine copy in `opsroot.orig` on first run and regenerate from it, so
repeated runs are idempotent. `task sync-images` runs the runtimes and opsroot syncs
together.
> **Gotcha.** `sync-opsroot` reads whatever tags are **local**. A freshly cloned subrepo has
> no tags, `for-each-ref` returns the empty string, and you silently get a tagless image
> reference such as `docker.io/apache/openserverless-wsk-controller:`. If images fail to
> pull with an empty tag, run `git fetch --tags` in the offending subdirectory and re-run
> the task.
## Developing against a local build
In development you set `OPS_ROOT` temporarily to your local task files, so that `ops` uses
the `oplugins` in your working copy rather than the downloaded ones. This is what
[`Taskfile.yml`](Taskfile.yml) does when running the tests:
```bash
export PATH="$PWD:$PATH"
export OPS_ROOT="$PWD/oplugins"
export OPS_BRANCH="<the branch>"
which ops
ops -info
```
Always confirm with `ops -info` that `OPS_ROOT` and `OPS_BIN` are what you expect before
concluding that a change did not work.
`task opslink` makes this semi-permanent: it symlinks `./ops` into `~/.local/bin` and your
`oplugins` into `~/.ops/$OPS_BRANCH/oplugins`, and appends `OPS_BRANCH` and the `PATH`
entry to `~/.profile`.
### The development loop, end to end
Building for development means producing images that exist **only on your machine**, tagged
with your operator ref, and pointing a local `kind` cluster at them. The full cycle:
```bash
# 1. build everything: ops, the images, and a matching opsroot.json
task build
# 2. make ops and your local task files the ones in use
export PATH="$PWD:$PATH"
export OPS_ROOT="$PWD/oplugins"
# 3. verify you are running against your own build
ops -info # OPS_ROOT must point inside your working copy
ops -t # list the tasks that will run
# 4. create the cluster and deploy
ops setup docker
```
The step that ties it together is invisible unless you go looking for it. Because these
images are never pushed to a registry, `task build` **saves each one to disk** as it is
built — `task image-save` writes it to
`~/.ops/<os>-<arch>/images/kind/<base64-of-the-image-name>`
([`Taskfile.yml:75`](Taskfile.yml#L75)). Later, `ops setup docker` calls
`ops util freeze kind-load`, which walks that directory, decodes each filename back into an
image name, and imports the layers straight into the kind node's containerd
([`oplugins/util/freeze/opsfile.yml:176`](oplugins/util/freeze/opsfile.yml#L176)).
So the chain from a code change to a running pod is:
```
your commit → operator ref = the tag → images built with that tag
→ saved under ~/.ops/.../images/kind/
→ opsroot.json rewritten with that tag (task opsroot)
→ IMAGES_* env vars (the CLI, on every run)
→ kind-load imports them into the cluster (ops setup docker)
→ the manifests reference exactly those images
```
Nothing is pulled from Docker Hub, because the tag in `opsroot.json` matches an image
already present in the node.
### Iterating on a single component
A full `task build` is rarely what you want after the first one. Each component has its own
task, so rebuild just the one you touched and redeploy:
```bash
task streamer # or: operator, admin-api, openwhisk, cli, runtimes
task opsroot # rewrite opsroot.json with the new tags
ops setup docker # reload the images and roll out
```
Two things to keep in mind:
- **`task cli` is a no-op once `./ops` exists** — it carries `status: ! test -e ../ops`
([`Taskfile.yml:95`](Taskfile.yml#L95)), and `task opslink` and `task build` both depend
on it, so neither will rebuild the binary either. After changing the CLI, `rm ./ops` (or
`task clean`) before rebuilding, or you will keep running the old one. `ops -info`
showing a stale `OPS_VERSION` is the tell.
- **The tag only changes when the operator ref changes.** `RELEASE` is
`git -C oplugins-op rev-parse --short HEAD`, so rebuilding the streamer without
committing in `oplugins-op` produces an image with the *same* tag as before. That is
usually what you want — but it means kind may keep the previously imported layers, so
delete the cluster (`ops setup docker delete`) if a change stubbornly refuses to appear.
### Overriding a single image
You do not have to rebuild anything to try a different image. Because `.config` is just a
source of env vars, editing `oplugins/opsroot.json` is enough:
```bash
jq '.config.images.streamer = "docker.io/apache/openserverless-streamer:some-other-tag"' \
oplugins/opsroot.json > tmp && mv tmp oplugins/opsroot.json
ops setup docker
```
`task opsroot` and `task sync-opsroot` will overwrite this on their next run — they
regenerate from `opsroot.orig` — so treat it as a temporary probe, not a durable change.
### Starting from a clean slate
When the state gets confusing, the reset ladder, from cheapest to most drastic:
```bash
ops setup docker delete # destroy the kind cluster
task clean # remove ./ops and the cached kind images
ops -reset # remove ~/.ops entirely (asks for confirmation)
ops -update # re-download tasks and prerequisites
```
## Publishing images
Everything above builds on your machine and stays there. Publishing is a different flow,
and it is governed by one rule:
> **All CI builds are triggered by pushing a git tag.** No branch push ever builds an
> image. Every component workflow is declared `on: push: tags:` with `branches-ignore: '*'`
> — the tag is not metadata attached to a build, the tag **is** the trigger, and its text
> **is** the image tag.
### The three tasks
Every component — the operator, admin-api, streamer, devcontainer, the OpenWhisk build, the
runtimes and the CLI — exposes the same trio, with the same meaning. You use them in this
order:
| Task | Runs where | Effect | Pushes? |
|------|------------|--------|---------|
| `task tag` | local | creates (force-replacing) the git tag that names this build | no |
| `task build` | local | builds using the current tag, into the local Docker daemon | no |
| `task ci` | local, triggers remote | pushes the tag, which starts the GitHub Actions build | yes, via CI |
Run them **in the component's own directory**, not in the root — each subrepo has its own
`Taskfile.yml` and its own workflow.
Two things newcomers consistently get wrong, so they are worth stating outright:
- **`task tag` deletes the existing local tags** before creating the new one. This is
deliberate — snapshot tags are disposable — but it is destructive, and it is not what
`git tag` normally does.
- **`task ci` does not build anything.** It runs `git push --tags`. The build happens in
GitHub Actions *on the repository that received the tag*. This is the whole reason
forking matters: the tag lands in **your** fork, so **your** Actions run, with **your**
credentials, pushing to **your** registry.
There is also a manual path: every workflow declares `workflow_dispatch`, so it can be
started from the GitHub Actions UI, where it creates the tag itself. Use it when you cannot
or do not want to push tags from the command line.
The CLI is the one component with a wrinkle: `task ci` (alias `task trigger`) refuses to
run on a dirty tree (`git diff --exit-code`), and its `task tag` **creates a commit** — it
writes `version.txt` and `branch.txt` and commits them, because the CLI embeds its own
version string. Every other component only moves a tag.
### The tag format
Tags are timestamps in a compacted, sortable form. The encoding is uniform across all
components, and it is worth learning because the tags are otherwise unreadable:
```
0.9.0-incubating.26i07r51-snapshot
└────┬─────────┘ ││││││└┬┘└───┬───┘
BASETAG ││││││ │ └───── SUFFIX: -snapshot marks a non-release build
││││││ └─────────── minute
│││││└───────────── hour, a letter: a=00 … x=23
│││└┴────────────── day of month
││└──────────────── month, a letter: a=January … l=December
└┴───────────────── year, two digits
```
So `26i07r51` is 2026, September (`i` = 9th letter), day 07, hour 17 (`r` = 18th letter,
zero-based → 17), minute 51.
Because the encoding is lexicographically sortable and the tags are created in order,
"the newest tag" and "the latest build" are the same thing — which is exactly what
`task sync-opsroot` relies on when it picks a tag with
`git for-each-ref --sort=-creatordate --count=1`.
The **runtimes** are the exception: their tag carries a prefix naming the family to build,
and CI parses it to decide what work to do (see below).
### Where the images go
The destination registry and the credentials are **derived automatically from the
repository the tag was pushed to**. In the normal case there is nothing to configure:
| Tag pushed to | Registry | Namespace | Credentials |
|---------------|----------|-----------|-------------|
| the Apache repository | `docker.io` | `apache` | secrets `DOCKERHUB_USER` / `DOCKERHUB_TOKEN` |
| any fork | `ghcr.io` | the fork owner's GitHub user | the current GitHub actor + the built-in `GITHUB_TOKEN` |
So a contributor who forks and pushes a tag gets images at `ghcr.io/<their-user>/…` with
**no secrets to create and no `.env` to write** — the workflow authenticates as the user
running it, with the token GitHub hands to every Actions run.
To send images somewhere else, set the `DOCKERHUB_REGISTRY` secret together with
`DOCKERHUB_USER` and `DOCKERHUB_TOKEN`. That is an override for people who want a specific
destination, not a setup step.
#### The exception: repositories that publish other repositories' images
Two repositories publish images whose names do **not** match the repository itself:
| Repository | Images published |
|------------|------------------|
| `openserverless-build` | `openwhisk2/controller`, `openwhisk2/invoker`, `openwhisk2/scheduler`, `openwhisk2/standalone`, `openwhisk2/scala` |
| `openserverless-runtimes` | `openserverless-runtime-<rt>` — one per language version |
The built-in `GITHUB_TOKEN` is scoped to packages **owned by the repository running the
workflow**: GHCR links a package to its creating repository on first push and grants write
access only to that repository. Since these two publish under names that do not match,
`GITHUB_TOKEN` carries no permission and the push is refused.
For these two repositories only, when pushing to ghcr.io, a **`GHCR_TOKEN`** secret must be
set on the fork — a classic Personal Access Token with the **`write:packages`** scope:
1. <https://github.com/settings/tokens> → *Generate new token (classic)*.
2. Select **`write:packages`** (this implies `read:packages` and `repo`).
3. In your fork: *Settings → Secrets and variables → Actions → New repository secret*,
named exactly **`GHCR_TOKEN`**.
When pushing to Docker Hub instead, the equivalent is a `DOCKERHUB_TOKEN` with write
permission on the target images.
The symptom when this is missing is `denied: permission_denied: write_package` at push
time, after a long and otherwise successful build. Both workflows now **fail fast** with
these instructions in the job summary rather than building first and failing at the end.
### Naming your local build
Locally no registry is involved: each component builds under the same default image name it
would use in CI, and the result exists only in your Docker daemon. To name it for your own
registry instead, export `MY_<COMPONENT>_IMAGE` — the **full image name without the tag**;
the tag is still computed as above. Each component ships a `.env.dist` to copy to `.env`
(which stays untracked):
| Component | Variable |
|-----------|----------|
| operator | `MY_OPERATOR_IMAGE` |
| streamer | `MY_STREAMER_IMAGE` |
| admin-api | `MY_ADMINAPI_IMAGE` |
| devcontainer | `MY_DEVCONTAINER_IMAGE` |
| OpenWhisk build | `MY_CONTROLLER_IMAGE`, `MY_INVOKER_IMAGE`, `MY_SCHEDULER_IMAGE`, `MY_STANDALONE_IMAGE` |
### What each component publishes
The trio above is identical everywhere; what differs is what one tag produces.
**One tag → one image** — `operator`, `admin-api`, `streamer`, `devcontainer`. The plain
case:
```bash
cd streamer
task tag # 0.9.0-incubating.26i07r51-snapshot
task build # local image, nothing pushed
task ci # push the tag → CI builds linux/amd64 + linux/arm64 and pushes
```
CI also runs the license check (skywalking-eyes) and the unit tests **before** building the
image, so a red build is not necessarily an image problem.
**One tag → a family of images** — `runtimes`. The tag selects the work:
```bash
cd runtimes
task tag RT=python # python_26i07r51-snapshot
task tag # RT defaults to "all"
```
CI parses the prefix (`all`, `common`, `experimental`, or a single language) and builds
only that family, producing one image per language version:
```
<registry>/<namespace>/openserverless-runtime-<rt>:<ver>-<tag>
```
Every runtime image is `FROM` the **common base**, so the common job runs first and the
language matrix depends on it; building a language family against a missing or mismatched
base is a failure mode, not a bug. This repository needs `GHCR_TOKEN` (see above). The
resulting `runtimes.json` is what reaches an installation, via `task sync-runtimes`.
**One tag → binaries, no image** — `cli`. `task build` compiles `ops` into the repository
root and is all you need locally; tagging matters only when the embedded version string
does. `task ci` tags, commits, and pushes, and the release workflow publishes binaries for
every supported platform.
**The OpenWhisk images** — `build`. This one does not follow the pattern of the others and
deserves its own note:
- It vendors an OpenWhisk source tree and builds through a devcontainer rather than on the
host, because the OpenWhisk Gradle build **requires Java 11**. On a host with a newer JDK
it fails with `Unsupported class file major version`. Run it inside
`ops ide devcontainer`.
- Its `task build` does the whole local round trip: build the images, update
`opsroot.json`, load into kind, roll out the controller.
- **The root `Taskfile.yml` and `build/Taskfile.yml` are separate build systems.** This is
the single most common wrong assumption. The root `task build` does invoke `task
openwhisk`, but the two Taskfiles maintain their own tags and their own `opsroot.json`
edits — when in doubt about which controller image is deployed, read
`.config.images.controller` rather than inferring it.
### Consuming a build you published
Once CI has pushed your images, point an installation at them by editing the image
references in `opsroot.json` — either your working copy's `oplugins/opsroot.json`, or
`~/.ops/$OPS_BRANCH/oplugins/opsroot.json` for an installed CLI:
```bash
jq '.config.images.streamer = "ghcr.io/<your-user>/openserverless-streamer:<your-tag>"' \
oplugins/opsroot.json > tmp && mv tmp oplugins/opsroot.json
ops setup docker
```
Runtimes are overridden the same way through `runtimes.json`.
Two failure modes worth recognising:
- **Locally built images are not automatically visible to kind.** If you skip the
`kind-load` step described earlier, the pod silently pulls the old upstream image
instead. The symptom is a deployment that succeeds while running the wrong code.
- **A change visible in the custom resource is not proof it reached the workload.** Confirm
at the pod level:
```bash
kubectl -n openserverless get pods -o jsonpath='{.items[*].spec.containers[*].image}'
```
## Testing
Run the whole suite (defaults to `kind`):
```
task test
```
Run a single test — with an empty or unknown `TEST` it lists the available ones:
```
task test-one TEST=<name>.sh
task test-one TEST=
```
Both live in [`testing/tests/`](testing/tests/) and export `OPS_ROOT` to the local
`oplugins`, so they always exercise your working copy.
## Other useful tasks
| Task | Purpose |
|------|---------|
| `task` | list all the tasks and print the current release |
| `task clean` | remove `./ops` and the cached kind images |
| `task license` | check the license headers with `license-eye` (`CMD=fix` to fix them) |
| `task sync-images` | refresh `runtimes.json` and `opsroot.json` from the latest published tags |
| `task release VER=<v> KEY=<gpg-key>` | build the signed source release tarball; the source must be in a version folder and you need a GPG key to sign |
See also [VERIFY.md](VERIFY.md) for verifying a release, and
[CONTRIBUTING.md](CONTRIBUTING.md).