Files
Alexei Fedotov 08a1acc2d4 Squashed commit of the following:
commit 3999d7e01b859fd7473f9cbc7d42786ee293e86e
Author: Alexei Fedotov <[email protected]>
Date:   Thu Apr 23 21:52:36 2026 +0200

    CI: rename \`allow_merge_on_failure\`/\`report_as_success_on_failure\` to \`allow_failure\`/\`force_success\`

    Rename fields and helper method in \`Job.Config\`:
    - \`allow_merge_on_failure\` → \`allow_failure\`
    - \`report_as_success_on_failure\` → \`force_success\`
    - \`set_allow_merge_on_failure\` → \`set_allow_failure\`

    Update all call sites in \`job_configs.py\`, \`native_jobs.py\`,
    \`digest.py\`, and \`pull_request.py\`. Improve field comments to clearly
    distinguish the two behaviours: \`allow_failure\` still surfaces the
    failure in the CI report while \`force_success\` hides it entirely.

    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

commit 774e2dc6bb79e65f56e31c72fe8b40c37089be0f
Author: Alexei Fedotov <[email protected]>
Date:   Thu Apr 23 11:31:30 2026 +0200

    CI: fix `force_success` status override and exit code handling

    `Result.Status.SUCCESS` does not exist (it is `Result.GHStatus.SUCCESS`);
    the correct outcome status is `Result.Status.OK`. This bug predates the
    rename and would raise an exception in `_run` when `force_success=True`
    on a failing job.

    Also moves the runner exit-code suppression out of the `run_hooks` block
    into the final `sys.exit` check, where it naturally belongs.

    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

commit 9dbabb44d5e16f8f34d420be435b137765f2b4e6
Author: Alexei Fedotov <[email protected]>
Date:   Thu Apr 23 11:19:24 2026 +0200

    CI: rename `allow_merge_on_failure`/`report_as_success_on_failure` to `allow_failure`/`force_success`

    Clearer names: `allow_failure` means the job still shows as failed but
    does not block merge; `force_success` hides the failure entirely and
    reports green. Removes the `set_allow_merge_on_failure` setter in favour
    of `dataclasses.replace` at call sites — note that `dataclasses.replace`
    performs a shallow copy, not a deep copy like the setter did.

    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

commit 4de764b436fc0997b6331d2ffc787f7d1fa24a04
Author: Alexei Fedotov <[email protected]>
Date:   Wed Apr 22 23:32:41 2026 +0200

    CI: fix stray `StatusExtended` reference in `Result.run`

    After `StatusExtended` was dropped and merged into `Status`, one line
    was left referencing `Result.StatusExtended.OK` / `Result.StatusExtended.FAIL`.
    Since `create_from` already converts a `bool` status to `Result.Status.OK` /
    `Result.Status.FAIL`, restore the original `status=res` (bool) form.

    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

commit c46cb717f7fe0a1251d62f9cbf22e446810c2643
Merge: 6e3b76dcebe 68ce619a3a
Author: Alexei Fedotov <[email protected]>
Date:   Wed Apr 22 23:27:26 2026 +0200

    Merge branch 'master' into praktika/report-as-success-on-failure

commit 6e3b76dcebe2fe84f950df35b884aece63caf64d
Author: Alexei Fedotov <[email protected]>
Date:   Wed Apr 22 18:01:20 2026 +0200

    Add `report_as_success_on_failure` field to `Job.Config`

    Replaces the ad-hoc `--set-status-success` CLI flag in job commands with a
    declarative field on `Job.Config`. When set, the runner overrides the job
    result status to `SUCCESS` after the job completes, so the report shows green
    and the job does not block PR merge — combining the effect of the old
    `--set-status-success` flag and `allow_merge_on_failure=True` in one place.

    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-23 22:19:34 +02:00

166 lines
5.8 KiB
Python

import dataclasses
import hashlib
import json
import os
from hashlib import md5
from pathlib import Path
from typing import List
from .docker import Docker
from .info import Info
from .job import Job
from .settings import Settings
from .utils import Shell, Utils
def _is_local_run():
"""Check if running locally. Returns False if can't determine (e.g., during workflow generation)."""
try:
return Info().is_local_run
except Exception:
# During workflow generation, Info can't be initialized - treat as CI mode
return False
class Digest:
def __init__(self):
self.digest_cache = {}
@staticmethod
def _hash_digest_config(digest_config: Job.CacheDigestConfig) -> str:
data_dict = dataclasses.asdict(digest_config)
hash_obj = md5()
hash_obj.update(str(data_dict).encode())
hash_string = hash_obj.hexdigest()
return hash_string
@classmethod
def get_null_digest(cls):
return "f" * Settings.CACHE_DIGEST_LEN
def calc_job_digest(self, job_config: Job.Config, docker_digests, artifact_configs):
config = job_config.digest_config
if not config:
return self.get_null_digest()
cache_key = self._hash_digest_config(config)
if cache_key in self.digest_cache:
print(
f"calc digest for job [{job_config.name}]: hash_key [{cache_key}] - from cache"
)
digest = self.digest_cache[cache_key]
else:
included_files = Utils.traverse_paths(
job_config.digest_config.include_paths,
job_config.digest_config.exclude_paths,
sorted=True,
)
print(
f"calc digest for job [{job_config.name}]: hash_key [{cache_key}], include [{len(included_files)}] files"
)
hash_md5 = hashlib.md5()
for i, file_path in enumerate(included_files):
hash_md5 = self._calc_file_digest(file_path, hash_md5)
if config.with_git_submodules:
submodules_shas = Digest.get_submodule_shas()
hash_md5.update(submodules_shas.encode())
digest = hash_md5.hexdigest()[: Settings.CACHE_DIGEST_LEN]
self.digest_cache[cache_key] = digest
if (
job_config.run_in_docker and ":" not in job_config.run_in_docker
): # if : in image name - there is a tag, thus it's not managed by praktika e.g.: ubuntu:22.04
# respect docker digest in the job digest
docker_digest = docker_digests[job_config.run_in_docker.split("+")[0]]
digest = "-".join([docker_digest, digest])
job_config_dict = dataclasses.asdict(job_config)
drop_fields = [
"requires",
"enable_commit_status",
"allow_failure",
"force_success",
"digest_config",
]
filtered_job_dict = {
k: v for k, v in job_config_dict.items() if k not in drop_fields
}
# add Articat.Configs list to the job config dict so that changed Articat.Config object affects job digest
job_provides_artifact_configs = []
for a in job_config.provides:
if a in artifact_configs:
job_provides_artifact_configs.append(
dataclasses.asdict(artifact_configs[a])
)
filtered_job_dict["provides"] = job_provides_artifact_configs
config_digest = hashlib.md5(
json.dumps(filtered_job_dict, sort_keys=True).encode()
).hexdigest()[: min(Settings.CACHE_DIGEST_LEN // 4, 4)]
return digest + "-" + config_digest
def calc_docker_digest(
self,
docker_config: Docker.Config,
dependency_configs: List[Docker.Config],
hash_md5=None,
):
"""
:param hash_md5:
:param dependency_configs: list of Docker.Config(s) that :param docker_config: depends on
:param docker_config: Docker.Config to calculate digest for
:return:
"""
if not _is_local_run():
print(f"Calculate digest for docker [{docker_config.name}]")
paths = Utils.traverse_path(docker_config.path, sorted=True)
if not hash_md5:
hash_md5 = hashlib.md5()
dependencies = []
for dependency_name in docker_config.depends_on:
for dependency_config in dependency_configs:
if dependency_config.name == dependency_name:
if not _is_local_run():
print(
f"Add docker [{dependency_config.name}] as dependency for docker [{docker_config.name}] digest calculation"
)
dependencies.append(dependency_config)
for dependency in dependencies:
_ = self.calc_docker_digest(dependency, dependency_configs, hash_md5)
for path in paths:
_ = self._calc_file_digest(path, hash_md5=hash_md5)
return hash_md5.hexdigest()[: Settings.CACHE_DIGEST_LEN]
@staticmethod
def get_submodule_shas():
return Shell.get_output(
"git submodule | awk '{print $1}' | sed 's/^[+-]//'", verbose=True
)
@staticmethod
def _calc_file_digest(file_path, hash_md5):
# Resolve file path if it's a symbolic link
resolved_path = file_path
if Path(file_path).is_symlink():
resolved_path = os.path.realpath(file_path)
if not Path(resolved_path).is_file():
print(
f"WARNING: No valid file resolved by link {file_path} -> {resolved_path} - skipping digest calculation"
)
return hash_md5
with open(resolved_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5