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