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
10 changes: 6 additions & 4 deletions docs/deploy-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 教學邊界
Expand Down Expand Up @@ -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
```
Expand All @@ -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;失敗時要看見未更新或已回復的邊界。
Expand Down Expand Up @@ -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` 保存進度。
Expand All @@ -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。
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/topics/deploy/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
22 changes: 19 additions & 3 deletions frontend/src/topics/deploy/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -180,7 +180,7 @@ export interface DeployScenarioFixture {
previousVerifiedVersion: string;
ciOutcome: "passed";
artifactOutcome: DeployArtifactState;
basePathOutcome: "verified";
basePathOutcome: Exclude<DeployBasePathState, "unknown">;
publishOutcome: "published" | "blocked";
deploymentOutcome: "live" | "failed" | "rolled-back";
liveStatus: number | null;
Expand Down Expand Up @@ -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 後回滾",
Expand Down Expand Up @@ -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;
2 changes: 1 addition & 1 deletion frontend/src/topics/deploy/lab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
4 changes: 2 additions & 2 deletions frontend/src/topics/deploy/lab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -124,7 +124,7 @@ export function DeployLab({ onComplete }: { onComplete?: () => void }) {

{completed ? (
<TopicCompletionCard
title="三個 Deploy scenarios 與 replay 都完成了。"
title="四個 Deploy scenarios 與 replay 都完成了。"
description="你已驗證 main release、artifact provenance、Pages publish、live probe、release record 與 rollback;Deploy Lab 完成。"
onReset={reset}
/>
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/topics/deploy/simulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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);

Expand All @@ -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,
Expand All @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/topics/deploy/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -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,
Expand Down
Loading