Skip to content

Commit 98367d4

Browse files
authored
[codex] Add engineering documentation metadata QA gate (#27)
Add metadata/navigation consistency QA, align published metadata, and extend CI with a Jekyll build. Review feedback addressed: - Validate repository URLs before deriving canonical URLs. - Keep the top page intro aligned with canonical metadata.
1 parent 6e5a761 commit 98367d4

7 files changed

Lines changed: 277 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@ jobs:
1313
runs-on: ubuntu-latest
1414
steps:
1515
- uses: actions/checkout@v6
16-
- uses: actions/setup-node@v4
16+
- uses: actions/setup-node@v6
1717
with:
1818
node-version: '20'
1919
cache: npm
2020
- name: Install
2121
run: npm ci
2222
- name: Test
2323
run: npm test
24+
- name: Setup Ruby
25+
uses: ruby/setup-ruby@v1
26+
with:
27+
ruby-version: '3.3'
28+
bundler-cache: true
29+
- name: Build Jekyll site
30+
run: npm run build

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,17 @@ npm start
3030
# ビルド
3131
npm run build
3232

33-
# テスト(markdown lint / link check)
33+
# メタデータ整合性チェック
34+
npm run check:metadata
35+
36+
# テスト(metadata / markdown lint / link check)
3437
npm test
3538
```
3639

40+
### 品質ゲート
41+
42+
`npm run check:metadata` は、`book-config.json` / `package.json` / `package-lock.json` / Jekyll 設定 / `docs/index.md` / ナビゲーションのタイトル・説明・版数・公開 URL がずれていないことを検証します。
43+
3744
## ライセンス
3845

3946
本書は **CC BY-NC-SA 4.0** で提供します。詳細は `LICENSE.md` を参照してください。

_config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
title: エンジニアリングドキュメント実践ガイド:README・手順書・Runbook・ADR・ポストモーテム
22
description: 実務で使える技術ドキュメント成果物(README/手順書/Runbook/ADR/障害報告・ポストモーテム等)の設計と運用を扱う。
33
author: 株式会社アイティードゥ
4+
version: "0.1.0"
45
lang: ja
56
url: https://itdojp.github.io
67
baseurl: /engineering-documentation-book

docs/_config.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
title: エンジニアリングドキュメント実践ガイド:README・手順書・Runbook・ADR・ポストモーテム
2-
description: 実務で使える技術ドキュメント成果物(README/手順書/Runbook/ADR/障害報告・ポストモーテムなど)の設計と運用を扱う。
2+
description: 実務で使える技術ドキュメント成果物(README/手順書/Runbook/ADR/障害報告・ポストモーテム等)の設計と運用を扱う。
33
author: 株式会社アイティードゥ
4+
version: "0.1.0"
45
lang: ja
56
url: https://itdojp.github.io
67
baseurl: /engineering-documentation-book

docs/index.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
---
22
title: "エンジニアリングドキュメント実践ガイド:README・手順書・Runbook・ADR・ポストモーテム"
3-
description: "未経験〜初級が、実務で求められるドキュメント成果物(README/手順書/Runbook/ADR/障害報告・ポストモーテム)を作成できる状態にする。"
3+
description: "実務で使える技術ドキュメント成果物(README/手順書/Runbook/ADR/障害報告・ポストモーテム等)の設計と運用を扱う。"
4+
author: "株式会社アイティードゥ"
5+
version: "0.1.0"
46
layout: book
57
order: 0
68
readerProfile:
@@ -17,7 +19,7 @@ learningOutcomes:
1719

1820
# エンジニアリングドキュメント実践ガイド:README・手順書・Runbook・ADR・ポストモーテム
1921

20-
未経験〜初級が、実務で求められるドキュメント成果物(README/手順書/Runbook/ADR/障害報告・ポストモーテム)を作成できる状態にする
22+
実務で使える技術ドキュメント成果物(README/手順書/Runbook/ADR/障害報告・ポストモーテム等)の設計と運用を扱うガイドです。未経験〜初級の読者が、必要な成果物を作成し、レビューと保守に組み込める状態を目指します
2123

2224
## 想定読者
2325

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
"scripts": {
77
"start": "bash scripts/jekyll.sh serve",
88
"build": "bash scripts/jekyll.sh build",
9-
"test": "npm run lint && npm run check-links",
9+
"test": "npm run check:metadata && npm run lint && npm run check-links",
1010
"lint": "markdownlint -c .markdownlint.json \"docs/**/*.md\"",
1111
"check-links": "find docs -name '*.md' -print0 | xargs -0 -n 1 markdown-link-check -c .markdown-link-check.json",
12-
"deploy": "gh-pages -d _site"
12+
"deploy": "gh-pages -d _site",
13+
"check:metadata": "node scripts/check-metadata-consistency.js"
1314
},
1415
"license": "CC-BY-NC-SA-4.0",
1516
"devDependencies": {
@@ -20,5 +21,9 @@
2021
"repository": {
2122
"type": "git",
2223
"url": "https://github.com/itdojp/engineering-documentation-book.git"
24+
},
25+
"homepage": "https://itdojp.github.io/engineering-documentation-book/",
26+
"bugs": {
27+
"url": "https://github.com/itdojp/engineering-documentation-book/issues"
2328
}
2429
}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
const fs = require('fs');
5+
const path = require('path');
6+
7+
const root = process.cwd();
8+
const errors = [];
9+
10+
function readText(file) {
11+
return fs.readFileSync(path.join(root, file), 'utf8');
12+
}
13+
14+
function readJson(file) {
15+
return JSON.parse(readText(file));
16+
}
17+
18+
function unquote(value) {
19+
const trimmed = String(value).trim();
20+
if (
21+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
22+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
23+
) {
24+
return trimmed.slice(1, -1);
25+
}
26+
return trimmed;
27+
}
28+
29+
function parseYamlScalars(text) {
30+
const result = {};
31+
for (const line of text.split(/\r?\n/)) {
32+
if (!line.trim() || line.trimStart().startsWith('#')) {
33+
continue;
34+
}
35+
const match = line.match(/^([A-Za-z0-9_.-]+):\s*(.*)$/);
36+
if (!match) {
37+
continue;
38+
}
39+
result[match[1]] = unquote(match[2]);
40+
}
41+
return result;
42+
}
43+
44+
function parseFrontMatter(file) {
45+
const text = readText(file);
46+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
47+
if (!match) {
48+
errors.push(`${file}: front matter is missing`);
49+
return {};
50+
}
51+
return parseYamlScalars(match[1]);
52+
}
53+
54+
function parseNavigationList(text, section) {
55+
const items = [];
56+
let inSection = false;
57+
let current = null;
58+
59+
for (const line of text.split(/\r?\n/)) {
60+
const sectionMatch = line.match(/^([A-Za-z0-9_-]+):\s*$/);
61+
if (sectionMatch) {
62+
if (current) {
63+
items.push(current);
64+
current = null;
65+
}
66+
inSection = sectionMatch[1] === section;
67+
continue;
68+
}
69+
70+
if (!inSection) {
71+
continue;
72+
}
73+
74+
if (!line.trim()) {
75+
continue;
76+
}
77+
78+
const titleMatch = line.match(/^\s*-\s+title:\s*(.+)$/);
79+
if (titleMatch) {
80+
if (current) {
81+
items.push(current);
82+
}
83+
current = { title: unquote(titleMatch[1]) };
84+
continue;
85+
}
86+
87+
const pathMatch = line.match(/^\s+path:\s*(.+)$/);
88+
if (pathMatch && current) {
89+
current.path = unquote(pathMatch[1]);
90+
}
91+
}
92+
93+
if (current) {
94+
items.push(current);
95+
}
96+
97+
return items;
98+
}
99+
100+
function expectEqual(label, actual, expected) {
101+
if (actual !== expected) {
102+
errors.push(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
103+
}
104+
}
105+
106+
function expectTruthy(label, actual) {
107+
if (!actual) {
108+
errors.push(`${label}: expected a non-empty value`);
109+
}
110+
}
111+
112+
function expectFile(file) {
113+
if (!fs.existsSync(path.join(root, file))) {
114+
errors.push(`${file}: expected file to exist`);
115+
return false;
116+
}
117+
return true;
118+
}
119+
120+
function parseGitHubRepositoryUrl(value, label, options = {}) {
121+
const { recordError = true } = options;
122+
if (typeof value !== 'string' || !value.trim()) {
123+
if (recordError) {
124+
errors.push(`${label}: expected a non-empty GitHub repository URL`);
125+
}
126+
return null;
127+
}
128+
129+
try {
130+
const parsed = new URL(value.trim().replace(/\.git$/, ''));
131+
const [owner, repo, ...rest] = parsed.pathname.replace(/^\//, '').split('/');
132+
if (parsed.hostname !== 'github.com' || !owner || !repo || rest.length) {
133+
if (recordError) {
134+
errors.push(`${label}: expected https://github.com/<owner>/<repo>[.git], got ${JSON.stringify(value)}`);
135+
}
136+
return null;
137+
}
138+
return {
139+
owner,
140+
repo,
141+
url: `https://github.com/${owner}/${repo}`,
142+
};
143+
} catch (error) {
144+
if (recordError) {
145+
errors.push(`${label}: expected a valid URL, got ${JSON.stringify(value)} (${error.message})`);
146+
}
147+
return null;
148+
}
149+
}
150+
151+
const bookConfig = readJson('book-config.json');
152+
const pkg = readJson('package.json');
153+
const lock = readJson('package-lock.json');
154+
const rootConfig = parseYamlScalars(readText('_config.yml'));
155+
const docsConfig = parseYamlScalars(readText('docs/_config.yml'));
156+
const docsIndex = parseFrontMatter('docs/index.md');
157+
const navigationText = readText('docs/_data/navigation.yml');
158+
159+
const packageRepositoryInfo = parseGitHubRepositoryUrl(
160+
pkg.repository && pkg.repository.url,
161+
'package.json repository.url'
162+
);
163+
const fallbackRepositoryInfo = packageRepositoryInfo ||
164+
parseGitHubRepositoryUrl(rootConfig.repository, '_config.yml repository', { recordError: false }) ||
165+
parseGitHubRepositoryUrl(docsConfig.repository, 'docs/_config.yml repository', { recordError: false });
166+
const owner = fallbackRepositoryInfo ? fallbackRepositoryInfo.owner : 'itdojp';
167+
const repo = fallbackRepositoryInfo ? fallbackRepositoryInfo.repo : (pkg.name || 'engineering-documentation-book');
168+
const repoUrl = `https://github.com/${owner}/${repo}`;
169+
const pagesUrl = `https://${owner}.github.io/${repo}/`;
170+
const issuesUrl = `${repoUrl}/issues`;
171+
const baseurl = `/${repo}`;
172+
173+
expectEqual('package.json name', pkg.name, repo);
174+
expectEqual('package.json version', pkg.version, bookConfig.version);
175+
expectEqual('package.json description', pkg.description, bookConfig.description);
176+
expectEqual('package.json license', pkg.license, 'CC-BY-NC-SA-4.0');
177+
expectEqual('package.json repository.url', pkg.repository && pkg.repository.url, `${repoUrl}.git`);
178+
expectEqual('package.json homepage', pkg.homepage, pagesUrl);
179+
expectEqual('package.json bugs.url', pkg.bugs && pkg.bugs.url, issuesUrl);
180+
expectEqual('package.json scripts.check:metadata', pkg.scripts && pkg.scripts['check:metadata'], 'node scripts/check-metadata-consistency.js');
181+
182+
expectEqual('package-lock.json name', lock.name, repo);
183+
expectEqual('package-lock.json version', lock.version, bookConfig.version);
184+
expectEqual('package-lock.json packages[""].name', lock.packages && lock.packages[''] && lock.packages[''].name, repo);
185+
expectEqual('package-lock.json packages[""].version', lock.packages && lock.packages[''] && lock.packages[''].version, bookConfig.version);
186+
expectEqual('package-lock.json packages[""].license', lock.packages && lock.packages[''] && lock.packages[''].license, 'CC-BY-NC-SA-4.0');
187+
188+
for (const [file, config] of [['_config.yml', rootConfig], ['docs/_config.yml', docsConfig]]) {
189+
expectEqual(`${file} title`, config.title, bookConfig.title);
190+
expectEqual(`${file} description`, config.description, bookConfig.description);
191+
expectEqual(`${file} author`, config.author, bookConfig.author);
192+
expectEqual(`${file} version`, config.version, bookConfig.version);
193+
expectEqual(`${file} url`, config.url, `https://${owner}.github.io`);
194+
expectEqual(`${file} baseurl`, config.baseurl, baseurl);
195+
expectEqual(`${file} repository`, config.repository, repoUrl);
196+
}
197+
198+
expectEqual('docs/index.md front matter title', docsIndex.title, bookConfig.title);
199+
expectEqual('docs/index.md front matter description', docsIndex.description, bookConfig.description);
200+
expectEqual('docs/index.md front matter author', docsIndex.author, bookConfig.author);
201+
expectEqual('docs/index.md front matter version', docsIndex.version, bookConfig.version);
202+
203+
const navigationChapters = parseNavigationList(navigationText, 'chapters');
204+
const navigationAppendices = parseNavigationList(navigationText, 'appendices');
205+
expectEqual('navigation chapter count', navigationChapters.length, bookConfig.structure.chapters.length);
206+
expectEqual('navigation appendix count', navigationAppendices.length, bookConfig.structure.appendices.length);
207+
208+
bookConfig.structure.chapters.forEach((chapter, index) => {
209+
const expectedPath = `/chapters/${chapter.id}/`;
210+
const item = navigationChapters[index] || {};
211+
expectEqual(`navigation chapter[${index}].title`, item.title, chapter.title);
212+
expectEqual(`navigation chapter[${index}].path`, item.path, expectedPath);
213+
214+
const file = `docs/chapters/${chapter.id}/index.md`;
215+
if (expectFile(file)) {
216+
const front = parseFrontMatter(file);
217+
expectEqual(`${file} front matter title`, front.title, chapter.title);
218+
expectTruthy(`${file} front matter order`, front.order);
219+
}
220+
});
221+
222+
bookConfig.structure.appendices.forEach((appendix, index) => {
223+
const expectedPath = `/appendices/${appendix.id}/`;
224+
const item = navigationAppendices[index] || {};
225+
expectEqual(`navigation appendix[${index}].title`, item.title, appendix.title);
226+
expectEqual(`navigation appendix[${index}].path`, item.path, expectedPath);
227+
228+
const file = `docs/appendices/${appendix.id}/index.md`;
229+
if (expectFile(file)) {
230+
const front = parseFrontMatter(file);
231+
expectEqual(`${file} front matter title`, front.title, appendix.title);
232+
expectTruthy(`${file} front matter order`, front.order);
233+
}
234+
});
235+
236+
if (errors.length) {
237+
console.error('Metadata consistency check failed:');
238+
for (const error of errors) {
239+
console.error(`- ${error}`);
240+
}
241+
process.exit(1);
242+
}
243+
244+
console.log('Metadata consistency check passed.');
245+
console.log(`Repository: ${owner}/${repo}`);
246+
console.log(`Version: ${bookConfig.version}`);
247+
console.log(`Pages: ${pagesUrl}`);

0 commit comments

Comments
 (0)