diff --git a/README.md b/README.md
index bf54ded..50f22bc 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,21 @@ node {
}
```
+## Publishing results as a GitHub check
+
+The plugin can publish the TestingBot outcome of a build as a GitHub check (✅/❌ on the commit and pull request) through the [Checks API](https://plugins.jenkins.io/checks-api/) plugin.
+
+Add the **Publish TestingBot results as a GitHub check** post-build action (freestyle), or call the `testingbotChecks` step in a pipeline:
+
+```groovy
+testingbotChecks(name: 'TestingBot', message: 'End-to-end tests on TestingBot')
+```
+
+* `name` — the check name (its *context* on the commit/PR). Defaults to `TestingBot`. This is the modern equivalent of the custom context you would set for the GitHub Pull Request Builder Plugin.
+* `message` — an optional summary; when omitted, a summary is generated from the TestingBot sessions in the build. The check links to the embedded TestingBot build report.
+
+The check conclusion reflects the TestingBot sessions found in the build (all passed → success), falling back to the overall build result when no sessions are present. Actual delivery to GitHub is handled by the [GitHub Checks](https://plugins.jenkins.io/github-checks/) plugin together with a GitHub App; if that is not installed, the step is a safe no-op.
+
## Configuration as Code (JCasC)
The TestingBot credentials can be configured with the [Configuration as Code](https://plugins.jenkins.io/configuration-as-code/) plugin using the `testingbot` symbol:
diff --git a/pom.xml b/pom.xml
index 7998b0e..e13719f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -197,5 +197,17 @@
org.jenkins-ci.plugins
structs
+
+
+
+ io.jenkins.plugins
+ checks-api
+
+
+ org.jenkins-ci.plugins
+ display-url-api
+
diff --git a/src/main/java/testingbot/TestingBotChecksPublisher.java b/src/main/java/testingbot/TestingBotChecksPublisher.java
new file mode 100644
index 0000000..d66cfb2
--- /dev/null
+++ b/src/main/java/testingbot/TestingBotChecksPublisher.java
@@ -0,0 +1,197 @@
+package testingbot;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.EnvVars;
+import hudson.Extension;
+import hudson.FilePath;
+import hudson.Launcher;
+import hudson.Util;
+import hudson.model.AbstractProject;
+import hudson.model.Result;
+import hudson.model.Run;
+import hudson.model.TaskListener;
+import hudson.tasks.BuildStepDescriptor;
+import hudson.tasks.BuildStepMonitor;
+import hudson.tasks.Publisher;
+import hudson.tasks.Recorder;
+import hudson.tasks.junit.CaseResult;
+import hudson.tasks.junit.SuiteResult;
+import hudson.tasks.junit.TestResult;
+import hudson.tasks.test.AbstractTestResultAction;
+import io.jenkins.plugins.checks.api.ChecksConclusion;
+import io.jenkins.plugins.checks.api.ChecksDetails;
+import io.jenkins.plugins.checks.api.ChecksOutput;
+import io.jenkins.plugins.checks.api.ChecksPublisherFactory;
+import io.jenkins.plugins.checks.api.ChecksStatus;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import jenkins.tasks.SimpleBuildStep;
+import org.jenkinsci.Symbol;
+import org.jenkinsci.plugins.displayurlapi.DisplayURLProvider;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+
+/**
+ * Publishes the TestingBot outcome of a build as a GitHub check (✓/✗ on the commit & PR), via the
+ * {@code checks-api} plugin. The check name (its context) and summary (message) are
+ * configurable — the modern equivalent of the "custom context + message" that Sauce Labs exposes for
+ * upstream GitHub Pull Request Builder jobs.
+ *
+ *
Works in freestyle (post-build action) and Pipeline (the {@code testingbotChecks} step). The
+ * actual GitHub delivery is done by the {@code github-checks} plugin; when it is not installed the
+ * publish is a safe no-op (a {@code NullChecksPublisher}).
+ */
+public class TestingBotChecksPublisher extends Recorder implements SimpleBuildStep {
+
+ private static final String DEFAULT_NAME = "TestingBot";
+
+ private String name;
+ private String message;
+
+ @DataBoundConstructor
+ public TestingBotChecksPublisher() {
+ }
+
+ /** The check name — the "context" shown on the commit/PR. Defaults to {@code TestingBot}. */
+ public String getName() {
+ return Util.fixEmptyAndTrim(name) == null ? DEFAULT_NAME : name;
+ }
+
+ @DataBoundSetter
+ public void setName(String name) {
+ this.name = Util.fixEmptyAndTrim(name);
+ }
+
+ /** Optional custom summary/message for the check; a session summary is generated when empty. */
+ public String getMessage() {
+ return message;
+ }
+
+ @DataBoundSetter
+ public void setMessage(String message) {
+ this.message = Util.fixEmptyAndTrim(message);
+ }
+
+ @Override
+ public BuildStepMonitor getRequiredMonitorService() {
+ return BuildStepMonitor.NONE;
+ }
+
+ @Override
+ public void perform(@NonNull Run, ?> run, @NonNull FilePath workspace, @NonNull EnvVars env,
+ @NonNull Launcher launcher, @NonNull TaskListener listener) {
+ int total = 0;
+ int passed = 0;
+ List rows = new ArrayList<>();
+ Set seenSessions = new LinkedHashSet<>();
+
+ AbstractTestResultAction> testResultAction = run.getAction(AbstractTestResultAction.class);
+ if (testResultAction != null && testResultAction.getResult() instanceof TestResult) {
+ TestResult testResult = (TestResult) testResultAction.getResult();
+ for (SuiteResult sr : testResult.getSuites()) {
+ for (CaseResult cr : sr.getCases()) {
+ boolean casePassed = cr.isPassed();
+ for (String sessionId : TestingBotReportFactory.findSessionIDs(cr)) {
+ String id = sessionId == null ? "" : sessionId.trim();
+ // Count each distinct TestingBot session once, even if it appears in several
+ // cases or a case logs several sessions; cases with no sessions add nothing.
+ if (id.isEmpty() || !seenSessions.add(id)) {
+ continue;
+ }
+ total++;
+ if (casePassed) {
+ passed++;
+ }
+ rows.add("| " + cr.getFullName() + " | " + (casePassed ? "✅ passed" : "❌ failed") + " |");
+ }
+ }
+ }
+ }
+
+ ChecksConclusion conclusion;
+ if (total > 0) {
+ conclusion = passed == total ? ChecksConclusion.SUCCESS : ChecksConclusion.FAILURE;
+ } else {
+ conclusion = fromBuildResult(run.getResult());
+ }
+
+ String summary = getMessage() != null ? getMessage() : defaultSummary(total, passed);
+
+ StringBuilder text = new StringBuilder();
+ if (total > 0) {
+ text.append("| Test | Result |\n| --- | --- |\n");
+ for (String row : rows) {
+ text.append(row).append('\n');
+ }
+ }
+ TestingBotBuildReportAction reportAction = run.getAction(TestingBotBuildReportAction.class);
+ if (reportAction != null) {
+ text.append("\n[View the full TestingBot build report](").append(reportAction.getReportUrl()).append(")\n");
+ }
+
+ ChecksOutput output = new ChecksOutput.ChecksOutputBuilder()
+ .withTitle(getName())
+ .withSummary(summary)
+ .withText(text.toString())
+ .build();
+
+ ChecksDetails details = new ChecksDetails.ChecksDetailsBuilder()
+ .withName(getName())
+ .withStatus(ChecksStatus.COMPLETED)
+ .withConclusion(conclusion)
+ .withDetailsURL(detailsUrl(run, reportAction))
+ .withStartedAt(LocalDateTime.ofInstant(Instant.ofEpochMilli(run.getStartTimeInMillis()), ZoneOffset.UTC))
+ .withCompletedAt(LocalDateTime.now(ZoneOffset.UTC))
+ .withOutput(output)
+ .build();
+
+ // A NullChecksPublisher is returned (and this is a no-op) when no GitHub checks implementation
+ // is installed, so this is always safe to call.
+ ChecksPublisherFactory.fromRun(run, listener).publish(details);
+ }
+
+ private static String detailsUrl(Run, ?> run, TestingBotBuildReportAction reportAction) {
+ if (reportAction != null) {
+ return reportAction.getReportUrl();
+ }
+ return DisplayURLProvider.get().getRunURL(run);
+ }
+
+ private static String defaultSummary(int total, int passed) {
+ if (total == 0) {
+ return "No TestingBot sessions were found in this build.";
+ }
+ return passed + " of " + total + " TestingBot session(s) passed.";
+ }
+
+ private static ChecksConclusion fromBuildResult(Result result) {
+ if (result == null || result == Result.SUCCESS) {
+ return ChecksConclusion.SUCCESS;
+ }
+ if (result == Result.FAILURE || result == Result.UNSTABLE) {
+ return ChecksConclusion.FAILURE;
+ }
+ return ChecksConclusion.NEUTRAL;
+ }
+
+ @Extension
+ @Symbol("testingbotChecks")
+ public static final class DescriptorImpl extends BuildStepDescriptor {
+
+ @NonNull
+ @Override
+ public String getDisplayName() {
+ return "Publish TestingBot results as a GitHub check";
+ }
+
+ @Override
+ public boolean isApplicable(Class extends AbstractProject> jobType) {
+ return true;
+ }
+ }
+}
diff --git a/src/main/resources/testingbot/TestingBotChecksPublisher/config.jelly b/src/main/resources/testingbot/TestingBotChecksPublisher/config.jelly
new file mode 100644
index 0000000..6bf69cb
--- /dev/null
+++ b/src/main/resources/testingbot/TestingBotChecksPublisher/config.jelly
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html b/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html
new file mode 100644
index 0000000..8a6f117
--- /dev/null
+++ b/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html
@@ -0,0 +1,6 @@
+
+ Optional summary shown on the check. When left empty, a summary is generated from the TestingBot
+ sessions in the build (for example, 3 of 4 TestingBot session(s) passed.).
+ The check links to the embedded TestingBot build report when one is available, otherwise to the
+ Jenkins run.
+
diff --git a/src/main/resources/testingbot/TestingBotChecksPublisher/help-name.html b/src/main/resources/testingbot/TestingBotChecksPublisher/help-name.html
new file mode 100644
index 0000000..61e3f17
--- /dev/null
+++ b/src/main/resources/testingbot/TestingBotChecksPublisher/help-name.html
@@ -0,0 +1,5 @@
+
+ The name of the GitHub check (its context) that appears on the commit and pull request,
+ e.g. TestingBot. Use a distinct name if you publish more than one check from a job.
+ Defaults to TestingBot.
+