Jenkins Plugin Development
Extension points, the Descriptor pattern, Stapler data-binding, credential handling, background tasks, security review process, testing, and publishing to the Update Center.
Most sections below end with a ❓ knowledge check — track how many you've cleared as you go:
Extension Point Model
Jenkins core defines interfaces/abstract classes (Builder, Publisher, Trigger, ...). Plugins provide implementations annotated @Extension. Jenkins discovers them at startup by scanning the classpath — no central registry file to edit.
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
START["Jenkins startup"]:::purple --> SCAN["PluginManager loads each .hpi/.jpi
extracts plugin JAR onto classpath"]:::orange
SCAN --> INDEX["Reads META-INF/annotations/
generated index of @Extension classes
(built at compile time by annotation processor)"]:::orange
INDEX --> INSTANTIATE["ExtensionFinder instantiates each class
via Guice/Jenkins injector"]:::blue
INSTANTIATE --> REGISTER["Registered into the matching
ExtensionList<T> for its base type"]:::green
REGISTER --> QUERY["Runtime code calls
ExtensionList.lookup(Builder.class)
or Jenkins.get().getExtensionList(...)"]:::blue
Same flow, one step at a time:
PluginManager starts loading installed plugins.
.hpi/.jpi archive is extracted and its plugin JAR is put on the classpath.
META-INF/annotations/ index of @Extension classes — generated at compile time by the annotation processor, not discovered by scanning the classpath at runtime.
ExtensionFinder instantiates each indexed class via the Guice/Jenkins injector.
ExtensionList<T> matching its base type (Builder, Publisher, etc.).
ExtensionList.lookup(Builder.class) or Jenkins.get().getExtensionList(...) to retrieve the registered instances — no further scanning happens.
Key mechanic: @Extension triggers an annotation processor (hpi:hpi / maven-hpi-plugin) at build time that writes an index file into META-INF/annotations/org.jenkinsci.Symbol and related indices — Jenkins doesn't do classpath reflection scanning at runtime for every class, it reads these pre-built indices. This is why a clean rebuild is sometimes required after adding a new @Extension class if your IDE's incremental compiler misses the annotation processing step.
@Extension
public class MyBuilder extends Builder implements SimpleBuildStep {
// Jenkins finds this automatically at startup — no registration file needed
}
Retrieving extensions programmatically:
// Get all registered Builder-derived descriptors, e.g. to populate a dropdown
ExtensionList<Builder> allBuilders = Jenkins.get().getExtensionList(Builder.class);
// Get a specific singleton extension (e.g. a global config extension)
MyGlobalConfig config = ExtensionList.lookupSingleton(MyGlobalConfig.class);
You add a new @Extension class but Jenkins doesn't pick it up until you force a clean rebuild. Why?
@Extension classes at runtime — it reads a pre-built index in META-INF/annotations/ that an annotation processor writes at compile time. If an IDE's incremental compiler skips re-running that annotation processor, the index is stale and the new extension is invisible until a full rebuild regenerates it.Builder vs SimpleBuildStep vs Publisher vs Recorder
| Base type | Runs when | Typical use | Notes |
|---|---|---|---|
Builder |
During the "build" phase of a freestyle job | Compile step, custom build action | Legacy freestyle-only concept; classic API is perform(AbstractBuild, Launcher, BuildListener) |
SimpleBuildStep (interface) |
Either build or post-build phase; freestyle and Pipeline | New plugins — this is the modern, recommended interface | Works with FilePath/Run/TaskListener directly, no AbstractBuild coupling, so it's Pipeline-compatible from day one |
Publisher |
Post-build phase of a freestyle job | Archiving, notifications, deployment steps | Also legacy-oriented; extend Recorder (a Publisher subclass) for most concrete post-build actions |
Recorder |
Post-build phase | Test result publishing, coverage reporting | Concrete Publisher subclass most plugins should extend instead of Publisher directly |
Modern guidance: implement SimpleBuildStep regardless of whether the step conceptually belongs in "build" or "post-build" — it works uniformly in Pipeline (steps { myStep() }) and freestyle, and Jenkins core has been steering plugin authors toward it for years. Only extend Builder/Publisher directly if you need freestyle-specific APIs (Descriptor.isApplicable(Class) gating by project type, or legacy perform signatures) that SimpleBuildStep doesn't expose.
public class MyBuilder extends Builder implements SimpleBuildStep {
@Override
public void perform(Run<?, ?> run, FilePath workspace, EnvVars env,
Launcher launcher, TaskListener listener)
throws InterruptedException, IOException {
// workspace: FilePath — works for both master-local and remote agent workspaces
// listener: TaskListener — get PrintStream via listener.getLogger()
}
}
A new plugin step logically belongs in the post-build phase. Should you extend Publisher directly, or implement SimpleBuildStep?
SimpleBuildStep, regardless of which phase the step conceptually belongs to. It works uniformly in both freestyle and Pipeline (steps { myStep() }) from day one, with no AbstractBuild coupling. Extend Builder/Publisher directly only if you need freestyle-specific APIs that SimpleBuildStep doesn't expose.The Descriptor Pattern
Every Describable (most extension base classes) pairs a model class (the immutable, serialized configuration — one instance per configured build step) with a Descriptor (a singleton holding metadata: display name, form validation, UI dropdown population). The Descriptor is itself the @Extension.
graph LR
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
JOB["Job config.xml
serialized MyBuilder instance
(one per configured step)"]:::blue
DESC["MyBuilder.DescriptorImpl
singleton, @Extension
knows how to validate + render UI"]:::orange
UI["config.jelly form
renders using Descriptor metadata"]:::green
JOB -->|"getDescriptor()"| DESC
DESC -->|"doFillXItems / doCheckX"| UI
UI -->|"submit -> newInstance(req, formData)"| JOB
public class DeployBuilder extends Builder implements SimpleBuildStep {
private final String environment;
private String credentialsId; // optional, has a setter
@DataBoundConstructor
public DeployBuilder(String environment) {
this.environment = environment;
}
public String getEnvironment() { return environment; }
@DataBoundSetter
public void setCredentialsId(String credentialsId) {
this.credentialsId = credentialsId;
}
public String getCredentialsId() { return credentialsId; }
@Override
public void perform(Run<?, ?> run, FilePath workspace, EnvVars env,
Launcher launcher, TaskListener listener) {
listener.getLogger().println("Deploying to " + environment);
}
@Symbol("deployApp") // enables `deployApp(environment: 'prod')` in Pipeline DSL
@Extension
public static class DescriptorImpl extends BuildStepDescriptor<Builder> {
@Override
public boolean isApplicable(Class<? extends AbstractProject> jobType) {
return true; // applicable to all freestyle project types
}
@Override
public String getDisplayName() {
return "Deploy Application"; // shown in the "Add build step" dropdown
}
// Populates a <select> dropdown in config.jelly named "environment"
public ListBoxModel doFillEnvironmentItems() {
ListBoxModel items = new ListBoxModel();
items.add("Staging", "staging");
items.add("Production", "production");
return items;
}
// Live form validation — Jenkins calls this via AJAX as the user types
// into the field named "environment"
public FormValidation doCheckEnvironment(@QueryParameter String value) {
if (value == null || value.isBlank()) {
return FormValidation.error("Environment is required");
}
if (!value.matches("staging|production")) {
return FormValidation.warning("Unrecognized environment");
}
return FormValidation.ok();
}
}
}
@Symbol matters for Pipeline UX: without it, Pipeline authors must use the verbose step([$class: 'DeployBuilder', environment: 'prod']) syntax. With @Symbol("deployApp"), they get deployApp(environment: 'prod') — Jenkins' Pipeline Groovy DSL generates these symbol-based step wrappers automatically from all @Symbol-annotated descriptors at startup.
doFillXItems / doCheckX naming convention is load-bearing: Stapler maps doFillEnvironmentItems to the field named environment in the corresponding Jelly <select> block, and doCheckEnvironment to live-validate the environment field. Get the field name wrong and the binding silently does nothing.
You rename a form field from environment to targetEnv in config.jelly but forget to rename doFillEnvironmentItems/doCheckEnvironment to match. What happens?
doFillXItems/doCheckX to a field by name convention alone, so a mismatch means the dropdown never populates and the field never gets live-validated, with no error to point you at the cause.Stapler Data-Binding
Stapler is the web framework embedded in Jenkins core that maps HTTP form submissions to Java objects and vice versa — no explicit serialization code required.
sequenceDiagram
participant Jelly as config.jelly (rendered form)
participant Browser
participant Stapler
participant Descriptor as DescriptorImpl
participant Model as DeployBuilder (model instance)
Jelly->>Browser: Renders <f:entry> for each field, bound to existing instance values
Browser->>Stapler: POST form data on save
Stapler->>Descriptor: newInstance(StaplerRequest, JSONObject formData)
Descriptor->>Model: new DeployBuilder(formData.getString("environment")) [@DataBoundConstructor]
Descriptor->>Model: setCredentialsId(...) [@DataBoundSetter, if present in formData]
Model-->>Descriptor: fully constructed, immutable-ish instance
Descriptor-->>Stapler: instance returned
Stapler-->>Jenkins core: persisted into job config.xml
Walk the same round-trip one hop at a time:
config.jelly renders an <f:entry> for each field, pre-filled from the existing model instance's values.
newInstance(StaplerRequest, JSONObject formData).
new DeployBuilder(formData.getString("environment")) — the @DataBoundConstructor — to build the required fields.
@DataBoundSetter, but only for fields actually present in formData.
config.xml.
@DataBoundConstructormarks the constructor Stapler uses to build the object from form data. Required fields belong here — they become immutable (no setter needed).@DataBoundSettermarks optional field setters. Stapler only calls them if the corresponding form field was present/non-empty — this is how truly optional config avoids forcing a giant constructor with nulls for everything.- Only one constructor may be
@DataBoundConstructor-annotated per class.
Jelly views and their relationship to the descriptor:
| File | Location | Purpose |
|---|---|---|
config.jelly |
src/main/resources/<package>/DeployBuilder/config.jelly |
Renders the configuration form for this step. Field names in <f:entry field="environment"> must match @DataBoundConstructor/@DataBoundSetter parameter/property names exactly. |
index.jelly |
Same directory, optional | Renders a summary/help view, e.g. in the build's side panel or step summary. |
help-<field>.html |
Same directory, optional | Renders inline help text (the "?" icon) next to a specific field — file name must match the field name. |
<!-- config.jelly -->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:entry title="Environment" field="environment">
<f:select/> <!-- populated by doFillEnvironmentItems() -->
</f:entry>
<f:entry title="Credentials" field="credentialsId">
<c:select/> <!-- credentials-plugin custom tag, populated via CredentialsProvider -->
</f:entry>
</j:jelly>
escape-by-default='true' matters — Jelly does NOT auto-escape output by default in older syntax forms; explicit escaping avoids XSS (see Security Review section).
A field is optional and has a @DataBoundSetter. The submitted form leaves that field blank. Does Stapler call the setter with an empty value?
@DataBoundSetter if the corresponding form field was present/non-empty in the submitted data. This is exactly what lets optional config stay optional instead of forcing a constructor with nulls for every field that might not be set.Credential Handling Best Practices
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
STEP["Build step needs a secret"]:::blue --> LOOKUP["CredentialsProvider.lookupCredentials(
Class, context, authentication, domainRequirements)"]:::orange
LOOKUP --> SCOPE{Scope of lookup}
SCOPE -->|"Job-scoped (preferred)"| JOBCTX["context = the Job/Run
authentication = Jenkins.getAuthentication2()
respects folder-level credential scoping + RBAC"]:::green
SCOPE -->|"ACL.SYSTEM2 (legacy ACL.SYSTEM)"| SYSCTX["Bypasses the requesting user's permissions
Only use for background/system-level operations,
NEVER for a user-triggered action needing their own creds"]:::red
import com.cloudbees.plugins.credentials.CredentialsProvider;
import com.cloudbees.plugins.credentials.common.StandardCredentials;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import com.cloudbees.plugins.credentials.domains.URIRequirementBuilder;
import org.jenkinsci.plugins.plaincredentials.StringCredentials;
// Job-scoped lookup — respects folder credential scoping and the requesting
// job's permissions. This is the correct default for most build steps.
StandardUsernamePasswordCredentials creds = CredentialsProvider.findCredentialById(
credentialsId,
StandardUsernamePasswordCredentials.class,
run, // the Run provides job context
URIRequirementBuilder.fromUri(targetUrl).build());
if (creds == null) {
throw new AbortException("Credentials '" + credentialsId + "' not found or not permitted");
}
String username = creds.getUsername();
String password = creds.getPassword().getPlainText(); // Secret — only call getPlainText() at point of use
StringCredentials vs UsernamePasswordCredentials:
| Type | Shape | Use case |
|---|---|---|
StringCredentials |
Single opaque secret string | API tokens, webhook secrets, single API keys |
UsernamePasswordCredentials |
Username + password pair | Registry logins, basic auth, service accounts with a login pair |
SSHUserPrivateKey |
Username + private key (+ optional passphrase) | SSH-based deploys |
CertificateCredentials |
Keystore (PKCS#12) | mTLS client certs |
Why credentials should never be logged:
Secret.getPlainText()deliberately requires an explicit call — never let atoString(), exception message, or debug log path incidentally call it.- Jenkins masks strings it recognizes as active credential values in the build log ("credential masking"), but this only works for the exact byte sequence of the secret. If you transform it (base64-encode, concatenate into a URL, hash it wrong) before it hits the log, masking won't catch it.
- Never pass credentials as CLI arguments visible in
ps auxon shared agents — prefer environment variables scoped to the subprocess, or files with restrictive permissions cleaned up after use. withCredentialsin Pipeline (credentials-binding-plugin) is the sanctioned pattern for this exact reason — it binds env vars and registers them with the masking filter automatically.
Your plugin base64-encodes a secret before writing a debug line to the build log. Does Jenkins' credential masking still catch it?
AsyncPeriodicWork for Background Tasks
AsyncPeriodicWork runs a recurring background job in its own thread, off the Jenkins request-handling threads, with built-in overlap protection (won't start a new run while the previous one is still executing).
@Extension
public class StaleBuildCleanup extends AsyncPeriodicWork {
public StaleBuildCleanup() {
super("Stale build artifact cleanup");
}
@Override
public long getRecurrencePeriod() {
return TimeUnit.HOURS.toMillis(1); // Jenkins checks roughly every hour
}
@Override
protected void execute(TaskListener listener) {
// Runs on Jenkins' dedicated background-task thread pool, not a web request thread
for (Job<?, ?> job : Jenkins.get().getAllItems(Job.class)) {
cleanupOldArtifacts(job, listener);
}
}
}
getRecurrencePeriod() limitations:
- It's a fixed
longin milliseconds, evaluated once at class load / plugin init in most versions — it does not support cron expressions natively, and cannot be dynamically reconfigured per-instance without restarting Jenkins in older APIs. - The period is a minimum interval, not a guarantee — if Jenkins is under load or the previous run hasn't finished, the next run is skipped/delayed, not queued.
- For actual cron-like scheduling (e.g. "only run between 2-4 AM"), the idiomatic pattern is: keep
getRecurrencePeriod()short (e.g. 15 minutes) and self-gate insideexecute().
@Override
protected void execute(TaskListener listener) {
LocalTime now = LocalTime.now();
if (now.isBefore(LocalTime.of(2, 0)) || now.isAfter(LocalTime.of(4, 0))) {
return; // self-gate: only do real work in the 2-4 AM maintenance window
}
doExpensiveCleanup(listener);
}
This cron-based self-gating pattern (short fixed tick, gate the actual work by wall-clock or a stored "last run" timestamp) is standard across Jenkins core's own AsyncPeriodicWork subclasses (e.g. fingerprint cleanup) because the API itself offers no native cron support.
You set getRecurrencePeriod() to 1 hour, but the previous run is still executing when the hour is up. Does Jenkins queue a second run to fire the moment the first one finishes?
AsyncPeriodicWork has built-in overlap protection — it won't start a new run while the previous one is still executing. The recurrence period is a minimum interval, not a guarantee: an overdue tick is skipped or delayed, never queued up to fire immediately after.Jenkins OSS Plugin Security Review Process
All plugins hosted on the Update Center go through Jenkins' security team review process (and ongoing monitoring via the Jenkins Security Advisories process). Common findings, by frequency:
graph TD
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
REVIEW["Security review finding categories"]:::orange
REVIEW --> SSRF["SSRF via user-controlled URLs
form validation methods (doCheckX) or build steps
that fetch a user-supplied URL server-side"]:::red
REVIEW --> CREDLEAK["Credential exposure in logs
secrets printed via toString(), exceptions,
or transformed before masking can catch them"]:::red
REVIEW --> XSS["XSS in Jelly views
unescaped ${} output, raw HTML via <j:out escape='false'/>,
or literal HTML string concatenation"]:::red
REVIEW --> CSRF["Missing CSRF protection on HTTP endpoints
custom doX() web methods without
@RequirePOST or crumb validation"]:::red
REVIEW --> DESER["Arbitrary code execution via unsafe deserialization
Java native (de)serialization of untrusted input,
old XStream configs without a type whitelist"]:::red
| Finding | Root cause | Mitigation |
|---|---|---|
| SSRF via user-controlled URLs | doCheckUrl or a build step performs an outbound HTTP request to a URL taken directly from form input, with no allowlist |
Validate/restrict target hosts; require ADMINISTER permission for any feature that makes the controller fetch attacker-influenced URLs; avoid pinging URLs from within form-validation methods (called unauthenticated-adjacent, frequently, as the user types) |
| Credential exposure in logs | Secret objects unwrapped and concatenated/logged before masking, or logged via a code path that bypasses Secret wrapping entirely |
Keep secrets wrapped in Secret/credential objects until the last possible moment; never log request/response bodies that may contain secrets; audit System.out/e.printStackTrace() calls |
| XSS in Jelly views | Jelly's ${} EL output is escaped by default in escape-by-default='true' files, but plugins using older Jelly idioms or explicit escape="false" render attacker-controlled strings (build parameter names, commit messages, job descriptions) as raw HTML |
Always set escape-by-default='true' on Jelly files; never disable escaping for user-controlled content; use <j:out value="${...}"/> deliberately only for trusted, pre-sanitized content |
| Missing CSRF protection | Custom doSomething() web-bound methods (via Stapler) that mutate state but don't validate the CSRF crumb, allowing a malicious page to trigger authenticated actions via the victim's browser session |
Annotate state-changing endpoints with @RequirePOST; Jenkins' crumb filter validates automatically for POST when enabled; never expose mutation via GET |
| Arbitrary code execution via unsafe deserialization | Accepting serialized Java objects (or permissive XStream config) from untrusted sources, deserializing them directly | Avoid Java native deserialization of any externally supplied data; if using XStream, apply an explicit class whitelist; prefer JSON/YAML for external config with schema validation |
Practical checklist before submitting a plugin for hosting:
- Run the plugin through the Jenkins security scan tooling locally.
- Grep for
Secret/Credentialsusages and confirm notoString()/logging path can leak them. - Confirm every Jelly file starts with
escape-by-default='true'. - Confirm every custom
doX()HTTP endpoint that mutates state is@RequirePOSTand checks the caller has an appropriatePermission(Item.CONFIGURE,Jenkins.ADMINISTER, etc.) — don't rely on UI hiding alone. - Search for any
ObjectInputStream/raw Java deserialization of external input.
A doCheckUrl form-validation method makes an outbound HTTP request to the URL the user just typed, so it can confirm the URL is reachable. Why does this show up in security review findings even though it's "just validation"?
Testing: JenkinsRule and JenkinsPipelineUnit
Two different tools for two different layers — flip between them before you pick one:
configRoundtrip, and actual perform() execution against a live core. Use it to test the Java Builder/Publisher implementation itself.
vars/*.groovy steps and Jenkinsfile behavior, never the underlying Java extension.
JenkinsRule (JUnit, full in-memory Jenkins instance)
public class DeployBuilderTest {
@Rule
public JenkinsRule jenkins = new JenkinsRule();
@Test
public void configRoundTrip() throws Exception {
FreeStyleProject project = jenkins.createFreeStyleProject();
DeployBuilder before = new DeployBuilder("staging");
project.getBuildersList().add(before);
// Simulates saving the config form and reloading — catches
// Stapler binding bugs (e.g. @DataBoundConstructor mismatch)
project = jenkins.configRoundtrip(project);
DeployBuilder after = project.getBuildersList().get(DeployBuilder.class);
jenkins.assertEqualDataBoundBeans(before, after);
}
@Test
public void performsDeploy() throws Exception {
FreeStyleProject project = jenkins.createFreeStyleProject();
project.getBuildersList().add(new DeployBuilder("staging"));
FreeStyleBuild build = jenkins.buildAndAssertSuccess(project);
jenkins.assertLogContains("Deploying to staging", build);
}
}
JenkinsRule spins up a real (but lightweight, in-memory) Jenkins controller per test class — slow relative to unit tests, but catches integration issues (Descriptor registration, Stapler binding, actual perform() execution) that pure unit tests miss.
JenkinsPipelineUnit (Pipeline/Groovy-level testing, faster feedback)
For Shared Library vars/*.groovy steps and Pipeline logic specifically (not for testing the Java extension itself):
// test/groovy/DeployAppStepTest.groovy — using JenkinsPipelineUnit
class DeployAppStepTest extends BasePipelineTest {
@Override
void setUp() throws Exception {
super.setUp()
helper.registerAllowedMethod("deployApp", [Map.class], { args ->
println "Mocked deployApp called with: ${args}"
})
}
@Test
void testPipelineCallsDeployApp() throws Exception {
def script = loadScript("Jenkinsfile")
script.execute()
assertJobStatusSuccess()
assertThat(helper.callStack.findAll { it.methodName == "deployApp" }.size(), is(1))
}
}
When to use which: JenkinsRule for testing the actual Java Builder/Publisher implementation, Descriptor form round-tripping, and real perform() execution against a live (in-memory) controller. JenkinsPipelineUnit for testing Groovy Pipeline scripts and Shared Library steps in isolation, fast, without spinning up a controller at all — it mocks the Jenkins Groovy CPS runtime.
You need to verify that a @DataBoundConstructor mismatch doesn't silently break config round-tripping for your Builder. Which test tool actually catches that, and which one can't?
JenkinsRule's configRoundtrip() catches it — it spins up a real (if lightweight) in-memory controller and exercises the actual Stapler binding path. JenkinsPipelineUnit can't: it mocks the Groovy CPS runtime for Pipeline scripts and never touches the Java extension's Descriptor or Stapler binding at all.Publishing to the Jenkins Update Center
graph LR
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
DEV["Develop plugin
maven-hpi-plugin project"]:::blue --> HOST["Request hosting via
Jenkins Artifactory (JIRA/GitHub-based
hosting request, one-time)"]:::orange
HOST --> CI["Plugin repo wired into
Jenkins Infra CI (ci.jenkins.io)"]:::orange
CI --> RELEASE["mvn release:prepare release:perform
or release-drafter GH Actions workflow"]:::green
RELEASE --> ARTIFACTORY["Artifact pushed to
Jenkins Artifactory repo"]:::green
ARTIFACTORY --> UC["Update Center metadata
regenerated (periodic job)
plugin now installable"]:::green
Same pipeline, one stage at a time:
ci.jenkins.io).
main/master trigger CI to build and run tests.
mvn release:prepare release:perform, or the release-drafter GitHub Action bumps the version and pushes the artifact.
Release process (typical modern flow via GitHub Actions):
- Merge changes to
main/master— CI (ci.jenkins.ioor GitHub Actions with thejenkins-infrarelease action) builds and runs tests. - Tag-based or manual-trigger release workflow runs
mvn -B release:prepare release:perform, or increasingly, the Jenkins release-drafter GitHub Action, which bumps the version and pushes the artifact. - Artifact lands in the Jenkins Artifactory Maven repo; the Update Center metadata generator (runs periodically) picks it up and it becomes installable from Manage Jenkins → Plugins.
BOM (Bill of Materials) versioning: Jenkins plugins should depend on the bom-<jenkins-version>.x artifact rather than pinning individual dependency versions directly. The BOM pins a mutually-compatible set of core + common plugin dependency versions (e.g. credentials-plugin, workflow-step-api) for a given Jenkins LTS baseline — this avoids the classic "plugin A depends on credentials-plugin 2.6, plugin B depends on 1.9, controller breaks" dependency hell.
<parent>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>plugin</artifactId>
<version>4.88</version>
</parent>
<properties>
<jenkins.version>2.470</jenkins.version> <!-- minimum supported core baseline -->
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.jenkins.tools.bom</groupId>
<artifactId>bom-2.470.x</artifactId>
<version>3266.vc4a_f27d3cc23</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Backward compatibility considerations:
- Bumping
<jenkins.version>(the minimum core baseline) is a breaking change for anyone on an older LTS — do it deliberately, document in release notes, and prefer aligning with an active LTS line rather than bleeding-edge weekly releases. - Changing a
@DataBoundConstructorsignature breaks deserialization of existingconfig.xmlfiles for jobs already using the old shape — add new fields via@DataBoundSetterinstead of changing the constructor, and implementreadResolve()for migrating old serialized state when a breaking model change is unavoidable. - Removing a
@Symbol-exposed Pipeline step breaks existing Jenkinsfiles across every consuming org — deprecate first (mark@Deprecated, log a warning), remove only in a major version bump with a clear migration note. - Run Plugin Compatibility Tester (PCT) or the incrementals/BOM compatibility check before releasing a core-baseline bump, if your plugin has significant downstream dependents.
You need to add a required-sounding new option to an existing @DataBoundConstructor. Should you change the constructor's signature directly?
@DataBoundConstructor signature breaks deserialization of every existing job's config.xml already using the old shape. Add new fields via @DataBoundSetter instead, and implement readResolve() to migrate old serialized state if a breaking model change is truly unavoidable.Common Jenkins Extension Points
| Extension point | Base type | Purpose |
|---|---|---|
Builder |
hudson.tasks.Builder |
Adds a "build step" — runs during the main build phase (freestyle jobs). Prefer implementing SimpleBuildStep alongside it for Pipeline compatibility. |
Publisher / Recorder |
hudson.tasks.Publisher / Recorder |
Adds a post-build action — runs after the build phase regardless of success/failure (archiving, notifications, test result publishing). |
BuildWrapper |
hudson.tasks.BuildWrapper |
Wraps the entire build — setup before, teardown after (e.g. inject env vars, start a sidecar service, timeout wrapper). Largely superseded by Pipeline's native wrap/withEnv/options blocks for Pipeline jobs, but still relevant for freestyle. |
SCMSource |
jenkins.scm.api.SCMSource |
Defines how Jenkins discovers branches/PRs/tags from a source control provider — powers Multibranch Pipeline and Organization Folder jobs (e.g. GitHub Branch Source plugin). |
Trigger |
hudson.triggers.Trigger |
Defines what causes a job to start automatically — cron-style polling (SCMTrigger), webhook-driven triggers, or custom event triggers. |
RunListener |
hudson.model.listeners.RunListener |
Global hook into the lifecycle of every Run (build) across all jobs — onStarted, onCompleted, onFinalized. Used for cross-cutting concerns (global notifications, metrics emission, audit logging) without modifying individual job configs. |
Complete Minimal Working Example
A custom Builder implementing SimpleBuildStep, with a Descriptor, @DataBoundConstructor, and a @Symbol-annotated Pipeline step — end to end, ready to compile in a standard maven-hpi-plugin project layout.
package io.example.jenkins.plugins.greeter;
import hudson.EnvVars;
import hudson.Extension;
import hudson.FilePath;
import hudson.Launcher;
import hudson.model.AbstractProject;
import hudson.model.Run;
import hudson.model.TaskListener;
import hudson.tasks.BuildStepDescriptor;
import hudson.tasks.Builder;
import hudson.util.FormValidation;
import jenkins.tasks.SimpleBuildStep;
import org.jenkinsci.Symbol;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.QueryParameter;
import java.io.IOException;
/**
* Minimal build step: prints a greeting to the build log.
* Usage in Pipeline (thanks to @Symbol below): greet name: 'World'
*/
public class GreeterBuilder extends Builder implements SimpleBuildStep {
private final String name;
private boolean shout = false; // optional, defaults false
@DataBoundConstructor
public GreeterBuilder(String name) {
this.name = name;
}
public String getName() {
return name;
}
public boolean isShout() {
return shout;
}
@DataBoundSetter
public void setShout(boolean shout) {
this.shout = shout;
}
@Override
public void perform(Run<?, ?> run, FilePath workspace, EnvVars env,
Launcher launcher, TaskListener listener) throws IOException {
String greeting = "Hello, " + name + "!";
if (shout) {
greeting = greeting.toUpperCase();
}
listener.getLogger().println(greeting);
}
@Symbol("greet") // enables: greet name: 'World', shout: true
@Extension
public static final class DescriptorImpl extends BuildStepDescriptor<Builder> {
@Override
public boolean isApplicable(Class<? extends AbstractProject> jobType) {
return true;
}
@Override
public String getDisplayName() {
return "Print a greeting";
}
public FormValidation doCheckName(@QueryParameter String value) {
if (value == null || value.trim().isEmpty()) {
return FormValidation.error("Name must not be empty");
}
if (value.length() > 100) {
return FormValidation.warning("Name is unusually long");
}
return FormValidation.ok();
}
}
}
<!-- src/main/resources/io/example/jenkins/plugins/greeter/GreeterBuilder/config.jelly -->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:entry title="Name" field="name">
<f:textbox/>
</f:entry>
<f:entry title="Shout" field="shout">
<f:checkbox/>
</f:entry>
</j:jelly>
// Jenkinsfile usage — works via the @Symbol("greet") mapping, no [$class: ...] needed
pipeline {
agent any
stages {
stage('Greet') {
steps {
greet name: 'World', shout: true
}
}
}
}
// JenkinsRule test verifying the config round-trip and execution
public class GreeterBuilderTest {
@Rule public JenkinsRule jenkins = new JenkinsRule();
@Test
public void greetsInBuildLog() throws Exception {
FreeStyleProject project = jenkins.createFreeStyleProject();
project.getBuildersList().add(new GreeterBuilder("World"));
FreeStyleBuild build = jenkins.buildAndAssertSuccess(project);
jenkins.assertLogContains("Hello, World!", build);
}
}
In GreeterBuilder, shout defaults to false and is set via @DataBoundSetter, while name is a constructor parameter. What breaks if a saved job's config never had shout set, and Stapler tries to reconstruct it?
setShout(...) if shout was present in the submitted form data — if it's absent, the field simply keeps its declared default (false). That's the whole point of putting optional fields on @DataBoundSetters instead of the constructor: old configs that predate the field still deserialize cleanly.Quick Reference
| Concern | API / Annotation |
|---|---|
| Register a plugin extension | @Extension |
| Modern build/post-build step base | implements SimpleBuildStep |
| Bind constructor params from form | @DataBoundConstructor |
| Bind optional setters from form | @DataBoundSetter |
| Pipeline DSL short step name | @Symbol("stepName") on the Descriptor |
| Populate a dropdown | doFill<Field>Items() returning ListBoxModel |
| Live form validation | doCheck<Field>(@QueryParameter String value) |
| Look up credentials safely | CredentialsProvider.findCredentialById(id, Type.class, run, domainRequirements) |
| Recurring background task | extends AsyncPeriodicWork, self-gate inside execute() for cron-like behavior |
| State-changing HTTP endpoint | @RequirePOST + permission check inside the doX() method |
| Full integration test | @Rule public JenkinsRule jenkins |
| Pipeline/Groovy-only test | JenkinsPipelineUnit BasePipelineTest |
| Pin compatible dependency versions | Import the io.jenkins.tools.bom BOM matching your jenkins.version baseline |