DevOpsIndex

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.


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

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);

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()
    }
}

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.


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
  • @DataBoundConstructor marks the constructor Stapler uses to build the object from form data. Required fields belong here — they become immutable (no setter needed).
  • @DataBoundSetter marks 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).


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 a toString(), 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 aux on shared agents — prefer environment variables scoped to the subprocess, or files with restrictive permissions cleaned up after use.
  • withCredentials in Pipeline (credentials-binding-plugin) is the sanctioned pattern for this exact reason — it binds env vars and registers them with the masking filter automatically.

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 long in 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 inside execute().
@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.


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/Credentials usages and confirm no toString()/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 @RequirePOST and checks the caller has an appropriate Permission (Item.CONFIGURE, Jenkins.ADMINISTER, etc.) — don't rely on UI hiding alone.
  • Search for any ObjectInputStream/raw Java deserialization of external input.

Testing: JenkinsRule and JenkinsPipelineUnit

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.


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

Release process (typical modern flow via GitHub Actions):

  1. Merge changes to main/master — CI (ci.jenkins.io or GitHub Actions with the jenkins-infra release action) builds and runs tests.
  2. 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.
  3. 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 @DataBoundConstructor signature breaks deserialization of existing config.xml files for jobs already using the old shape — add new fields via @DataBoundSetter instead of changing the constructor, and implement readResolve() 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.

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);
    }
}

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