Skip to content

Commit ec9419c

Browse files
authored
Merge pull request #8 from shipiyouniao/ci/validation-and-release
ci: 增加仓库校验和 Release 打包流水线
2 parents e4a57bf + c85d2a2 commit ec9419c

9 files changed

Lines changed: 490 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main, develop]
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
validate:
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [ubuntu-latest, windows-latest]
17+
python-version: ["3.10", "3.13"]
18+
runs-on: ${{ matrix.os }}
19+
steps:
20+
- uses: actions/checkout@v4
21+
- uses: actions/setup-python@v5
22+
with:
23+
python-version: ${{ matrix.python-version }}
24+
- name: Validate repository
25+
run: python tools/repo_checks.py
26+
- name: Run tests
27+
run: python tools/run_tests.py
28+
29+
package:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
with:
34+
fetch-depth: 0
35+
- uses: actions/setup-python@v5
36+
with:
37+
python-version: "3.13"
38+
- name: Validate repository
39+
run: python tools/repo_checks.py
40+
- name: Build release archives
41+
run: python tools/package_release.py --version ci
42+
- name: Verify checksums
43+
working-directory: dist
44+
run: sha256sum --check SHA256SUMS
45+
- uses: actions/upload-artifact@v4
46+
with:
47+
name: release-preview
48+
path: dist/

‎.github/workflows/release.yml‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.13"
21+
- name: Validate repository
22+
run: python tools/repo_checks.py
23+
- name: Run tests
24+
run: python tools/run_tests.py
25+
- name: Build release archives
26+
run: python tools/package_release.py --version "${GITHUB_REF_NAME#v}"
27+
- name: Verify checksums
28+
working-directory: dist
29+
run: sha256sum --check SHA256SUMS
30+
- name: Publish GitHub release
31+
env:
32+
GH_TOKEN: ${{ github.token }}
33+
shell: bash
34+
run: |
35+
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
36+
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
37+
else
38+
gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --generate-notes
39+
fi

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ config.json
88

99
__pycache__
1010
.embeddedskills
11+
dist/

