diff --git a/.docs/catalog.json b/.docs/catalog.json index dbe9f5f..9f9f63d 100644 --- a/.docs/catalog.json +++ b/.docs/catalog.json @@ -498,7 +498,8 @@ "verification": [ "apps/site/src/utils/setup-access.test.mjs", "test/local.spec.ts", - "test/site-content.spec.ts" + "test/site-content.spec.ts", + "test/invitations.spec.ts" ], "source": "User requested integration of preserved local work with current main; implementation choices and historical production observations are distinguished in prose." }, diff --git a/.docs/production-deployment.md b/.docs/production-deployment.md index 6ea0d59..43b084d 100644 --- a/.docs/production-deployment.md +++ b/.docs/production-deployment.md @@ -34,3 +34,7 @@ PUBLIC_APP_URL=https://app.stack.open.inta.site PORTLESS_URL=https://stack.open. GitHubの検証は `main` と `release` へのpush、およびプルリクエストで実行する。公開は明示的な依頼を受け、クリーンな `release` のチェックアウトで `vp run deploy:production` を実行する。このコマンドは公開先ブランチの保護・リモートSHA・同一SHAのCI成功を確認し、全E2Eを含む検証と秘密情報検査を実行してから、既存のCloudflare認証でアプリとサイトを公開する。複数アカウントがある端末でも既存の公開先アカウントを明示して選択する。GitHubへCloudflareの秘密情報を追加せず、自動公開は行わない。必要な依存関係、ブラウザと検査道具は通常のセットアップ手順で準備する。[[core-verification]]、[[core-delivery]]。 アプリとサイトは順に公開されるため、片方だけ成功する可能性がある。失敗時は実際の公開バージョンとデータ互換性を調べ、成功した部分も含めて報告する。コマンドはDBを復元しない。移行がある場合は公開前に内容・互換性・回復手順を確認する。以前の直接公開コマンドは過去の手順として残し、通常の公開入口はこの検証付きコマンドとする。設定の存在と実際のGitHub保護・本番応答は別々に確認する。 + +公開経路の検証中、ブラウザの初期化より先にエラー表示の確認を始める競合、別領域に同時に出た警告を区別しない招待試験、CMS操作と全リンク巡回が1つの時間枠を共有する問題を観測した。初期化試験は合成した503応答を待ってから同じエラー表示・操作禁止・復旧を確認する。招待試験はメンバーダイアログ内の警告と再読み込みを対象にする。CMSは操作・公開と全リンク巡回を直列の別試験に分け、各試験の制限時間、全リンク、HTTP状態、ガイド到達性の期待値を維持する。ローカルD1の一時的な内部エラーそのものを修正したとは扱わない。公開前の全試験と秘密情報検査は引き続き必須とする。 + +公開直前の再確認では、ブランチ名・クリーン状態・リモートSHAに加え、端末のHEADも検証開始時のSHAと一致することを要求する。検証後に別のコミットへ移動した場合は、同じブランチ名でも公開しない。 diff --git a/scripts/deploy-production.mjs b/scripts/deploy-production.mjs index d706d54..b124d87 100644 --- a/scripts/deploy-production.mjs +++ b/scripts/deploy-production.mjs @@ -7,6 +7,8 @@ const read = (command, args) => execFileSync(command, args, { encoding: "utf8" } const api = (path) => JSON.parse(read("gh", ["api", `repos/${repository}/${path}`])) const sha = read("git", ["rev-parse", "HEAD"]) function assertTarget() { + if (read("git", ["rev-parse", "HEAD"]) !== sha) + throw new Error("Release checkout changed during verification") if (read("git", ["branch", "--show-current"]) !== "release") throw new Error("Deploy only from the release branch") if (read("git", ["status", "--porcelain"])) throw new Error("Release checkout must be clean") diff --git a/test/invitations.spec.ts b/test/invitations.spec.ts index 2088405..3ebce2f 100644 --- a/test/invitations.spec.ts +++ b/test/invitations.spec.ts @@ -391,7 +391,8 @@ test("unknown delivery recovery requires confirmation, invalidates the old link ).status(), ).toBe(401) await owner.page.getByRole("button", { name: "メンバー", exact: true }).first().click() - const row = owner.page.getByRole("group", { name: `招待 ${invite.email}`, exact: true }) + const members = owner.page.getByRole("dialog", { name: "メンバー", exact: true }) + const row = members.getByRole("group", { name: `招待 ${invite.email}`, exact: true }) await expect(row).toContainText("送信結果不明") await row.getByRole("button", { name: "再試行", exact: true }).click() const confirmation = owner.page.getByRole("alertdialog") @@ -426,7 +427,7 @@ test("unknown delivery recovery requires confirmation, invalidates the old link .getByRole("button", { name: "旧リンクを無効にして再送", exact: true }) .click() await expect(row).toContainText("送信受付済み · 2/3") - await expect(owner.page.getByRole("alert")).toContainText("操作結果を確認できません") + await expect(members.getByRole("alert")).toContainText("操作結果を確認できません") const recovered = await mailbox(owner.page.request) expect(recovered.count - before.count).toBe(1) expect(recovered.invitation === invite.token).toBe(false) @@ -443,7 +444,7 @@ test("unknown delivery recovery requires confirmation, invalidates the old link const concurrent = await Promise.all([retry(2), retry(2)]) expect(concurrent.map((response) => response.status()).sort()).toEqual([200, 409]) expect((await retry(3, true)).status()).toBe(409) - await owner.page.getByRole("button", { name: "再読み込み", exact: true }).click() + await members.getByRole("button", { name: "再読み込み", exact: true }).click() await expect(row).toContainText("送信受付済み · 3/3") const last = await mailbox(owner.page.request) expect(last.count - recovered.count).toBe(1) diff --git a/test/local.spec.ts b/test/local.spec.ts index da81473..e9b2f44 100644 --- a/test/local.spec.ts +++ b/test/local.spec.ts @@ -15,10 +15,15 @@ test("initialization state failures are explicit and recover without a reload", await page.route("**/api/auth/status", (route) => route.fulfill({ status: 503, json: { error: "Synthetic initialization failure" } }), ) + const initializationFailure = page.waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/auth/status" && response.status() === 503, + ) const response = await page.goto("/") expect(response?.headers()["x-robots-tag"]).toBe("noindex") await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex") expect((await page.request.get("/api/health")).headers()["x-robots-tag"]).toBe("noindex") + await initializationFailure await expect(page.getByRole("alert")).toContainText("ログイン状態を確認できません") await expect(page.getByRole("button", { name: "パスキーでログイン", exact: true })).toBeDisabled() await page.unroute("**/api/auth/status") diff --git a/test/site-content.spec.ts b/test/site-content.spec.ts index c65de8e..9584bef 100644 --- a/test/site-content.spec.ts +++ b/test/site-content.spec.ts @@ -40,6 +40,8 @@ function seed() { return JSON.parse(summary) } +test.describe.configure({ mode: "serial" }) + test("EmDash seed preserves edits and public content follows the publishing lifecycle", async ({ page, browser, @@ -186,6 +188,10 @@ test("EmDash seed preserves edits and public content follows the publishing life await editing.close() await page.goto(`${site}/posts/architecture`) await expect(page.getByRole("heading", { name: "管理画面で更新した記事" })).toBeVisible() +}) + +test("all public navigation and guide links resolve after CMS publication", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) const visited = new Set() const pending = [site + "/"] const report: Array<{ source: string; target: string; status: number }> = []