Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/release-engine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ jobs:
# what triggered this workflow; goreleaser needs the plain vX.Y.Z form.
git tag "${TAG}"

- name: Generate NOTICE
run: |
go install github.com/google/go-licenses@latest
go-licenses report ./... > NOTICE
working-directory: packages/engine

- name: Run goreleaser
uses: goreleaser/goreleaser-action@v6
with:
Expand Down
10 changes: 4 additions & 6 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The automated CI path (Version PR → merge) requires no local tooling beyond gi
```
1. changeset file added during dev
2. push to main → release-please.yml creates/updates "Version PR"
2. push to main → changeset-version.yml creates/updates "Version PR"
3. merge Version PR
Expand All @@ -28,8 +28,6 @@ The automated CI path (Version PR → merge) requires no local tooling beyond gi
5b. release-helm.yml → helm push OCI + GitHub Release
```

> **Note on workflow naming:** `release-please.yml` is named after a tool we evaluated but didn't adopt — it actually runs the Changesets CLI. A rename to `changeset-version.yml` is tracked in [dvflw/mantle#123](https://github.com/dvflw/mantle/issues/123).

## Step 1: Add a Changeset (During Development)

Every PR that changes user-visible behavior needs a changeset. Run this from the repo root:
Expand All @@ -56,7 +54,7 @@ If a PR touches only CI, tests, or tooling with no user impact, skip the changes

## Step 2: The Version PR

After any push to `main` that contains pending changesets, the `release-please.yml` workflow automatically creates (or updates) a PR titled **"ci: version packages"**.
After any push to `main` that contains pending changesets, the `changeset-version.yml` workflow automatically creates (or updates) a PR titled **"ci: version packages"**.

This PR:
- Bumps `version` in each affected `package.json` (taking the highest bump across all pending changesets)
Expand All @@ -65,7 +63,7 @@ This PR:

Review the PR to confirm the version bumps and changelog entries are correct. If more changesets land on `main` before you merge, CI updates the PR automatically.

> **Loop guard:** the workflow has an `if` condition that skips runs where the triggering commit message starts with `"ci: version packages"`. This prevents the workflow from retriggering on its own Version PR merge commit. If you ever rename the commit message in the workflow, update the guard condition in `release-please.yml` to match.
> **Loop guard:** the workflow has an `if` condition that skips runs where the triggering commit message starts with `"ci: version packages"`. This prevents the workflow from retriggering on its own Version PR merge commit. If you ever rename the commit message in the workflow, update the guard condition in `changeset-version.yml` to match.

## Step 3: Merge the Version PR

Expand Down Expand Up @@ -107,7 +105,7 @@ The tag-triggered workflows are safe to retrigger — goreleaser will fail clean

This applies when `package.json` is already at the target version but `.changeset/` contains no pending changeset files — for example, the first-ever release of a newly bootstrapped repo, or after manually editing `package.json` outside the changeset flow.

In this state the `release-please.yml` workflow has nothing to process and will not create a Version PR. Push the tags directly:
In this state the `changeset-version.yml` workflow has nothing to process and will not create a Version PR. Push the tags directly:

