From 4bbf57951fe2ef76ab2e7047e995603f010df121 Mon Sep 17 00:00:00 2001 From: ytwei Date: Sun, 23 Aug 2026 11:50:04 +0800 Subject: [PATCH] fix(deploy): cover base path mismatch scenario --- docs/deploy-acceptance.md | 10 +++--- frontend/src/topics/deploy/content.test.ts | 4 ++- frontend/src/topics/deploy/content.ts | 22 ++++++++++++-- frontend/src/topics/deploy/lab.test.ts | 2 +- frontend/src/topics/deploy/lab.tsx | 4 +-- frontend/src/topics/deploy/simulator.test.ts | 32 ++++++++++++++++++++ frontend/src/topics/deploy/simulator.ts | 27 +++++++++++++++++ 7 files changed, 90 insertions(+), 11 deletions(-) diff --git a/docs/deploy-acceptance.md b/docs/deploy-acceptance.md index 34cd15c..5853e2b 100644 --- a/docs/deploy-acceptance.md +++ b/docs/deploy-acceptance.md @@ -15,7 +15,7 @@ 2. 讀懂部署 workflow 的 `main` trigger、`workflow_dispatch`、`frontend/dist` 與 `gh-pages` publish branch。 3. 理解 release source、版本識別、Pages base path 與 artifact provenance 的關係。 4. 在 artifact 缺失或部署驗證失敗時,保留 blocked/failed evidence,不把失敗版本誤標成 live。 -5. 以固定 release scenario 練習成功發布、artifact 缺失與 rollback 到上一個可用版本。 +5. 以固定 release scenario 練習成功發布、artifact 缺失、base path mismatch 與 rollback 到上一個可用版本。 6. 用部署狀態、live probe、release record 與 rollback evidence 判斷一次交付是否真的完成。 ## 2. 教學邊界 @@ -47,7 +47,7 @@ Deploy Lesson → verify deployment → record release → evaluate release/rollback - → 完成 success、missing artifact、rollback 三個 scenarios + → 完成 success、missing artifact、base path mismatch、rollback 四個 scenarios → reset 後重跑 green release regression → 標記 Deploy topic complete ``` @@ -69,6 +69,7 @@ Deploy Lesson | --- | --- | --- | --- | | `main-pages-success` | source `main`、CI passed、`dist` verified、Pages base path verified | `gh-pages` updated to `release-2026.08.23`;live probe 200;release `verified` | artifact、publish 與 live verification 必須全部完成,才是成功部署。 | | `missing-artifact-blocked` | source `main`、CI passed、`dist` missing | publish blocked;`gh-pages` 保持上一個 verified version;release `blocked` | build/artifact 缺失時不可更新 Pages,也不可假裝 live。 | +| `base-path-mismatch-blocked` | source `main`、CI passed、`dist` verified、Pages base path mismatch | publish blocked;`gh-pages` 保持上一個 verified version;release `blocked` | artifact 存在不代表資產可用;repository path 與 Vite base path 不一致時必須阻擋發布。 | | `rollback-after-probe-failure` | source `main`、new artifact verified、publish succeeded、live probe failed | release `release-2026.08.23` failed;rollback 到 `release-2026.08.16`;live probe 200 | rollback 指向上一個可用版本,保留失敗版本與原因。 | 每個 scenario 都必須讓學習者看見:release source、version、CI result、artifact、base path、Pages branch pointer、publish result、deployment status、live URL/probe、release record 與 rollback evidence;失敗時要看見未更新或已回復的邊界。 @@ -206,9 +207,10 @@ DeployLabState { 只有下列條件全部成立時,Deploy Lab 才算完成: -- 三個 required scenarios 都完成各自的 terminal outcome。 +- 四個 required scenarios 都完成各自的 terminal outcome。 - `main-pages-success` 顯示 candidate artifact、gh-pages publish、live status 200 與 verified release record。 - `missing-artifact-blocked` 顯示 artifact missing、publish blocked、Pages pointer 保持 previous verified version 與 blocked record。 +- `base-path-mismatch-blocked` 顯示 artifact verified、base path mismatch、publish blocked、Pages pointer 保持 previous verified version 與 blocked record。 - `rollback-after-probe-failure` 顯示 candidate probe failed、failed release record、rollback version、Pages pointer 回到 previous verified version 與 rolled-back record。 - reset 後重跑 green release,source、version、artifact、Pages pointer、probe、record 與 feedback 與第一次一致。 - 完成後使用 `se-workshop-deploy-complete` 保存進度。 @@ -226,7 +228,7 @@ DeployLabState { ## 11. DEPLOY-01 驗收 -- 文件明確描述 release/artifact/Pages/live probe/rollback boundary、workflow fixture、八個 observable stages、三個 scenarios、failure feedback、completion 與 out-of-scope。 +- 文件明確描述 release/artifact/Pages/live probe/rollback boundary、workflow fixture、八個 observable stages、四個 scenarios、failure feedback、completion 與 out-of-scope。 - `DEPLOY-02` 可依本文件撰寫 lesson 與 workflow/artifact fixture,不需要重新決定 release source、version 或 Pages semantics。 - `DEPLOY-03` 可依本文件建立純 simulator;不需要真實 GitHub Pages、network、secret、shell 或 runner。 - `DEPLOY-04` 可依本文件設計 Lab 的 release selector、stage evidence、live probe、rollback、reset、keyboard、mobile 與 reduced-motion interaction。 diff --git a/frontend/src/topics/deploy/content.test.ts b/frontend/src/topics/deploy/content.test.ts index e8b15eb..f6cac46 100644 --- a/frontend/src/topics/deploy/content.test.ts +++ b/frontend/src/topics/deploy/content.test.ts @@ -46,11 +46,13 @@ describe("Deploy lesson contract", () => { expect(deployScenarioFixtures.map((scenario) => scenario.id)).toEqual([ "main-pages-success", "missing-artifact-blocked", + "base-path-mismatch-blocked", "rollback-after-probe-failure", ]); expect(deployScenarioFixtures[0]).toMatchObject({ artifactOutcome: "verified", publishOutcome: "published", finalRecord: "verified" }); expect(deployScenarioFixtures[1]).toMatchObject({ artifactOutcome: "missing", publishOutcome: "blocked", finalRecord: "blocked" }); - expect(deployScenarioFixtures[2]).toMatchObject({ deploymentOutcome: "rolled-back", previousVerifiedVersion: "release-2026.08.16", finalRecord: "rolled-back" }); + expect(deployScenarioFixtures[2]).toMatchObject({ artifactOutcome: "verified", basePathOutcome: "mismatch", publishOutcome: "blocked", finalRecord: "blocked" }); + expect(deployScenarioFixtures[3]).toMatchObject({ deploymentOutcome: "rolled-back", previousVerifiedVersion: "release-2026.08.16", finalRecord: "rolled-back" }); expect(deployFailureFixtures.map((fixture) => fixture.expectedBoundary)).toEqual(["CI / artifact", "base path", "live probe", "verify / rollback"]); }); }); diff --git a/frontend/src/topics/deploy/content.ts b/frontend/src/topics/deploy/content.ts index 7e545bd..a2ae70b 100644 --- a/frontend/src/topics/deploy/content.ts +++ b/frontend/src/topics/deploy/content.ts @@ -66,7 +66,7 @@ export type DeployBasePathState = "unknown" | "verified" | "mismatch"; export type DeployPublishState = "pending" | "published" | "blocked"; export type DeployDeploymentState = "pending" | "live" | "failed" | "rolled-back"; export type DeployReleaseRecord = "none" | "verified" | "failed" | "blocked" | "rolled-back"; -export type DeployScenarioId = "main-pages-success" | "missing-artifact-blocked" | "rollback-after-probe-failure"; +export type DeployScenarioId = "main-pages-success" | "missing-artifact-blocked" | "base-path-mismatch-blocked" | "rollback-after-probe-failure"; export interface DeployLessonStep { id: DeployStageId; @@ -180,7 +180,7 @@ export interface DeployScenarioFixture { previousVerifiedVersion: string; ciOutcome: "passed"; artifactOutcome: DeployArtifactState; - basePathOutcome: "verified"; + basePathOutcome: Exclude; publishOutcome: "published" | "blocked"; deploymentOutcome: "live" | "failed" | "rolled-back"; liveStatus: number | null; @@ -221,6 +221,22 @@ export const deployScenarioFixtures: readonly DeployScenarioFixture[] = [ finalRecord: "blocked", learningPoint: "沒有 frontend/dist 時 gh-pages 必須保持上一個 verified version,不能假裝已上線。", }, + { + id: "base-path-mismatch-blocked", + title: "Base path mismatch 阻擋發布", + trigger: "workflow_dispatch", + releaseSource: "main", + candidateVersion: deployFixture.candidateRelease, + previousVerifiedVersion: deployFixture.currentVerifiedRelease, + ciOutcome: "passed", + artifactOutcome: "verified", + basePathOutcome: "mismatch", + publishOutcome: "blocked", + deploymentOutcome: "failed", + liveStatus: null, + finalRecord: "blocked", + learningPoint: "artifact 存在不代表 Pages 可用;base path mismatch 時 publish 必須阻擋並保留上一版。", + }, { id: "rollback-after-probe-failure", title: "Live probe failure 後回滾", @@ -265,7 +281,7 @@ export interface DeployFailureFixture { export const deployFailureFixtures: readonly DeployFailureFixture[] = [ { command: "artifact: frontend/dist", message: "frontend/dist missing;gh-pages 保持上一個 verified version。", expectedBoundary: "CI / artifact" }, - { command: "cat frontend/.env.pages", message: "Pages build profile 尚未 verified;不能把 publish 當成 live。", expectedBoundary: "base path" }, + { command: "cat frontend/.env.pages", message: "VITE_BASE mismatch;不能安全更新 gh-pages。", expectedBoundary: "base path" }, { command: "probe /software-engineering-workshop/", message: "live probe failed;candidate release 需要 rollback。", expectedBoundary: "live probe" }, { command: "evaluate release / rollback", message: "failed release 尚未完成 rollback evidence,release 維持 blocked。", expectedBoundary: "verify / rollback" }, ] as const; diff --git a/frontend/src/topics/deploy/lab.test.ts b/frontend/src/topics/deploy/lab.test.ts index 72c4b9a..acf92a1 100644 --- a/frontend/src/topics/deploy/lab.test.ts +++ b/frontend/src/topics/deploy/lab.test.ts @@ -5,6 +5,6 @@ import { deployLabProgress } from "./lab"; describe("Deploy Lab progress", () => { it("starts empty and records the first release without claiming full completion", () => { expect(deployLabProgress(createInitialDeployState())).toBe(0); - expect(deployLabProgress(runDeployEvents(deployGreenHappyPath).state)).toBe(27); + expect(deployLabProgress(runDeployEvents(deployGreenHappyPath).state)).toBe(20); }); }); diff --git a/frontend/src/topics/deploy/lab.tsx b/frontend/src/topics/deploy/lab.tsx index b42fbb3..a13cd31 100644 --- a/frontend/src/topics/deploy/lab.tsx +++ b/frontend/src/topics/deploy/lab.tsx @@ -26,7 +26,7 @@ interface DeployHistoryEntry { } const INITIAL_HISTORY: readonly DeployHistoryEntry[] = [ - { lines: ["Deploy sandbox v1", "固定 Pages workflow、release record 與三個 deployment scenario 已準備好。"] }, + { lines: ["Deploy sandbox v1", "固定 Pages workflow、release record 與四個 deployment scenario 已準備好。"] }, ]; function scenarioFor(scenarioId: DeployScenarioId | null): DeployScenarioFixture | undefined { @@ -124,7 +124,7 @@ export function DeployLab({ onComplete }: { onComplete?: () => void }) { {completed ? ( diff --git a/frontend/src/topics/deploy/simulator.test.ts b/frontend/src/topics/deploy/simulator.test.ts index d50c0b9..8dd6da2 100644 --- a/frontend/src/topics/deploy/simulator.test.ts +++ b/frontend/src/topics/deploy/simulator.test.ts @@ -18,6 +18,16 @@ const missingArtifactPath = [ { type: "evaluate-release" as const }, ]; +const basePathMismatchPath = [ + { type: "select-scenario" as const, scenarioId: "base-path-mismatch-blocked" as const }, + { type: "inspect-workflow" as const }, + { type: "select-release" as const }, + { type: "verify-ci-artifact" as const }, + { type: "verify-pages-base" as const }, + { type: "record-release" as const }, + { type: "evaluate-release" as const }, +]; + const rollbackPath = [ { type: "select-scenario" as const, scenarioId: "rollback-after-probe-failure" as const }, { type: "inspect-workflow" as const }, @@ -108,6 +118,25 @@ describe("Deploy deterministic simulator", () => { }); }); + it("blocks publish and preserves the previous Pages pointer on base path mismatch", () => { + const result = runDeployEvents(basePathMismatchPath); + + expect(result.accepted).toBe(true); + expect(result.results[4]?.observedFailure).toBe(true); + expect(result.state).toMatchObject({ + phase: "completed", + artifactState: "verified", + basePathState: "mismatch", + publishState: "blocked", + pagesBranchVersion: "release-2026.08.16", + deploymentState: "failed", + liveStatus: null, + releaseRecord: "blocked", + completedScenarioIds: ["base-path-mismatch-blocked"], + }); + expect(result.state.lastFeedback).toContain("blocked"); + }); + it("preserves failed candidate evidence and rolls back after probe failure", () => { const result = runDeployEvents(rollbackPath); @@ -133,6 +162,8 @@ describe("Deploy deterministic simulator", () => { { type: "reset" as const }, ...missingArtifactPath, { type: "reset" as const }, + ...basePathMismatchPath, + { type: "reset" as const }, ...rollbackPath, { type: "reset" as const }, ...deployGreenHappyPath, @@ -145,6 +176,7 @@ describe("Deploy deterministic simulator", () => { expect(first.state.completedScenarioIds).toEqual([ "main-pages-success", "missing-artifact-blocked", + "base-path-mismatch-blocked", "rollback-after-probe-failure", ]); expect(first.state.regressionVerified).toBe(true); diff --git a/frontend/src/topics/deploy/simulator.ts b/frontend/src/topics/deploy/simulator.ts index 5689a32..0f927a8 100644 --- a/frontend/src/topics/deploy/simulator.ts +++ b/frontend/src/topics/deploy/simulator.ts @@ -318,6 +318,24 @@ export function runDeployEvent(current: DeployLabState, event: DeployLabEvent): ); case "verify-pages-base": if (!hasCompletedStage(state, "verify-ci-artifact") || state.artifactState !== "verified") return blocked(state, commandFor(event.type), "artifact 尚未 verified;先確認 frontend/dist,再檢查 Pages base path。 "); + if (scenario.basePathOutcome === "mismatch") { + return accepted( + state, + { + phase: "blocked", + activeStageId: "record-release", + completedStageIds: withStage(state, event.type), + basePathState: "mismatch", + publishState: "blocked", + pagesBranchVersion: scenario.previousVerifiedVersion, + deploymentState: "failed", + lastCommand: commandFor(event.type), + }, + "VITE_BASE 與 repository path 不一致;publish blocked,Pages pointer 保持上一個 verified version。", + [`VITE_BASE: mismatch`, `expected: ${deployFixture.repositoryBasePath}`, "publish: blocked", `gh-pages: ${scenario.previousVerifiedVersion}`, "next: record blocked release"], + true, + ); + } return accepted( state, { phase: "releasing", activeStageId: "publish-pages", completedStageIds: withStage(state, event.type), basePathState: scenario.basePathOutcome, lastCommand: commandFor(event.type) }, @@ -351,6 +369,15 @@ export function runDeployEvent(current: DeployLabState, event: DeployLabEvent): ); case "record-release": if (!hasCompletedStage(state, "verify-ci-artifact")) return blocked(state, commandFor(event.type), "至少要先驗證 CI artifact,再建立 release record。 "); + if (state.basePathState === "mismatch") { + return accepted( + state, + { phase: "blocked", activeStageId: "evaluate-release", completedStageIds: withStage(state, event.type), releaseRecord: "blocked", liveStatus: null, liveUrl: null, pagesBranchVersion: state.previousVerifiedVersion, lastCommand: commandFor(event.type) }, + "blocked release record 已保留 base path mismatch;candidate 不可發布,下一步是評估 release outcome。", + [`source: ${state.releaseSource}`, `candidate: ${state.candidateVersion}`, "base path: mismatch", "record: blocked", "next: evaluate release"], + true, + ); + } if (state.artifactState === "missing") { return accepted( state,