‎README.en.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
[![MIT License](https://img.shields.io/badge/License-MIT-green?style=flat-square)](LICENSE)
1010
[![Status](https://img.shields.io/badge/status-active-success?style=flat-square)]()
1111
[![Stars](https://img.shields.io/github/stars/zhinkgit/embeddedskills?style=flat-square)](https://github.com/zhinkgit/embeddedskills/stargazers)
12+
[![CI](https://github.com/zhinkgit/embeddedskills/actions/workflows/ci.yml/badge.svg)](https://github.com/zhinkgit/embeddedskills/actions/workflows/ci.yml)
1213

1314
[![Claude Code](https://img.shields.io/badge/Claude_Code-black?style=flat-square&logo=anthropic&logoColor=white)](https://claude.ai/code)
1415
[![OpenAI Codex CLI](https://img.shields.io/badge/OpenAI_Codex_CLI-412991?style=flat-square&logo=openai&logoColor=white)](https://github.com/openai/codex)
@@ -100,6 +101,7 @@ npx skills add https://github.com/zhinkgit/embeddedskills -g -y
100101
|:---:|:---:|---|---|
101102
| 🔨 Build | **keil** | Keil MDK project scan, Target enumeration, compile, rebuild, clean | `scan` `targets` `build` `rebuild` `clean` `flash` |
102103
| 🔨 Build | **gcc** | CMake-based GCC project configuration, compile, size analysis | `scan` `presets` `configure` `build` `rebuild` `size` |
104+
| 🔨 Build | **eide** | EIDE project scan, configuration discovery, build, and size analysis | `scan` `configs` `build` `rebuild` `clean` `size` |
103105
| 🔬 Debug | **jlink** | Flash, read/write memory/registers, RTT/SWO, GDB debug | `flash` `read-mem` `write-mem` `regs` `rtt` `swo` + GDB |
104106
| 🔬 Debug | **openocd** | Flash, erase, GDB/Telnet, Semihosting/ITM | `flash` `erase` `reset` `gdb-server` `semihosting` `itm` |
105107
| 🔬 Debug | **probe-rs** | Probe discovery, flashing, reset, memory access, GDB debug, RTT | `list` `info` `flash` `erase` `reset` `read-mem` `write-mem` `gdb` `rtt` |

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
[![MIT License](https://img.shields.io/badge/License-MIT-green?style=flat-square)](LICENSE)
1010
[![Status](https://img.shields.io/badge/status-active-success?style=flat-square)]()
1111
[![Stars](https://img.shields.io/github/stars/zhinkgit/embeddedskills?style=flat-square)](https://github.com/zhinkgit/embeddedskills/stargazers)
12+
[![CI](https://github.com/zhinkgit/embeddedskills/actions/workflows/ci.yml/badge.svg)](https://github.com/zhinkgit/embeddedskills/actions/workflows/ci.yml)
1213

1314
[![Claude Code](https://img.shields.io/badge/Claude_Code-black?style=flat-square&logo=anthropic&logoColor=white)](https://claude.ai/code)
1415
[![OpenAI Codex CLI](https://img.shields.io/badge/OpenAI_Codex_CLI-412991?style=flat-square&logo=openai&logoColor=white)](https://github.com/openai/codex)
@@ -100,6 +101,7 @@ npx skills add https://github.com/zhinkgit/embeddedskills -g -y
100101
|:---:|:---:|---|---|
101102
| 🔨 构建 | **keil** | Keil MDK 工程扫描、Target 枚举、编译、重建、清理 | `scan` `targets` `build` `rebuild` `clean` `flash` |
102103
| 🔨 构建 | **gcc** | CMake 型 GCC 工程配置、编译、大小分析 | `scan` `presets` `configure` `build` `rebuild` `size` |
104+
| 🔨 构建 | **eide** | EIDE 工程扫描、配置枚举、构建和大小分析 | `scan` `configs` `build` `rebuild` `clean` `size` |
103105
| 🔬 调试 | **jlink** | 烧录、读写内存/寄存器、RTT/SWO、GDB 调试 | `flash` `read-mem` `write-mem` `regs` `rtt` `swo` + GDB |
104106
| 🔬 调试 | **openocd** | 烧录、擦除、GDB/Telnet、Semihosting/ITM | `flash` `erase` `reset` `gdb-server` `semihosting` `itm` |
105107
| 🔬 调试 | **probe-rs** | 探针发现、烧录、复位、内存读写、GDB 调试、RTT | `list` `info` `flash` `erase` `reset` `read-mem` `write-mem` `gdb` `rtt` |

‎tests/test_repo_tools.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import tempfile
2+
import unittest
3+
import zipfile
4+
from pathlib import Path
5+
6+
from tools.package_release import build_archives, write_checksums
7+
from tools.repo_checks import local_link_target, parse_frontmatter
8+
9+
10+
class RepositoryCheckTests(unittest.TestCase):
11+
def test_frontmatter_parser_reads_required_fields(self) -> None:
12+
with tempfile.TemporaryDirectory() as directory:
13+
path = Path(directory) / "SKILL.md"
14+
path.write_text(
15+
"---\nname: demo\ndescription: demo skill\n---\n\n# Demo\n",
16+
encoding="utf-8",
17+
)
18+
self.assertEqual(parse_frontmatter(path)["name"], "demo")
19+
20+
def test_local_link_parser_ignores_urls_and_anchors(self) -> None:
21+
self.assertIsNone(local_link_target("https://example.com/doc"))
22+
self.assertIsNone(local_link_target("#section"))
23+
self.assertEqual(local_link_target("docs/guide.md#start"), "docs/guide.md")
24+
25+
26+
class ReleasePackageTests(unittest.TestCase):
27+
def test_release_archives_exclude_tests_and_are_reproducible(self) -> None:
28+
with tempfile.TemporaryDirectory() as directory:
29+
root = Path(directory) / "repo"
30+
skill = root / "demo"
31+
(skill / "tests").mkdir(parents=True)
32+
(skill / "SKILL.md").write_text("demo", encoding="utf-8")
33+
(skill / "script.py").write_text("print('ok')\n", encoding="utf-8")
34+
(skill / "tests" / "test_demo.py").write_text("", encoding="utf-8")
35+
(root / "LICENSE").write_text("MIT", encoding="utf-8")
36+
37+
first = root / "first"
38+
second = root / "second"
39+
first_archives = build_archives(root, first, "1.0.0")
40+
second_archives = build_archives(root, second, "1.0.0")
41+
42+
self.assertEqual(
43+
[path.read_bytes() for path in first_archives],
44+
[path.read_bytes() for path in second_archives],
45+
)
46+
with zipfile.ZipFile(first / "embeddedskills-demo-1.0.0.zip") as archive:
47+
self.assertIn("demo/SKILL.md", archive.namelist())
48+
self.assertNotIn("demo/tests/test_demo.py", archive.namelist())
49+
50+
checksum_file = write_checksums(first, first_archives)
51+
self.assertIn("embeddedskills-demo-1.0.0.zip", checksum_file.read_text())
52+
53+
54+
if __name__ == "__main__":
55+
unittest.main()

‎tools/package_release.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import hashlib
4+
import re
5+
import shutil
6+
import subprocess
7+
import zipfile
8+
from pathlib import Path
9+
10+
11+
FIXED_TIMESTAMP = (2020, 1, 1, 0, 0, 0)
12+
EXCLUDED_PARTS = {".git", ".github", ".venv", "__pycache__", "tests"}
13+
14+
15+
def repository_root() -> Path:
16+
return Path(__file__).resolve().parents[1]
17+
18+
19+
def normalized_version(root: Path, requested: str | None) -> str:
20+
version = requested
21+
if not version:
22+
process = subprocess.run(
23+
["git", "describe", "--tags", "--always", "--dirty"],
24+
cwd=root,
25+
text=True,
26+
capture_output=True,
27+
check=True,
28+
)
29+
version = process.stdout.strip()
30+
version = version.removeprefix("v")
31+
if not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z._-]*", version):
32+
raise ValueError(f"invalid release version: {version!r}")
33+
return version
34+
35+
36+
def included(path: Path, root: Path) -> bool:
37+
relative = path.relative_to(root)
38+
return not any(part in EXCLUDED_PARTS for part in relative.parts)
39+
40+
41+
def write_zip(archive: Path, root: Path, files: list[Path]) -> None:
42+
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output:
43+
for path in sorted(files, key=lambda item: item.as_posix()):
44+
relative = path.relative_to(root).as_posix()
45+
info = zipfile.ZipInfo(relative, FIXED_TIMESTAMP)
46+
info.compress_type = zipfile.ZIP_DEFLATED
47+
info.external_attr = 0o100644 << 16
48+
output.writestr(info, path.read_bytes())
49+
50+
51+
def build_archives(root: Path, output_directory: Path, version: str) -> list[Path]:
52+
if output_directory.exists():
53+
shutil.rmtree(output_directory)
54+
output_directory.mkdir(parents=True)
55+
56+
skill_directories = sorted(
57+
path for path in root.iterdir() if path.is_dir() and (path / "SKILL.md").is_file()
58+
)
59+
runtime_files = [
60+
path for path in root.iterdir() if path.is_file() and included(path, root)
61+
]
62+
for directory in [root / "docs", *skill_directories]:
63+
if directory.is_dir():
64+
runtime_files.extend(
65+
path
66+
for path in directory.rglob("*")
67+
if path.is_file() and included(path, root)
68+
)
69+
70+
archives: list[Path] = []
71+
bundle = output_directory / f"embeddedskills-{version}.zip"
72+
write_zip(bundle, root, runtime_files)
73+
archives.append(bundle)
74+
75+
for skill_directory in skill_directories:
76+
files = [root / "LICENSE"]
77+
files.extend(
78+
path
79+
for path in skill_directory.rglob("*")
80+
if path.is_file() and included(path, root)
81+
)
82+
archive = output_directory / f"embeddedskills-{skill_directory.name}-{version}.zip"
83+
write_zip(archive, root, files)
84+
archives.append(archive)
85+
return archives
86+
87+
88+
def write_checksums(output_directory: Path, archives: list[Path]) -> Path:
89+
checksum_file = output_directory / "SHA256SUMS"
90+
lines = [
91+
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}"
92+
for path in sorted(archives)
93+
]
94+
checksum_file.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
95+
return checksum_file
96+
97+
98+
def main() -> int:
99+
parser = argparse.ArgumentParser(description="Build deterministic release archives")
100+
parser.add_argument("--root", type=Path, default=repository_root())
101+
parser.add_argument("--output", type=Path)
102+
parser.add_argument("--version")
103+
args = parser.parse_args()
104+
105+
root = args.root.resolve()
106+
output = (args.output or root / "dist").resolve()
107+
version = normalized_version(root, args.version)
108+
archives = build_archives(root, output, version)
109+
checksum_file = write_checksums(output, archives)
110+
for path in [*archives, checksum_file]:
111+
print(path)
112+
return 0
113+
114+
115+
if __name__ == "__main__":
116+
raise SystemExit(main())

0 commit comments

Comments
 (0)