From 9dccf5a81a3889d5a979e093393502bb65e3ecae Mon Sep 17 00:00:00 2001 From: Gui <51219838+gui-ace@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:09:40 +0900 Subject: [PATCH] =?UTF-8?q?CI=E3=81=A8CMS=E6=A4=9C=E8=A8=BC=E3=82=92?= =?UTF-8?q?=E6=95=B4=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 40 +++++++++ .github/workflows/create-translation-prs.yml | 2 +- .github/workflows/merge-translation-pr.yml | 2 +- .github/workflows/translation-pr-build.yml | 3 +- .node-version | 1 + AGENTS.md | 1 + README.md | 2 +- docs/cms-write-workflow.md | 30 ++++++- package.json | 3 +- scripts/merge-translation-pr.mjs | 53 +++++++----- scripts/validate-content.mjs | 82 +++++++++++++++++++ src/components/Breadcrumb.astro | 8 +- src/components/Checklist.astro | 14 +++- src/components/CopyCodeButton.astro | 3 +- src/components/StatBar.astro | 5 +- src/content/tags/accessibility.json | 56 ++++++------- src/content/tags/acecore-schools.json | 56 ++++++------- src/content/tags/announcement.json | 56 ++++++------- src/content/tags/astro.json | 56 ++++++------- src/content/tags/cloudflare.json | 56 ++++++------- src/content/tags/cms.json | 56 ++++++------- src/content/tags/community.json | 56 ++++++------- src/content/tags/dns.json | 56 ++++++------- src/content/tags/education.json | 56 ++++++------- src/content/tags/event.json | 56 ++++++------- src/content/tags/github-copilot.json | 56 ++++++------- src/content/tags/i18n.json | 56 ++++++------- src/content/tags/infrastructure.json | 56 ++++++------- src/content/tags/mail.json | 56 ++++++------- src/content/tags/open-source.json | 56 ++++++------- src/content/tags/performance.json | 56 ++++++------- src/content/tags/security.json | 56 ++++++------- src/content/tags/seo.json | 56 ++++++------- src/content/tags/service.json | 56 ++++++------- src/content/tags/starlight.json | 56 ++++++------- src/content/tags/technology.json | 56 ++++++------- src/content/tags/vs-code.json | 56 ++++++------- src/content/tags/web-production.json | 56 ++++++------- src/content/tags/website.json | 56 ++++++------- src/i18n/config.ts | 12 ++- src/pages/404.astro | 1 - src/pages/[locale]/blog/[...slug].astro | 6 +- src/pages/[locale]/blog/archive/[month].astro | 20 +++-- .../[locale]/blog/authors/[author].astro | 13 ++- src/pages/[locale]/blog/page/[page].astro | 6 +- src/pages/[locale]/blog/tags/[tag].astro | 6 +- src/pages/blog/[...slug].astro | 6 +- src/pages/blog/archive/[month].astro | 6 +- src/pages/blog/authors/[author].astro | 13 ++- src/pages/blog/page/[page].astro | 14 +++- src/pages/blog/tags/[tag].astro | 6 +- src/utils/blog-i18n.ts | 4 +- 52 files changed, 960 insertions(+), 746 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .node-version create mode 100644 scripts/validate-content.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..52a8cf7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build and Format + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Validate CMS content + run: npm run validate:content + + - name: Build site + run: npm run build diff --git a/.github/workflows/create-translation-prs.yml b/.github/workflows/create-translation-prs.yml index 45325a4..0261acb 100644 --- a/.github/workflows/create-translation-prs.yml +++ b/.github/workflows/create-translation-prs.yml @@ -54,7 +54,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .node-version - name: Create translation PR tasks env: diff --git a/.github/workflows/merge-translation-pr.yml b/.github/workflows/merge-translation-pr.yml index d5cbd55..75ff844 100644 --- a/.github/workflows/merge-translation-pr.yml +++ b/.github/workflows/merge-translation-pr.yml @@ -35,7 +35,7 @@ jobs: if: steps.pr.outputs.pr_number != '' uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .node-version - name: Merge eligible translation PR if: steps.pr.outputs.pr_number != '' diff --git a/.github/workflows/translation-pr-build.yml b/.github/workflows/translation-pr-build.yml index 0e25091..c0fb4e2 100644 --- a/.github/workflows/translation-pr-build.yml +++ b/.github/workflows/translation-pr-build.yml @@ -29,7 +29,6 @@ jobs: startsWith(github.event.pull_request.head.ref, 'copilot/')) && contains(github.event.pull_request.title, '[translation]')) runs-on: ubuntu-latest - steps: - name: Resolve pull request metadata id: pr @@ -71,7 +70,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .node-version cache: npm - name: Install dependencies diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..e222811 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.19.0 diff --git a/AGENTS.md b/AGENTS.md index 5ec29d1..53be6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ - サイト出力に影響する変更では原則 `npm run build` を実行する。 - Markdown、JSON、YAML、Astro、TypeScript を変更した場合は、対象ファイルに対して Prettier check を行う。 +- CMS/content/schema/route/link に関わる変更では `npm run validate:content` を実行する。 - コミット前に `git diff --check` を実行する。 - UI 変更では可能な限りブラウザでデスクトップとモバイルを確認する。ブラウザや Playwright が使えない場合は理由と代替確認を PR に記載する。 - ドキュメント、GitHub テンプレート、エージェント指示だけの変更では、`npm run build` は不要。対象ファイルの format check と `git diff --check` を行う。 diff --git a/README.md b/README.md index 52a4188..9b508af 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ src/ - 恒久的な `cms-content` 投稿受け皿 branch は使いません。 - CMS PR は通常の merge commit または rebase merge でマージします。squash merge では `cms: ...` commit subject が失われ、翻訳 PR task の自動検出対象外になる場合があります。 - CMS PR が `main` に merge されると、Cloudflare Pages が GitHub `main` push を受けて production deploy します。 -- 既存の remote `cms-content` branch は、この変更の反映後に未反映差分がないことを確認して削除候補にします。 +- 旧 remote `cms-content` branch は未反映差分がないことを確認して削除済みです。 運用判断は [docs/cms-write-workflow.md](docs/cms-write-workflow.md) を参照してください。 diff --git a/docs/cms-write-workflow.md b/docs/cms-write-workflow.md index 9fc9bba..be26b96 100644 --- a/docs/cms-write-workflow.md +++ b/docs/cms-write-workflow.md @@ -2,21 +2,34 @@ 最終確認日: 2026-06-19 +## 現在の live 状態 + +- GitHub repository: `acecore-systems/acecore-net` +- GitHub default branch: `main` +- CMS backend: `public/admin/config.yml` の `backend.name: github` +- CMS OAuth backend: `https://sveltia-cms-auth.sparkling-tree-7cef.workers.dev` +- CMS publication branch: `main` +- CMS publish mode: `editorial_workflow` +- `main`: GitHub branch API 上の `protected` は `true` +- Branch protection: admin enforcement on、PR review on、required status check `Build and Format` +- GitHub ruleset: なし + ## 方針 `main` は本番ソースの唯一の正にします。Cloudflare Pages の production deploy 元も GitHub 連携の `main` だけにします。 Sveltia CMS は `backend.branch: main` と `publish_mode: editorial_workflow` で運用します。CMS 保存は恒久的な投稿受け皿 branch ではなく、短命な `cms/...` branch と PR として扱います。 -`cms-content` は恒久運用しません。既存 remote branch は、この変更が `main` に反映され、未反映差分や open PR がないことを確認してから削除候補にします。 +`cms-content` は恒久運用しません。旧 remote branch は未反映差分や open PR がないことを確認して削除済みです。 ## 現行フロー 1. Sveltia CMS が `main` を publication branch として読み込む。 2. CMS 保存時、editorial workflow が短命な CMS branch と PR を作る。 -3. CMS PR を merge commit または rebase merge で `main` に入れる。 -4. `main` push を受けて Cloudflare Pages が production deploy する。 -5. `src/content/blog/*.md` または `src/i18n/source/ja/**/*.json` の CMS commit を検出した場合、翻訳 PR task が作成される。 +3. PR CI が `npm run format:check`、`npm run validate:content`、`npm run build` を実行する。 +4. CMS PR を merge commit または rebase merge で `main` に入れる。 +5. `main` push を受けて Cloudflare Pages が production deploy する。 +6. `src/content/blog/*.md` または `src/i18n/source/ja/**/*.json` の CMS commit を検出した場合、翻訳 PR task が作成される。 CMS PR は squash merge しません。squash merge では `cms: ...` commit subject が失われ、翻訳 PR task の自動検出対象外になる場合があります。 @@ -25,3 +38,12 @@ CMS PR は squash merge しません。squash merge では `cms: ...` commit sub 現在の Sveltia CMS は GitHub OAuth 経由で保存します。editorial workflow により `main` 直 commit は避けられますが、PR branch 作成の actor は編集者個人の GitHub 権限です。 編集者個人ではなく専用 bot / GitHub App / backend actor に完全移行したい場合は、CMS 保存を受ける backend を別途実装し、その backend が content-only PR を作る形にします。 + +## 検証方針 + +`npm run validate:content` は CMS config が次の条件を満たすことも確認します。 + +- `backend.branch` が `main` +- `publish_mode` が `editorial_workflow` +- CMS に `path` field を露出しない +- CMS 管理対象が許可された content / i18n source path に収まっている diff --git a/package.json b/package.json index f7756e2..f010007 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build": "astro build && pagefind --site dist", "preview": "astro preview", "format": "prettier . --write", - "format:check": "prettier . --check" + "format:check": "prettier . --check", + "validate:content": "node scripts/validate-content.mjs" }, "dependencies": { "@astrojs/rss": "^4.0.18", diff --git a/scripts/merge-translation-pr.mjs b/scripts/merge-translation-pr.mjs index 4127a9a..b2bb05e 100644 --- a/scripts/merge-translation-pr.mjs +++ b/scripts/merge-translation-pr.mjs @@ -29,7 +29,9 @@ function inferRepositoryFromGitRemote() { const sshMatch = remoteUrl.match(/github\.com:([^/]+\/[^/.]+)(?:\.git)?$/) if (sshMatch) return sshMatch[1] - const httpsMatch = remoteUrl.match(/github\.com\/([^/]+\/[^/.]+)(?:\.git)?$/) + const httpsMatch = remoteUrl.match( + /github\.com\/([^/]+\/[^/.]+)(?:\.git)?$/, + ) if (httpsMatch) return httpsMatch[1] } catch { return null @@ -60,7 +62,9 @@ async function requestGitHub(path, { method = 'GET', body } = {}) { if (!response.ok) { const errorText = await response.text() - throw new Error(`GitHub API ${method} ${path} failed: ${response.status} ${errorText}`) + throw new Error( + `GitHub API ${method} ${path} failed: ${response.status} ${errorText}`, + ) } if (response.status === 204) return null @@ -96,7 +100,8 @@ async function requestGitHubGraphQl(query, variables = {}) { } function getRepositoryInfo() { - const repository = process.env.GITHUB_REPOSITORY || inferRepositoryFromGitRemote() + const repository = + process.env.GITHUB_REPOSITORY || inferRepositoryFromGitRemote() if (!repository) { throw new Error('GITHUB_REPOSITORY is required') } @@ -127,12 +132,10 @@ function isEligibleTranslationPullRequest(pr) { pr && pr.state === 'open' && pr.base?.ref === 'main' && - ( - pr.user?.login === 'Copilot' || + (pr.user?.login === 'Copilot' || pr.user?.login === 'app/copilot-swe-agent' || pr.user?.login === 'copilot-swe-agent[bot]' || - pr.head?.ref?.startsWith('copilot/') - ) && + pr.head?.ref?.startsWith('copilot/')) && hasTranslationMarker(pr.title) ) } @@ -150,13 +153,16 @@ async function mergePullRequest(pr) { const { owner, repo, repository } = getRepositoryInfo() try { - const result = await requestGitHub(`/repos/${owner}/${repo}/pulls/${pr.number}/merge`, { - method: 'PUT', - body: { - merge_method: 'squash', - commit_title: pr.title, + const result = await requestGitHub( + `/repos/${owner}/${repo}/pulls/${pr.number}/merge`, + { + method: 'PUT', + body: { + merge_method: 'squash', + commit_title: pr.title, + }, }, - }) + ) console.log(`Merged PR #${pr.number}: ${result?.sha ?? 'ok'}`) } catch (error) { console.warn(`Could not merge PR #${pr.number}: ${error.message}`) @@ -168,9 +174,12 @@ async function mergePullRequest(pr) { if (branchName && branchRepoFullName === repository) { try { - await requestGitHub(`/repos/${owner}/${repo}/git/refs/heads/${branchName}`, { - method: 'DELETE', - }) + await requestGitHub( + `/repos/${owner}/${repo}/git/refs/heads/${branchName}`, + { + method: 'DELETE', + }, + ) console.log(`Deleted branch ${branchName}`) } catch (error) { console.warn(`Could not delete branch ${branchName}: ${error.message}`) @@ -183,7 +192,9 @@ async function mergePullRequest(pr) { async function markPullRequestReadyForReview(pr) { if (!pr.draft) return true if (!pr.node_id) { - console.warn(`Pull request #${pr.number} has no node_id. Skipping draft conversion.`) + console.warn( + `Pull request #${pr.number} has no node_id. Skipping draft conversion.`, + ) return false } @@ -216,14 +227,18 @@ async function main() { } if (!isEligibleTranslationPullRequest(pullRequest)) { - console.log(`Pull request #${pullRequest.number} is not an eligible translation PR. Skipping.`) + console.log( + `Pull request #${pullRequest.number} is not an eligible translation PR. Skipping.`, + ) return } if (!args.skipBuildCheck) { const checkRuns = await getCheckRuns(pullRequest.head.sha) if (!hasSuccessfulTranslationBuild(checkRuns)) { - console.log(`Pull request #${pullRequest.number} does not have a successful Translation PR Build check yet.`) + console.log( + `Pull request #${pullRequest.number} does not have a successful Translation PR Build check yet.`, + ) return } } diff --git a/scripts/validate-content.mjs b/scripts/validate-content.mjs new file mode 100644 index 0000000..7e33638 --- /dev/null +++ b/scripts/validate-content.mjs @@ -0,0 +1,82 @@ +import { access, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))) +const errors = [] + +function fail(scope, message) { + errors.push(`${scope}: ${message}`) +} + +async function fileExists(relativePath) { + try { + await access(path.join(root, relativePath)) + return true + } catch { + return false + } +} + +function extractCmsContentPaths(config) { + const paths = [] + const pathPattern = /^\s*(?:folder|file):\s*['"]?([^'"\n#]+?)['"]?\s*$/gm + for (const match of config.matchAll(pathPattern)) { + paths.push(match[1].trim()) + } + return paths +} + +function isAllowedCmsContentPath(contentPath) { + return ( + contentPath === 'src/content/blog' || + contentPath === 'src/content/authors' || + contentPath === 'src/content/tags' || + contentPath === 'src/i18n/source/ja/campaigns' || + /^src\/i18n\/source\/ja\/(?:common|blog)\.json$/.test(contentPath) || + /^src\/i18n\/source\/ja\/pages\/[a-z0-9-]+\.json$/.test(contentPath) + ) +} + +async function validateCmsConfig() { + const scope = 'public/admin/config.yml' + const config = await readFile(path.join(root, scope), 'utf8') + + if (/^\s*-?\s*name:\s*path\b/m.test(config)) { + fail(scope, 'path field must not be exposed in CMS') + } + if (!/backend:\s*[\s\S]*?\n\s+branch:\s*main\b/.test(config)) { + fail( + scope, + 'CMS backend branch must be main; do not use a permanent cms-content branch', + ) + } + if (!/^publish_mode:\s*editorial_workflow\b/m.test(config)) { + fail( + scope, + 'CMS must use editorial_workflow so saves create short-lived branches and PRs', + ) + } + + for (const contentPath of extractCmsContentPaths(config)) { + if (!isAllowedCmsContentPath(contentPath)) { + fail(scope, `unexpected CMS content path (${contentPath})`) + continue + } + if (!(await fileExists(contentPath))) { + fail(scope, `CMS content path does not exist (${contentPath})`) + } + } +} + +await validateCmsConfig() + +if (errors.length > 0) { + console.error('Content validation failed:') + for (const error of errors) { + console.error(`- ${error}`) + } + process.exit(1) +} + +console.log('Content validation passed.') diff --git a/src/components/Breadcrumb.astro b/src/components/Breadcrumb.astro index 32d1638..17c27e9 100644 --- a/src/components/Breadcrumb.astro +++ b/src/components/Breadcrumb.astro @@ -15,7 +15,11 @@ const locale = Astro.props.locale ?? getLocaleFromUrl(Astro.url) const currentPath = Astro.url.pathname --- -