This is an automated email from the ASF dual-hosted git repository.

rombert pushed a commit to branch feat/llmao
in repository https://gitbox.apache.org/repos/asf/sling-tooling-jenkins.git


The following commit(s) were added to refs/heads/feat/llmao by this push:
     new c657196  feat: allow pushing successful updates to a fork and creating 
a PR
c657196 is described below

commit c65719642c057ad4f6108162defc88ef0cdd7855
Author: Robert Munteanu <[email protected]>
AuthorDate: Wed Oct 14 12:37:21 2026 +0100

    feat: allow pushing successful updates to a fork and creating a PR
---
 README.md                                          |  52 +++-
 .../org/apache/sling/jenkins/llmao/publish-pr.py   | 306 +++++++++++++++++++++
 .../apache/sling/jenkins/llmao/run-publishing.sh   |  51 ++++
 vars/slingOsgiBundleBuild.groovy                   |  97 ++++++-
 4 files changed, 501 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 3995dc9..bc8cacc 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,8 @@ The dedicated job's stages reflect the local agent workflow:
 9. **Publish Token Usage** — stop any remaining containers and summarize and 
archive observed usage, also after build, task, or verification failures.
 10. **Clean Up** — remove generated runtime inputs and temporary reports, 
leaving project edits untouched.
 
