atlas-project-harness 0.2.0-beta.13 → 0.2.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Atlas — 践行 Project Harness 的全流程 AI 编程工作流
2
2
 
3
3
  <p align="left">
4
- <img src="https://img.shields.io/badge/version-0.2.0--beta.13-blue.svg" alt="Version" />
4
+ <img src="https://img.shields.io/badge/version-0.2.0--beta.14-blue.svg" alt="Version" />
5
5
  <img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" />
6
6
  <img src="https://img.shields.io/badge/tests-passing-success.svg" alt="Tests" />
7
7
  <a href="https://sunwenzhe-git.github.io/atlas/"><img src="https://img.shields.io/badge/docs-VitePress-green.svg" alt="Docs" /></a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas-project-harness",
3
- "version": "0.2.0-beta.13",
3
+ "version": "0.2.0-beta.14",
4
4
  "description": "践行 Project Harness 的全流程 AI 编程工作流治理引擎",
5
5
  "bin": {
6
6
  "atlas": "bin/atlas.js"
@@ -5,8 +5,8 @@
5
5
  1. 代理检查:默认检测并应用 7897 本地代理,保证 GitHub 联通;
6
6
  2. 真实版本推导:以 npm registry dist-tags + git tag 为唯一真相源,杜绝跳号事故;
7
7
  3. 测试门禁:发版前严格执行全量 pytest 校验;
8
- 4. 四处版本落点同步:package.json, README.md(双连字符), docs config, CHANGELOG(两份);
9
- 5. Git 提交、打 Tag、推送与 NPM 发布(--tag beta + latest dist-tag);
8
+ 4. 多处版本落点同步:package.json, README.md(双连字符), docs config, CHANGELOG(两份);
9
+ 5. Git 提交、打 Tag、推送、GitHub Release 补建与 NPM 发布(--tag beta + latest dist-tag);
10
10
  6. 轮询并验证 npm registry 异步生效状态。
11
11
 
12
12
  用法:
@@ -24,15 +24,19 @@ import datetime
24
24
  import json
25
25
  import os
26
26
  import re
27
+ import shutil
27
28
  import socket
28
29
  import subprocess
29
30
  import sys
30
31
  import time
32
+ import urllib.error
33
+ import urllib.request
31
34
  from pathlib import Path
32
35
 
33
36
  PKG_NAME = "atlas-project-harness"
34
37
  DEFAULT_PROXY_PORT = 7897
35
38
  PROXY_URL = f"http://127.0.0.1:{DEFAULT_PROXY_PORT}"
39
+ DEFAULT_REPO_SLUG = "sunwenzhe-git/atlas"
36
40
  ROOT_DIR = Path(__file__).resolve().parent.parent
37
41
 
38
42
 
@@ -140,6 +144,8 @@ def compute_next_beta_version(explicit_version: str | None = None) -> str:
140
144
  """严格以 npm 和 git tag 为基准计算顺延的下一个 beta 版本号。"""
141
145
  if explicit_version:
142
146
  clean = explicit_version.lstrip("v")
147
+ if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[a-zA-Z]+\.\d+)?", clean):
148
+ raise SystemExit(f"非法版本号:{explicit_version}(期望 vX.Y.Z 或 vX.Y.Z-beta.N)")
143
149
  return clean
144
150
 
145
151
  dist_tags = get_npm_dist_tags()
@@ -202,7 +208,7 @@ def compute_next_beta_version(explicit_version: str | None = None) -> str:
202
208
 
203
209
 
204
210
  def sync_version_files(new_version: str, dry_run: bool = False) -> list[str]:
205
- """同步全部四处版本号落点。"""
211
+ """同步全部多处版本号落点。"""
206
212
  modified = []
207
213
  today_str = datetime.date.today().isoformat()
208
214
 
@@ -338,6 +344,152 @@ def verify_npm_live(version: str, max_wait_sec: int = 180) -> bool:
338
344
  return False
339
345
 
340
346
 