```bash
git tag engine/v<version>
Expand Down
3 changes: 3 additions & 0 deletions packages/engine/.goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ archives:
# (packages/engine); LICENSE lives at the repo root.
- src: "../../LICENSE"
dst: "LICENSE"
# Third-party license notices generated by go-licenses during release CI.
- src: "NOTICE"
dst: "NOTICE"

checksum:
name_template: checksums.txt
Expand Down
6 changes: 3 additions & 3 deletions packages/engine/Dockerfile.goreleaser
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
FROM alpine:3.21
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S mantle \
&& adduser -S -G mantle -h /home/mantle -s /bin/sh mantle
&& adduser -S -u 10001 -G mantle -h /home/mantle -s /bin/sh mantle

ARG TARGETPLATFORM
COPY $TARGETPLATFORM/mantle /usr/local/bin/mantle
Expand All @@ -15,7 +15,7 @@ EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/healthz || exit 1

USER mantle
USER 10001:10001
WORKDIR /home/mantle

ENTRYPOINT ["mantle"]
4 changes: 3 additions & 1 deletion packages/engine/internal/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ func AuthMiddleware(store *Store, oidcValidator TokenValidator, next http.Handle
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for health checks only. /metrics requires authentication.
// Skip auth for health checks only. /metrics is intentionally protected —
// path labels expose workflow names. Configure Prometheus with a bearer
// token via serviceMonitor.bearerTokenSecret in the Helm values.
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
next.ServeHTTP(w, r)
return
Expand Down
13 changes: 11 additions & 2 deletions packages/engine/internal/connector/email_receive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,17 @@ func TestEmailReceive_FetchesUnseenMessages(t *testing.T) {
"\r\n" +
"This is the body of the test email.\r\n")

if err := smtp.SendMail(smtpAddr, nil, "sender@example.com", []string{username}, msg); err != nil {
t.Fatalf("failed to send test email: %v", err)
var smtpErr error
for attempt := 1; attempt <= 3; attempt++ {
smtpErr = smtp.SendMail(smtpAddr, nil, "sender@example.com", []string{username}, msg)
if smtpErr == nil {
break
}
t.Logf("smtp attempt %d: %v (retrying in 500ms)", attempt, smtpErr)
time.Sleep(500 * time.Millisecond)
}
if smtpErr != nil {
t.Fatalf("failed to send test email after retries: %v", smtpErr)
}

// Fetch via the connector — retry to allow GreenMail time to propagate
Expand Down
61 changes: 52 additions & 9 deletions packages/engine/internal/secret/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,27 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"

"github.com/dvflw/mantle/internal/audit"
"github.com/dvflw/mantle/internal/auth"
)

// Store handles CRUD operations for encrypted credentials in Postgres.
// Every state-changing method emits an audit event in the same transaction.
type Store struct {
DB *sql.DB
Encryptor *Encryptor
Actor string // defaults to "cli" when empty
}

func (s *Store) actor() string {
if s.Actor == "" {
return "cli"
}
return s.Actor
}

// Create validates, encrypts, and stores a new credential.
Expand Down Expand Up @@ -62,6 +73,17 @@ func (s *Store) Create(ctx context.Context, name, typeName string, data map[stri
return nil, fmt.Errorf("storing credential: %w", err)
}

if err := audit.EmitTx(ctx, tx, audit.Event{
Timestamp: time.Now(),
Actor: s.actor(),
Action: audit.ActionCredentialCreated,
Resource: audit.Resource{Type: "credential", ID: cred.ID},
TeamID: teamID,
Metadata: map[string]string{"name": name, "type": typeName},
}); err != nil {
return nil, fmt.Errorf("emitting audit event: %w", err)
}

if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("committing credential: %w", err)
}
Expand Down Expand Up @@ -119,21 +141,42 @@ func (s *Store) List(ctx context.Context) ([]Credential, error) {
return creds, rows.Err()
}

// Delete removes a credential by name.
// Delete removes a credential by name and emits a credential.deleted audit
// event in the same transaction. Returns an error when no row matches.
func (s *Store) Delete(ctx context.Context, name string) error {
teamID := auth.TeamIDFromContext(ctx)
result, err := s.DB.ExecContext(ctx,
`DELETE FROM credentials WHERE name = $1 AND team_id = $2`, name, teamID,
)

tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("deleting credential: %w", err)
return fmt.Errorf("starting transaction: %w", err)
}
defer tx.Rollback()

var deletedID string
err = tx.QueryRowContext(ctx,
`DELETE FROM credentials WHERE name = $1 AND team_id = $2 RETURNING id`,
name, teamID,
).Scan(&deletedID)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("credential %q not found", name)
}
rows, err := result.RowsAffected()
if err != nil {
return err
return fmt.Errorf("deleting credential: %w", err)
}
if rows == 0 {
return fmt.Errorf("credential %q not found", name)

if err := audit.EmitTx(ctx, tx, audit.Event{
Timestamp: time.Now(),
Actor: s.actor(),
Action: audit.ActionCredentialDeleted,
Resource: audit.Resource{Type: "credential", ID: deletedID},
TeamID: teamID,
Metadata: map[string]string{"name": name},
}); err != nil {
return fmt.Errorf("emitting audit event: %w", err)
}

if err := tx.Commit(); err != nil {
return fmt.Errorf("committing credential delete: %w", err)
}
return nil
}
Expand Down
34 changes: 1 addition & 33 deletions packages/engine/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,6 @@ type Server struct {
running map[string]context.CancelFunc // execution ID → cancel
}

// workerLivenessChecker adapts the Worker to the health.LivenessChecker interface.
type workerLivenessChecker struct {
w *engine.Worker
threshold time.Duration
}

func (c *workerLivenessChecker) IsAlive() bool { return c.w.IsAlive(c.threshold) }
func (c *workerLivenessChecker) Name() string { return "worker" }

// reaperLivenessChecker adapts the Reaper to the health.LivenessChecker interface.
type reaperLivenessChecker struct {
r *engine.Reaper
threshold time.Duration
}

func (c *reaperLivenessChecker) IsAlive() bool { return c.r.IsAlive(c.threshold) }
func (c *reaperLivenessChecker) Name() string { return "reaper" }

// New creates a Server with the given configuration.
func New(db *sql.DB, eng *engine.Engine, address string) *Server {
logger := slog.Default()
Expand Down Expand Up @@ -128,9 +110,6 @@ func metricsMiddleware(next http.Handler) http.Handler {
func (s *Server) Start(ctx context.Context) error {
mux := http.NewServeMux()

// Collect liveness checkers for readyz.
var livenessCheckers []health.LivenessChecker

// Prometheus metrics endpoint.
mux.Handle("/metrics", promhttp.Handler())

Expand Down Expand Up @@ -193,17 +172,6 @@ func (s *Server) Start(ctx context.Context) error {
go s.reaper.Run(ctx)
s.Logger.Info("reaper started", "interval", cfg.Engine.ReaperInterval)

// Register liveness checkers for readyz.
workerThreshold := 3 * cfg.Engine.WorkerPollInterval
if workerThreshold < time.Second {
workerThreshold = 3 * time.Second
}
reaperThreshold := 3 * cfg.Engine.ReaperInterval
livenessCheckers = append(livenessCheckers,
&workerLivenessChecker{w: s.worker, threshold: workerThreshold},
&reaperLivenessChecker{r: s.reaper, threshold: reaperThreshold},
)

// Start retention cleanup if configured.
if cfg.Retention.ExecutionDays > 0 || cfg.Retention.AuditDays > 0 {
go func() {
Expand Down Expand Up @@ -272,7 +240,7 @@ func (s *Server) Start(ctx context.Context) error {

// Health endpoints (registered after engine components so checkers are populated).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
mux.Handle("/healthz", health.HealthzHandler())
mux.Handle("/readyz", health.ReadyzHandler(s.DB, livenessCheckers...))
mux.Handle("/readyz", health.ReadyzHandler(s.DB))
Comment thread
michaelmcnees marked this conversation as resolved.

// Wrap with metrics middleware (innermost, runs for every request).
handler := metricsMiddleware(mux)
Expand Down
7 changes: 7 additions & 0 deletions packages/helm-chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ resources:

podSecurityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001

securityContext:
readOnlyRootFilesystem: true
Expand Down Expand Up @@ -85,4 +87,9 @@ serviceMonitor:
enabled: false
interval: 30s
labels: {}
# bearerTokenSecret: populate with a Kubernetes Secret that holds a Mantle
# API key so Prometheus can authenticate against /metrics. Example:
# bearerTokenSecret:
# name: mantle-prometheus-token
# key: token
bearerTokenSecret: {}
Loading