6de5d52363
build / build (push) Successful in 51s
按使用者要求定的版本规则(自动升的是**开发标识**,发布版本与 tag 仍由人批准): - **开发号自动递增**:display_version(development) = `<正式版本的主.次>.<自 v<正式版本> 以来的提交数>` (正式 0.1.0 之后第 1 个提交 0.1.1、第 12 个 0.1.12;恰在发布提交上 = 正式号)。 它**不写进 CHANGELOG**(发布记录只在发版时动),由 git 算出来 ⇒ 每个提交都有自己的号、数字只增。 - **浅克隆不猜号**:`--depth` 会把条数算少 ⇒ 浅仓库里拒绝算号,退回 `<版本>-dev+g<短SHA>`; 检出步骤在仓库有 tag 时 `--unshallow` 补全历史。 - 接线三处齐全:`version.py --identity` → 门禁 `-X …CommitsSinceRelease=<n>`;Makefile 的 `COMMITS_SINCE_RELEASE`;Dockerfile 同名 build-arg;Go 侧新增 `CommitsSinceRelease` 与 DisplayVersion() 新规则;契约测试**交叉核对**"注入的提交数 == git 算出的"(防注入错号)。 - **首个正式版本 0.1.0**:CHANGELOG 的 `[Unreleased]` 提成 `## [0.1.0] - 2026-10-13`; 头部补"版本怎么走"两行;RELEASE_RUNBOOK 补递增规则与"下一次发布把次版本加一"。 - 发版预检新增:打印**当前开发号**,并挡住"候选正式版本 <= 当前开发号"(会把开发号当正式号;建议次版本加一)。 验证:version.py --self-test(含 tag 后第 2 个提交 = 0.2.2、恰在 tag 上 = 0.2.0、无 tag 不猜号); release_preflight --self-test(21 分支,含开发号两条);go test 本地三态 + 临时仓库 tag+2 提交实测 (Python 与 Go 都得出 0.2.2);反向夹具 85/0;make verify 15/15 success。
357 lines
18 KiB
Python
Executable File
357 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""版本与构建身份 —— 版本号的唯一来源是仓库根的 CHANGELOG.md。
|
||
|
||
为什么是它而不是 git tag:**日常检出不做 `git describe`**(CI 检出是 `git fetch --depth 50`,
|
||
`git describe --tags` 会失败或给出误导结果)。正式 tag 的**创建**是人工批准的发布动作,
|
||
CI 只做**核验**(tag 版本 == 它指到那个提交里的版本记录),见 `check_changelog.py --tags`。
|
||
仓库里的文件则一定在。
|
||
|
||
为什么不是另放一个 `VERSION` 文件:那是第二份副本,发版要改两处,忘一处就漂移。
|
||
|
||
用法:
|
||
python3 scripts/version.py # 打印顶部「已发布」版本号;没有就非 0 退出
|
||
python3 scripts/version.py --identity # 打印构建身份 JSON(**开发构建也能用**)
|
||
python3 scripts/version.py --identity --release # 声明发布构建;没有已发布版本 ⇒ 拒绝(rc=2)
|
||
python3 scripts/version.py --check # 打印版本号并跑结构自检(等价 check-changelog.py)
|
||
|
||
**正式构建身份不是"加个 --release 标志"就算**:`--identity --release` 会**核证据**——
|
||
完整 40 位 SHA + 一个**指向该提交的合规注释 tag** `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致;
|
||
缺任何一条 ⇒ **拒绝标为正式版**(rc=2,JSON 里带 `release_blocked` 说明缺什么)。
|
||
|
||
**开发版本自动递增(零人工)**:`<正式版本的主.次>.<自 v<正式版本> 以来的提交数>` ——
|
||
例:正式 `0.1.0` 之后第 1 个提交 = `0.1.1`,第 12 个 = `0.1.12`;打新 tag 后从新 tag 重新起算。
|
||
它**不写进 CHANGELOG**(发布记录只在发版时动),而是从 git 算出来 ⇒ 每次提交必然不同、数字只增。
|
||
自动升的是**开发标识**,不是发布版本:正式版本与 tag 仍需人批准(规范禁止 CI 自动打 tag)。
|
||
|
||
构建身份字段(`--identity`):
|
||
release_version 正式版本基线;本仓还没有正式发布过 ⇒ `null`
|
||
build_kind development | release(**只有显式声明**才是 release,普通 main CI 永远不是)
|
||
commit_sha 完整 40 位提交 SHA(取不到 ⇒ `unknown`)
|
||
display_version 用户可见标识:`X.Y.Z`(正式)/ `<主.次>.<提交数>`(开发,随提交递增)/
|
||
`dev+g<短SHA>`(还没有正式基线)/ `dev`(连 SHA 都取不到)
|
||
built_at 构建时间(`BUILT_AT` 环境变量,缺省取当前 UTC)
|
||
commits_since_release 自 `v<release_version>` 以来的提交数(没有 tag ⇒ null)
|
||
|
||
⚠️ 开发构建**不能**只显示上一正式版本号(那正是要修的问题),也不许自称正式版。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
CHANGELOG = ROOT / "CHANGELOG.md"
|
||
|
||
# 已发布版本标题:`## [0.1.0] - 2026-10-10` / `## [0.1.0]`;`## [Unreleased]` 不算
|
||
RELEASE_HEADING = re.compile(r"^##\s*\[(\d+\.\d+\.\d+)\]", re.MULTILINE)
|
||
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
||
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
|
||
|
||
|
||
def current_version(path: Path = CHANGELOG) -> str | None:
|
||
"""取最上面那个已发布版本号;没有就返回 None。"""
|
||
if not path.is_file():
|
||
return None
|
||
m = RELEASE_HEADING.search(path.read_text(encoding="utf-8"))
|
||
return m.group(1) if m else None
|
||
|
||
|
||
def git_sha(root: Path = ROOT) -> str:
|
||
p = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True)
|
||
s = p.stdout.strip()
|
||
return s if FULL_SHA.match(s) else "unknown"
|
||
|
||
|
||
def commits_since_release(release_version: str | None, root: Path = ROOT) -> int | None:
|
||
"""自 `v<release_version>` 那个 tag 以来的提交数(含 HEAD;HEAD 恰在 tag 上 ⇒ 0)。
|
||
|
||
取不到 tag(还没发布过 / 检出没取 tag)⇒ None —— 这时退回 `dev+g<短SHA>`,不猜号。
|
||
⚠️ **浅克隆里不算**:`--depth` 截断的历史会把条数算少 ⇒ 宁可退回 `dev+g<短SHA>`,
|
||
也不报一个"看起来正常但其实错"的开发号(检出步骤会在有 tag 时补全历史,见 `ci_checkout.sh`)。
|
||
"""
|
||
if not release_version:
|
||
return None
|
||
rc, sh = _git("rev-parse", "--is-shallow-repository", cwd=root)
|
||
if rc == 0 and sh.strip() == "true":
|
||
return None
|
||
rc, out = _git("rev-list", "--count", f"v{release_version}..HEAD", cwd=root)
|
||
t = out.strip()
|
||
return int(t) if rc == 0 and t.isdigit() else None
|
||
|
||
|
||
def dev_version(release_version: str | None, n: int | None) -> str | None:
|
||
"""开发版本号:`<主>.<次>.<n>`;`n==0` ⇒ 就是发布提交本身(返回正式版本号)。"""
|
||
if not release_version or n is None:
|
||
return None
|
||
if n == 0:
|
||
return release_version
|
||
a, b, _c = release_version.split(".")
|
||
return f"{int(a)}.{int(b)}.{n}"
|
||
|
||
|
||
def display_version(release_version: str | None, kind: str, sha: str,
|
||
n: int | None = None) -> str:
|
||
short = sha[:12] if FULL_SHA.match(sha) else ""
|
||
if kind == "release" and release_version:
|
||
return release_version
|
||
# 开发构建:有正式基线就报"随提交递增"的开发号(无 tag/取不到计数才退回 dev+g<短SHA>)
|
||
dev = dev_version(release_version, n)
|
||
if dev:
|
||
return dev
|
||
if release_version and short:
|
||
return f"{release_version}-dev+g{short}"
|
||
if release_version:
|
||
return f"{release_version}-dev"
|
||
return f"dev+g{short}" if short else "dev"
|
||
|
||
|
||
def _git(*args, cwd: Path = ROOT) -> tuple[int, str]:
|
||
p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
||
return p.returncode, (p.stdout if p.returncode == 0 else p.stderr)
|
||
|
||
|
||
def release_evidence(version: str | None, sha: str, root: Path = ROOT) -> list[str]:
|
||
"""正式构建要核的证据;返回缺失项(空列表 = 证据齐)。
|
||
|
||
三条都必需(缺一条就**不许**把产物标成正式版):
|
||
1. 完整 40 位提交 SHA —— 没有准确提交就谈不上"正式版";
|
||
2. 该提交上有**注释 tag** `v<版本号>`(`git tag --points-at <sha>` + `cat-file -t` 判类型);
|
||
3. 该提交里的 `CHANGELOG.md` 顶部已发布版本 == 这个版本号(版本一致性)。
|
||
"""
|
||
problems: list[str] = []
|
||
if not version:
|
||
problems.append("CHANGELOG.md 里没有已发布版本条目(没有正式版本基线)")
|
||
if not FULL_SHA.match(sha or ""):
|
||
problems.append(f"提交 SHA 不是完整 40 位({sha!r})—— 没有准确提交就不能标为正式版")
|
||
if version and FULL_SHA.match(sha or ""):
|
||
name = f"v{version}"
|
||
rc, out = _git("tag", "--points-at", sha, cwd=root)
|
||
tags = out.split() if rc == 0 else []
|
||
if name not in tags:
|
||
problems.append(f"提交 {sha[:12]} 上没有 tag {name}"
|
||
f"(正式构建必须有一个指向该提交的合规 tag;现有指向该提交的 tag:{tags or '无'})")
|
||
else:
|
||
rc, kind = _git("cat-file", "-t", f"refs/tags/{name}", cwd=root)
|
||
if rc != 0 or kind.strip() != "tag":
|
||
problems.append(f"tag {name} 不是**注释 tag**(`git tag -a`)—— 轻量 tag 不算发布证据")
|
||
rc, blob = _git("show", f"{sha}:CHANGELOG.md", cwd=root)
|
||
if rc != 0:
|
||
problems.append(f"取不到该提交里的 CHANGELOG.md({sha[:12]})—— 没法核对版本一致性")
|
||
else:
|
||
m = RELEASE_HEADING.search(blob)
|
||
top = m.group(1) if m else None
|
||
if top != version:
|
||
problems.append(f"该提交里 CHANGELOG 顶部的已发布版本是 {top!r},与 {version!r} 不一致")
|
||
return problems
|
||
|
||
|
||
def identity(*, release: bool = False, built_at: str | None = None,
|
||
root: Path = ROOT, sha: str | None = None) -> dict:
|
||
"""构建身份(不含证据核验,供 `--identity` 的非发布路径与测试使用)。"""
|
||
return build_identity(release=release, built_at=built_at, root=root, sha=sha)[0]
|
||
|
||
|
||
def build_identity(*, release: bool = False, built_at: str | None = None,
|
||
root: Path = ROOT, sha: str | None = None) -> tuple[dict, list[str]]:
|
||
"""构建身份 + 证据缺失项。`release=True` 但证据不齐 ⇒ 身份退回 development 并给出原因。"""
|
||
rel = current_version(root / "CHANGELOG.md")
|
||
sha = sha if sha is not None else git_sha(root)
|
||
n = commits_since_release(rel, root)
|
||
problems = release_evidence(rel, sha, root) if release else []
|
||
kind = "release" if (release and not problems) else "development"
|
||
ident = {
|
||
"release_version": rel,
|
||
"build_kind": kind,
|
||
"commit_sha": sha,
|
||
"commits_since_release": n,
|
||
"display_version": display_version(rel, kind, sha, n),
|
||
"built_at": built_at or os.environ.get("BUILT_AT") or os.environ.get("BUILD_TIME")
|
||
or dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||
}
|
||
if problems:
|
||
ident["release_blocked"] = problems
|
||
return ident, problems
|
||
|
||
|
||
# ── 反向自检:**全部在临时仓库里造 tag**,不给真仓造任何 tag ─────────────────
|
||
_SAMPLE = """# Changelog
|
||
|
||
## [Unreleased]
|
||
|
||
### Added
|
||
|
||
- 还没发布
|
||
|
||
## [0.2.0] - 2026-02-01
|
||
|
||
### Fixed
|
||
|
||
- y
|
||
"""
|
||
|
||
|
||
def _tmp_repo(changelog: str = _SAMPLE) -> tuple[Path, str, dict]:
|
||
"""造一个临时仓库(一个提交),返回 (目录, HEAD, git 环境)。"""
|
||
d = Path(tempfile.mkdtemp(prefix="kg-ver-"))
|
||
(d / "no-hooks").mkdir(exist_ok=True)
|
||
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
|
||
"GIT_CONFIG_VALUE_0": str(d / "no-hooks")}
|
||
(d / "CHANGELOG.md").write_text(changelog, encoding="utf-8")
|
||
for args in (["init", "-q", "-b", "main"], ["add", "-A"],
|
||
["commit", "-q", "-m", "chore: 夹具提交"]):
|
||
subprocess.run(["git", *args], cwd=d, env=env, check=True)
|
||
sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True,
|
||
text=True).stdout.strip()
|
||
return d, sha, env
|
||
|
||
|
||
def self_test() -> int:
|
||
problems: list[str] = []
|
||
dirs: list[Path] = []
|
||
|
||
def case(name: str, cond: bool, detail: str = "") -> None:
|
||
if not cond:
|
||
problems.append(f"{name} —— {detail}")
|
||
|
||
# ① 证据齐:注释 tag 指向该提交 + 提交内 CHANGELOG 一致 ⇒ 正式构建
|
||
d, sha, env = _tmp_repo(); dirs.append(d)
|
||
subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release 0.2.0"], cwd=d, env=env, check=True)
|
||
ident, blocked = build_identity(release=True, root=d, sha=sha, built_at="t")
|
||
case("证据齐 ⇒ build_kind=release", ident["build_kind"] == "release" and not blocked, f"{ident}")
|
||
case("证据齐 ⇒ display_version=0.2.0", ident["display_version"] == "0.2.0", f"{ident['display_version']!r}")
|
||
|
||
# ② 有版本但**没有 tag** ⇒ 不许标正式
|
||
d2, sha2, _ = _tmp_repo(); dirs.append(d2)
|
||
ident2, blocked2 = build_identity(release=True, root=d2, sha=sha2, built_at="t")
|
||
case("有版本但无 tag ⇒ 不标正式",
|
||
ident2["build_kind"] == "development" and any("没有 tag" in x for x in blocked2), f"{blocked2}")
|
||
|
||
# ③ SHA 不是完整 40 位 ⇒ 不许标正式
|
||
ident3, blocked3 = build_identity(release=True, root=d, sha="unknown", built_at="t")
|
||
case("unknown SHA ⇒ 不标正式",
|
||
ident3["build_kind"] == "development" and any("不是完整 40 位" in x for x in blocked3), f"{blocked3}")
|
||
ident3b, blocked3b = build_identity(release=True, root=d, sha=sha[:12], built_at="t")
|
||
case("短 SHA ⇒ 不标正式",
|
||
ident3b["build_kind"] == "development" and any("不是完整 40 位" in x for x in blocked3b), f"{blocked3b}")
|
||
|
||
# ④ tag 指错提交 ⇒ 不许标正式(tag 打在**后一个**提交上,却按前一个提交去问)
|
||
d4, first4, env4 = _tmp_repo(); dirs.append(d4)
|
||
(d4 / "extra.txt").write_text("x", encoding="utf-8")
|
||
subprocess.run(["git", "add", "-A"], cwd=d4, env=env4, check=True)
|
||
subprocess.run(["git", "commit", "-q", "-m", "chore: 第二个提交"], cwd=d4, env=env4, check=True)
|
||
second4 = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d4, capture_output=True,
|
||
text=True).stdout.strip()
|
||
subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release", second4], cwd=d4, env=env4, check=True)
|
||
ident4, blocked4 = build_identity(release=True, root=d4, sha=first4, built_at="t")
|
||
case("tag 指错提交 ⇒ 不标正式",
|
||
ident4["build_kind"] == "development" and any("没有 tag v0.2.0" in x for x in blocked4), f"{blocked4}")
|
||
|
||
# ⑤ 轻量 tag ⇒ 不许标正式(注释 tag 才是发布证据)
|
||
d5, sha5, env5 = _tmp_repo(); dirs.append(d5)
|
||
subprocess.run(["git", "tag", "v0.2.0"], cwd=d5, env=env5, check=True)
|
||
ident5, blocked5 = build_identity(release=True, root=d5, sha=sha5, built_at="t")
|
||
case("轻量 tag ⇒ 不标正式",
|
||
ident5["build_kind"] == "development" and any("注释 tag" in x for x in blocked5), f"{blocked5}")
|
||
|
||
# ⑥ 提交内 CHANGELOG 顶部版本与"声称的版本"不一致 ⇒ 不许标正式
|
||
# 做法:提交里的记录是 0.2.0,tag 却叫 v0.3.0(指向该提交),工作区又写着 0.3.0 ⇒ 三条中
|
||
# "版本一致性"这一条会红(tag 存在、注释、SHA 也完整,问题只出在版本对不上)。
|
||
d6, sha6, env6 = _tmp_repo(); dirs.append(d6)
|
||
subprocess.run(["git", "tag", "-a", "v0.3.0", "-m", "release 0.3.0"], cwd=d6, env=env6, check=True)
|
||
(d6 / "CHANGELOG.md").write_text(_SAMPLE.replace("0.2.0", "0.3.0"), encoding="utf-8")
|
||
ident6, blocked6 = build_identity(release=True, root=d6, sha=sha6, built_at="t")
|
||
case("声称版本与提交内记录不一致 ⇒ 不标正式",
|
||
ident6["build_kind"] == "development" and any("不一致" in x for x in blocked6), f"{blocked6}")
|
||
|
||
# ⑥b 开发号随提交递增:临时仓库里打 tag 后再加 2 个提交 ⇒ 0.1.2;恰在 tag 上 ⇒ 0.1.0
|
||
d8, _, env8 = _tmp_repo(); dirs.append(d8)
|
||
subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release 0.2.0"], cwd=d8, env=env8, check=True)
|
||
at_tag, _ = build_identity(root=d8, built_at="t")
|
||
case("恰在发布提交上 ⇒ 显示正式版本号 0.2.0",
|
||
at_tag["display_version"] == "0.2.0" and at_tag["commits_since_release"] == 0, f"{at_tag}")
|
||
for i in (1, 2):
|
||
(d8 / f"f{i}.txt").write_text("x", encoding="utf-8")
|
||
subprocess.run(["git", "add", "-A"], cwd=d8, env=env8, check=True)
|
||
subprocess.run(["git", "commit", "-q", "-m", f"chore: 第 {i} 个提交"], cwd=d8, env=env8, check=True)
|
||
after, _ = build_identity(root=d8, built_at="t")
|
||
case("tag 之后第 2 个提交 ⇒ 开发号 0.2.2(随提交递增)",
|
||
after["display_version"] == "0.2.2" and after["commits_since_release"] == 2, f"{after}")
|
||
case("tag 之后不再等于正式版本号(不许只显示上一正式号)",
|
||
after["display_version"] != after["release_version"], f"{after}")
|
||
|
||
# ⑦ 开发构建(取不到 tag 时):有基线 ⇒ X.Y.Z-dev+g<短SHA>;无基线 ⇒ dev+g<短SHA>
|
||
# 有基线但**一个 tag 都没有** ⇒ 退回 `<版本>-dev+g<短SHA>`(不猜号)
|
||
d9, sha9, _env9 = _tmp_repo(); dirs.append(d9)
|
||
dev, _ = build_identity(root=d9, sha=sha9, built_at="t")
|
||
case("有基线但取不到 tag = 0.2.0-dev+g<短SHA>(不猜号)",
|
||
dev["build_kind"] == "development" and dev["commits_since_release"] is None
|
||
and dev["display_version"] == f"0.2.0-dev+g{sha9[:12]}", f"{dev}")
|
||
d7, sha7, _ = _tmp_repo("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- x\n"); dirs.append(d7)
|
||
dev7, _ = build_identity(root=d7, sha=sha7, built_at="t")
|
||
case("开发构建(无基线)= dev+g<短SHA>",
|
||
dev7["release_version"] is None and dev7["display_version"] == f"dev+g{sha7[:12]}",
|
||
f"{dev7}")
|
||
|
||
for x in dirs:
|
||
subprocess.run(["rm", "-rf", str(x)], capture_output=True)
|
||
|
||
if problems:
|
||
print("❌ 构建身份自检失败:", file=sys.stderr)
|
||
for x in problems:
|
||
print(f" · {x}", file=sys.stderr)
|
||
return 1
|
||
print("✅ 构建身份自检通过:证据齐(注释 tag + 完整 SHA + 提交内版本一致)才是正式构建;"
|
||
"无 tag / unknown 或短 SHA / tag 指错提交 / 轻量 tag / 版本不一致 逐条拒绝;"
|
||
"开发号随提交递增(tag 后第 2 个提交 = 0.2.2,恰在发布提交上 = 0.2.0),"
|
||
"取不到 tag / 浅克隆时不猜号(退回 dev+g<短SHA>)")
|
||
return 0
|
||
|
||
|
||
def main(argv: list[str]) -> int:
|
||
if "--self-test" in argv:
|
||
return self_test()
|
||
release = "--release" in argv or os.environ.get("BUILD_KIND") == "release"
|
||
if "--identity" in argv:
|
||
ident, problems = build_identity(release=release)
|
||
print(json.dumps(ident, ensure_ascii=False, indent=2))
|
||
if release and problems:
|
||
print("❌ 声明了正式构建(--release / BUILD_KIND=release),但发布证据不齐 —— "
|
||
"**不标为正式版**(身份已退回 development):", file=sys.stderr)
|
||
for x in problems:
|
||
print(f" · {x}", file=sys.stderr)
|
||
return 2
|
||
return 0
|
||
|
||
v = current_version()
|
||
if v is None:
|
||
print(
|
||
"❌ CHANGELOG.md 里找不到已发布版本条目(形如 `## [0.1.0] - 2026-10-10`)。\n"
|
||
" 本仓还没有正式发布过 ⇒ 没有正式版本号可用。\n"
|
||
" 要构建身份(开发构建也能用)请跑 `python3 scripts/version.py --identity`;\n"
|
||
" 发布构建必须先在 CHANGELOG.md 写下正式版本条目。",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
if "--check" in argv:
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
from check_changelog import check # type: ignore # noqa: PLC0415
|
||
|
||
problems = check()
|
||
if problems:
|
||
for p in problems:
|
||
print(f"❌ {p}", file=sys.stderr)
|
||
return 1
|
||
print(v)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main(sys.argv[1:])) |