347
+ def get_repo_slug() -> str:
348
+ """从 origin 远端解析 owner/repo,失败时回落到本仓常量。"""
349
+ res = run_cmd("git remote get-url origin", check=False)
350
+ m = re.search(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?$", res.stdout.strip()) if res.returncode == 0 else None
351
+ return f"{m.group(1)}/{m.group(2)}" if m else DEFAULT_REPO_SLUG
352
+
353
+
354
+ def get_github_token() -> str:
355
+ """三级取 token:环境变量 → gh CLI → git 凭据(HTTPS 推送用的 PAT)。"""
356
+ for key in ("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_PAT"):
357
+ val = os.environ.get(key, "").strip()
358
+ if val:
359
+ return val
360
+ if shutil.which("gh"):
361
+ res = run_cmd("gh auth token", check=False)
362
+ if res.returncode == 0 and res.stdout.strip():
363
+ return res.stdout.strip()
364
+ try:
365
+ res = subprocess.run(
366
+ ["git", "credential", "fill"],
367
+ input="protocol=https\nhost=github.com\n\n",
368
+ text=True,
369
+ capture_output=True,
370
+ timeout=15,
371
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
372
+ )
373
+ if res.returncode == 0:
374
+ for line in res.stdout.splitlines():
375
+ if line.startswith("password="):
376
+ return line[len("password="):].strip()
377
+ except Exception:
378
+ pass
379
+ return ""
380
+
381
+
382
+ def github_api(
383
+ method: str,
384
+ path: str,
385
+ token: str,
386
+ payload: dict | None = None,
387
+ timeout: int = 30,
388
+ ) -> tuple[int, dict | list]:
389
+ """调 GitHub REST API;返回 (HTTP 状态码, 响应体);网络错误返回 (0, {...})。"""
390
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
391
+ req = urllib.request.Request(f"https://api.github.com{path}", data=data, method=method)
392
+ req.add_header("Authorization", f"Bearer {token}")
393
+ req.add_header("Accept", "application/vnd.github+json")
394
+ req.add_header("X-GitHub-Api-Version", "2022-11-28")
395
+ req.add_header("User-Agent", "atlas-release")
396
+ if data is not None:
397
+ req.add_header("Content-Type", "application/json")
398
+ handlers = [urllib.request.ProxyHandler({"http": PROXY_URL, "https": PROXY_URL})] if is_proxy_alive(DEFAULT_PROXY_PORT) else []
399
+ opener = urllib.request.build_opener(*handlers)
400
+ try:
401
+ with opener.open(req, timeout=timeout) as resp:
402
+ raw = resp.read().decode("utf-8", errors="replace")
403
+ return resp.status, (json.loads(raw) if raw.strip() else {})
404
+ except urllib.error.HTTPError as e:
405
+ raw = e.read().decode("utf-8", errors="replace")
406
+ try:
407
+ return e.code, json.loads(raw)
408
+ except Exception:
409
+ return e.code, {"message": raw[:300]}
410
+ except Exception as e:
411
+ return 0, {"message": str(e)}
412
+
413
+
414
+ def extract_changelog_section(text: str, version: str) -> str:
415
+ """取出 CHANGELOG 中 `## [vX]` 段正文(到下一个 `## [` 为止)。"""
416
+ m = re.search(
417
+ rf"^## \[v{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\Z)",
418
+ text,
419
+ re.M | re.S,
420
+ )
421
+ return m.group(1).strip() if m else ""
422
+
423
+
424
+ def changelog_section(version: str) -> str:
425
+ p = ROOT_DIR / "CHANGELOG.md"
426
+ if not p.is_file():
427
+ return ""
428
+ return extract_changelog_section(p.read_text(encoding="utf-8"), version)
429
+
430
+
431
+ def get_previous_tag(tag_name: str) -> str:
432
+ """沿提交图取目标 tag 之前最近的 tag(自动 notes 的对比基线)。"""
433
+ res = run_cmd(["git", "describe", "--tags", "--abbrev=0", f"{tag_name}^"], check=False)
434
+ return res.stdout.strip() if res.returncode == 0 else ""
435
+
436
+
437
+ def get_github_latest_release() -> tuple[bool, str]:
438
+ """返回 (查询是否可用, 最新非草稿 Release 的 tag)。
439
+
440
+ 不用 `/releases/latest`:它只认最新非 prerelease,存在稳定版时会对后续 beta 误报落后。
441
+ """
442
+ token = get_github_token()
443
+ if not token:
444
+ return False, ""
445
+ code, data = github_api("GET", f"/repos/{get_repo_slug()}/releases?per_page=5", token, timeout=10)
446
+ if code == 200:
447
+ releases = [r for r in data if isinstance(r, dict) and not r.get("draft")] if isinstance(data, list) else []
448
+ return True, (releases[0].get("tag_name", "") if releases else "")
449
+ return False, ""
450
+
451
+
452
+ def create_github_release(tag_name: str, version: str) -> bool:
453
+ """按已推送的 tag 补建 GitHub Release:beta ⇒ prerelease,正文取 CHANGELOG 段。
454
+
455
+ 幂等:同 tag 的 Release 已存在则跳过。失败只告警不抛错——调用点已推 tag 并即将发 npm,
456
+ 此处阻断只会留下「tag 已占号但未发布」的半态。
457
+ """
458
+ token = get_github_token()
459
+ if not token:
460
+ print(" ⚠️ 未取得 GitHub token(GITHUB_TOKEN / GH_TOKEN / git 凭据),跳过 Release 创建")
461
+ return False
462
+ slug = get_repo_slug()
463
+ code, data = github_api("GET", f"/repos/{slug}/releases/tags/{tag_name}", token)
464
+ if code == 200:
465
+ print(f" ℹ️ GitHub Release {tag_name} 已存在,跳过创建")
466
+ return True
467
+ if code != 404:
468
+ print(f" ⚠️ 查询 GitHub Release 失败 (HTTP {code}):{data.get('message', '')}")
469
+ return False
470
+ body = changelog_section(version)
471
+ payload: dict[str, object] = {
472
+ "tag_name": tag_name,
473
+ "name": tag_name,
474
+ "draft": False,
475
+ "prerelease": bool(parse_semver(version)[3]),
476
+ "generate_release_notes": not body,
477
+ }
478
+ if body:
479
+ payload["body"] = body
480
+ else:
481
+ # 不传则以「上一个 Release」为基线——Release 缺席时基线会跨过多个 tag
482
+ previous_tag = get_previous_tag(tag_name)
483
+ if previous_tag:
484
+ payload["previous_tag_name"] = previous_tag
485
+ code, data = github_api("POST", f"/repos/{slug}/releases", token, payload)
486
+ if code == 201:
487
+ print(f" ✅ GitHub Release 已创建:{data.get('html_url', tag_name)}")
488
+ return True
489
+ print(f" ⚠️ GitHub Release 创建失败 (HTTP {code}):{data.get('message', '')}")
490
+ return False
491
+
492
+
341
493
  def cmd_status() -> None:
342
494
  """只读状态诊断。"""
343
495
  print("=" * 60)
@@ -360,6 +512,16 @@ def cmd_status() -> None:
360
512
  print(f"📦 package.json 版本 : {pkg_ver}")
361
513
  print(f"🏷️ 最新 Git Tag : {git_state['latest_git_tag']}")
362
514
  print(f"🚀 NPM 当前发布版本 : latest={dist_tags.get('latest', 'none')}, beta={dist_tags.get('beta', 'none')}")
515
+ rel_ok, rel_tag = get_github_latest_release()
516
+ if not rel_ok:
517
+ rel_line = "查询不可用(无 token / 网络受阻)"
518
+ elif not rel_tag:
519
+ rel_line = "无(下次发版自动创建)"
520
+ elif rel_tag != git_state["latest_git_tag"]:
521
+ rel_line = f"{rel_tag} ⚠️ 落后最新 tag(下次发版自动补建)"
522
+ else:
523
+ rel_line = rel_tag
524
+ print(f"🐙 GitHub 最新 Release : {rel_line}")
363
525
  print(f"⏭️ 推导下一个发版版本 : v{next_ver}")
364
526
 
365
527
  if git_state["dirty"]:
@@ -445,13 +607,20 @@ def cmd_release(target_version: str | None = None, auto: bool = False, dry_run:
445
607
  run_cmd(f"git push -f origin {tag_name}", use_proxy=True)
446
608
  print(" ✅ Git 提交与 Tag 推送成功!")
447
609
 
448
- # 7. NPM 发布
610
+ # 7. GitHub Release(跟随 tag;失败只告警,不阻断 npm)
611
+ print("\n🐙 正在创建 GitHub Release...")
612
+ try:
613
+ create_github_release(tag_name, next_ver)
614
+ except Exception as e:
615
+ print(f" ⚠️ GitHub Release 创建异常(不阻断发版):{e}")
616
+
617
+ # 8. NPM 发布
449
618
  print(f"\n📦 正在发布 NPM 包 {PKG_NAME}@{next_ver} (--tag beta)...")
450
619
  run_cmd("npm publish --tag beta")
451
620
  print(f"🔖 更新 NPM dist-tag latest -> {next_ver}...")
452
621
  run_cmd(f"npm dist-tag add {PKG_NAME}@{next_ver} latest")
453
622
 
454
- # 8. 轮询验证
623
+ # 9. 轮询验证
455
624
  ok = verify_npm_live(next_ver)
456
625
  if ok:
457
626
  print(f"\n🎉 恭喜!{PKG_NAME}@{next_ver} 已正式在 NPM 生效!")
package/shared/gates.md CHANGED
@@ -47,7 +47,7 @@
47
47
  | `validators/validate_design_exemptions.py`(2026-10-08,`#182`) | 设计档案 detect 豁免 ⇔ `.impeccable/config.json` `detector.ignoreRules` | `[机器]` 双向集合相等:`product/design.md` 内 `detect:<rule-id>` ⇔ config ignoreRules,单侧漂移 ⇒ FAIL 指名(配置缺 = 报警没真关 / 档案缺 = 关报警没台账);无档案 ⇒ SKIP(三态 `§B120`);config 损坏 ⇒ FAIL | 豁免理由是否成立(语义,人侧终审);rule id 是否存在于 impeccable 规则表(不内嵌第三方规则表——升级会误伤);档案其余事实行内容(frontend-design skill 辖区) | 档案 / 配置改动后 + 每批收口 | `出厂级(本轮实测)`:`tests/test_validate_design_exemptions.py` **8 条**(**M43** 摘双向差集判据 ⇒ 两侧漂移钉子双红) |
48
48
  | `validators/validate_ac_alignment.py`(2026-10-12,`3507 BT` / B209-1 Phase 1) | 后端与全栈测试用例 ⇔ PRD 验收标准(AC)资产 | `[机器]` ① 无主 AC 拦截(测试挂载不存在的 AC ID ⇒ FAIL);② 无效跳过拦截(测试被 skip/xfail 但无 waiver 理由 ⇒ FAIL);③ 双向覆盖对账(输出已声明 AC 与测试挂载覆盖比;enforce 模式下零测试挂载 ⇒ FAIL);④ 差量防退化(对比基线快照,挂载测试数与覆盖 AC 数净增不得为负 ⇒ WARN);⑤ **AC × 覆盖维度(后端通道,D153)**:读域级 PRD 可选列 `后端适用覆盖维度` ⇔ 测试 `@pytest.mark.ac(..., dims=[...])`;缺维度 / 仅弱证据 / 零测试 / 未知 / 孤儿 ⇒ WARN;无合法后端列、后端通道不适用/不可用、多后端 app、非 pytest ⇒ N/A;扫描面为空/无 `test_*.py` ⇒ WARN(点名扫描面);逐 PRD/逐表聚合,错位/备注含维度形态 ⇒ WARN | 测试断言是否语义有效(Phase 2/3 变异测试与证据链承接);非 Python 测试提取(v1 仅原生支持 Python AST);**E2E 第 4 列(D130 归 validate_e2e_index,不并集)**;**维度是否真被断言(语义,归独立审查)**;**资产是否真执行(执行证据归 E2E 真跑 / backend_test.command)** | 后端代码实现完成 / 收口;**调度者 = `atlas_check`**(三态前置:无 PRD 资产 ⇒ `SKIP`;无 PRD AC 且无测试 ⇒ `N/A`) | `出厂级(本轮实测)`:`tests/test_validate_ac_alignment.py` **39 条**(既有四维 7 条 + D153 后端维度 32 条;无主 AC 报 FAIL / 无 waiver skip 报 FAIL / 有 waiver 放行 / enforce 零测试挂载报 FAIL / 差量退化报 WARN / 后端列 PASS·缺维度·零测试·弱证据·形态·N/A);**M156a-f** 四段协议全咬(dims 解析 / 缺失聚合 / 零测试 / weak-only / 通道闸 / 本地 parser;原始输出 `.atlas/runs/mutation-proof/`) |
49
49
  | `scripts/refresh_structure.py` | 结构事实区 ⇔ 适配器输出 | `[机器]` `atlas:facts` 标记内的行键级单元格重写、语义列保留、预算(每域 L1+L2 字符上限);**键级增删 ⇒ 未登记漂移记录**(落 `.adapter-out/_drift.json`,2026-10-04,⇒ 2.2 `结构漂移` 门) | 语义区内容;无 `.adapter-out/` 时**跳过**(那是合法降级,不是通过) | 收口 / `after_finish` hook | `出厂级(本轮实测)`:`tests/test_refresh_facts.py` **21 条**本轮跑绿(`3509 §119`;漂移注入/清除 + 竖线往返为 2026-10-04 门禁负向用例 #3 补;F25 `--chain` 序 + F26 基线自进四前置 + F27 单遍化 + F29 tour 消费行为 R7 追加;**F41**(2026-10-15,issue #8)`.adapter-out/` 单一位点三消费者同址钉;**F47**(2026-10-16,issue #16)渲染层「写出 ⇒ 读回」**逐单元格不变式** + 渲染即自校(单元格换行 → `<br>`,不再把一行撑成多行 ⇒ 事实不再丢行、幻影漂移不再常驻;`_norm` 把 `<br>` 与换行视作**等价**以保键稳定);**M-R1** 摘掉单行化 ⇒ `test_multiline_cell_value_round_trips` 红 / **M-R2** 换行→**空格** ⇒ 行结构断言红(防「用空格糊过去也算修好」)/ **M-R9** `_norm` 退回不折叠 `<br>` ⇒ `test_key_column_with_newline_does_not_phantom_drift` 红 / **M-R10** 摘掉 `_selfcheck_round_trip` 调用 ⇒ `test_render_selfcheck_fires_on_unroundtrippable_row` 红(自校必须**活着能咬**)) |
50
- | `scripts/gen_testid_stub.py` | 分片应然 ⇔ 实现期薄桩 | `[机器]` 派生薄桩的存在与稳定重生成(缺失由 `validate_testids` **就地自动补齐并记 `PASS`** —— F32 兜底,2026-10-15;`FAIL` 只在生成失败) | 薄桩被实现者**读了没有** | 用例定稿后 | `出厂级(本轮实测)`:`tests/test_tools.py` **18 条**本轮跑绿(`3509 §119`;F23 `-m pytest` 命令形态 + F30 摘要装配集同步钉为 R7 追加;**F45**(2026-10-16,issue #14)用例 ID 文法单源污染门(唯一实现 + 登记面对账);**F37**(2026-10-15,issue #1)摘要根相对过滤 + 空装配响亮报错、**F40**(issue #4)运行命令由测试文件**后缀族**推定 + 冲突置空出声;**M62** 退回「只看配置存在性」⇒ `test_f40_ts_test_with_only_pytest_config_gets_no_command` 红 / 配置探测退回 app 作用域 ⇒ `test_f40_root_config_found_under_app_scope` 红;**F48**(2026-10-16,issue #17)装配面**三张表**同步补第三腿(`package.json.files` ⊇ `install.sh` 拷贝列表;此前漏 `tests` ⇒ npm 分发不含 `tests/` ⇒ 经 npm 装配的项目恒无 `.atlas/tests` ⇒ `atlas_check` 全量模式的包自检门**恒红且采用者自己修不了**);**M-R8** 从 `files` 摘掉 `tests` ⇒ `test_f47_npm_files_cover_assembly_list` 红) |
50
+ | `scripts/gen_testid_stub.py` | 分片应然 ⇔ 实现期薄桩 | `[机器]` 派生薄桩的存在与稳定重生成(缺失由 `validate_testids` **就地自动补齐并记 `PASS`** —— F32 兜底,2026-10-15;`FAIL` 只在生成失败) | 薄桩被实现者**读了没有** | 用例定稿后 | `出厂级(本轮实测)`:`tests/test_tools.py` **26 条**本轮跑绿(`3509 §119`;F23 `-m pytest` 命令形态 + F30 摘要装配集同步钉为 R7 追加;**F45**(2026-10-16,issue #14)用例 ID 文法单源污染门(唯一实现 + 登记面对账);**F37**(2026-10-15,issue #1)摘要根相对过滤 + 空装配响亮报错、**F40**(issue #4)运行命令由测试文件**后缀族**推定 + 冲突置空出声;**M62** 退回「只看配置存在性」⇒ `test_f40_ts_test_with_only_pytest_config_gets_no_command` 红 / 配置探测退回 app 作用域 ⇒ `test_f40_root_config_found_under_app_scope` 红;**F48**(2026-10-16,issue #17)装配面**三张表**同步补第三腿(`package.json.files` ⊇ `install.sh` 拷贝列表;此前漏 `tests` ⇒ npm 分发不含 `tests/` ⇒ 经 npm 装配的项目恒无 `.atlas/tests` ⇒ `atlas_check` 全量模式的包自检门**恒红且采用者自己修不了**);**M-R8** 从 `files` 摘掉 `tests` ⇒ `test_f47_npm_files_cover_assembly_list` 红) |
51
51
  | `scripts/cli_review_convert.py`(实例 ⑤ 专用,2026-09-29) | CLI 审查器输出 ⇔ `review.json` 契约 + **应审 N / 实审 M** | `[机器]` 严重度四档→三档写死映射(未知档位 exit 2);category 闭集外归 `other`;N(diff 文件数)/ M(CLI 汇总实审数)对账(M<N 或预算击穿 ⇒ `WARN`;**缺实审数 ⇒ 拒绝产出**);`(unit, round)` 落盘不得覆盖、round 递增 | findings 的**语义对错**(那是实例 ⑤ 审查者本身);回退环 triage(默认 `implement`,人 / agent 改判) | 实现代码审查产物落盘时(实例 ⑤ 运行后;执行器未接 ⇒ 当前未触发) | `出厂级(本轮实测)`:`tests/test_cli_review_convert.py` **12 条**(未知严重度 / 缺 summary / 缺应审口径 / 覆盖旧轮 ⇒ 各自 exit 2;Critical ⇒ FAIL 退出码 1;M<N ⇒ WARN;other 归档不计门禁;四档→三档映射;三件产物形态;D129 v2 指纹与 round 4 升级复议;**D129 评审 Major 修正**:`test_fingerprint_stable_across_line_numbers` 钉「行号漂移后指纹不变 + 内容不同指纹不同」两侧;**M130c**(`clean_target_file` 行号正则退回 `:\d+$` ⇒ 剥不掉 CLI 恒产的 `path:12-15`)钉红,四段协议还原逐字节一致) |
52
52
  | `scripts/validate_review_report.py`(审查报告落盘门,2026-10-06) | 审查报告单目录 ⇔ `independent-review.md` §3/§4/§6 形态 + 应审/实审 | `[机器]` 双份留痕(`review.json` + `README.md`)/ §3 必备键齐且类型对(`ok`/`status`/`unit`/`round`/`checks` 等)/ 分级闭集 + 每条 finding 带非空 `rollback` / 状态映射复算(**单源内核 `_support/review_policy.py`,与 `cli_review_convert` / `gen_e2e_scripts` 同口径**:v2 阻断性 Critical = `Critical ∧ pre_existing≠true ∧ disposition 未关闭`;存量 Critical 与带签字取证的 false_positive 降为 WARN 下限;**v1 报告任一 Critical 即 FAIL、不接受字段豁免**(防 `--schema-version 1` 成绕过后门))/ `expected`·`reviewed` 必填(缺 ⇒ FAIL;`reviewed < expected` ⇒ WARN)/ **D129 v2 判据**:open 项 fingerprint 重复 ⇒ FAIL、`disposition` 闭集且 fixed/false_positive 必带签字取证、`round>=4` 必带 escalation、`delta_only=true` 必带非空 `reviewed_files` | findings 的**语义对错**(归审查者);实例 ⑤ 的 N/M 对账(那归 `cli_review_convert`,本器在其后再验一道);**不回扫存量历史报告**(形态契约对新落盘生效——回扫 = 永久红 ⇒ 静音门) | 审查报告落盘当轮必跑(`closeout.md` 触发表「审查报告落盘」行) | `出厂级(本轮实测)`:`tests/test_review_report.py` **18 条**(**M35** 摘映射复算 ⇒ 红 / **M36** 缺对账字段降放行 ⇒ 红 / D129 pre_existing、disposition、去重指纹、escalation 四侧)(`§B100` 静默少审族清偿) |
53
53
  | `scripts/impact_reconcile.py`(impact 对账门,2026-10-07,B191-1) | `impact.json` 预测集 ⇔ 收口时实际 git diff | `[机器]` 双向差集:漏报(实际改动在预测集外)/ 多报(预测未改)⇒ **WARN** 逐文件指名;基准漂移(报告 `head` ≠ 当前 HEAD / `--diff-base` ≠ `inputs.diff_base`)⇒ WARN 指名两个基准——**不得静默对账**;predicted = `touched_files` ∪ `inputs.paths` ∪ ⋃`layers[].files` | 差集的**语义解释**(漏报是预测失准还是越界改动——归收口人终审);符号级对账(归语义审查);untracked(不在 `git diff` 内,与 impact_report 同语义);`.adapter-out` 台账在无 `.gitignore` 的仓会挂 git status(第 5 触点不首创该文件——边界明示) | 收口时(`impact.json` 存在的需求;`closeout.md` 触发行);**调度者 = 人/收口流程;观察窗(2026-10-15 升档规则落档,D79 ④——判据值全在本行,不赖 `3509 §B191-1` 台账数字)**:台账 = 本器**已无条件落盘**的 `impact-reconcile.json`(每单一份,含 predicted/actual/miss/over);**单** = 一条收口(其 `impact.json` 存在且 reconcile 已跑);**升 FAIL 触发 = 裁决日起连续 3 单漏报(miss)为 0 且无同文件重复漏报**;升档后分档 = **miss ⇒ FAIL**(真空盲面硬阻)、**over 保持 WARN**(预测偏宽 = 命中率分母的良性量,收口不得被它钉死);漏报 > 0 ⇒ 保持 WARN + 台账佐证;升档动作 = 本脚本 WARN 分支按级别拆分支(一行级,变异钉升档当日配) | `出厂级(本轮实测)`:`tests/test_impact_reconcile.py` 5 条(全等 ok / 差集 WARN 双侧指名 / head 漂移 / diff-base 不一致 / 缺报告 exit 3);**M39** 摘差集检测 ⇒ 红 / **M40** 摘 head 检查 ⇒ 红 |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: atlas-release
3
- description: "Atlas 仓库专用自动化发布与推送技能。全自动处理网络代理(7897)、全量测试门禁、版本推导防跳号、四处版本落点同步、Git 提交打标推送、NPM prerelease 发布与 registry 轮询验证。"
3
+ description: "Atlas 仓库专用自动化发布与推送技能。全自动处理网络代理(7897)、全量测试门禁、版本推导防跳号、多处版本落点同步、Git 提交打标推送、GitHub Release 补建、NPM prerelease 发布与 registry 轮询验证。"
4
4
  ---
5
5
 
6
6
  # Atlas 自动化发版与推送技能 (Atlas Release Skill)
@@ -34,7 +34,7 @@ python3 scripts/release.py --auto
34
34
 
35
35
  ## 📋 核心发版纪律与关键落点(严防踩坑)
36
36
 
37
- 如果需要人工或逐步介入,必须严格遵循以下 **6 大纪律**:
37
+ 如果需要人工或逐步介入,必须严格遵循以下 **7 大纪律**:
38
38
 
39
39
  ### 1. 网络代理(必须走 7897 端口)
40
40
  - **现象**:本机直连 GitHub 443 必超时(`Failed to connect to github.com port 443 after 75s`)。
@@ -97,6 +97,12 @@ pytest tests/ -q
97
97
  ```
98
98
  确认 `beta` 和 `latest` 双双指向目标版本。
99
99
 
100
+ ### 7. GitHub Release 跟随 tag(自动补建)
101
+ - **现象**:GitHub Releases 页只读 Release 对象,不跟 tag。只推 tag 不建 Release,页面会停在最后一次手工发布(2026-10 实测:停在 `v0.1.0-beta.19`,其后的 tag 全部缺席)。
102
+ - **规则**:`release.py` 在 tag 推送成功后调 GitHub REST API 补建 Release——`beta` 打 `prerelease`,正文取 `CHANGELOG.md` 对应 `## [vX]` 段(缺失则用 GitHub 自动生成 notes,以相邻 tag 为对比基线);同 tag 已有 Release 则跳过(幂等)。
103
+ - **token 来源**:`GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_PAT` → `gh auth token` → git 凭据(`git credential fill`,即 HTTPS 推送用的 PAT),无需额外配置。
104
+ - **失败语义**:只告警不阻断——此时 tag 已推送、版本号已占用,继续发 npm 优于留下半态;`--status` 会显示 GitHub Release 与最新 tag 的落差。
105
+
100
106
  ---
101
107
 
102
108
  ## 🛠️ 标准发版执行流模板
@@ -114,7 +120,7 @@ pytest tests/ -q
114
120
  python3 scripts/release.py --auto
115
121
  ```
116
122
  4. **第四步:报结果**
117
- 汇报最终 Git 提交 hash、Tag、NPM 生效版本,并提示用户在采纳仓更新:
123
+ 汇报最终 Git 提交 hash、Tag、GitHub Release 链接、NPM 生效版本,并提示用户在采纳仓更新:
118
124
  ```bash
119
125
  atlas upgrade && atlas update
120
126
  ```
@@ -26,6 +26,7 @@ def load(name: str, rel: str):
26
26
 
27
27
  mp = load("mutation_proof", "scripts/mutation_proof.py")
28
28
  stub = load("gen_testid_stub", "scripts/gen_testid_stub.py")
29
+ rel = load("release_tool", "scripts/release.py")
29
30
 
30
31
 
31
32
  # ---------------------------------------------------------------- B87 变异证明工具
@@ -560,3 +561,95 @@ def test_f47_npm_files_cover_assembly_list() -> None:
560
561
  assert not missing, (
561
562
  f"install.sh 会拷 {missing},但 package.json.files 不发布它们 ⇒ 经 npm 装配的项目副本"
562
563
  "缺这些目录(issue #17:`.atlas/tests` 恒缺 ⇒ 包自检门恒红,而源包态跑不出来)")
564
+
565
+
566
+ # ---------------------------------------------------------------- release.py:GitHub Release 跟随 tag
567
+
568
+ def _stub_github(monkeypatch, responses):
569
+ """按调用顺序返回预设 (status, body),并记录 (method, path, payload)。"""
570
+ calls = []
571
+
572
+ def fake_api(method, path, token, payload=None, timeout=30):
573
+ calls.append((method, path, payload))
574
+ return responses[min(len(calls), len(responses)) - 1]
575
+
576
+ monkeypatch.setattr(rel, "get_github_token", lambda: "t")
577
+ monkeypatch.setattr(rel, "get_repo_slug", lambda: "o/r")
578
+ monkeypatch.setattr(rel, "github_api", fake_api)
579
+ return calls
580
+
581
+
582
+ def test_release_creates_prerelease_with_changelog_body(monkeypatch) -> None:
583
+ calls = _stub_github(monkeypatch, [(404, {}), (201, {"html_url": "u"})])
584
+ monkeypatch.setattr(rel, "changelog_section", lambda v: "- 变更")
585
+
586
+ assert rel.create_github_release("v0.2.0-beta.13", "0.2.0-beta.13") is True
587
+ assert [c[0] for c in calls] == ["GET", "POST"]
588
+ assert calls[0][1] == "/repos/o/r/releases/tags/v0.2.0-beta.13"
589
+ assert calls[1][1] == "/repos/o/r/releases"
590
+ payload = calls[-1][2]
591
+ assert payload["tag_name"] == "v0.2.0-beta.13"
592
+ assert payload["prerelease"] is True
593
+ assert payload["body"] == "- 变更"
594
+ assert payload["generate_release_notes"] is False
595
+
596
+
597
+ def test_release_non_beta_is_not_prerelease(monkeypatch) -> None:
598
+ calls = _stub_github(monkeypatch, [(404, {}), (201, {})])
599
+ monkeypatch.setattr(rel, "changelog_section", lambda v: "")
600
+ monkeypatch.setattr(rel, "get_previous_tag", lambda t: "v0.9.0")
601
+
602
+ assert rel.create_github_release("v1.0.0", "1.0.0") is True
603
+ payload = calls[-1][2]
604
+ assert payload["prerelease"] is False
605
+ assert payload["generate_release_notes"] is True
606
+ assert payload["previous_tag_name"] == "v0.9.0" # 自动 notes 不得以上一个 Release 为基线
607
+ assert "body" not in payload
608
+
609
+
610
+ def test_release_skips_existing_release(monkeypatch) -> None:
611
+ calls = _stub_github(monkeypatch, [(200, {"tag_name": "v0.2.0-beta.13"})])
612
+ assert rel.create_github_release("v0.2.0-beta.13", "0.2.0-beta.13") is True
613
+ assert [c[0] for c in calls] == ["GET"] # 已存在 ⇒ 不重复创建
614
+ assert calls[0][1] == "/repos/o/r/releases/tags/v0.2.0-beta.13"
615
+
616
+
617
+ def test_release_without_token_skips_without_api_call(monkeypatch) -> None:
618
+ calls = []
619
+ monkeypatch.setattr(rel, "get_github_token", lambda: "")
620
+ monkeypatch.setattr(rel, "github_api", lambda *a, **k: calls.append(1) or (0, {}))
621
+ assert rel.create_github_release("v1.0.0", "1.0.0") is False
622
+ assert calls == []
623
+
624
+
625
+ def test_release_api_error_returns_false(monkeypatch) -> None:
626
+ calls = _stub_github(monkeypatch, [(500, {"message": "boom"})])
627
+ assert rel.create_github_release("v1.0.0", "1.0.0") is False
628
+ assert [c[0] for c in calls] == ["GET"]
629
+
630
+
631
+ def test_extract_changelog_section_stops_at_next_heading() -> None:
632
+ text = (
633
+ "# 更新日志\n\n"
634
+ "## [v0.2.0-beta.12] - 2026-10-26\n\n- A\n\n"
635
+ "## [v0.2.0-beta.7] - 2026-10-25\n\n- B\n"
636
+ )
637
+ assert rel.extract_changelog_section(text, "0.2.0-beta.12") == "- A"
638
+ assert rel.extract_changelog_section(text, "9.9.9") == ""
639
+
640
+
641
+ def test_github_latest_release_skips_drafts(monkeypatch) -> None:
642
+ monkeypatch.setattr(rel, "get_github_token", lambda: "t")
643
+ monkeypatch.setattr(rel, "get_repo_slug", lambda: "o/r")
644
+ monkeypatch.setattr(rel, "github_api", lambda *a, **k: (
645
+ 200,
646
+ [{"draft": True, "tag_name": "v0.3.0-beta.1"}, {"draft": False, "tag_name": "v0.2.0-beta.13"}],
647
+ ))
648
+ assert rel.get_github_latest_release() == (True, "v0.2.0-beta.13")
649
+
650
+
651
+ def test_release_rejects_invalid_explicit_version() -> None:
652
+ import pytest
653
+
654
+ with pytest.raises(SystemExit):
655
+ rel.compute_next_beta_version("0.2.0-beta.14; touch /tmp/pwned")