diff --git a/CHANGELOG.md b/CHANGELOG.md index 411e8f7..c459e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,15 @@ shipped alongside it. Providers live one-per-package under `ci/project`, mirroring pack's structure. +- **Deploy detection from secret names.** A generated workflow gains a + deploy step when the injected secrets name a target — RAILWAY_TOKEN, + FLY_API_TOKEN, CLOUDFLARE_API_TOKEN, VERCEL_TOKEN, NETLIFY_AUTH_TOKEN, + DENO_DEPLOY_TOKEN, KOYEB_TOKEN, HEROKU_API_KEY — first match wins, + runners-up are announced, and `--dry-run` makes the step announce the + exact command instead of running it. Generated workflows only: a + committed CI config already says what it deploys. One target per file + under `ci/project/deploy`. + ### Fixed - **Steps no longer run login shells.** The guest agent already hands every diff --git a/ci/README.md b/ci/README.md index aa7838f..8137018 100644 --- a/ci/README.md +++ b/ci/README.md @@ -187,6 +187,26 @@ detected go project (go.mod) — no CI configuration found, workflow generated: Everything the generated workflow says is announced before anything boots — a workflow the operator has not read must be shown, not sprung. +**Deploy detection**: the secrets say where a project ships. When the +injected secrets include a known deploy token — `RAILWAY_TOKEN`, +`FLY_API_TOKEN`, `CLOUDFLARE_API_TOKEN`, `VERCEL_TOKEN`, +`NETLIFY_AUTH_TOKEN`, `DENO_DEPLOY_TOKEN`, `KOYEB_TOKEN`, +`HEROKU_API_KEY` — the generated workflow gains a deploy step (first match +wins; runners-up are announced). `--dry-run` makes the step announce the +exact command instead of running it: + +``` +$ bsdkrun ci --dry-run --secret RAILWAY_TOKEN +deploy: railway (RAILWAY_TOKEN detected) [dry-run] +... + ▶ deploy (railway) [dry-run] + [dry-run] would deploy to railway (RAILWAY_TOKEN detected): railway up --detach +``` + +Detection keys on the secret's *name*, never its value, and only generated +workflows gain the step — a committed CI config already says what it +deploys. + ## Secrets Spindle injects a repository's vault secrets as environment variables into diff --git a/ci/main.go b/ci/main.go index e4fd5a7..e4017cb 100644 --- a/ci/main.go +++ b/ci/main.go @@ -24,6 +24,7 @@ import ( "github.com/tsirysndr/bsdkrun/ci/platforms" "github.com/tsirysndr/bsdkrun/ci/project" + "github.com/tsirysndr/bsdkrun/ci/project/deploy" ) func jsonMarshal(v any) ([]byte, error) { return json.Marshal(v) } @@ -101,6 +102,8 @@ Run flags: --json spindle log-line JSON on stdout --plain plain line output even on a terminal (the default when stdout is not a tty) + --dry-run a generated deploy step announces its + command instead of running it --detect ignore CI configs: detect the project (go, rust, nodejs, bun, deno, python, ruby, php, elixir, gleam, zig, clojure, @@ -175,6 +178,7 @@ func cmdRun(args []string) error { plain := fs.Bool("plain", false, "") platformFlag := fs.String("platform", "auto", "") detect := fs.Bool("detect", false, "") + dryRun := fs.Bool("dry-run", false, "") nixery := fs.String("nixery", "", "") otlp := fs.String("otlp", "", "") var inputs, files, secretFlags, secretFiles repeatable @@ -244,6 +248,19 @@ func cmdRun(args []string) error { if err != nil { return err } + // The secrets say where this ships: a deploy step joins the + // generated workflow (and only the generated one — a + // committed config already says what it deploys). + secretNames := make([]string, 0, len(secrets)) + for k := range secrets { + secretNames = append(secretNames, k) + } + target, alsoDetected := deploy.Detect(secretNames) + if target != nil { + last := len(proj.Jobs) - 1 + proj.Jobs[last].Steps = append(proj.Jobs[last].Steps, + target.Step(*dryRun)) + } opts := runOpts{ Cpus: *cpus, Mem: *mem, @@ -281,6 +298,18 @@ func cmdRun(args []string) error { fmt.Fprintf(os.Stderr, " %d. %s\n", n, st.Name) } } + if target != nil { + mode := "" + if *dryRun { + mode = " [dry-run]" + } + fmt.Fprintf(os.Stderr, "deploy: %s (%s detected)%s\n", + target.Platform, target.Secret, mode) + if len(alsoDetected) > 0 { + fmt.Fprintf(os.Stderr, " (tokens for %s also present — first match wins)\n", + joinNames(alsoDetected)) + } + } return runPlans(plans, opts, *jsonOut, *plain) } if *detect { diff --git a/ci/project/deploy/cloudflare.go b/ci/project/deploy/cloudflare.go new file mode 100644 index 0000000..85e8c60 --- /dev/null +++ b/ci/project/deploy/cloudflare.go @@ -0,0 +1,11 @@ +package deploy + +// Cloudflare Workers: wrangler reads CLOUDFLARE_API_TOKEN from the +// environment; npx keeps the CLI out of the image. +func Cloudflare() Target { + return Target{ + Platform: "cloudflare", + Secret: "CLOUDFLARE_API_TOKEN", + Command: `npx wrangler deploy`, + } +} diff --git a/ci/project/deploy/denodeploy.go b/ci/project/deploy/denodeploy.go new file mode 100644 index 0000000..5017090 --- /dev/null +++ b/ci/project/deploy/denodeploy.go @@ -0,0 +1,11 @@ +package deploy + +// Deno Deploy: deployctl reads DENO_DEPLOY_TOKEN from the environment +// and ships the module graph directly. +func DenoDeploy() Target { + return Target{ + Platform: "deno-deploy", + Secret: "DENO_DEPLOY_TOKEN", + Command: `deployctl deploy --prod`, + } +} diff --git a/ci/project/deploy/deploy.go b/ci/project/deploy/deploy.go new file mode 100644 index 0000000..aef71ee --- /dev/null +++ b/ci/project/deploy/deploy.go @@ -0,0 +1,82 @@ +// Package deploy detects where a project ships from the *names* of its +// secrets — an operator who injects RAILWAY_TOKEN has named the deploy +// target as surely as a marker file names the language — and renders the +// deploy step a generated workflow gains. Only generated workflows: a +// committed CI config already says what it deploys, and appending steps to +// someone else's pipeline uninvited would be wrong. +// +// One target per file, mirroring the provider layout. All() lists them in +// priority order: when several tokens are present the first match wins and +// the announcement names the runners-up. Dry-run renders the step as an +// announcement of the exact command instead of running it — the right mode +// while wiring a new project, and the only mode this feature's own tests +// use (a real deploy is not something a test suite should trigger). +package deploy + +import ( + "fmt" + "sort" + "strings" + + "github.com/tsirysndr/bsdkrun/ci/platforms" +) + +// Target is one deploy destination. +type Target struct { + // Platform names the target, e.g. "railway". + Platform string + // Secret is the name (never the value) that gives the target away. + Secret string + // Command is the real deploy command the step runs. + Command string +} + +// All returns every target in priority order. +func All() []Target { + return []Target{ + Railway(), + Fly(), + Cloudflare(), + Vercel(), + Netlify(), + DenoDeploy(), + Koyeb(), + Heroku(), + } +} + +// Detect picks the target the secret names imply, and lists any additional +// targets whose tokens are also present. +func Detect(secretNames []string) (target *Target, also []string) { + names := map[string]bool{} + for _, n := range secretNames { + names[n] = true + } + for _, t := range All() { + t := t + if !names[t.Secret] { + continue + } + if target == nil { + target = &t + } else { + also = append(also, t.Platform) + } + } + sort.Strings(also) + return target, also +} + +// Step renders the target as a workflow step. +func (t *Target) Step(dryRun bool) platforms.Step { + name := fmt.Sprintf("deploy (%s)", t.Platform) + if dryRun { + return platforms.Step{ + Name: name + " [dry-run]", + Command: fmt.Sprintf( + `echo "[dry-run] would deploy to %s (%s detected): %s"`, + t.Platform, t.Secret, strings.ReplaceAll(t.Command, "\"", "\\\"")), + } + } + return platforms.Step{Name: name, Command: t.Command} +} diff --git a/ci/project/deploy/deploy_test.go b/ci/project/deploy/deploy_test.go new file mode 100644 index 0000000..74a6908 --- /dev/null +++ b/ci/project/deploy/deploy_test.go @@ -0,0 +1,29 @@ +package deploy + +import "testing" + +func TestDetectionPriorityAndRunnersUp(t *testing.T) { + d, also := Detect([]string{"NPM_TOKEN", "FLY_API_TOKEN", "RAILWAY_TOKEN"}) + if d == nil || d.Platform != "railway" { + t.Fatalf("priority order broken: %+v", d) + } + if len(also) != 1 || also[0] != "fly" { + t.Fatalf("runners-up: %v", also) + } + + if d, _ := Detect([]string{"NPM_TOKEN"}); d != nil { + t.Fatalf("NPM_TOKEN is not a deploy token: %+v", d) + } +} + +func TestStepRendering(t *testing.T) { + fly, _ := Detect([]string{"FLY_API_TOKEN"}) + dry := fly.Step(true) + if dry.Command != `echo "[dry-run] would deploy to fly (FLY_API_TOKEN detected): flyctl deploy --remote-only"` { + t.Fatalf("dry-run step: %q", dry.Command) + } + real := fly.Step(false) + if real.Command != "flyctl deploy --remote-only" { + t.Fatalf("real step: %q", real.Command) + } +} diff --git a/ci/project/deploy/flyio.go b/ci/project/deploy/flyio.go new file mode 100644 index 0000000..e8e7b34 --- /dev/null +++ b/ci/project/deploy/flyio.go @@ -0,0 +1,11 @@ +package deploy + +// Fly.io: FLY_API_TOKEN is flyctl's own auth variable; --remote-only +// builds on Fly's builders, so the guest needs no Docker. +func Fly() Target { + return Target{ + Platform: "fly", + Secret: "FLY_API_TOKEN", + Command: `flyctl deploy --remote-only`, + } +} diff --git a/ci/project/deploy/heroku.go b/ci/project/deploy/heroku.go new file mode 100644 index 0000000..e554f36 --- /dev/null +++ b/ci/project/deploy/heroku.go @@ -0,0 +1,11 @@ +package deploy + +// Heroku: HEROKU_API_KEY is the platform's standard CLI auth +// variable; builds:create ships the source as a build. +func Heroku() Target { + return Target{ + Platform: "heroku", + Secret: "HEROKU_API_KEY", + Command: `heroku builds:create`, + } +} diff --git a/ci/project/deploy/koyeb.go b/ci/project/deploy/koyeb.go new file mode 100644 index 0000000..4d04a45 --- /dev/null +++ b/ci/project/deploy/koyeb.go @@ -0,0 +1,10 @@ +package deploy + +// Koyeb: KOYEB_TOKEN authenticates the CLI. +func Koyeb() Target { + return Target{ + Platform: "koyeb", + Secret: "KOYEB_TOKEN", + Command: `koyeb deploy`, + } +} diff --git a/ci/project/deploy/netlify.go b/ci/project/deploy/netlify.go new file mode 100644 index 0000000..1d0d83a --- /dev/null +++ b/ci/project/deploy/netlify.go @@ -0,0 +1,10 @@ +package deploy + +// Netlify: NETLIFY_AUTH_TOKEN is the CLI's own auth variable. +func Netlify() Target { + return Target{ + Platform: "netlify", + Secret: "NETLIFY_AUTH_TOKEN", + Command: `npx netlify-cli deploy --prod`, + } +} diff --git a/ci/project/deploy/railway.go b/ci/project/deploy/railway.go new file mode 100644 index 0000000..5418891 --- /dev/null +++ b/ci/project/deploy/railway.go @@ -0,0 +1,11 @@ +package deploy + +// Railway: RAILWAY_TOKEN authenticates the CLI directly; `up` builds +// and deploys the linked project. +func Railway() Target { + return Target{ + Platform: "railway", + Secret: "RAILWAY_TOKEN", + Command: `railway up --detach`, + } +} diff --git a/ci/project/deploy/vercel.go b/ci/project/deploy/vercel.go new file mode 100644 index 0000000..56d48b1 --- /dev/null +++ b/ci/project/deploy/vercel.go @@ -0,0 +1,11 @@ +package deploy + +// Vercel: the CLI wants the token as a flag rather than the +// environment; --yes skips the interactive link step. +func Vercel() Target { + return Target{ + Platform: "vercel", + Secret: "VERCEL_TOKEN", + Command: `npx vercel deploy --prod --yes --token "$VERCEL_TOKEN"`, + } +}