+Opt-in PR publishing adds **Prepare Pull Request** before target selection 
(refresh the clean checkout from the configured upstream base and capture the 
fork branch's tip), and **Publish Pull Request** after independent 
verification. All other jobs retain the stages above.
+
 ### A separate scheduled job
 
 1. Create a **dedicated Pipeline job** for the project, for example 
`<project>-llmao`. This library does not create that job automatically.
@@ -106,7 +108,52 @@ Each run fetches `skills/` from the `master` branch of 
[Apache Sling Whiteboard]
 
 Pi is instructed to summarize its changes and verification, without staging, 
committing, pushing, switching branches, or altering Git metadata. It must use 
the independently selected parent target, which it can read but cannot 
overwrite. The job requires a normally completed Pi process, unchanged parent 
coordinates with the target version, and a successful independent Maven build. 
Neither `SUCCESS` nor `FAILURE` in assistant text or a diff decides the result. 
If the parent is already cur [...]
 
-The resulting Git diff is printed to stdout in the Jenkins build log, 
including partial changes on failure and new, non-ignored files without staging 
them. Project edits remain in the job workspace for inspection; no commit, 
push, or automatic recovery is performed. Generated input/configuration files 
are cleaned up, and sessions are not archived.
+The resulting Git diff is printed to stdout in the Jenkins build log, 
including partial changes on failure and new, non-ignored files without staging 
them. Project edits remain in the job workspace for inspection. By default, no 
commit, push, or automatic recovery is performed; successful verified updates 
can optionally be published as described below. Generated input/configuration 
files are cleaned up, and sessions are not archived.
+
+### Publish an upstream pull request
+
+Publishing is **disabled by default**. Enable it explicitly in a trusted 
dedicated job's library invocation, not in `.sling-module.json`. The module 
descriptor cannot select publishing targets, Git credentials, or author 
identity. Use a separate Jenkins **Secret text** credential for GitHub; do not 
reuse the LLMAO gateway's `llmao-pat`.
+
+For the discovery-standalone test fork, use 
[`examples/llmao/Jenkinsfile.publish`](examples/llmao/Jenkinsfile.publish), or 
this invocation after making the library changes available on `feat/llmao`:
+
+```groovy
+@Library('sling@feat/llmao') _
+slingOsgiBundleBuild(
+    llmaoOnly: true,
+    llmaoRebuildFrequency: '', // Manual runs until publishing is validated.
+    llmaoInput: 'Update this project to the most recent released parent POM 
version',
+    llmaoPublish: true,
+    llmaoForkRepository: 'rombert/sling-org-apache-sling-discovery-standalone',
+    llmaoUpstreamRepository: 
'apache/sling-org-apache-sling-discovery-standalone',
+    llmaoGithubCredentialsId: 'jenkins-llmao-discovery-standalone'
+)
+```
+
+Only these library arguments control publishing:
+
+| Setting | Default | Meaning |
+| --- | --- | --- |
+| `llmaoPublish` | `false` | Opt into commit, fork push, and upstream PR 
creation/reuse. Must be a boolean. |
+| `llmaoForkRepository` | Empty | Existing public fork, in `owner/repository` 
form. Required when publishing. |
+| `llmaoUpstreamRepository` | Empty | Upstream PR repository, in 
`owner/repository` form. Required when publishing. |
+| `llmaoBaseBranch` | `master` | Upstream base branch to refresh from and 
target in the PR. |
+| `llmaoGithubCredentialsId` | Empty | Jenkins Secret text credential 
containing the GitHub PAT. Required when publishing. |
+| `llmaoGitAuthorName` | `LLMAO Jenkins` | Commit author and committer name. |
+| `llmaoGitAuthorEmail` | `[email protected]` | Commit author and 
committer email; override with the intended identity, such as your 
GitHub-provided noreply address. |
+
+For a **single token** that pushes to a public personal fork and opens a PR in 
a public Apache repository, use a **classic PAT with only `public_repo`**, 
subject to the upstream organization's token policy and any required 
authorization. A classic PAT cannot be restricted to one repository: this 
grants access to public repositories according to its owner's permissions, but 
not private-repository access. A fine-grained PAT restricted to the personal 
fork can push there, but cannot create  [...]
+
+The publishing workflow:
+
+1. Requires a clean, non-sparse checkout and confirms the configured 
repository is a public fork of the upstream. Adds `fork` and `llmao-upstream` 
remotes without changing `origin` or replacing unfamiliar existing remotes. 
Refreshes the checkout to a detached commit from the configured upstream base 
**before** Pi runs, so an outdated fork's `master` does not become the PR base.
+2. Captures that base commit and the current remote tip of the fixed 
**`llmao/update-sling-parent-pom`** branch in a read-only Pi input. No 
per-build branch is created. Reserve this branch for automation; human work on 
it can be replaced by later runs.
+3. Runs Pi and both independent checks as before. A failed check never binds 
the GitHub credential or pushes. An already-current parent skips Pi, 
committing, pushing, and PR creation.
+4. Uses a fresh publisher container and temporary Git repository to commit the 
verified tracked/new, non-ignored files. The project is read-only, raw file 
bytes are hashed without executing project hooks, attribute clean filters, or 
signing commands, and no project build is run with the GitHub credential. 
Submodule snapshots and symlink-directory traversal are rejected.
+5. Pushes only the fixed fork branch with **an explicit `--force-with-lease` 
expected SHA**, including an empty lease when the branch did not exist. Another 
writer changing or creating the branch during the build causes failure rather 
than an unconditional overwrite. If upstream advances after preparation, 
publishing fails and requires a new run; it never rebases an unverified result 
silently.
+6. Reuses the exact matching open upstream PR, refreshing its title/body with 
the parent target and Jenkins build link while preserving its existing 
draft/ready state. Otherwise it creates a **draft PR** against the configured 
upstream base, not inside the fork; a human can mark it ready for review. A 
lost PR-creation response triggers a lookup before any retry. Once a previous 
PR is closed or merged, a later update can use the same branch for a new draft 
PR. Force-pushing rewrites branc [...]
+7. Logs the PR URL, adds its number to the build description, and includes the 
publication result in `llmao-usage.json`. Publishing failures fail the job; 
usage reporting and container/input cleanup still run. A failed PR creation 
after a successful push leaves the branch available for a subsequent retry.
+
+The GitHub PAT is supplied only to the publisher via an environment variable 
and an ephemeral Git credential helper, never embedded in a remote URL, 
command-line argument, input JSON, or committed file. The helper passes 
credentials over Git's private pipe and persists nothing; it works with the 
container's noexec `/tmp`. Pi and Maven never receive it. Commits live in the 
temporary publishing repository and the pushed fork branch; project edits 
remain unstaged in the Jenkins checkout for [...]
 
 ### Run the LLMAO Pi setup locally
 
@@ -246,10 +293,11 @@ node tests/tool-output-logger.mjs
 node tests/usage-reporter.mjs
 node tests/pi-events.mjs
 python3 tests/verification.py
+python3 tests/publishing.py
 python3 tests/pi-docker.py
 ```
 
-These stub Jenkins steps and Docker/Pi output; they require no credentials or 
LLMAO access. To exercise the real Docker image and Pi CLI against a local mock 
gateway and a fixture skills repository:
+These stub Jenkins steps and Docker/Pi output; they require no credentials or 
LLMAO access. Publishing tests use disposable local Git repositories and a fake 
GitHub API to check fixed-branch force pushes, exact PR reuse, closed PRs, 
concurrent writers, upstream races, lost API responses, credential mounts, 
failure reporting, and hook/filter isolation. They do not push to GitHub or 
create real PRs. To exercise the real Docker image and Pi CLI against a local 
mock gateway and a fixture ski [...]
 
 ```sh
 docker build --tag sling-llmao-pi:1.1.0-jdk21-v3 
resources/org/apache/sling/jenkins/llmao
diff --git a/resources/org/apache/sling/jenkins/llmao/publish-pr.py 
b/resources/org/apache/sling/jenkins/llmao/publish-pr.py
new file mode 100644
index 0000000..3cfbf9e
--- /dev/null
+++ b/resources/org/apache/sling/jenkins/llmao/publish-pr.py
@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Trusted GitHub publishing; never execute project code with the GitHub 
PAT."""
+
+import json
+import os
+from pathlib import Path
+import re
+import stat
+import subprocess
+import sys
+import tempfile
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode
+from urllib.request import HTTPRedirectHandler, Request, build_opener
+
+BRANCH = "llmao/update-sling-parent-pom"
+# Git invokes ! helpers through its shell. Only the environment reference is
+# passed on the command line; get returns the credential over a private pipe.
+CREDENTIAL_HELPER = ('!f() { if [ "$1" = get ]; then printf "%s\\n" '
+                     '"username=x-access-token" "password=$LLMAO_GITHUB_PAT"; 
fi; }; f')
+
+
+def validate_config(config):
+    for key in ("forkRepository", "upstreamRepository"):
+        if not 
re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]*/[A-Za-z0-9_-][A-Za-z0-9_.-]*",
+                            config.get(key, "")):
+            raise ValueError(f"Invalid {key}: expected owner/repository")
+    if config["forkRepository"].lower() == 
config["upstreamRepository"].lower():
+        raise ValueError("The fork and upstream repositories must differ")
+    if not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_./-]*", 
config.get("baseBranch", "")):
+        raise ValueError("Invalid baseBranch")
+    if config["baseBranch"] == BRANCH:
+        raise ValueError("The automation branch must differ from the base 
branch")
+    for key in ("authorName", "authorEmail"):
+        value = config.get(key, "")
+        if not value.strip() or any(character in value for character in 
"\r\n\0<>"):
+            raise ValueError(f"Invalid {key}")
+
+
+class NoRedirect(HTTPRedirectHandler):
+    # Never forward Authorization to a redirected host.
+    def redirect_request(self, request, fp, code, message, headers, new_url):
+        return None
+
+
+class GitHub:
+    def __init__(self, token=""):
+        self.token = token
+
+    def __call__(self, path, data=None, method=None):
+        headers = {"Accept": "application/vnd.github+json", "User-Agent": 
"sling-llmao-jenkins",
+                   "X-GitHub-Api-Version": "2022-11-28"}
+        if self.token:
+            headers["Authorization"] = f"Bearer {self.token}"
+        if data is not None:
+            headers["Content-Type"] = "application/json"
+        request = Request("https://api.github.com"; + path, headers=headers,
+                          data=None if data is None else 
json.dumps(data).encode("utf-8"), method=method)
+        try:
+            with build_opener(NoRedirect()).open(request, timeout=60) as 
response:
+                return json.load(response)
+        except HTTPError as error:
+            # Response bodies/headers can contain sensitive diagnostics.
+            raise RuntimeError(f"GitHub API returned HTTP {error.code}") from 
None
+
+
+class Git:
+    def __init__(self, directory, environment):
+        self.directory = directory
+        self.environment = environment
+
+    def __call__(self, *arguments, input=None):
+        command = ["git", "-c", "core.hooksPath=/dev/null", "-c", 
"core.fsmonitor=false",
+                   "-c", "credential.helper=", "-c", 
"http.followRedirects=false"]
+        if self.environment.get("LLMAO_GITHUB_PAT"):
+            command += ["-c", "credential.helper=" + CREDENTIAL_HELPER]
+        command += ["-C", str(self.directory), *arguments]
+        result = subprocess.run(command, input=input, capture_output=True, 
env=self.environment)
+        if result.returncode:
+            message = result.stderr.decode("utf-8", errors="replace")
+            token = self.environment.get("LLMAO_GITHUB_PAT")
+            if token:
+                message = message.replace(token, "[REDACTED]")
+            raise RuntimeError(f"git {arguments[0]} failed: {message.strip()}")
+        return result.stdout
+
+    def text(self, *arguments):
+        return self(*arguments).decode("utf-8").strip()
+
+
+class Publisher:
+    def __init__(self, config, workspace, github, environment, url_for=None):
+        validate_config(config)
+        self.config = config
+        self.workspace = Path(workspace)
+        self.github = github
+        self.environment = environment
+        self.url_for = url_for or (lambda repository: 
f"https://github.com/{repository}.git";)
+
+    def check_fork(self):
+        repository = self.github(f"/repos/{self.config['forkRepository']}")
+        network = [(repository.get(key) or {}).get("full_name", "").lower() 
for key in ("parent", "source")]
+        if not repository.get("fork") or 
self.config["upstreamRepository"].lower() not in network:
+            raise ValueError("The configured repository is not a fork of the 
configured upstream")
+        if repository.get("private"):
+            raise ValueError("Only public forks are supported")
+
+    def prepare(self):
+        git = Git(self.workspace, self.environment)
+        if not (self.workspace / ".git").is_dir():
+            raise ValueError("Publishing requires a standalone Git checkout")
+        if git.text("status", "--porcelain"):
+            raise ValueError("The project checkout must be clean before 
preparing publication")
+        if any(entry.startswith(b"S ") for entry in git("ls-files", "-t", 
"-z").split(b"\0")):
+            raise ValueError("Publishing does not support sparse or 
skip-worktree checkouts")
+        git("check-ref-format", f"refs/heads/{self.config['baseBranch']}")
+        self.check_fork()
+        # Never replace an unfamiliar existing remote or touch origin.
+        for remote, repository in (("fork", self.config["forkRepository"]),
+                                   ("llmao-upstream", 
self.config["upstreamRepository"])):
+            url = self.url_for(repository)
+            if remote in git.text("remote").splitlines():
+                if git.text("remote", "get-url", remote) != url:
+                    raise ValueError(f"Existing {remote} remote has a 
different URL")
+            else:
+                git("remote", "add", remote, url)
+        git("fetch", "--no-tags", 
self.url_for(self.config["upstreamRepository"]),
+            f"refs/heads/{self.config['baseBranch']}")
+        base = git.text("rev-parse", "FETCH_HEAD")
+        git("checkout", "--detach", base)
+        tip = git.text("ls-remote", "--heads", 
self.url_for(self.config["forkRepository"]),
+                       f"refs/heads/{BRANCH}")
+        return {"baseCommit": base, "forkBranchTip": tip.split()[0] if tip 
else ""}
+
+    def open_pr(self):
+        owner = self.config["forkRepository"].split("/")[0]
+        query = urlencode({"state": "open", "head": f"{owner}:{BRANCH}",
+                           "base": self.config["baseBranch"], "per_page": 100})
+        result = 
self.github(f"/repos/{self.config['upstreamRepository']}/pulls?{query}")
+        matches = [pr for pr in result
+                   if (pr.get("head", {}).get("repo") or {}).get("full_name", 
"").lower()
+                   == self.config["forkRepository"].lower()
+                   and pr["head"].get("ref") == BRANCH
+                   and (pr.get("base", {}).get("repo") or {}).get("full_name", 
"").lower()
+                   == self.config["upstreamRepository"].lower()
+                   and pr["base"].get("ref") == self.config["baseBranch"]]
+        if len(matches) > 1:
+            raise ValueError("Multiple matching open PRs exist; refusing to 
choose one")
+        return matches[0] if matches else None
+
+    def snapshot_tree(self, git):
+        # Use a fresh index and hash raw bytes, not git add: project 
attributes,
+        # clean filters, hooks, and signing commands must not execute with a 
PAT.
+        paths = set(git("--work-tree=" + str(self.workspace), "ls-files", 
"--cached", "--others",
+                        "--exclude-standard", "-z").split(b"\0")) - {b""}
+        entries = []
+        for raw_path in sorted(paths):
+            relative = Path(os.fsdecode(raw_path))
+            if relative.is_absolute() or ".." in relative.parts or ".git" in 
relative.parts:
+                raise ValueError("Unsafe path in publication snapshot")
+            path = self.workspace / relative
+            for parent in relative.parents:
+                if (self.workspace / parent).is_symlink():
+                    raise ValueError("Refusing to traverse a symlink directory 
in the snapshot")
+            try:
+                mode = path.lstat().st_mode
+            except FileNotFoundError:
+                entries.append(b"0 " + b"0" * 40 + b"\t" + raw_path + b"\0")
+                continue
+            if stat.S_ISLNK(mode):
+                content = os.fsencode(os.readlink(path))
+                git_mode = "120000"
+            elif stat.S_ISREG(mode):
+                content = path.read_bytes()
+                git_mode = "100755" if mode & stat.S_IXUSR else "100644"
+            else:
+                raise ValueError("Only regular files and symlinks can be 
published (no submodules)")
+            blob = git("hash-object", "-w", "--stdin", input=content).strip()
+            entries.append(git_mode.encode() + b" " + blob + b"\t" + raw_path 
+ b"\0")
+        git("update-index", "-z", "--index-info", input=b"".join(entries))
+        return git.text("write-tree")
+
+    def publish(self, plan, expected):
+        for key in ("baseCommit", "forkBranchTip"):
+            value = plan.get(key, "")
+            if not re.fullmatch(r"[0-9a-f]{40}", value) and not (key == 
"forkBranchTip" and value == ""):
+                raise ValueError(f"Invalid publication plan {key}")
+        if Git(self.workspace, self.environment).text("rev-parse", "HEAD") != 
plan["baseCommit"]:
+            raise ValueError("The checkout base changed after publication 
preparation")
+        with tempfile.TemporaryDirectory(prefix="llmao-publish-") as directory:
+            git = Git(directory, self.environment)
+            git("init", "--quiet")
+            git("fetch", "--no-tags", 
self.url_for(self.config["upstreamRepository"]),
+                f"refs/heads/{self.config['baseBranch']}")
+            if git.text("rev-parse", "FETCH_HEAD") != plan["baseCommit"]:
+                raise ValueError("Upstream advanced after verification; rerun 
against the new base")
+            git("read-tree", plan["baseCommit"])
+            tree = self.snapshot_tree(git)
+            if tree == git.text("rev-parse", f"{plan['baseCommit']}^{{tree}}"):
+                return {"status": "skipped", "reason": "no-changes", "branch": 
BRANCH}
+            self.check_fork()
+            pr = self.open_pr()
+            title = f"Update Sling parent to {expected['targetVersion']}"
+            commit = git.text("commit-tree", tree, "-p", plan["baseCommit"], 
"-m", title)
+            git("update-ref", f"refs/heads/{BRANCH}", commit)
+            git("remote", "add", "fork", 
self.url_for(self.config["forkRepository"]))
+            # Empty expected tip means the branch must not exist. An explicit
+            # SHA avoids leases weakened by background fetches or other 
writers.
+            git("push", "--porcelain", 
f"--force-with-lease=refs/heads/{BRANCH}:{plan['forkBranchTip']}",
+                "fork", f"refs/heads/{BRANCH}:refs/heads/{BRANCH}")
+            status = "reused" if pr else "created"
+            data = {"title": title, "body": (
+                f"Automated update of 
`{expected['groupId']}:{expected['artifactId']}` "
+                f"from {expected['version']} to 
{expected['targetVersion']}.\n\n"
+                "Jenkins independently checked the parent coordinates/version 
and ran "
+                "`mvn -B -ntp clean verify` with Java 21.\n\n"
+                "Please review the generated changes before merging.")}
+            build_url = self.config.get("buildUrl", "")
+            if re.fullmatch(r"https?://[^\s<>]+", build_url):
+                data["body"] += f"\n\nJenkins build: {build_url}"
+            if pr is not None:
+                # Keep reused PR metadata accurate when a newer parent is
+                # released or a different Jenkins build refreshes the branch.
+                pr = 
self.github(f"/repos/{self.config['upstreamRepository']}/pulls/{int(pr['number'])}",
+                                 data, method="PATCH")
+            else:
+                
data.update(head=f"{self.config['forkRepository'].split('/')[0]}:{BRANCH}",
+                            base=self.config["baseBranch"], draft=True)
+                try:
+                    pr = 
self.github(f"/repos/{self.config['upstreamRepository']}/pulls", data)
+                except (RuntimeError, URLError, TimeoutError):
+                    # A POST can succeed even if the response is lost. Look up
+                    # the exact head/base again instead of blindly retrying it.
+                    pr = self.open_pr()
+                    if pr is None:
+                        raise
+                    status = "reused"
+            url = pr.get("html_url", "")
+            if pr.get("state") != "open":
+                raise ValueError("The PR closed during publication; rerun to 
find or create an open PR")
+            if not re.fullmatch(r"https://github\.com/"; + 
re.escape(self.config["upstreamRepository"])
+                                + r"/pull/[0-9]+", url, re.IGNORECASE):
+                raise ValueError("GitHub returned an unexpected PR URL")
+            return {"status": status, "url": url, "number": pr["number"], 
"branch": BRANCH,
+                    "commit": commit}
+
+
+def git_environment(config, home, token):
+    # Do not inherit executor Git configuration, credential helpers, or homes.
+    environment = {key: value for key, value in os.environ.items()
+                   if not key.startswith("GIT_") and key not in ("LLMAO_PAT", 
"LLMAO_GITHUB_PAT")}
+    environment.update(HOME=home, XDG_CONFIG_HOME=home, 
GIT_CONFIG_NOSYSTEM="1",
+                       GIT_TERMINAL_PROMPT="0", 
GIT_AUTHOR_NAME=config["authorName"],
+                       GIT_AUTHOR_EMAIL=config["authorEmail"], 
GIT_COMMITTER_NAME=config["authorName"],
+                       GIT_COMMITTER_EMAIL=config["authorEmail"])
+    if token:
+        environment["LLMAO_GITHUB_PAT"] = token
+    return environment
+
+
+def main():
+    action = sys.argv[1]
+    config = 
json.loads(Path("/inputs/publish-config.json").read_text(encoding="utf-8"))
+    token = os.environ.get("LLMAO_GITHUB_PAT", "") if action == "publish" else 
""
+    if action == "publish" and not token:
+        raise ValueError("The GitHub publishing credential is missing")
+    with tempfile.TemporaryDirectory(prefix="llmao-git-home-") as home:
+        environment = git_environment(config, home, token)
+        publisher = Publisher(config, "/workspace", GitHub(token), environment)
+        if action == "prepare":
+            result = publisher.prepare()
+        elif action == "publish":
+            result = 
publisher.publish(json.loads(Path("/inputs/publication-plan.json").read_text()),
+                                       
json.loads(Path("/inputs/expected-parent.json").read_text()))
+        else:
+            raise ValueError("Unknown publishing action")
+        print(json.dumps(result))
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except Exception as error:
+        message = str(error)
+        token = os.environ.get("LLMAO_GITHUB_PAT")
+        if token:
+            message = message.replace(token, "[REDACTED]")
+        print(f"Pull request publishing failed: {message}", file=sys.stderr)
+        sys.exit(1)
diff --git a/resources/org/apache/sling/jenkins/llmao/run-publishing.sh 
b/resources/org/apache/sling/jenkins/llmao/run-publishing.sh
new file mode 100644
index 0000000..22c62e9
--- /dev/null
+++ b/resources/org/apache/sling/jenkins/llmao/run-publishing.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set +x
+set -eu
+
+container="${LLMAO_CONTAINER}-publish"
+trap 'docker rm --force "$container" >/dev/null 2>&1 || true' EXIT HUP INT TERM
+options=()
+case "$LLMAO_PUBLISH_ACTION" in
+    prepare) 
workspace_mount="type=bind,source=$LLMAO_WORKSPACE,target=/workspace" ;;
+    publish)
+        
workspace_mount="type=bind,source=$LLMAO_WORKSPACE,target=/workspace,readonly"
+        options=(--env LLMAO_GITHUB_PAT)
+        mkdir -p publication
+        ;;
+    *) echo 'Unknown publishing action.' >&2; exit 1 ;;
+esac
+
+run() {
+    docker run --rm --init \
+        --name "$container" \
+        --user "$(id -u):$(id -g)" \
+        --read-only --tmpfs /tmp:rw,nosuid,nodev,mode=1777 \
+        "${options[@]}" \
+        --mount "type=bind,source=$(pwd)/inputs,target=/inputs,readonly" \
+        --mount "$workspace_mount" \
+        --workdir /tmp \
+        "$LLMAO_IMAGE" python3 /inputs/publish-pr.py "$LLMAO_PUBLISH_ACTION"
+}
+
+if [ "$LLMAO_PUBLISH_ACTION" = publish ]; then
+    run > publication/pull-request.json
+else
+    run
+fi
diff --git a/vars/slingOsgiBundleBuild.groovy b/vars/slingOsgiBundleBuild.groovy
index 9c3e2a3..77fafd2 100644
--- a/vars/slingOsgiBundleBuild.groovy
+++ b/vars/slingOsgiBundleBuild.groovy
@@ -152,6 +152,17 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
         llmaoInput: '',
         llmaoInputFile: ''
     ]
+    // Publishing targets and credential selection belong to the trusted job
+    // invocation, never to the module descriptor or the agent's output.
+    def publishingConfig = [
+        llmaoPublish: false,
+        llmaoForkRepository: '',
+        llmaoUpstreamRepository: '',
+        llmaoBaseBranch: 'master',
+        llmaoGithubCredentialsId: '',
+        llmaoGitAuthorName: 'LLMAO Jenkins',
+        llmaoGitAuthorEmail: '[email protected]'
+    ]
 
     node(globalConfig.mainNodeLabel) {
         timeout(time: 45, unit: 'MINUTES') {
@@ -171,6 +182,21 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                 }
                 if ( !(llmaoConfig.llmaoMaxTokens instanceof Integer) || 
llmaoConfig.llmaoMaxTokens <= 0 )
                     error('llmaoMaxTokens must be a positive integer.')
+                publishingConfig.keySet().each { key ->
+                    if ( params.containsKey(key) )
+                        publishingConfig[key] = params[key]
+                }
+                if ( !(publishingConfig.llmaoPublish instanceof Boolean) )
+                    error('llmaoPublish must be a boolean.')
+                if ( publishingConfig.llmaoPublish ) {
+                    ['llmaoForkRepository', 'llmaoUpstreamRepository'].each { 
key ->
+                        if ( !(publishingConfig[key] instanceof String) ||
+                            !(publishingConfig[key] ==~ 
/[A-Za-z0-9_-][A-Za-z0-9_.-]*\/[A-Za-z0-9_-][A-Za-z0-9_.-]*/) )
+                            error("${key} must be an owner/repository name.")
+                    }
+                    if ( !publishingConfig.llmaoGithubCredentialsId?.trim() )
+                        error('Provide llmaoGithubCredentialsId when 
publishing is enabled.')
+                }
             }
 
             def contextData
@@ -193,6 +219,7 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
             }
 
             def updateStatus
+            def publication
             def projectWorkspace = pwd()
             // Keep generated files outside the source checkout; leave the 
input file untouched.
             dir("${pwd(tmp: true)}/llmao") {
@@ -226,6 +253,17 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                                 writeFile file: 'pi-events.mjs', text: 
libraryResource('org/apache/sling/jenkins/llmao/pi-events.mjs')
                                 writeFile file: 'parent-pom.py', text: 
libraryResource('org/apache/sling/jenkins/llmao/parent-pom.py')
                                 writeFile file: 'verify-build.sh', text: 
libraryResource('org/apache/sling/jenkins/llmao/verify-build.sh')
+                                if ( publishingConfig.llmaoPublish ) {
+                                    writeFile file: 'publish-pr.py', text: 
libraryResource('org/apache/sling/jenkins/llmao/publish-pr.py')
+                                    writeJSON file: 'publish-config.json', 
json: [
+                                        forkRepository: 
publishingConfig.llmaoForkRepository,
+                                        upstreamRepository: 
publishingConfig.llmaoUpstreamRepository,
+                                        baseBranch: 
publishingConfig.llmaoBaseBranch,
+                                        authorName: 
publishingConfig.llmaoGitAuthorName,
+                                        authorEmail: 
publishingConfig.llmaoGitAuthorEmail,
+                                        buildUrl: env.BUILD_URL
+                                    ]
+                                }
                                 writeFile file: 'system-prompt.txt', text: 
'You are a coding assistant working on the checked-out project. Follow the 
relevant supplied Apache Sling skills; use update-sling-parent-pom for upgrades 
to the most recent released parent POM version. Read 
/inputs/expected-parent.json for the independently selected parent coordinates 
and targetVersion; keep the parent coordinates unchanged and upgrade to that 
target version. Complete the requested update and its [...]
                                 writeFile file: 'prompt_input.txt', text: 
contextData, encoding: 'UTF-8'
                                 writeJSON file: 'models.json', json: [
@@ -255,6 +293,20 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                                 ]
                             }
                         }
+                        if ( publishingConfig.llmaoPublish ) {
+                            stage('Prepare Pull Request') {
+                                def plan
+                                withEnv(['LLMAO_PUBLISH_ACTION=prepare']) {
+                                    plan = readJSON(text: sh(
+                                        script: 
libraryResource('org/apache/sling/jenkins/llmao/run-publishing.sh'),
+                                        returnStdout: true
+                                    ).trim(), returnPojo: true)
+                                }
+                                // Read-only to Pi; its writable reports cannot
+                                // change the base commit or expected branch 
tip.
+                                writeJSON file: 
'inputs/publication-plan.json', json: plan
+                            }
+                        }
                         def expectedParent
                         stage('Determine Parent Target') {
                             withEnv(['LLMAO_CHECK=prepare-parent']) {
@@ -311,6 +363,38 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                                     error("Independent mvn clean verify failed 
(exit code ${updateStatus}).")
                             }
                         }
+                        if ( publishingConfig.llmaoPublish ) {
+                            stage('Publish Pull Request') {
+                                if ( expectedParent.updateRequired ) {
+                                    // A cancellation must not retain Maven's
+                                    // success as the overall publishing 
result.
+                                    updateStatus = null
+                                    withEnv(['LLMAO_PUBLISH_ACTION=publish']) {
+                                        withCredentials([string(credentialsId: 
publishingConfig.llmaoGithubCredentialsId,
+                                            variable: 'LLMAO_GITHUB_PAT')]) {
+                                            updateStatus = sh(
+                                                script: 
libraryResource('org/apache/sling/jenkins/llmao/run-publishing.sh'),
+                                                returnStatus: true
+                                            )
+                                        }
+                                    }
+                                    if ( updateStatus != 0 )
+                                        error("Pull request publishing failed 
(exit code ${updateStatus}).")
+                                    updateStatus = null
+                                    publication = readJSON(file: 
'publication/pull-request.json', returnPojo: true)
+                                    if ( !(publication.status in ['created', 
'reused', 'skipped']) )
+                                        error('The publishing result has an 
invalid status.')
+                                    if ( publication.url )
+                                        echo "LLMAO pull request 
(${publication.status}): ${publication.url}"
+                                    else
+                                        echo 'No project changes to publish.'
+                                    updateStatus = 0
+                                } else {
+                                    publication = [status: 'skipped', reason: 
'parent-current']
+                                    echo 'No parent update needed; skipping 
commit, push, and PR creation.'
+                                }
+                            }
+                        }
                     } finally {
                         try {
                             stage('Publish Token Usage') {
@@ -319,9 +403,10 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                                     set +x
                                     docker rm --force "$LLMAO_CONTAINER" 
>/dev/null 2>&1 || true
                                     docker rm --force 
"${LLMAO_CONTAINER}-verify" >/dev/null 2>&1 || true
+                                    docker rm --force 
"${LLMAO_CONTAINER}-publish" >/dev/null 2>&1 || true
                                 '''
                                 dir(reportDirectory) {
-                                    publishLlmaoUsage(llmaoConfig.llmaoModel, 
updateStatus)
+                                    publishLlmaoUsage(llmaoConfig.llmaoModel, 
updateStatus, publication)
                                 }
                             }
                         } finally {
@@ -331,7 +416,9 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                                     rm -f image/Dockerfile inputs/models.json 
inputs/settings.json \\
                                       inputs/system-prompt.txt 
inputs/prompt_input.txt inputs/run-pi.sh \\
                                       inputs/tool-output-logger.mjs 
inputs/usage-reporter.mjs inputs/pi-events.mjs \\
-                                      inputs/parent-pom.py 
inputs/expected-parent.json inputs/verify-build.sh
+                                       inputs/parent-pom.py 
inputs/expected-parent.json inputs/verify-build.sh \\
+                                       inputs/publish-pr.py 
inputs/publish-config.json inputs/publication-plan.json \\
+                                      publication/pull-request.json
                                     rm -f "$LLMAO_REPORT_DIR/llmao-usage.json" 
"$LLMAO_REPORT_DIR/llmao-usage.json.tmp"
                                     rmdir "$LLMAO_REPORT_DIR" >/dev/null 2>&1 
|| true
                                 '''
@@ -344,7 +431,7 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
     }
 }
 
-def publishLlmaoUsage(def model, def updateStatus) {
+def publishLlmaoUsage(def model, def updateStatus, def publication = null) {
     def report = fileExists('llmao-usage.json') ? readJSON(file: 
'llmao-usage.json', returnPojo: true) : [
         schemaVersion: 1, provider: 'llmao', model: model,
         state: 'unknown', aborted: null, usageStatus: 'unavailable',
@@ -353,6 +440,8 @@ def publishLlmaoUsage(def model, def updateStatus) {
     report.build = [job: env.JOB_NAME, number: env.BUILD_NUMBER, url: 
env.BUILD_URL]
     report.result = updateStatus == null ? 'INCOMPLETE' : updateStatus == 0 ? 
'SUCCESS' : 'FAILURE'
     report.exitCode = updateStatus
+    if ( publication != null )
+        report.publication = publication
     def description = "LLMAO: ${report.result}"
     if ( report.tokens != null ) {
         def tokens = report.tokens
@@ -362,6 +451,8 @@ def publishLlmaoUsage(def model, def updateStatus) {
         echo 'LLMAO token usage unavailable; no gateway-reported usage was 
collected.'
         description += ' | tokens unavailable'
     }
+    if ( publication?.number )
+        description += " | PR #${publication.number}"
     currentBuild.description = description
     writeJSON file: 'llmao-usage.json', json: report
     archiveArtifacts artifacts: 'llmao-usage.json', allowEmptyArchive: false

Reply via email to