Phase 3C — Commit Summary

feat: workspaces — collaborative repo namespaces
Backend
- internal/models/workspace.go — Workspace (handle, displayName,
  description, createdBy) + WorkspaceMember (workspaceId, userId,
  username, role: owner/admin/member)
- internal/models/repo.go — added nullable workspace_id column; existing
  user repos unaffected
- internal/models/migrations/011_workspaces.go — syncs both tables +
  adds column to repository
- internal/api/handlers/workspace.go — ListWorkspaces, CreateWorkspace,
  GetWorkspace, UpdateWorkspace, DeleteWorkspace (blocks if repos
  remain), ListRepos, ListMembers, AddMember, UpdateMember, RemoveMember
- internal/api/handlers/repos.go — lookupRepo resolves workspace
  handles; Create accepts workspace field; List includes workspace
  member repos; withOwnerName uses workspace handle for workspace-owned
  repos
- internal/api/handlers/dashboard.go — recentRuns + repo list include
  workspace repos the user is a member of
- internal/api/router.go — /workspaces, /workspaces/:handle/* routes
  Workspace rules enforced:
- Handle globally unique across usernames + workspace handles (409 on
  collision)
- Creator auto-assigned owner role
- Delete blocked if repos exist
- Last owner cannot be demoted/removed
  ---
  feat: secret management hierarchy (Global → Workspace → Repo → Env)
  Backend
- internal/models/secret.go — Secret struct +
  EncryptSecret/DecryptSecret with AES-256-GCM (key = SHA-256 of
  SESSION_SECRET); values never serialised to JSON
- internal/models/migrations/012_secrets.go — syncs secret table
- internal/api/handlers/secret.go — List/Upsert/Delete for all four
  scopes; ResolveSecretsForRun builds merged env map for CI
- internal/domain/ci/executor.go — JobContext.Secrets field; secrets
  injected as --env KEY=VALUE into docker run; buildJobContext calls
  resolveSecrets(Global < Workspace < Repo < Env)
- internal/domain/ci/runner_manager.go — passes cfg.SessionSecret to
  buildJobContext
- internal/api/router.go — /repos/:owner/:repo/secrets,
  /environments/:envName/secrets, /workspaces/:handle/secrets,
  /admin/secrets
  ---
  feat: workspace + secret management UI
  Frontend
- types/api.ts — Workspace, WorkspaceWithMeta, WorkspaceMember,
  SecretListItem types
- api/queries/workspaces.ts — full CRUD hooks + WorkspaceRepo type
- api/queries/secrets.ts — repo/env/workspace secret hooks
- pages/WorkspacesPage.tsx — list + create modal
- pages/WorkspacePage.tsx — workspace dashboard with repo list
- pages/WorkspaceSettingsPage.tsx — general settings, members CRUD,
  workspace secrets, danger zone
- pages/RepoSecretsPage.tsx — repo secrets + per-environment secret
  sections with priority hierarchy callout
- pages/CreateRepoPage.tsx — ?workspace= query param pre-fills owner
  selector; only admin/owner workspaces shown
- components/layout/Sidebar.tsx — "Workspaces" global nav item +
  workspace quick-links; "Secrets" in RepoSubNav; new SecretsIcon,
  WorkspaceIcon
- App.tsx — routes for /workspaces, /workspaces/:handle,
  /workspaces/:handle/settings, /repos/:owner/:repo/secrets
This commit is contained in:
2026-05-11 23:34:46 +02:00
parent 06e96ba16a
commit edf3c9824e
27 changed files with 2306 additions and 67 deletions
+58 -15
View File
@@ -18,10 +18,11 @@ import (
// JobContext holds everything needed to execute a single pipeline job.
type JobContext struct {
Run models.PipelineRun
Job models.PipelineJob
Steps []models.PipelineStep
Repo models.Repository
Run models.PipelineRun
Job models.PipelineJob
Steps []models.PipelineStep
Repo models.Repository
Secrets map[string]string // resolved secret key→value map (Env > Repo > Workspace > Global)
}
// ExecuteJob runs all steps of a job inside isolated Docker containers,
@@ -60,7 +61,7 @@ func ExecuteJob(ctx context.Context, db *xorm.Engine, bus events.EventBus, jc Jo
markStep(db, step, "skipped", 0)
continue
}
exitCode, err := runStep(ctx, db, bus, jc.Run.ID, jc.Job.ID, step, image, workDir)
exitCode, err := runStep(ctx, db, bus, jc.Run.ID, jc.Job.ID, step, image, workDir, jc.Secrets)
if err != nil || exitCode != 0 {
if exitCode == 0 {
exitCode = 1
@@ -83,20 +84,26 @@ func ExecuteJob(ctx context.Context, db *xorm.Engine, bus events.EventBus, jc Jo
// runStep runs a single shell-command step inside a Docker container.
func runStep(ctx context.Context, db *xorm.Engine, bus events.EventBus,
runID, jobID int64, step *models.PipelineStep, image, workDir string) (int, error) {
runID, jobID int64, step *models.PipelineStep, image, workDir string,
secrets map[string]string) (int, error) {
now := time.Now().UTC()
step.Status = "running"
step.StartedAt = &now
db.ID(step.ID).Cols("status", "started_at").Update(step) //nolint:errcheck
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", workDir+":/workspace",
// Build docker args: base flags + one --env per secret.
args := []string{"run", "--rm",
"-v", workDir + ":/workspace",
"-w", "/workspace",
"--network=none", // no network by default; Phase 2C will add network scopes
image,
"/bin/sh", "-ec", step.RunCmd,
)
"--network=none",
}
for k, v := range secrets {
args = append(args, "--env", k+"="+v)
}
args = append(args, image, "/bin/sh", "-ec", step.RunCmd)
cmd := exec.CommandContext(ctx, "docker", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -235,8 +242,9 @@ func repoForRun(db *xorm.Engine, runID int64) (models.Repository, models.Pipelin
return repo, run, true
}
// buildJobContext assembles a JobContext from DB rows.
func buildJobContext(db *xorm.Engine, jobID int64) (JobContext, bool) {
// buildJobContext assembles a JobContext from DB rows and resolves the secret
// hierarchy (Env > Repo > Workspace > Global) for injection into docker run.
func buildJobContext(db *xorm.Engine, jobID int64, sessionSecret string) (JobContext, bool) {
var job models.PipelineJob
if found, _ := db.ID(jobID).Get(&job); !found {
return JobContext{}, false
@@ -249,7 +257,42 @@ func buildJobContext(db *xorm.Engine, jobID int64) (JobContext, bool) {
if err != nil {
return JobContext{}, false
}
return JobContext{Run: run, Job: job, Steps: steps, Repo: repo}, true
// Determine workspace ID (0 if user-owned repo).
var wsID int64
if repo.WorkspaceID != nil {
wsID = *repo.WorkspaceID
}
secrets := resolveSecrets(db, repo.ID, wsID, 0, sessionSecret)
return JobContext{Run: run, Job: job, Steps: steps, Repo: repo, Secrets: secrets}, true
}
// resolveSecrets builds a merged key→plaintext map respecting hierarchy:
// Global < Workspace < Repo < Env (last writer wins per key).
func resolveSecrets(db *xorm.Engine, repoID, workspaceID, envID int64, sessionSecret string) map[string]string {
out := map[string]string{}
load := func(scope models.SecretScope, scopeID int64) {
var secrets []models.Secret
db.Where("scope = ? AND scope_id = ?", scope, scopeID).Find(&secrets)
for _, s := range secrets {
// Higher-priority scopes loaded later — simply overwrite.
if pt, err := models.DecryptSecret(s.EncryptedValue, sessionSecret); err == nil {
out[s.Name] = pt
}
}
}
load(models.SecretScopeGlobal, 0)
if workspaceID != 0 {
load(models.SecretScopeWorkspace, workspaceID)
}
load(models.SecretScopeRepo, repoID)
if envID != 0 {
load(models.SecretScopeEnv, envID)
}
return out
}
// pipeForRun returns the longest-matching step label for an image.