Files
Alexei FedotovandClaude Opus 4.8 185d204228 Add Praktika-based CreateRelease workflow and move release scripts into ci/
Convert the hand-written .github/workflows/create_release.yml into a
Praktika-generated workflow and move the release tooling under ci/:

- ci/workflows/create_release.py + ci/jobs/release_job.py: the Praktika
  CreateRelease workflow (workflow_dispatch + workflow_call, a concurrency
  group, the amd-release-maker runner, robot-PAT pushes) and its orchestrator.
- ci/jobs/scripts/create_release.py, ci/jobs/scripts/artifactory.py: moved out
  of tests/ci/ (they are release scripts, not jobs); the s3_helper/ssh sys.path
  is adjusted for the new location.
- ci/jobs/scripts/clickhouse_version.py: tag-free CHVersion — the tweak is the
  commit count since the version-file githash (git rev-list --count), never
  git describe / release tags.
- ci/tests/test_create_release.py: regression and end-to-end guards for the
  workflow contract and the version arithmetic.

Hardening found via real release + dry-run testing (26.3 / 26.6):
- create the release tag locally even on a dry run so later steps resolve it;
- retry the changelog-PR branch push past GitHub's transient push-time
  workflow-file check (which the robot PAT already has scope for);
- fail loud (get_output_or_raise) instead of stamping VERSION_GITHASH 000...0
  on a git failure;
- remove the write-scoped release PAT from ci/tmp in the credential cleanup so
  it does not persist on a reused runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 01:43:53 +00:00

146 lines
5.2 KiB
Python

import re
import shlex
from praktika.utils import Shell
class Git:
"""Minimal git helper using Shell; reads tags for version tweak computation."""
_TAG_PATTERN = re.compile(
r"\Av\d{2}([.][1-9]\d*){3}-(new|testing|prestable|stable|lts)\Z"
)
@staticmethod
def is_shallow() -> bool:
return (
Shell.get_output("git rev-parse --is-shallow-repository") or ""
).strip() == "true"
@staticmethod
def get_shortlog(ref: str = "HEAD") -> str:
return Shell.get_output(f"git shortlog {shlex.quote(ref)} --summary") or ""
@staticmethod
def get_commit_sha(ref: str) -> str:
return Shell.get_output_or_raise(f"git rev-list -n1 {shlex.quote(ref)}")
@staticmethod
def tag_exists(name: str) -> bool:
return bool(
Shell.get_output(
f"git rev-parse --verify --quiet refs/tags/{shlex.quote(name)}^{{commit}}"
)
)
@staticmethod
def branch_exists(name: str) -> bool:
# A remote-tracking branch: the release job fetches every head into
# refs/remotes/origin/*, so this is the reliable place to look.
return Shell.check(
f"git show-ref --verify --quiet refs/remotes/origin/{shlex.quote(name)}",
verbose=False,
)
@staticmethod
def push(
repo: str,
refspec: str,
force: bool = False,
dry_run: bool = False,
strict: bool = False,
retries: int = 1,
verbose: bool = True,
) -> bool:
"""Push `refspec` to `repo` over HTTPS with an App/PAT token.
The token is `$GH_TOKEN` when the caller has one exported (the release
job sets it to the robot PAT, which carries the `workflow` scope), else
the App installation token from the `gh` session (native_jobs). It is
used in the URL instead of the checkout's default GITHUB_TOKEN, and the
inherited http extraheader is cleared per-command so that tokenized URL
is what authenticates (only an App/PAT push re-triggers downstream
workflows). The token expands at runtime, so its literal `${token}`
stays out of the f-string and the URL is assembled by concatenation;
`repo`/`refspec` are passed shell-quoted. Retry helps past GitHub's
push-time workflow-file check timing out on a large repo.
`verbose` is safe to enable: the command carries only the literal
`${token}`/`$(gh auth token)` (expanded at runtime, and git redacts URL
credentials), so the token never reaches the log while the push command
and retry attempts stay visible.
"""
# Log the files changed by the pushed commit, so it is visible whether
# the push touches .github/workflows (the trigger for GitHub's
# workflows-scope check).
src_ref = refspec.split(":", 1)[0]
commit = Shell.get_output(
f"git rev-list -n1 {shlex.quote(src_ref)}", verbose=False
)
files = (
Shell.get_output(
f"git show --name-only --format= {shlex.quote(commit)}", verbose=False
)
if commit
else ""
)
print(f"Files in pushed commit [{src_ref} -> {commit}]:\n{files or '(none)'}")
repo_url = (
"https://x-access-token:${token}@github.com/" + shlex.quote(repo) + ".git"
)
force_flag = "--force " if force else ""
push_cmd = (
'token="${GH_TOKEN:-$(gh auth token)}" && '
"git -c http.https://github.com/.extraheader= push "
f"{force_flag}{repo_url} {shlex.quote(refspec)}"
)
return Shell.check(
push_cmd,
dry_run=dry_run,
strict=strict,
verbose=verbose,
retries=retries,
)
@staticmethod
def push_tag(
repo: str,
tag: str,
commit: str,
message: str,
user_name: str,
user_email: str,
dry_run: bool = False,
retries: int = 1,
) -> None:
"""Create an annotated tag at `commit` and push it to `repo` as the App.
Creates (force, so reruns are idempotent) the local annotated tag with
the given tagger identity and no GPG signing, then pushes it with
`Git.push` (App token) using the explicit `refs/tags/...` refspec.
The local tag is created even on a dry run (only the push is skipped):
later release steps resolve the tag locally (e.g. `changelog.py` runs
`git rev-parse <tag>`), so a dry run that skipped the local tag would
fail on a tag the real run would have created.
"""
Shell.check(
f"git -c user.name={shlex.quote(user_name)}"
f" -c user.email={shlex.quote(user_email)} -c commit.gpgsign=false"
f" tag -f -a -m {shlex.quote(message)}"
f" {shlex.quote(tag)} {shlex.quote(commit)}",
strict=True,
verbose=True,
)
Git.push(
repo,
f"refs/tags/{tag}:refs/tags/{tag}",
dry_run=dry_run,
strict=True,
retries=retries,
)
def __init__(self):
self.latest_tag = Shell.get_output("git describe --tags --abbrev=0") or ""