Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/create-translation-prs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/merge-translation-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 != ''
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/translation-pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22.19.0
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` を行う。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) を参照してください。

Expand Down
30 changes: 26 additions & 4 deletions docs/cms-write-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の自動検出対象外になる場合があります。

Expand All @@ -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 に収まっている
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 34 additions & 19 deletions scripts/merge-translation-pr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
}
Expand Down Expand Up @@ -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)
)
}
Expand All @@ -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}`)
Expand All @@ -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}`)
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
}
Expand Down
82 changes: 82 additions & 0 deletions scripts/validate-content.mjs
Original file line number Diff line number Diff line change
@@ -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.')
8 changes: 6 additions & 2 deletions src/components/Breadcrumb.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ const locale = Astro.props.locale ?? getLocaleFromUrl(Astro.url)
const currentPath = Astro.url.pathname
---

<nav aria-label={t(locale, 'common.breadcrumb')} class="text-sm mb-4" data-pagefind-ignore>
<nav
aria-label={t(locale, 'common.breadcrumb')}
class="text-sm mb-4"
data-pagefind-ignore
>
<ol class="flex flex-wrap items-center gap-1 text-slate-500" role="list">
<li>
<a
Expand All @@ -30,7 +34,7 @@ const currentPath = Astro.url.pathname
items.map((item, i) => (
<li class="flex items-center gap-1">
<Icon name="chevron-right" class="text-xs text-slate-400" />
{(item.href || i === items.length - 1) ? (
{item.href || i === items.length - 1 ? (
<a
href={item.href ?? currentPath}
aria-current={i === items.length - 1 ? 'page' : undefined}
Expand Down
Loading
Loading