diff --git a/.github/workflows/e2e-ci.yml b/.github/workflows/e2e-ci.yml index 3ca58b3..fb4b1e7 100644 --- a/.github/workflows/e2e-ci.yml +++ b/.github/workflows/e2e-ci.yml @@ -197,11 +197,11 @@ jobs: # judging so one broken platform does not hide the others. # Strict since its first green run (2026-08-18): all seven passed on # x86_64/KVM on the first attempt, so a failure here is a regression. - - name: e2e — foreign platforms (github, gitlab, woodpecker, drone, circleci, buildkite, semaphore, travis) + - name: e2e — foreign platforms (github, gitlab, woodpecker, drone, circleci, buildkite, semaphore, jenkins, travis) run: | set -eux failed="" - for p in github gitlab woodpecker drone circleci buildkite semaphore travis; do + for p in github gitlab woodpecker drone circleci buildkite semaphore jenkins travis; do d=$(mktemp -d) cp -r "examples/ci-$p/." "$d" git -C "$d" init -q diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d98356..0aee507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,9 @@ command, from every SDK, from every screen — with tracing to match. the terminal dashboard gained tabs with a CI/CD tab of its own. - **Foreign CI platforms run locally.** `bsdkrun ci` now translates and runs GitHub Actions (plus Forgejo/Gitea), GitLab CI, Woodpecker, Drone, - CircleCI, Buildkite, Semaphore and Travis configs in microVMs — auto-detected from + CircleCI, Buildkite, Semaphore, Jenkins (declarative pipelines, + via a structural parser — scripted Groovy is refused, not mistranslated) + and Travis configs in microVMs — auto-detected from their well-known files, or forced with `--platform`. Jobs translate (images, env, ordering, platform identity variables); what cannot translate becomes a visible skip, and non-Linux jobs are skipped outright. diff --git a/ci/README.md b/ci/README.md index d1fb334..cfd553c 100644 --- a/ci/README.md +++ b/ci/README.md @@ -118,6 +118,7 @@ files are probed automatically; `--platform` forces one: | `circleci` | `.circleci/config.yml` | [`examples/ci-circleci`](../examples/ci-circleci) | | `buildkite` | `.buildkite/pipeline.yml` | [`examples/ci-buildkite`](../examples/ci-buildkite) | | `semaphore` | `.semaphore/semaphore.yml` | [`examples/ci-semaphore`](../examples/ci-semaphore) | +| `jenkins` | `Jenkinsfile` (declarative pipelines only) | [`examples/ci-jenkins`](../examples/ci-jenkins) | | `travis` | `.travis.yml` | [`examples/ci-travis`](../examples/ci-travis) | ```sh @@ -142,6 +143,14 @@ cannot become another OS, and a green checkmark on a lie helps nobody. Images without bash (alpine) run their steps under `sh`, exactly as GitLab's own runner would. +Jenkins deserves its own footnote: only **declarative** Jenkinsfiles +translate, parsed by a small structural tokenizer — not a Groovy +implementation, because none is needed for the declarative skeleton and +none short of Jenkins itself would suffice for the scripted dialect. A +scripted pipeline (`node { ... }`) is refused with a clear error rather +than mistranslated, and `environment` values that are Groovy expressions +(`credentials(...)`) are dropped rather than faked. + ## Secrets Spindle injects a repository's vault secrets as environment variables into diff --git a/ci/main.go b/ci/main.go index c4375ef..cd2c6bc 100644 --- a/ci/main.go +++ b/ci/main.go @@ -103,7 +103,7 @@ Run flags: --platform run a foreign CI config locally: github (also forgejo/gitea), gitlab, woodpecker, drone, circleci, buildkite, - semaphore, travis — detected + semaphore, jenkins, travis — detected automatically when the repository has no .tangled/workflows; linux jobs only --secret KEY=VALUE | KEY inject a secret env var into every step diff --git a/ci/platforms/jenkins.go b/ci/platforms/jenkins.go new file mode 100644 index 0000000..f8d12b0 --- /dev/null +++ b/ci/platforms/jenkins.go @@ -0,0 +1,392 @@ +package platforms + +// Jenkins: the Jenkinsfile at the repository root — the declarative dialect +// only, and that is a scope decision, not a shortcut. A *scripted* pipeline +// (`node { ... }`) is an arbitrary Groovy program executing against Jenkins' +// CPS runtime; nothing short of embedding Jenkins runs one faithfully, so a +// scripted file gets a clear refusal instead of a wrong translation. A +// *declarative* pipeline is a rigid block skeleton, and parsing that needs a +// small structural tokenizer (comments, strings, braces, statements), not a +// Groovy implementation. +// +// What translates: the pipeline (or per-stage) docker agent image, `agent +// any` on the default image, `environment { K = 'literal' }` at pipeline +// and stage level, stages in order — `parallel` stages run serially, like +// every parallel construct here — and `sh` steps in all three spellings +// (`sh 'x'`, `sh "x"`, `sh(script: 'x')`), plus `echo`. `checkout scm` +// dissolves into the clone that already happened. Everything else in +// `steps` becomes a visible skip; `post`, `options`, `triggers`, `when` and +// `tools` are ignored; environment values that are Groovy expressions +// (`credentials(...)`, string interpolation of calls) are dropped rather +// than mistranslated. Agent labels naming windows or macos skip the job. + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func detectJenkins(root string) bool { + return fileExists(filepath.Join(root, "Jenkinsfile")) +} + +func loadJenkins(root string, repo Repo) ([]Job, error) { + data, err := os.ReadFile(filepath.Join(root, "Jenkinsfile")) + if err != nil { + return nil, err + } + nodes, err := gvParse(string(data)) + if err != nil { + return nil, fmt.Errorf("Jenkinsfile: %w", err) + } + + var pipeline *gvNode + for i := range nodes { + switch nodes[i].name { + case "pipeline": + pipeline = &nodes[i] + case "node", "stage", "properties": + return nil, fmt.Errorf( + "this Jenkinsfile is a scripted pipeline (a Groovy program); only " + + "declarative pipelines (`pipeline { ... }`) translate locally") + } + } + if pipeline == nil { + return nil, fmt.Errorf("no `pipeline { ... }` block in the Jenkinsfile") + } + + job := Job{Name: "pipeline", Env: map[string]string{}} + var divergent []string + + for _, n := range pipeline.block { + switch n.name { + case "agent": + img, skip := gvAgent(n) + job.Image = img + if skip != "" { + job.SkipReason = skip + } + case "environment": + for k, v := range gvEnv(n) { + job.Env[k] = v + } + case "stages": + gvStages(n, &job, &divergent) + } + } + if len(divergent) > 0 { + note := Step{ + Name: "per-stage agents (not supported)", + Command: fmt.Sprintf( + `echo "stages declaring their own agents run on the pipeline image here: %s"`, + strings.Join(divergent, ", ")), + } + job.Steps = append([]Step{note}, job.Steps...) + } + if len(job.Steps) == 0 { + return nil, fmt.Errorf("the Jenkinsfile's stages contain no translatable steps") + } + return []Job{job}, nil +} + +// gvStages walks stages, including `parallel` and nested `stages` groups. +func gvStages(stages gvNode, job *Job, divergent *[]string) { + for _, st := range stages.block { + if st.name != "stage" { + continue + } + stageName := "stage" + if len(st.args) > 0 { + stageName = st.args[0] + } + stageEnv := map[string]string{} + var steps *gvNode + for i := range st.block { + n := &st.block[i] + switch n.name { + case "agent": + if img, _ := gvAgent(*n); img != "" && img != job.Image { + *divergent = append(*divergent, fmt.Sprintf("%s (%s)", stageName, img)) + } + case "environment": + for k, v := range gvEnv(*n) { + stageEnv[k] = v + } + case "steps": + steps = n + case "parallel", "stages": + gvStages(*n, job, divergent) + } + } + if steps == nil { + continue + } + var commands []string + for _, s := range steps.block { + switch s.name { + case "sh": + // All three spellings: sh 'x', sh "x", sh(script: 'x') — + // the command is the first quoted string either way. + if v, ok := gvFirstString(s); ok { + commands = append(commands, v) + } + case "echo": + if v, ok := gvFirstString(s); ok { + commands = append(commands, "echo "+shellQuote(v)) + } + case "checkout": + // `checkout scm` is the clone that already happened. + default: + commands = append(commands, fmt.Sprintf( + `echo "skipped step %q — only sh/echo translate locally"`, s.name)) + } + } + if len(commands) == 0 { + continue + } + env := stageEnv + if len(env) == 0 { + env = nil + } + job.Steps = append(job.Steps, Step{ + Name: stageName, + Command: strings.Join(commands, "\n"), + Env: env, + }) + } +} + +// gvAgent reads `agent any|none`, `agent { docker 'img' }`, +// `agent { docker { image 'img' } }`, `agent { label 'x' }`. +func gvAgent(n gvNode) (image, skip string) { + for _, a := range n.args { + if s := linuxOnly(a); s != "" { + skip = s + } + } + for _, c := range n.block { + switch c.name { + case "docker", "dockerfile": + if len(c.args) > 0 { + image = c.args[0] + } + for _, cc := range c.block { + if cc.name == "image" && len(cc.args) > 0 { + image = cc.args[0] + } + } + case "label", "node": + for _, a := range c.args { + if s := linuxOnly(a); s != "" { + skip = s + } + } + } + } + return image, skip +} + +// gvEnv keeps only literal assignments; a Groovy expression on the right — +// credentials(...), a call, arithmetic — is not a value this runner can +// truthfully provide. +func gvEnv(n gvNode) map[string]string { + out := map[string]string{} + for _, c := range n.block { + if len(c.args) >= 2 && c.args[0] == "=" && c.literal[1] { + out[c.name] = c.args[1] + } + } + return out +} + +func gvFirstString(n gvNode) (string, bool) { + for i, a := range n.args { + if n.literal[i] { + return a, true + } + } + return "", false +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// --- the structural parser --------------------------------------------------- + +// gvNode is one statement: a leading identifier, its argument tokens on the +// same line (strings unquoted; `literal` marks which were quoted strings), +// and a nested block when `{ ... }` followed. +type gvNode struct { + name string + args []string + literal []bool + block []gvNode +} + +type gvToken struct { + kind byte // 'i' ident, 's' string, 'p' punct + text string + line int +} + +// gvParse tokenizes and parses top-level statements. +func gvParse(src string) ([]gvNode, error) { + toks, err := gvLex(src) + if err != nil { + return nil, err + } + pos := 0 + nodes := gvBlock(toks, &pos, 0) + if pos < len(toks) { + return nil, fmt.Errorf("unbalanced braces near line %d", toks[pos].line) + } + return nodes, nil +} + +func gvLex(src string) ([]gvToken, error) { + var toks []gvToken + line := 1 + i := 0 + for i < len(src) { + c := src[i] + switch { + case c == '\n': + line++ + i++ + case c == ' ' || c == '\t' || c == '\r': + i++ + case c == '/' && i+1 < len(src) && src[i+1] == '/': + for i < len(src) && src[i] != '\n' { + i++ + } + case c == '/' && i+1 < len(src) && src[i+1] == '*': + i += 2 + for i+1 < len(src) && !(src[i] == '*' && src[i+1] == '/') { + if src[i] == '\n' { + line++ + } + i++ + } + i += 2 + case c == '\'' || c == '"': + quote := string(c) + if strings.HasPrefix(src[i:], quote+quote+quote) { + quote = quote + quote + quote + } + end := i + len(quote) + var sb strings.Builder + for { + if end >= len(src) { + return nil, fmt.Errorf("unterminated string at line %d", line) + } + if src[end] == '\\' && end+1 < len(src) && len(quote) == 1 { + sb.WriteByte(gvEscape(src[end+1])) + end += 2 + continue + } + if strings.HasPrefix(src[end:], quote) { + break + } + if src[end] == '\n' { + line++ + } + sb.WriteByte(src[end]) + end++ + } + toks = append(toks, gvToken{kind: 's', text: sb.String(), line: line}) + i = end + len(quote) + case gvIdentByte(c): + j := i + for j < len(src) && gvIdentByte(src[j]) { + j++ + } + toks = append(toks, gvToken{kind: 'i', text: src[i:j], line: line}) + i = j + default: + toks = append(toks, gvToken{kind: 'p', text: string(c), line: line}) + i++ + } + } + return toks, nil +} + +func gvEscape(c byte) byte { + switch c { + case 'n': + return '\n' + case 't': + return '\t' + default: + return c + } +} + +func gvIdentByte(c byte) bool { + return c == '_' || c == '$' || c == '.' || + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') +} + +// gvBlock parses statements until the matching close brace (depth > 0) or +// the end of input. A statement is an identifier plus everything on its +// line (parens tracked), optionally followed by a `{ ... }` block. +func gvBlock(toks []gvToken, pos *int, depth int) []gvNode { + var nodes []gvNode + for *pos < len(toks) { + t := toks[*pos] + if t.kind == 'p' && t.text == "}" { + if depth > 0 { + *pos++ + } + return nodes + } + if t.kind != 'i' && t.kind != 's' { + *pos++ // stray punctuation between statements + continue + } + node := gvNode{name: t.text} + startLine := t.line + *pos++ + parens := 0 + for *pos < len(toks) { + a := toks[*pos] + if a.kind == 'p' { + switch a.text { + case "(": + parens++ + *pos++ + continue + case ")": + parens-- + *pos++ + continue + case "{": + *pos++ + node.block = gvBlock(toks, pos, depth+1) + goto done + case "}": + goto done + default: + if parens == 0 && a.line > startLine { + goto done + } + node.args = append(node.args, a.text) + node.literal = append(node.literal, false) + *pos++ + continue + } + } + // A fresh identifier on a new line starts the next statement. + if a.kind == 'i' && parens == 0 && a.line > startLine { + goto done + } + node.args = append(node.args, a.text) + node.literal = append(node.literal, a.kind == 's') + *pos++ + } + done: + nodes = append(nodes, node) + } + return nodes +} diff --git a/ci/platforms/platforms.go b/ci/platforms/platforms.go index 71eb016..fc8e9a4 100644 --- a/ci/platforms/platforms.go +++ b/ci/platforms/platforms.go @@ -1,5 +1,6 @@ // Package platforms translates foreign CI configurations — GitHub Actions, -// GitLab CI, Woodpecker, Drone, CircleCI, Buildkite, Semaphore, Travis — +// GitLab CI, Woodpecker, Drone, CircleCI, Buildkite, Semaphore, Jenkins +// (declarative), Travis — // into a // platform-neutral job list the runner turns into microVM plans. // @@ -87,6 +88,7 @@ func Registry() []Platform { {Name: "circleci", Detect: detectCircleci, Load: loadCircleci}, {Name: "buildkite", Detect: detectBuildkite, Load: loadBuildkite}, {Name: "semaphore", Detect: detectSemaphore, Load: loadSemaphore}, + {Name: "jenkins", Detect: detectJenkins, Load: loadJenkins}, {Name: "travis", Detect: detectTravis, Load: loadTravis}, } } @@ -170,6 +172,15 @@ func Env(platform string, repo Repo) map[string]string { "SEMAPHORE_PROJECT_NAME": repo.Name, "SEMAPHORE_GIT_DIR": repo.Workspace, } + case "jenkins": + return map[string]string{ + "JENKINS_URL": "local", + "BUILD_NUMBER": "1", + "GIT_COMMIT": repo.Sha, + "BRANCH_NAME": repo.branch(), + "JOB_NAME": repo.Name, + "WORKSPACE": repo.Workspace, + } } return nil } diff --git a/ci/platforms/platforms_test.go b/ci/platforms/platforms_test.go index 4f9b4b4..00ca5f9 100644 --- a/ci/platforms/platforms_test.go +++ b/ci/platforms/platforms_test.go @@ -3,6 +3,7 @@ package platforms import ( "os" "path/filepath" + "strings" "testing" ) @@ -259,7 +260,7 @@ func TestDetectPriorityAndForce(t *testing.T) { if err != nil || p.Name != "drone" { t.Fatalf("force broken: %+v, %v", p, err) } - if _, err := Detect(root, "jenkins"); err == nil { + if _, err := Detect(root, "bamboo"); err == nil { t.Fatal("unknown platform accepted") } } @@ -411,3 +412,87 @@ blocks: t.Fatalf("macos pipeline must be skipped: %+v, %v", mjobs, err) } } + +func TestJenkinsDeclarative(t *testing.T) { + root := t.TempDir() + write(t, root, "Jenkinsfile", ` +// a comment +pipeline { + agent { + docker { image 'golang:1.22' } + } + environment { + FOO = 'bar' + DYN = credentials('secret-id') + } + stages { + stage('Build') { + steps { + checkout scm + sh 'go build ./...' + sh(script: 'go vet ./...') + } + } + stage('Test') { + environment { + STAGE = 'test' + } + steps { + echo 'running tests' + sh """ + go test ./... + """ + junit 'report.xml' + } + } + } + post { + always { echo 'done' } + } +} +`) + if !detectJenkins(root) { + t.Fatal("jenkins not detected") + } + jobs, err := loadJenkins(root, testRepo) + if err != nil || len(jobs) != 1 { + t.Fatalf("jobs: %+v, %v", jobs, err) + } + j := jobs[0] + if j.Image != "golang:1.22" { + t.Fatalf("docker agent image lost: %q", j.Image) + } + if j.Env["FOO"] != "bar" { + t.Fatalf("literal env lost: %v", j.Env) + } + if _, has := j.Env["DYN"]; has { + t.Fatalf("credentials() must be dropped, not mistranslated: %v", j.Env) + } + if len(j.Steps) != 2 || j.Steps[0].Name != "Build" || j.Steps[1].Name != "Test" { + t.Fatalf("stages: %+v", j.Steps) + } + if j.Steps[0].Command != "go build ./...\ngo vet ./..." { + t.Fatalf("sh forms: %q", j.Steps[0].Command) + } + if j.Steps[1].Env["STAGE"] != "test" { + t.Fatalf("stage env lost: %+v", j.Steps[1]) + } + if !strings.Contains(j.Steps[1].Command, `skipped step "junit"`) { + t.Fatalf("junit skip not visible: %q", j.Steps[1].Command) + } +} + +func TestJenkinsScriptedRefused(t *testing.T) { + root := t.TempDir() + write(t, root, "Jenkinsfile", ` +node { + stage('Build') { + sh 'make' + } +} +`) + _, err := loadJenkins(root, testRepo) + if err == nil || !strings.Contains(err.Error(), "scripted") { + t.Fatalf("scripted pipeline must be refused clearly, got: %v", err) + } +} diff --git a/examples/ci-jenkins/Jenkinsfile b/examples/ci-jenkins/Jenkinsfile new file mode 100644 index 0000000..3638643 --- /dev/null +++ b/examples/ci-jenkins/Jenkinsfile @@ -0,0 +1,19 @@ +pipeline { + agent { + docker { image 'alpine:3.20' } + } + environment { + GREETING = 'from-jenkinsfile' + } + stages { + stage('Test') { + steps { + checkout scm + sh 'test "$JENKINS_URL" = "local"' + sh 'test -f hello.txt' + sh 'echo "$GREETING"' + sh 'echo "jenkins-example-ok"' + } + } + } +} diff --git a/examples/ci-jenkins/README.md b/examples/ci-jenkins/README.md new file mode 100644 index 0000000..6427054 --- /dev/null +++ b/examples/ci-jenkins/README.md @@ -0,0 +1,22 @@ +# ci-jenkins — a declarative Jenkinsfile, run locally by `bsdkrun ci` + +The smallest useful declarative pipeline: a docker agent, an environment +block, one stage. It checks the identity environment, checks the clone +landed, and prints `jenkins-example-ok`. `bsdkrun ci` detects the +`Jenkinsfile` automatically — no flag needed (use `--platform jenkins` if +several configs coexist). + +Only the **declarative** dialect translates: a scripted pipeline +(`node { ... }`) is an arbitrary Groovy program that nothing short of +Jenkins itself can run, and `bsdkrun ci` refuses it with a clear error +rather than mistranslating it. + +CI runs the repository's **HEAD commit**, so the example needs its own git +repository: + +```sh +cp -r examples/ci-jenkins /tmp/ci-jenkins +cd /tmp/ci-jenkins +git init -q && git add -A && git commit -qm init +bsdkrun ci run +``` diff --git a/examples/ci-jenkins/hello.txt b/examples/ci-jenkins/hello.txt new file mode 100644 index 0000000..61708c4 --- /dev/null +++ b/examples/ci-jenkins/hello.txt @@ -0,0 +1 @@ +hello from the Jenkins example