Files
datastore/ci/jobs/changelog_nightly.py
robot-clickhouseandClaude Fable 5 31ea3f7553 Address review: unresolved reverts grant no deletion credit
An entry disappearance must now be bound to a PR that a raw-block
revert actually reverts (the `Reverts owner/repo#N` marker in the
revert PR body); a revert without the marker or with a failed lookup
no longer licenses an arbitrary deletion — verification fails closed
instead, and the raw entries stay committed for the next run.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 20:14:57 +00:00

894 lines
37 KiB
Python

"""Nightly job: prepare `CHANGELOG.md` for the upcoming release.
Maintains one bot branch and one draft PR per release cycle
(`auto/changelog-X.Y`, "Prepare changelog for X.Y"). Every day the job:
1. Generates raw changelog entries for the pull requests merged into `master`
since the previous run, using `tests/ci/changelog.py` inside the
`clickhouse/style-test` container (the same way `create_release.yml` runs
it). The raw entries are inserted into `CHANGELOG.md` as a block delimited
by HTML-comment markers, above the first released section, and committed.
2. Edits the raw block following `.claude/skills/edit-changelog/SKILL.md`
(via the `codex` CLI, like the Code Review job) and integrates the result
into the in-progress release section, as a separate commit. The
generate/edit split keeps both states reviewable, mirroring the manual
autogenerated-then-edited commit pairs of past releases.
The point up to which the changelog was generated is remembered as a
`Changelog-generated-up-to: <sha>` trailer in the generate commit message, so
the branch itself carries the state and the PR diff stays clean. The first
run of a cycle starts from the `vX.Y.1.1-new` tag that marks the cycle start
on `master`.
When `master` moves to the next version (the release branch is cut), the job
gives the previous cycle's branch a final catch-up (up to the new
`v*.1.1-new` tag) as long as its PR is still open, and starts a fresh
branch/PR for the new version. The draft PR is finalized manually by the
release manager (release date, presentation/video links, last-minute edits).
Fail-closed: a failed generation never commits; a failed edit resets the
working tree to the generate commit (which is still pushed, so the raw
entries stay visible in the PR and are picked up by the next run) and fails
the job. If a human closed the PR without merging, the branch is left alone.
"""
import os
import re
import shlex
import subprocess
import tempfile
import time
import traceback
from ci.praktika import Secret
from ci.praktika.gh_auth import GHAuth
from ci.praktika.info import Info
from ci.praktika.result import Result
from ci.praktika.utils import Shell
CHANGELOG_FILE = "CHANGELOG.md"
BRANCH_PREFIX = "auto/changelog-"
RAW_BEGIN = "<!-- CHANGELOG-RAW-BEGIN: auto-generated entries below are edited and removed by the NightlyChangelog CI job; do not edit them manually -->"
RAW_BEGIN_PREFIX = "<!-- CHANGELOG-RAW-BEGIN"
RAW_END = "<!-- CHANGELOG-RAW-END -->"
STATE_TRAILER = "Changelog-generated-up-to:"
RAW_OUTPUT = "./ci/tmp/changelog_raw_delta.md"
EDIT_SKILL = ".claude/skills/edit-changelog/SKILL.md"
STYLE_TEST_IMAGE = "clickhouse/style-test:latest"
# OpenAI API key for the codex CLI, same secret as the Code Review job.
OPENAI_KEY_SECRET = "/ci/llm/openai_api_key"
MAX_EDIT_ATTEMPTS = 3
VERSIONS_FILE = "cmake/autogenerated_versions.txt"
def _anchor_id(version):
"""'26.7' -> '267', the anchor used in CHANGELOG.md section headers."""
return version.replace(".", "")
def _branch(version):
return f"{BRANCH_PREFIX}{version}"
def _start_tag(version):
return f"v{version}.1.1-new"
def _sha(ref):
return Shell.get_output(f"git rev-parse '{ref}^{{}}'", strict=True).strip()
def get_master_version():
"""Version 'X.Y' currently developed on master = the upcoming release."""
text = Shell.get_output(f"git show origin/master:{VERSIONS_FILE}", strict=True)
major = re.search(r"SET\(VERSION_MAJOR (\d+)\)", text)
minor = re.search(r"SET\(VERSION_MINOR (\d+)\)", text)
assert major and minor, f"Cannot parse version from {VERSIONS_FILE}"
return f"{major.group(1)}.{minor.group(1)}"
def get_cycle_start_tags():
"""All vX.Y.1.1-new tags (each marks the start of a development cycle)."""
tags = {}
for tag in Shell.get_output("git tag --list 'v*-new'").splitlines():
m = re.fullmatch(r"v(\d+)\.(\d+)\.1\.1-new", tag.strip())
if m:
tags[(int(m.group(1)), int(m.group(2)))] = tag.strip()
return tags
def get_cycle_end_ref(version, master_version):
"""The ref up to which the changelog of `version` should be generated.
For the version currently developed on master it is the master head.
For a past version it is the tag that started the next cycle, i.e. the
point where master moved on to the next version.
"""
if version == master_version:
return "origin/master"
tags = get_cycle_start_tags()
key = tuple(int(x) for x in version.split("."))
later = sorted(k for k in tags if k > key)
if not later:
return None
return tags[later[0]]
def get_last_generated_sha(branch):
"""Read the state trailer from the branch-only commits, newest first.
Requires origin/{branch} to be fetched (checkout_branch does that)."""
log = Shell.get_output(
f"git log --format=%B origin/master..origin/{branch}", strict=True
)
for line in log.splitlines():
m = re.fullmatch(rf"{STATE_TRAILER}\s*([0-9a-f]{{40}})", line.strip())
if m:
return m.group(1)
return None
def determine_from_ref(version, branch, exists_on_remote):
"""The point to continue the generation from: the last state trailer of
the branch, or the cycle start tag for a fresh branch. Must run after
checkout_branch. Refuses the tag fallback when the branch already carries
changelog content: regenerating from the cycle start would duplicate the
entries that were already integrated."""
if exists_on_remote:
from_ref = get_last_generated_sha(branch)
if from_ref:
return from_ref
text = _read_changelog()
if (
RAW_BEGIN_PREFIX in text
or extract_in_progress_section(text, _anchor_id(version)) is not None
):
raise RuntimeError(
f"Branch {branch} contains changelog content for {version} but "
f"no '{STATE_TRAILER}' trailer in "
f"origin/master..origin/{branch}; refusing to regenerate from "
f"the cycle start, manual investigation required"
)
return _start_tag(version)
def remote_branch_exists(branch):
return bool(Shell.get_output(f"git ls-remote --heads origin {branch}").strip())
def get_pr_states(branch):
"""States of the PRs from the repository's own branch `branch`. Fork PRs
that happen to use the same head branch name are excluded: they must not
make the job think its own PR already exists or was closed."""
out = Shell.get_output(
f"gh pr list --head {branch} --base master --state all "
f"--repo {shlex.quote(Info().repo_name)} "
"--json number,state,isCrossRepository "
"--jq '.[] | select(.isCrossRepository | not) | \"\\(.number) \\(.state)\"'",
strict=True,
)
return [tuple(line.split()) for line in out.splitlines() if line.strip()]
# --- CHANGELOG.md text manipulation ----------------------------------------
RELEASE_HEADING_RE = re.compile(r'^### <a id="(\d+)"></a>')
def strip_generated_preamble(raw_text):
"""Drop the docs frontmatter and the year header emitted by changelog.py,
keeping from the '### ClickHouse release ...' line on."""
lines = raw_text.splitlines()
for i, line in enumerate(lines):
if line.startswith("### "):
return "\n".join(lines[i:]).strip()
raise ValueError("No '### ' release header in the generated changelog")
def count_entries(text):
return len(re.findall(r"^\* ", text, flags=re.MULTILINE))
def make_raw_block(body):
return f"{RAW_BEGIN}\n{body}\n{RAW_END}"
def insert_before_first_release(text, block):
"""Insert `block` right above the first release section heading."""
lines = text.splitlines()
for i, line in enumerate(lines):
if RELEASE_HEADING_RE.match(line):
return "\n".join(lines[:i] + block.splitlines() + [""] + lines[i:]) + "\n"
raise ValueError(f"No release section heading found in {CHANGELOG_FILE}")
def split_at_first_released_section(text, anchor):
"""Split into (head, tail) where tail starts at the first release heading
that is not the in-progress section `anchor`. The tail must never be
touched by the edit step."""
lines = text.splitlines()
for i, line in enumerate(lines):
m = RELEASE_HEADING_RE.match(line)
if m and m.group(1) != anchor:
return "\n".join(lines[:i]), "\n".join(lines[i:])
return text, ""
def extract_in_progress_section(text, anchor):
lines = text.splitlines()
start = None
for i, line in enumerate(lines):
m = RELEASE_HEADING_RE.match(line)
if m and m.group(1) == anchor and start is None:
start = i
elif m and start is not None:
return "\n".join(lines[start:i]).rstrip()
if start is not None:
return "\n".join(lines[start:]).rstrip()
return None
def extract_raw_blocks(text):
blocks = []
lines = text.splitlines()
begin = None
for i, line in enumerate(lines):
if line.startswith(RAW_BEGIN_PREFIX):
begin = i
elif line.strip() == RAW_END and begin is not None:
blocks.append("\n".join(lines[begin : i + 1]))
begin = None
return blocks
def extract_toc_line(text, version):
for line in text.splitlines():
if line.startswith(f"**[ClickHouse release v{version},"):
return line
return None
def extract_pr_links(text):
"""PR numbers referenced as changelog entry links in `text`."""
return set(
re.findall(
r"\[#(\d+)\]\(https://github\.com/[^/)]+/[^/)]+/pull/\d+\)", text
)
)
# The attribution form the generator emits at the end of every entry:
# `[#N](.../pull/N) ([Author](...))`. Inline mentions of other PRs inside the
# entry text lack the author parenthesis.
PR_ATTRIBUTION_RE = re.compile(
r"\[#(\d+)\]\(https://github\.com/[^/)]+/[^/)]+/pull/\d+\) \(\["
)
# Categories whose raw entries the editing agent must carry into the edited
# section (possibly rewritten, merged, or under another category — the PR
# link must survive). The skill legitimately prunes the other sections
# (NOT FOR CHANGELOG, NO CL ENTRY, Build/Testing/Packaging).
STRICT_RETENTION_CATEGORIES = {
"Backward Incompatible Change",
"New Feature",
"Experimental Feature",
"Performance Improvement",
"Improvement",
"Bug Fix (user-visible misbehavior in an official stable release)",
}
# A bullet that IS a revert: the entry text (the generator uses the PR title
# for entries without a changelog description) starts with "Revert"/"Reverts"
# — possibly wrapped in the NO CL ENTRY prefix — or quotes a git revert
# commit message. A bullet merely mentioning reverts ("Improve revert
# logic ...") earns no deletion credit.
_REVERT_ENTRY_RE = re.compile(
r"""(?ix)^\*\s* (?:NO\ CL\ ENTRY:\s*'?\s*)? revert(s|ed)?\b"""
)
def raw_strict_prs_and_reverts(text):
"""From the raw marker blocks in `text`: the attribution PR numbers of
entries under the strict-retention categories, and the PR numbers of the
revert entries (each licenses the deletion of the entry it reverts)."""
strict_prs = set()
revert_prs = []
for block in extract_raw_blocks(text):
category = None
for line in block.splitlines():
if line.startswith("#### "):
category = line[5:].strip()
elif line.startswith("* "):
if _REVERT_ENTRY_RE.match(line) or "this reverts commit" in line.lower():
revert_prs.extend(PR_ATTRIBUTION_RE.findall(line))
if category in STRICT_RETENTION_CATEGORIES:
strict_prs.update(PR_ATTRIBUTION_RE.findall(line))
return strict_prs, revert_prs
def resolve_reverted_originals(revert_prs):
"""Map revert PRs to the PRs they revert, via the `Reverts owner/repo#N`
line GitHub puts into revert PR bodies. Returns (originals, unresolved):
unresolved reverts (a manual revert without the marker, or a failed
lookup) grant no deletion credit — the caller fails closed on any
disappearance they cannot bind to a resolved original."""
originals = set()
unresolved = []
for pr in revert_prs:
body = Shell.get_output(
f"gh pr view {pr} --repo {shlex.quote(Info().repo_name)} "
"--json body --jq .body"
)
found = re.findall(r"(?i)reverts\s+[\w.-]+/[\w.-]+#(\d+)", body)
if found:
originals.update(found)
else:
unresolved.append(pr)
return originals, unresolved
def compose_on_master(master_text, version, toc_line, raw_blocks, section):
"""Rebuild our additions on top of master's CHANGELOG.md. Used to resolve
the merge conflict that appears when master's CHANGELOG.md changed (e.g.
the previous release section landed while this branch was open)."""
anchor = _anchor_id(version)
if f'<a id="{anchor}"></a>' in master_text:
raise ValueError(
f"Master already contains a section for {version}; "
"manual reconciliation required"
)
text = master_text
if toc_line:
lines = text.splitlines()
for i, line in enumerate(lines):
if line.startswith("**[ClickHouse release v"):
lines.insert(i, toc_line)
break
else:
raise ValueError("No table-of-contents entries found on master")
text = "\n".join(lines) + ("\n" if text.endswith("\n") else "")
for part in [section] + raw_blocks:
if part:
text = insert_before_first_release(text, part)
return text
def _untracked_files():
"""Untracked, not-ignored files (the gitignored ci/tmp scratch space is
excluded, so the job's own temporary files never show up here).
NUL-delimited so file names with spaces survive."""
out = Shell.get_output(
"git ls-files -z --others --exclude-standard", strict=True
)
return {path for path in out.split("\0") if path}
def _reset_worktree(base_sha, pre_untracked=None):
"""Restore the branch and all tracked files to the given commit, and
remove untracked files that appeared since `pre_untracked` was snapshot
(fail-closed cleanup after a bad edit attempt, including one where the
editing agent created commits or new files of its own)."""
Shell.check(f"git reset --hard {base_sha}", verbose=True, strict=True)
if pre_untracked is not None:
for path in sorted(_untracked_files() - pre_untracked):
print(f"Removing file left behind by the editing agent: {path}")
os.unlink(path)
def _read_changelog():
with open(CHANGELOG_FILE, encoding="utf-8") as fd:
return fd.read()
def _write_changelog(text):
with open(CHANGELOG_FILE, "w", encoding="utf-8") as fd:
fd.write(text)
# --- steps ------------------------------------------------------------------
# Each step returns a human-readable info string on success and raises on
# failure; _step() adapts that to praktika's Result convention.
def prepare_repo():
if Shell.get_output("git rev-parse --is-shallow-repository").strip() == "true":
Shell.check("git fetch --unshallow origin", verbose=True, strict=True)
# Explicit refspec: the CI checkout restricts remote.origin.fetch to the
# triggering ref, so a plain `git fetch origin master` would not be
# guaranteed to update the remote-tracking ref.
Shell.check(
"git fetch --tags origin '+refs/heads/master:refs/remotes/origin/master'",
verbose=True,
strict=True,
)
Shell.check(
'git config user.name "robot-clickhouse" && '
'git config user.email "[email protected]"',
verbose=True,
strict=True,
)
return "Fetched master and tags"
def checkout_branch(branch, exists_on_remote):
if exists_on_remote:
# Explicit refspec, see prepare_repo.
Shell.check(
f"git fetch origin "
f"'+refs/heads/{branch}:refs/remotes/origin/{branch}'",
verbose=True,
strict=True,
)
Shell.check(
f"git checkout -B {branch} origin/{branch}", verbose=True, strict=True
)
else:
Shell.check(
f"git checkout -B {branch} origin/master", verbose=True, strict=True
)
def reconcile_with_master(version):
"""Merge origin/master into the branch when master's CHANGELOG.md changed
since the branch base. A conflict in CHANGELOG.md is resolved structurally
by re-inserting our additions on top of master's file; any other conflict
fails the job for manual resolution."""
base = Shell.get_output("git merge-base HEAD origin/master", strict=True).strip()
if Shell.check(f"git diff --quiet {base} origin/master -- {CHANGELOG_FILE}"):
return "No CHANGELOG.md changes on master to reconcile"
ours = _read_changelog()
if Shell.check("git merge --no-edit origin/master", verbose=True):
return "Merged origin/master cleanly"
conflicts = Shell.get_output(
"git diff --name-only --diff-filter=U", strict=True
).split()
if conflicts != [CHANGELOG_FILE]:
Shell.check("git merge --abort", verbose=True)
raise RuntimeError(
f"Merge of origin/master conflicts outside {CHANGELOG_FILE}: "
f"{conflicts}; manual reconciliation required"
)
try:
rebuilt = compose_on_master(
Shell.get_output(
f"git show origin/master:{CHANGELOG_FILE}", strict=True
),
version,
extract_toc_line(ours, version),
extract_raw_blocks(ours),
extract_in_progress_section(ours, _anchor_id(version)),
)
_write_changelog(rebuilt)
Shell.check(f"git add {CHANGELOG_FILE}", strict=True)
Shell.check("git commit --no-edit", verbose=True, strict=True)
except BaseException:
# Never leave the repository mid-merge: it would break the checkout
# of the next processed cycle in the same run.
Shell.check("git merge --abort", verbose=True)
raise
return "Merged origin/master, re-inserted the in-progress section on top"
def generate_raw_entries(version, from_ref, to_sha):
"""Run changelog.py in the style-test container for from_ref..to_sha and
commit the raw entries as a marker block."""
from_sha = _sha(from_ref)
if from_sha == to_sha:
return "No new commits since the last generation"
if not Shell.check(f"git merge-base --is-ancestor {from_sha} {to_sha}"):
raise RuntimeError(
f"Recorded generation point {from_ref} ({from_sha}) is not an "
f"ancestor of {to_sha}; refusing to generate"
)
if os.path.exists(RAW_OUTPUT):
os.unlink(RAW_OUTPUT)
Shell.check(f"docker pull {STYLE_TEST_IMAGE}", verbose=True, strict=True)
# The token is passed through the environment (the command line, which
# gets logged, only carries the unexpanded expressions): the host shell
# exports it to the docker CLI and the in-container shell expands it for
# changelog.py's --gh-user-or-token.
cmd = (
f"./tests/ci/changelog.py -v --jobs=5 "
f'--gh-user-or-token "$GH_TOKEN" '
f"--from {shlex.quote(from_ref)} --output {shlex.quote(RAW_OUTPUT)} {to_sha}"
)
if not Shell.check(
f'GH_TOKEN="$(gh auth token)" '
f'docker run --rm -u "$(id -u):$(id -g)" -e PYTHONUNBUFFERED=1 -e CI=1 '
f"-e GH_TOKEN --network=host --volume=$(pwd):/wd --workdir=/wd "
f"{STYLE_TEST_IMAGE} sh -c {shlex.quote(cmd)}",
verbose=True,
):
raise RuntimeError("changelog.py failed")
with open(RAW_OUTPUT, encoding="utf-8") as fd:
body = strip_generated_preamble(fd.read())
entries = count_entries(body)
from_short, to_short = from_sha[:11], to_sha[:11]
if entries == 0:
return f"No new changelog entries in {from_short}..{to_short}"
_write_changelog(
insert_before_first_release(_read_changelog(), make_raw_block(body))
)
message = (
f"Update changelog for {version}: generate raw entries "
f"({from_short}..{to_short})\n\n"
f"{entries} raw entries generated with tests/ci/changelog.py "
f"by the NightlyChangelog CI job.\n\n"
f"{STATE_TRAILER} {to_sha}\n"
)
Shell.check(f"git add {CHANGELOG_FILE}", strict=True)
Shell.check(f"git commit -m {shlex.quote(message)}", verbose=True, strict=True)
return f"Generated {entries} raw entries for {from_short}..{to_short}"
def _edit_prompt(version):
anchor = _anchor_id(version)
return f"""\
You are updating {CHANGELOG_FILE} for the upcoming ClickHouse release {version} as part of an automated daily CI job.
{CHANGELOG_FILE} contains one or more blocks delimited by marker lines starting with `{RAW_BEGIN_PREFIX}` and ending with `{RAW_END}`. Each block holds raw auto-generated changelog entries (the output of `utils/changelog/changelog.py`) for pull requests merged into master since the previous run of this job.
Task:
1. Read {EDIT_SKILL} and apply its editing rules to the raw entries inside the marker blocks.
2. Integrate the edited entries into the in-progress release section, the one with the header containing `<a id="{anchor}"></a>`:
- If that section does not exist yet, create it directly above the first released section, with exactly this header: `### <a id="{anchor}"></a> ClickHouse release {version}, FIXME (in progress)`, and add this line at the top of the "Table of Contents" list: `**[ClickHouse release v{version}, FIXME](#{anchor})**<br/>`.
- Place entries under the proper `#### <Category>` headers in the preferred category order from the skill; create missing category headers as needed; keep exactly one blank line before each `####` header.
- Entries already present in the in-progress section were edited on previous days: do not rewrite or reorder them, except where the skill requires it — a revert arriving in a raw block may delete or amend a previously added entry (skill section 2), and a follow-up PR may be merged into an existing bullet (skill section 7).
3. Delete the marker blocks, including the marker lines themselves, after integrating them.
4. Do not commit. Do not modify any file other than {CHANGELOG_FILE}. Do not touch the sections of already-released versions or anything below them. Keep the `FIXME` placeholders in the header and table of contents — the release manager fills in the date and links at release time.
The `gh` CLI is authenticated; use `gh pr view <N> --json title,body` when the skill requires consulting a pull request (reverts, unclear entries).
"""
def verify_edit(version, base_sha, pre_untracked=frozenset()):
"""Fail-closed checks after the editing agent ran, all against the
pre-edit generate commit `base_sha` rather than a mutable HEAD (the agent
has git access, and an agent-made commit must not bypass the checks).
Returns an error string or None."""
if _sha("HEAD") != base_sha:
return "The editing agent created commits instead of leaving the edit uncommitted"
changed = Shell.get_output(f"git diff --name-only {base_sha}", strict=True).split()
if changed != [CHANGELOG_FILE]:
return f"Unexpected working tree changes after edit: {changed or 'none'}"
created = _untracked_files() - pre_untracked
if created:
return f"The editing agent created files: {sorted(created)}"
text = _read_changelog()
anchor = _anchor_id(version)
if RAW_BEGIN_PREFIX in text or RAW_END in text:
return "Raw entry markers are still present after edit"
if f'<a id="{anchor}"></a>' not in text:
return f"In-progress section for {version} is missing after edit"
if not extract_toc_line(text, version):
return f"Table-of-contents line for {version} is missing after edit"
old_text = Shell.get_output(f"git show {base_sha}:{CHANGELOG_FILE}", strict=True)
section = extract_in_progress_section(text, anchor)
old_section = extract_in_progress_section(old_text, anchor) or ""
if section is None:
return "In-progress section disappeared after edit"
# Entries must survive the edit: previously accumulated entries (the
# skill only deletes one when a newly arrived revert cancels it, section
# 2; merges keep all PR links, section 7), and today's raw entries from
# the strict-retention categories (the skill prunes only the junk
# sections; real entries are edited, merged, or moved — their PR links
# stay, otherwise the entry is silently lost forever because the state
# trailer has already advanced past it). A revert usually names the
# original PR only in its title, so per-entry attribution is not
# checkable — instead each revert entry in the raw block justifies at
# most one loss, and a raw block without reverts justifies none.
removed = extract_pr_links(old_section) - extract_pr_links(section)
strict_prs, revert_prs = raw_strict_prs_and_reverts(old_text)
# Membership is checked against the whole file: an entry may
# legitimately already sit in a previously released section (a PR merged
# into the last release branch after the cycle start appears in the
# range but was already published).
lost_new = {pr for pr in strict_prs if f"/pull/{pr})" not in text}
disappeared = removed | lost_new
if disappeared:
originals, unresolved = resolve_reverted_originals(revert_prs)
unmatched = disappeared - originals
if unmatched:
return (
f"Entries disappeared in the edit without a matching revert "
f"({sorted(unmatched)}); the raw block's reverts resolve to "
f"{sorted(originals) or 'nothing'}"
+ (
f", and reverts {unresolved} could not be resolved "
f"(no `Reverts owner/repo#N` marker or failed lookup)"
if unresolved
else ""
)
)
_, old_tail = split_at_first_released_section(old_text, anchor)
_, new_tail = split_at_first_released_section(text, anchor)
if old_tail.rstrip() != new_tail.rstrip():
return "Sections of already-released versions were modified by the edit"
return None
def edit_raw_entries(version):
"""Run the editing agent on the committed raw blocks and commit the result.
Retries a few times; on failure resets to the generate commit and raises."""
prompt = _edit_prompt(version)
openai_key = Secret.Config(
name=OPENAI_KEY_SECRET, type=Secret.Type.AWS_SSM_PARAMETER
).get_value()
# The generate commit the edit must be based on; every verification and
# cleanup is pinned to this SHA, not to HEAD, which the agent can move.
base_sha = _sha("HEAD")
pre_untracked = _untracked_files()
last_error = None
for attempt in range(1, MAX_EDIT_ATTEMPTS + 1):
# Full reset to the generate commit: a failed attempt may have left
# changes beyond CHANGELOG.md, new files, or even commits (all are
# verify_edit failure modes), and they must not leak into the next
# attempt or the next processed version.
_reset_worktree(base_sha, pre_untracked)
# The GitHub App token expires after an hour and an editing attempt
# can run long; refresh so the agent's `gh pr view` calls work.
GHAuth.auth_from_settings()
try:
with tempfile.TemporaryDirectory(dir="./ci/tmp") as codex_home:
subprocess.run(
["codex", "login", "--with-api-key"],
input=openai_key,
text=True,
check=True,
env={**os.environ, "CODEX_HOME": codex_home},
)
# Same invocation as the Code Review job (proven on these
# runners): writable workspace, network for `gh pr view`.
ok = Shell.check(
f"CODEX_HOME={shlex.quote(codex_home)} codex exec "
f"-m gpt-5.4 -c 'model_reasoning_effort=xhigh' "
f"-s workspace-write "
f"-c sandbox_workspace_write.network_access=true "
f"-c approval_policy=never "
f"--color never "
f"{shlex.quote(prompt)}",
verbose=True,
)
last_error = None if ok else "codex exited with non-zero status"
except Exception as e:
last_error = f"{type(e).__name__}: {e}"
traceback.print_exc()
if not last_error:
last_error = verify_edit(version, base_sha, pre_untracked)
if not last_error:
break
print(
f"WARNING: edit attempt {attempt}/{MAX_EDIT_ATTEMPTS} failed: {last_error}"
)
if attempt < MAX_EDIT_ATTEMPTS:
time.sleep(min(2**attempt, 60))
if last_error:
_reset_worktree(base_sha, pre_untracked)
raise RuntimeError(
f"Editing failed after {MAX_EDIT_ATTEMPTS} attempts: {last_error}. "
f"The raw entries stay committed and will be edited by the next run."
)
message = (
f"Update changelog for {version}: edit new entries\n\n"
f"Edited by the NightlyChangelog CI job following {EDIT_SKILL}.\n"
)
Shell.check(f"git add {CHANGELOG_FILE}", strict=True)
Shell.check(f"git commit -m {shlex.quote(message)}", verbose=True, strict=True)
return "Edited the raw entries and committed the result"
def push_branch(branch, exists_on_remote):
ahead_of = f"origin/{branch}" if exists_on_remote else "origin/master"
if not Shell.get_output(f"git log --oneline {ahead_of}..HEAD").strip():
return "Nothing to push"
# Push with the App token rather than the GITHUB_TOKEN that the checkout
# action persists in the http extraheader: GITHUB_TOKEN pushes do not
# trigger workflow runs, so the PR would get no CI on refreshes. Same
# pattern as the workflow-regeneration push in praktika's native_jobs.
# The token expands at run time and is kept out of the logs
# (verbose=False); the inherited extraheader is cleared so the tokenized
# URL is the one that authenticates.
repo_url = (
"https://x-access-token:${token}@github.com/"
+ shlex.quote(Info().repo_name)
+ ".git"
)
refspec = shlex.quote(f"HEAD:refs/heads/{branch}")
if not Shell.check(
'token="$(gh auth token)" && '
"git -c http.https://github.com/.extraheader= push "
f"{repo_url} {refspec}",
verbose=False,
):
raise RuntimeError(f"Failed to push {branch}")
return f"Pushed {branch}"
def ensure_pr(branch, version):
# Refresh the App token: the preceding editing step may have outlived it.
GHAuth.auth_from_settings()
states = get_pr_states(branch)
open_prs = [num for num, state in states if state == "OPEN"]
if open_prs:
return f"PR #{open_prs[0]} already open"
if states:
return f"PR was closed or merged ({states}); not creating a new one"
body = f"""\
Automated daily preparation of `CHANGELOG.md` for the upcoming {version} release.
Every day the `NightlyChangelog` CI job appends the raw changelog entries for the pull requests newly merged into `master` (generated with `utils/changelog/changelog.py`) as one commit, and edits them following `.claude/skills/edit-changelog/SKILL.md` as a separate commit, so both the raw and the edited state stay reviewable. The point up to which entries were generated is recorded as a `{STATE_TRAILER}` trailer in the generate commits.
This pull request stays a draft until the release. The release manager finalizes it manually: fills in the release date and the presentation/video links (the `FIXME` placeholders), reviews the entries, and marks it ready.
### Changelog category (leave one):
- Not for changelog (changelog entry is not required)
"""
with tempfile.NamedTemporaryFile(
"w", dir="./ci/tmp", suffix=".md", delete=False
) as fd:
fd.write(body)
body_file = fd.name
try:
Shell.check(
f"gh pr create --draft --base master --head {branch} "
f"--repo {shlex.quote(Info().repo_name)} "
f'--title {shlex.quote(f"Prepare changelog for {version}")} '
f'--label "do not test" --body-file {shlex.quote(body_file)}',
verbose=True,
strict=True,
)
finally:
os.unlink(body_file)
return "Created a draft PR"
def cycle_skip_reason(branch, is_current, branch_exists, pr_states):
"""Decide whether a release cycle must not be processed: a finished cycle
without a branch, a cycle whose PRs were all closed or merged by a human,
or a branch that was deleted while its PRs exist (deleting the branch
auto-closes the PR; silently recreating the branch from the cycle start
would override that deliberate action)."""
if not branch_exists and not is_current:
return f"No branch {branch} for a finished cycle"
if pr_states and not any(state == "OPEN" for _, state in pr_states):
return f"PR for {branch} was closed or merged ({pr_states})"
if not branch_exists and pr_states:
return (
f"Branch {branch} does not exist but its PRs do ({pr_states}); "
f"refusing to recreate the branch, manual investigation required"
)
return None
def _step(results, name, fn):
"""Run `fn` as a praktika sub-result: `fn` returns an info string on
success and raises on failure (stdout/traceback become the info then)."""
holder = {}
def _run():
holder["info"] = fn()
return True
result = Result.from_commands_run(name=name, command=_run)
if result.is_ok() and holder.get("info"):
result.set_info(holder["info"])
print(f"[{name}] {holder['info']}")
results.append(result)
return result.is_ok()
def process_version(version, master_version, results):
"""Run the full generate/edit/push/PR pipeline for one release cycle.
Appends step Results to `results`; returns False if something failed."""
branch = _branch(version)
is_current = version == master_version
def _skip(info):
print(f"[{version}] {info}")
results.append(
Result(name=f"{version}: prepare", status=Result.Status.SKIPPED, info=info)
)
return True
end_ref = get_cycle_end_ref(version, master_version)
if end_ref is None:
return _skip(f"No cycle end tag for finished cycle {version} yet")
to_sha = _sha(end_ref)
exists = remote_branch_exists(branch)
skip_reason = cycle_skip_reason(
branch, is_current, exists, get_pr_states(branch)
)
if skip_reason:
return _skip(skip_reason)
checkout_branch(branch, exists)
from_ref = determine_from_ref(version, branch, exists)
ok = True
if exists:
ok = _step(
results,
f"{version}: reconcile with master",
lambda: reconcile_with_master(version),
)
if ok:
ok = _step(
results,
f"{version}: generate raw entries",
lambda: generate_raw_entries(version, from_ref, to_sha),
)
edit_ok = True
if ok and RAW_BEGIN_PREFIX in _read_changelog():
# A failed edit is reported, but the generate commit is still pushed
# below so the raw entries are visible in the PR and picked up by the
# next run.
edit_ok = _step(
results, f"{version}: edit entries", lambda: edit_raw_entries(version)
)
if ok:
ok = _step(
results, f"{version}: push", lambda: push_branch(branch, exists)
)
if ok and remote_branch_exists(branch):
ok = _step(results, f"{version}: ensure PR", lambda: ensure_pr(branch, version))
return ok and edit_ok
def main():
# The GitHub App token from enable_gh_auth expires; re-issue it at job
# start so the whole run (git push, gh, changelog.py) has a fresh one.
GHAuth.auth_from_settings()
os.makedirs("./ci/tmp", exist_ok=True)
results = []
ok = _step(results, "prepare repository", prepare_repo)
if ok:
master_version = get_master_version()
print(f"Upcoming release on master: {master_version}")
# Older cycles first (their final catch-up after the version bump),
# then the current one.
versions = []
for ref in Shell.get_output(
f"git ls-remote --heads origin '{BRANCH_PREFIX}*'"
).splitlines():
m = re.search(rf"refs/heads/{re.escape(BRANCH_PREFIX)}(\d+\.\d+)$", ref)
if m and m.group(1) != master_version:
versions.append(m.group(1))
versions = sorted(versions, key=lambda v: tuple(int(x) for x in v.split(".")))
versions.append(master_version)
for version in versions:
try:
process_version(version, master_version, results)
except Exception as e:
traceback.print_exc()
results.append(
Result(
name=f"{version}: processing",
status=Result.Status.FAIL,
info=f"ERROR: {e}",
)
)
Result.create_from(results=results).complete_job()
if __name__ == "__main__":
main()