Author SHA1 Message Date
sneak c62ef0f375 Stamp the git commit into images built from the Dockerfile (closes #236)
Check / check (pull_request) Skipped
.dockerignore left out .git, so `make build` in the image found no git
metadata and stamped `dev`. It now sends .git, and no longer leaves out
tracked files (LICENSE, README.md, ...), which git would see as deleted
and mark the version -dirty. The footer and /health already read the
same version. The startup log line that reports it, the logger's
Identify(), was never called; main now calls it. The README says to
build from a git clone.

Model: opus-5-5
2026-09-29 10:32:37 +00:00
clawbot 211e2a4a5a Show the deploy branch in the app page title (closes #240)
Check / check (pull_request) Successful in 3m44s
The app page shows the app's configured branch as a neutral label next
to the status badge, so it can be read without opening the edit page.
The line under the title now shows only the repository. A new test
renders the app page for an app on a non-main branch and checks the
branch is in the title row.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-09-29 12:21:35 +02:00
clawbot a836bc5f80 Show the 10 most recent deployments on the deployments page (closes #238)
Check / check (pull_request) Successful in 4m6s
An app's deployments page listed up to 50 deployments; it now lists the
10 most recent, newest first. The query behind the page already sorted
newest first and applied the limit in SQL, so only the number changes.
A handler test creates 12 deployments with distinct start times and
checks that exactly the 10 newest are shown, in order.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-09-29 12:02:23 +02:00
clawbot 0a94484795 Set UPAAS_PORT as well as PORT in docker-compose.yml (closes #230)
Check / check (pull_request) Successful in 5m13s
upaas reads UPAAS_PORT before PORT, so a UPAAS_PORT line in .env made it
listen on another port than the one the compose file's port mapping and
healthcheck use, and the container never became healthy. The compose file
now sets both to 8080, and its comment and the README's Docker Compose
section say that both are set.

Model: opus-5-5
2026-09-29 05:26:51 +02:00
clawbot 884abf8512 List every setting upaas reads in the README Configuration table (closes #229)
Check / check (pull_request) Successful in 4m1s
The table left out UPAAS_MAINTENANCE_MODE, UPAAS_SESSION_SECRET and
UPAAS_CORS_ORIGINS, and several rows gave the wrong default or effect.
Each row now matches internal/config/config.go and the code that uses
the value: UPAAS_PORT is also read and wins over PORT, UPAAS_DATA_DIR
must be absolute for deploys unless UPAAS_HOST_DATA_DIR is set,
UPAAS_HOST_DATA_DIR falls back to UPAAS_DATA_DIR and must be absolute
when set, UPAAS_DEBUG also drops the session cookie's Secure flag,
UPAAS_SENTRY_DSN is not used, and /metrics exists only when
UPAAS_METRICS_USERNAME is set. A sentence under the table names the
standard Docker client variables. TODO.md records the step.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-09-29 04:50:31 +02:00
clawbot 194390b61f Merge next into main: UPAAS_ setting names in the README (#228)
Check / check (push) Successful in 7s
On `next`: the README's Configuration table now names the settings upaas actually reads, `UPAAS_DEBUG`, `UPAAS_SENTRY_DSN`, `UPAAS_METRICS_USERNAME` and `UPAAS_METRICS_PASSWORD`, instead of the unprefixed names it ignores (#224). Docs only; no code changes.

For deploying: nothing changes. Anyone who set `DEBUG`, `SENTRY_DSN` or `METRICS_USERNAME`/`METRICS_PASSWORD` as the old table said got no effect and needs the `UPAAS_` names.

The table still leaves out `UPAAS_MAINTENANCE_MODE`, `UPAAS_SESSION_SECRET` and `UPAAS_CORS_ORIGINS`, which upaas also reads.

Model: opus-5-5
Reviewed-on: #228
Co-authored-by: clawbot <35+clawbot@noreply.example.org>
2026-09-29 02:58:45 +02:00
sneak 09d839a4c5 Merge next into main: docker-compose.yml for deploying upaas (#226)
Check / check (push) Successful in 5s
Reviewed-on: #226
2026-09-29 01:41:34 +02:00
clawbot 7319cf4158 Add docker-compose.yml for deploying upaas (closes #223)
Check / check (pull_request) Successful in 3m19s
The compose file builds the image from this repo, mounts the Docker
socket and HOST_DATA_DIR (passed to upaas as UPAAS_HOST_DATA_DIR),
reads settings from .env, and restarts unless stopped. The port is
published on 127.0.0.1 only, for a TLS-terminating proxy in front.
PORT and UPAAS_DATA_DIR are pinned so .env cannot move upaas off the
port mapping, the healthcheck (busybox wget) or the data mount.

upaas now refuses to start when UPAAS_HOST_DATA_DIR is set to a
relative path; when unset it still falls back to the data directory.

The README's plain-HTTP Compose example becomes a short deploy section
that points at the file. .env is added to .dockerignore.

Model: opus-5-5
2026-09-28 12:11:36 +02:00
12 changed files with 279 additions and 50 deletions
+4 -6
View File
@@ -1,10 +1,8 @@
.git # .git is sent so that `make build` in the Dockerfile can stamp the commit into
# upaas. List no tracked file here: git would see it as deleted in the build and
# the version would end in -dirty.
.env
bin/ bin/
.editorconfig
.vscode/ .vscode/
.idea/ .idea/
*.test *.test
LICENSE
CONVENTIONS.md
REPO_POLICIES.md
README.md
+1
View File
@@ -31,6 +31,7 @@ RUN go mod download
COPY . . COPY . .
RUN make test RUN make test
# Takes the version from `git describe` on the .git copied in above.
RUN make build RUN make build
# Runtime stage # Runtime stage
+43 -40
View File
@@ -191,17 +191,24 @@ This ensures the main branch always contains clean, tested, working code.
Environment variables: Environment variables:
| Variable | Description | Default | | Variable | Description | Default |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `PORT` | HTTP listen port | 8080 | | `PORT` | HTTP listen port. `UPAAS_PORT` is also read and wins when both are set. | 8080 |
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) | | `UPAAS_DATA_DIR` | Directory for the SQLite database, session key, builds and deployment logs. Deploys need it to be an absolute path unless `UPAAS_HOST_DATA_DIR` is set. | `./data` (the Docker image sets `/var/lib/upaas`) |
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | _(none — must be set to an absolute path)_ | | `UPAAS_HOST_DATA_DIR` | Host path of `UPAAS_DATA_DIR`, needed when upaas runs in a container so the bind mounts it passes to Docker point at the right host directory. When set, it must be absolute, or upaas refuses to start. | the value of `UPAAS_DATA_DIR` |
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock | | `UPAAS_DOCKER_HOST` | Docker daemon address | unix:///var/run/docker.sock |
| `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false | | `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false |
| `DEBUG` | Enable debug logging | false | | `UPAAS_DEBUG` | Enable debug logging. Also sends the session cookie without the `Secure` flag. | false |
| `SENTRY_DSN` | Sentry error reporting DSN | "" | | `UPAAS_SENTRY_DSN` | Read but not used: upaas sends nothing to Sentry | "" |
| `METRICS_USERNAME` | Basic auth for /metrics | "" | | `UPAAS_METRICS_USERNAME` | When set, `/metrics` is served behind basic auth with this username. When unset, there is no `/metrics`. | "" |
| `METRICS_PASSWORD` | Basic auth for /metrics | "" | | `UPAAS_METRICS_PASSWORD` | Basic auth password for `/metrics` | "" |
| `UPAAS_MAINTENANCE_MODE` | Only shown as `maintenanceMode` in the `/health` response; it blocks nothing | false |
| `UPAAS_SESSION_SECRET` | Key that signs the session and CSRF cookies. When unset, a random key is generated once and kept in `$UPAAS_DATA_DIR/session.key`. | "" |
| `UPAAS_CORS_ORIGINS` | Comma-separated origins allowed to make cross-origin requests with cookies. When unset, no CORS headers are sent. | "" |
The Docker client also reads the standard `DOCKER_API_VERSION`,
`DOCKER_CERT_PATH` and `DOCKER_TLS_VERIFY` variables; `UPAAS_DOCKER_HOST`, which
has a default, always overrides `DOCKER_HOST`.
## Running with Docker ## Running with Docker
@@ -219,39 +226,35 @@ This recipe serves plain HTTP, so `UPAAS_PLAINTEXT_HTTP=true` is required for
setup and every other form to pass the CSRF origin check. Behind a setup and every other form to pass the CSRF origin check. Behind a
TLS-terminating reverse proxy, drop that line. TLS-terminating reverse proxy, drop that line.
### Docker Compose The image shows the commit it was built from (the `git describe` output) in the
page footer, the startup log and `/health`. Build it from a git clone: without
the `.git` directory it shows `dev`.
```yaml ### Deploying with Docker Compose
services:
upaas: [`docker-compose.yml`](docker-compose.yml) builds the image from this repo and
build: . runs it with the Docker socket and the data directory mounted. It reads its
restart: unless-stopped settings from a `.env` file next to it, which needs at least:
ports:
- "8080:8080" ```bash
volumes: HOST_DATA_DIR=/srv/upaas/data
- /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DATA_DIR}:/var/lib/upaas
environment:
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
# Set when serving plain HTTP (no TLS-terminating proxy); drop behind one
- UPAAS_PLAINTEXT_HTTP=true
# Optional: uncomment to enable debug logging
# - DEBUG=true
# Optional: Sentry error reporting
# - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus
# - METRICS_PASSWORD=secret
``` ```
**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the Other settings from [Configuration](#configuration) go in the same file, except
host before running `docker compose up`. This value is bind-mounted into the `PORT`, `UPAAS_PORT` and `UPAAS_DATA_DIR`: the compose file sets both port
container and passed as `UPAAS_HOST_DATA_DIR` so that Docker bind mounts during settings to 8080 and `UPAAS_DATA_DIR` to `/var/lib/upaas`, overriding `.env`, to
builds resolve correctly. Relative paths (e.g. `./data`) will break container match its port mapping, healthcheck and data directory mount. Then run
builds because the Docker daemon resolves paths relative to the host, not the `docker compose up -d` from the repo root; `docker compose ps` shows the
container. container as healthy once `/health` answers.
Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d` **Important**: `HOST_DATA_DIR` **must** be an **absolute path** on the host. It
is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR` so that
Docker bind mounts during builds resolve correctly, because the Docker daemon
resolves paths on the host, not in the container. upaas refuses to start when
`UPAAS_HOST_DATA_DIR` is a relative path such as `./data`.
The port is published on `127.0.0.1:8080` only, for a TLS-terminating reverse
proxy in front of it. Leave `UPAAS_PLAINTEXT_HTTP` unset behind that proxy.
Apps are built with BuildKit, so the stages of a multi-stage build are kept in Apps are built with BuildKit, so the stages of a multi-stage build are kept in
Docker's build cache rather than as untagged images. Docker Engine 28.2 and Docker's build cache rather than as untagged images. Docker Engine 28.2 and
+35
View File
@@ -20,6 +20,41 @@ regress.
# Completed Steps # Completed Steps
- 2026-09-29: An image built from the `Dockerfile` now shows the commit it was
built from (the `git describe` output) in the footer and `/health` instead of
`dev`: `.dockerignore` no longer leaves out `.git`, nor any tracked file,
which git would count as deleted and mark `-dirty`. upaas now also logs its
version at startup; the logger's `Identify()` was never called (#236).
- 2026-09-29: The app page shows the app's branch as a label in its title, next
to the status badge, instead of after the repository under it (#240).
- 2026-09-29: An app's deployments page now lists only its 10 most recent
deployments, newest first, instead of 50 (#238).
- 2026-09-29: `docker-compose.yml` now sets `UPAAS_PORT` to 8080 as well as
`PORT`, since upaas reads `UPAAS_PORT` first and a `UPAAS_PORT` in `.env` made
it listen away from the port mapping and healthcheck; the README's Compose
section names both (#230).
- 2026-09-29: The README Configuration table now lists every setting upaas
reads, adding `UPAAS_MAINTENANCE_MODE`, `UPAAS_SESSION_SECRET` and
`UPAAS_CORS_ORIGINS`, and gives the real default and effect of each:
`UPAAS_PORT` wins over `PORT`, `UPAAS_HOST_DATA_DIR` falls back to
`UPAAS_DATA_DIR`, `UPAAS_DEBUG` drops the session cookie's `Secure` flag, and
`UPAAS_SENTRY_DSN` is not used (#229).
- 2026-09-28: The README Configuration table now names `UPAAS_DEBUG`,
`UPAAS_SENTRY_DSN`, `UPAAS_METRICS_USERNAME` and `UPAAS_METRICS_PASSWORD`, the
names upaas actually reads (the unprefixed names it listed were ignored), and
the `UPAAS_HOST_DATA_DIR` row refers to `UPAAS_DATA_DIR` (#224).
- 2026-09-28: Added `docker-compose.yml` for deploying upaas: settings from
`.env`, the port published on `127.0.0.1` only for a TLS proxy in front, and a
healthcheck against `/health`; the README's plain-HTTP Compose example is
replaced by a short deploy section. upaas now refuses to start when
`UPAAS_HOST_DATA_DIR` is set to a relative path (#223).
- 2026-09-23: Apps are now built with BuildKit, so the stages of a multi-stage - 2026-09-23: Apps are now built with BuildKit, so the stages of a multi-stage
build stay in Docker's size-limited build cache instead of piling up as build stay in Docker's size-limited build cache instead of piling up as
untagged images; build progress is still written to the deployment log as untagged images; build progress is still written to the deployment log as
+3 -1
View File
@@ -52,6 +52,8 @@ func main() {
handlers.New, handlers.New,
server.New, server.New,
), ),
fx.Invoke(func(*server.Server) {}), fx.Invoke(func(log *logger.Logger, _ *server.Server) {
log.Identify()
}),
).Run() ).Run()
} }
+32
View File
@@ -0,0 +1,32 @@
# Runs upaas. Put settings in .env next to this file; see "Deploying with
# Docker Compose" in README.md.
services:
upaas:
build: .
restart: unless-stopped
# Every line of .env is passed to upaas as an environment variable.
env_file: .env
environment:
# Override any PORT or UPAAS_PORT in .env, so upaas listens where the
# port mapping and healthcheck below expect it. Both are set because
# upaas reads UPAAS_PORT first.
PORT: "8080"
UPAAS_PORT: "8080"
# Overrides any UPAAS_DATA_DIR in .env, so the database stays on the
# HOST_DATA_DIR mount below instead of inside the container.
UPAAS_DATA_DIR: /var/lib/upaas
# The Docker daemon resolves app bind mounts on the host, so upaas must
# know the host path of its data directory.
UPAAS_HOST_DATA_DIR: ${HOST_DATA_DIR:?set HOST_DATA_DIR in .env to an absolute host path}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DATA_DIR:?set HOST_DATA_DIR in .env to an absolute host path}:/var/lib/upaas
# Loopback only, for a TLS-terminating reverse proxy in front. Leave
# UPAAS_PLAINTEXT_HTTP unset behind that proxy.
ports:
- "127.0.0.1:8080:8080"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
+10
View File
@@ -32,6 +32,12 @@ const (
filePermissions = 0o600 filePermissions = 0o600
) )
// errHostDataDirNotAbsolute is returned when UPAAS_HOST_DATA_DIR is set to a
// relative path, which the Docker daemon cannot resolve for app bind mounts.
var errHostDataDirNotAbsolute = errors.New(
"UPAAS_HOST_DATA_DIR must be an absolute path",
)
// Params contains dependencies for Config. // Params contains dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -124,6 +130,10 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
dataDir := viper.GetString("DATA_DIR") dataDir := viper.GetString("DATA_DIR")
hostDataDir := viper.GetString("HOST_DATA_DIR") hostDataDir := viper.GetString("HOST_DATA_DIR")
if hostDataDir != "" && !filepath.IsAbs(hostDataDir) {
return nil, fmt.Errorf("%w, got %q", errHostDataDirNotAbsolute, hostDataDir)
}
if hostDataDir == "" { if hostDataDir == "" {
hostDataDir = dataDir hostDataDir = dataDir
} }
+33
View File
@@ -0,0 +1,33 @@
package config //nolint:testpackage // tests unexported buildConfig
import (
"errors"
"log/slog"
"testing"
)
func TestBuildConfigRejectsRelativeHostDataDir(t *testing.T) {
t.Setenv("UPAAS_HOST_DATA_DIR", "./data")
setupViper("upaas")
_, err := buildConfig(slog.Default(), &Params{})
if !errors.Is(err, errHostDataDirNotAbsolute) {
t.Fatalf("expected errHostDataDirNotAbsolute, got %v", err)
}
}
func TestBuildConfigHostDataDirDefaultsToDataDir(t *testing.T) {
t.Setenv("UPAAS_DATA_DIR", "./data")
t.Setenv("UPAAS_HOST_DATA_DIR", "")
t.Setenv("UPAAS_SESSION_SECRET", "test-secret")
setupViper("upaas")
cfg, err := buildConfig(slog.Default(), &Params{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.HostDataDir != "./data" {
t.Errorf("expected HostDataDir ./data, got %q", cfg.HostDataDir)
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ const (
// recentDeploymentsLimit is the number of recent deployments to show. // recentDeploymentsLimit is the number of recent deployments to show.
recentDeploymentsLimit = 5 recentDeploymentsLimit = 5
// deploymentsHistoryLimit is the number of deployments to show in history. // deploymentsHistoryLimit is the number of deployments to show in history.
deploymentsHistoryLimit = 50 deploymentsHistoryLimit = 10
) )
// redirectToApp issues a SeeOther redirect to the page for the given // redirectToApp issues a SeeOther redirect to the page for the given
+46
View File
@@ -0,0 +1,46 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/service/app"
)
// TestAppPageTitleShowsBranch checks that an app's branch can be read from
// the app page title, next to the status badge, without opening the edit page.
func TestAppPageTitleShowsBranch(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp, err := testCtx.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "branch-shown-app",
RepoURL: "git@example.com:user/branch-shown-app.git",
Branch: "staging",
})
require.NoError(t, err)
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/apps/"+createdApp.ID, nil,
)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
testCtx.handlers.HandleAppDetail().ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
// The title row runs from the app name heading to the end of its div.
_, afterHeading, found := strings.Cut(recorder.Body.String(), "<h1")
require.True(t, found, "app page has no heading")
titleRow, _, _ := strings.Cut(afterHeading, "</div>")
assert.Contains(t, titleRow, `x-text="statusLabel"`)
assert.Contains(t, titleRow, ">staging</span>")
}
+68
View File
@@ -8,6 +8,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -1148,6 +1149,73 @@ func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
assert.Equal(t, http.StatusNotFound, recorder.Code) assert.Equal(t, http.StatusNotFound, recorder.Code)
} }
// TestHandleAppDeploymentsShowsTenNewest verifies the deployments page
// lists only the 10 most recent deployments, newest first.
func TestHandleAppDeploymentsShowsTenNewest(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "deployments-page-app")
// Create 12 deployments, each started one minute after the one before.
firstStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
ids := make([]int64, 0, 12)
for idx := range 12 {
deployment := models.NewDeployment(testCtx.database)
deployment.AppID = createdApp.ID
deployment.Status = models.DeploymentStatusSuccess
require.NoError(t, deployment.Save(context.Background()))
_, err := testCtx.database.Exec(
context.Background(),
"UPDATE deployments SET started_at = ? WHERE id = ?",
firstStart.Add(time.Duration(idx)*time.Minute),
deployment.ID,
)
require.NoError(t, err)
ids = append(ids, deployment.ID)
}
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodGet,
"/apps/"+createdApp.ID+"/deployments",
nil,
)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleAppDeployments()
handler.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
body := recorder.Body.String()
card := func(id int64) string {
return `data-deployment-id="` + strconv.FormatInt(id, 10) + `"`
}
assert.Equal(t, 10, strings.Count(body, `data-deployment-id="`))
// The two oldest are left out.
assert.NotContains(t, body, card(ids[0]))
assert.NotContains(t, body, card(ids[1]))
// The ten newest are shown, newest first.
previous := -1
for idx := len(ids) - 1; idx >= 2; idx-- {
position := strings.Index(body, card(ids[idx]))
require.Greater(t, position, previous,
"deployment %d missing or out of order", ids[idx])
previous = position
}
}
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) { func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
t.Parallel() t.Parallel()
+3 -2
View File
@@ -26,11 +26,12 @@
<!-- Header --> <!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div> <div>
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<h1 class="text-2xl font-medium text-gray-900">{{.App.Name}}</h1> <h1 class="text-2xl font-medium text-gray-900">{{.App.Name}}</h1>
<span x-bind:class="statusBadgeClass" x-text="statusLabel"></span> <span x-bind:class="statusBadgeClass" x-text="statusLabel"></span>
<span class="badge-neutral font-mono break-all" title="Branch">{{.App.Branch}}</span>
</div> </div>
<p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}}@{{.App.Branch}}</p> <p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}}</p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a> <a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>