diff --git a/docs/cicd-acceptance.md b/docs/cicd-acceptance.md index 67de100..aca6e93 100644 --- a/docs/cicd-acceptance.md +++ b/docs/cicd-acceptance.md @@ -15,7 +15,7 @@ 2. 讀懂 workflow trigger,知道 push、pull request 與手動 dispatch 會在什麼 ref 上啟動哪些 fixture job。 3. 理解 checkout、Node setup/cache、install、test、lint、build 的順序與 gate 責任。 4. 以 required check 判斷 pull request 是否可以進入 merge,而不是只看某個單獨 step 的綠色結果。 -5. 分辨 test failure 與 build failure 的 evidence,知道下游 step 在 job failure 後不應被偽造為已執行。 +5. 分辨 install、test 與 build failure 的 evidence,知道下游 step 在 job failure 後不應被偽造為已執行。 6. 用固定的 success/failure scenario 重跑 pipeline,理解 reset、retry 與 deterministic feedback 的關係;本 Lab 不連接真實 GitHub Actions。 ## 2. 教學邊界 @@ -45,7 +45,7 @@ CI/CD Lesson → run build → publish required check → 判斷 mergeable/blocked - → 完成 success、test failure、build failure 三個 scenarios + → 完成 success、install failure、test failure、build failure 四個 scenarios → reset 後重跑 green pipeline regression → 標記 CI/CD topic complete ``` @@ -67,6 +67,7 @@ CI/CD Lesson | Scenario | Fixture input | Fixture outcome | 教學重點 | | --- | --- | --- | --- | | `pull-request-green` | event `pull_request`、base `dev`、test/lint/build 全部 pass | `frontend` required check passed,merge gate `mergeable` | 完整 CI gate 通過才表示 pull request 可以進入 merge。 | +| `pull-request-install-failure` | event `pull_request`、base `dev`、`npm ci` fixture failed | install `failed`;test/lint/build `not-run`;required check failed;merge gate blocked | install 是第一個依賴 boundary,不能用舊的 node_modules 或下游輸出假裝通過。 | | `pull-request-test-failure` | event `pull_request`、base `dev`、test fixture failed | test `failed`;lint/build `not-run`;required check failed;merge gate blocked | 不把下游未執行的 steps 畫成綠色,先保留第一個 failure boundary。 | | `pull-request-build-failure` | event `pull_request`、base `dev`、test/lint pass、build fixture failed | build `failed`;required check failed;merge gate blocked | test 與 lint 綠色不能掩蓋 production build failure。 | @@ -204,8 +205,9 @@ CicdLabState { 只有下列條件全部成立時,CI/CD Lab 才算完成: -- 三個 required scenarios 都完成各自的 terminal outcome。 +- 四個 required scenarios 都完成各自的 terminal outcome。 - `pull-request-green` 顯示完整 stage、required check passed 與 mergeable。 +- `pull-request-install-failure` 顯示 install failed、test/lint/build not-run、required check failed 與 merge gate blocked。 - `pull-request-test-failure` 顯示 test failed、lint/build not-run、required check failed 與 merge gate blocked。 - `pull-request-build-failure` 顯示 test/lint passed、build failed、artifact missing、required check failed 與 merge gate blocked。 - reset 後重跑 green pipeline,trigger、ref、stage status、check、merge gate 與 feedback 與第一次一致。 @@ -224,7 +226,7 @@ CicdLabState { ## 11. CICD-01 驗收 -- 文件明確描述 CI/CD boundary、trigger/ref、workflow fixture、九個 observable stages、三個 scenarios、failure feedback、completion 與 out-of-scope。 +- 文件明確描述 CI/CD boundary、trigger/ref、workflow fixture、九個 observable stages、四個 scenarios、failure feedback、completion 與 out-of-scope。 - `CICD-02` 可依本文件撰寫 lesson 與 workflow fixture,不需要重新決定 job step、required check 或 failure semantics。 - `CICD-03` 可依本文件建立純 simulator;不需要真實 GitHub Actions、runner、network、secret 或 shell。 - `CICD-04` 可依本文件設計 Lab 的 trigger selector、step evidence、required check、merge gate、reset、keyboard、mobile 與 reduced-motion interaction。 diff --git a/frontend/src/topics/cicd/content.test.ts b/frontend/src/topics/cicd/content.test.ts index fc90106..08d6501 100644 --- a/frontend/src/topics/cicd/content.test.ts +++ b/frontend/src/topics/cicd/content.test.ts @@ -58,9 +58,10 @@ describe("CI/CD topic content contract", () => { expect(cicdStageFixtures.every((fixture) => fixture.successEvidence.length > 0 && fixture.failureEvidence.length > 0)).toBe(true); }); - it("keeps green, test failure, and build failure scenarios distinct", () => { + it("keeps green, install failure, test failure, and build failure scenarios distinct", () => { expect(cicdScenarioFixtures.map((scenario) => scenario.id)).toEqual([ "pull-request-green", + "pull-request-install-failure", "pull-request-test-failure", "pull-request-build-failure", ]); @@ -74,6 +75,16 @@ describe("CI/CD topic content contract", () => { mergeGate: "mergeable", }); expect(cicdScenarioFixtures[1]).toMatchObject({ + installOutcome: "failed", + testOutcome: "not-run", + lintOutcome: "not-run", + buildOutcome: "not-run", + artifactState: "missing", + requiredCheck: "failed", + mergeGate: "blocked", + failureStage: "install-dependencies", + }); + expect(cicdScenarioFixtures[2]).toMatchObject({ testOutcome: "failed", lintOutcome: "not-run", buildOutcome: "not-run", @@ -82,7 +93,7 @@ describe("CI/CD topic content contract", () => { mergeGate: "blocked", failureStage: "run-test", }); - expect(cicdScenarioFixtures[2]).toMatchObject({ + expect(cicdScenarioFixtures[3]).toMatchObject({ testOutcome: "passed", lintOutcome: "passed", buildOutcome: "failed", @@ -95,6 +106,7 @@ describe("CI/CD topic content contract", () => { it("documents failure boundaries without running Actions or shell", () => { expect(cicdFailureFixtures.map((fixture) => fixture.expectedBoundary)).toEqual([ + "lockfile / runtime", "behavior gate", "TypeScript gate", "production artifact", diff --git a/frontend/src/topics/cicd/content.ts b/frontend/src/topics/cicd/content.ts index 9622ca7..5f459ef 100644 --- a/frontend/src/topics/cicd/content.ts +++ b/frontend/src/topics/cicd/content.ts @@ -184,7 +184,7 @@ export const cicdWorkflowFixture: CicdWorkflowFixture = { export type CicdStepOutcome = "passed" | "failed" | "not-run"; export type CicdTriggerEvent = "pull_request" | "push" | "workflow_dispatch"; export type CicdTargetRef = "dev" | "main"; -export type CicdScenarioId = "pull-request-green" | "pull-request-test-failure" | "pull-request-build-failure"; +export type CicdScenarioId = "pull-request-green" | "pull-request-install-failure" | "pull-request-test-failure" | "pull-request-build-failure"; export interface CicdScenarioFixture { id: CicdScenarioId; @@ -218,6 +218,21 @@ export const cicdScenarioFixtures: readonly CicdScenarioFixture[] = [ failureStage: null, learningPoint: "所有必要 gate 通過後,frontend required check 才能讓 PR mergeable。", }, + { + id: "pull-request-install-failure", + title: "Install failure 停住 pipeline", + triggerEvent: "pull_request", + targetRef: "dev", + installOutcome: "failed", + testOutcome: "not-run", + lintOutcome: "not-run", + buildOutcome: "not-run", + artifactState: "missing", + requiredCheck: "failed", + mergeGate: "blocked", + failureStage: "install-dependencies", + learningPoint: "npm ci 失敗時保留 lockfile/runtime evidence,test、lint、build 都必須維持 not-run。", + }, { id: "pull-request-test-failure", title: "Test failure 停住 pipeline", @@ -276,6 +291,7 @@ export interface CicdFailureFixture { } export const cicdFailureFixtures: readonly CicdFailureFixture[] = [ + { command: "npm ci # frontend", message: "install failed;test、lint 與 build 維持 not-run,先修正 lockfile/runtime boundary。", expectedBoundary: "lockfile / runtime" }, { command: "npm test", message: "test failed;lint 與 build 維持 not-run,先修正行為 gate。", expectedBoundary: "behavior gate" }, { command: "npm run lint", message: "lint 尚未通過;build 不能被標記為成功。", expectedBoundary: "TypeScript gate" }, { command: "npm run build", message: "build failed;production artifact missing,required check 會 blocked。", expectedBoundary: "production artifact" }, diff --git a/frontend/src/topics/cicd/lab.test.ts b/frontend/src/topics/cicd/lab.test.ts index 67501ca..d4e8817 100644 --- a/frontend/src/topics/cicd/lab.test.ts +++ b/frontend/src/topics/cicd/lab.test.ts @@ -5,6 +5,6 @@ import { cicdLabProgress } from "./lab"; describe("CI/CD Lab progress", () => { it("starts empty and records the first pipeline without claiming full completion", () => { expect(cicdLabProgress(createInitialCicdState())).toBe(0); - expect(cicdLabProgress(runCicdEvents(cicdGreenHappyPath).state)).toBe(27); + expect(cicdLabProgress(runCicdEvents(cicdGreenHappyPath).state)).toBe(20); }); }); diff --git a/frontend/src/topics/cicd/lab.tsx b/frontend/src/topics/cicd/lab.tsx index 99ffdf5..34bedb8 100644 --- a/frontend/src/topics/cicd/lab.tsx +++ b/frontend/src/topics/cicd/lab.tsx @@ -26,7 +26,7 @@ interface CicdHistoryEntry { } const INITIAL_HISTORY: readonly CicdHistoryEntry[] = [ - { lines: ["CI/CD sandbox v1", "固定 workflow、required check 與三個 pipeline scenario 已準備好。"] }, + { lines: ["CI/CD sandbox v1", "固定 workflow、required check 與四個 pipeline scenario 已準備好。"] }, ]; function scenarioFor(scenarioId: CicdScenarioId | null): CicdScenarioFixture | undefined { @@ -60,6 +60,7 @@ function eventForCommand(rawCommand: string): CicdLabEvent | null { function scenarioOutcomeLabel(scenario: CicdScenarioFixture): string { if (scenario.mergeGate === "mergeable") return "MERGEABLE"; + if (scenario.failureStage === "install-dependencies") return "INSTALL BLOCKED"; if (scenario.failureStage === "run-test") return "TEST BLOCKED"; return "BUILD BLOCKED"; } @@ -124,7 +125,7 @@ export function CicdLab({ onComplete }: { onComplete?: () => void }) { {completed ? ( diff --git a/frontend/src/topics/cicd/simulator.test.ts b/frontend/src/topics/cicd/simulator.test.ts index 9981ef9..8c38ad2 100644 --- a/frontend/src/topics/cicd/simulator.test.ts +++ b/frontend/src/topics/cicd/simulator.test.ts @@ -20,6 +20,16 @@ const testFailurePath = [ { type: "evaluate-merge-gate" as const }, ]; +const installFailurePath = [ + { type: "select-scenario" as const, scenarioId: "pull-request-install-failure" as const }, + { type: "inspect-workflow" as const }, + { type: "select-trigger" as const }, + { type: "checkout-source" as const }, + { type: "install-dependencies" as const }, + { type: "publish-required-check" as const }, + { type: "evaluate-merge-gate" as const }, +]; + const buildFailurePath = [ { type: "select-scenario" as const, scenarioId: "pull-request-build-failure" as const }, { type: "inspect-workflow" as const }, @@ -126,6 +136,32 @@ describe("CI/CD deterministic simulator", () => { }); }); + it("stops install failure before test and keeps every downstream gate not-run", () => { + const beforeCheck = runCicdEvents(installFailurePath.slice(0, 5)); + + expect(beforeCheck.accepted).toBe(true); + expect(beforeCheck.results.at(-1)?.observedFailure).toBe(true); + expect(beforeCheck.state).toMatchObject({ + phase: "blocked", + installState: "failed", + testState: "not-run", + lintState: "not-run", + buildState: "not-run", + artifactState: "missing", + requiredCheck: "pending", + mergeGate: "pending", + }); + expect(beforeCheck.state.lastFeedback).toContain("not-run"); + + const completed = runCicdEvents(installFailurePath, beforeCheck.state); + expect(completed.state).toMatchObject({ + phase: "completed", + requiredCheck: "failed", + mergeGate: "blocked", + completedScenarioIds: ["pull-request-install-failure"], + }); + }); + it("preserves test/lint success when build fails", () => { const result = runCicdEvents(buildFailurePath); @@ -147,6 +183,8 @@ describe("CI/CD deterministic simulator", () => { const fullFlow = [ ...cicdGreenHappyPath, { type: "reset" as const }, + ...installFailurePath, + { type: "reset" as const }, ...testFailurePath, { type: "reset" as const }, ...buildFailurePath, @@ -160,6 +198,7 @@ describe("CI/CD deterministic simulator", () => { expect(first.state).toEqual(second.state); expect(first.state.completedScenarioIds).toEqual([ "pull-request-green", + "pull-request-install-failure", "pull-request-test-failure", "pull-request-build-failure", ]); diff --git a/frontend/src/topics/cicd/simulator.ts b/frontend/src/topics/cicd/simulator.ts index 648beaa..0ff2ef1 100644 --- a/frontend/src/topics/cicd/simulator.ts +++ b/frontend/src/topics/cicd/simulator.ts @@ -316,6 +316,15 @@ export function runCicdEvent(current: CicdLabState, event: CicdLabEvent): CicdEv ); case "install-dependencies": if (!hasCompletedStage(state, "checkout-source")) return blocked(state, commandFor(event.type), "請先 checkout source,再執行 npm ci。 "); + if (scenario.installOutcome === "failed") { + return accepted( + state, + { phase: "blocked", activeStageId: "publish-required-check", completedStageIds: withStage(state, event.type), installState: "failed", testState: "not-run", lintState: "not-run", buildState: "not-run", artifactState: "missing", lastCommand: commandFor(event.type) }, + "npm ci failed;test、lint 與 build 維持 not-run,required check 後續會 blocked。", + [`node: ${cicdFixture.nodeVersion}`, `cache: ${cicdFixture.cacheDependencyPath}`, "npm ci: failed", "test: not-run", "lint: not-run", "build: not-run", "next: publish required check"], + true, + ); + } return accepted( state, { phase: "running", activeStageId: "run-test", completedStageIds: withStage(state, event.type), installState: scenario.installOutcome, lastCommand: commandFor(event.type) }, @@ -366,7 +375,7 @@ export function runCicdEvent(current: CicdLabState, event: CicdLabEvent): CicdEv ); case "publish-required-check": { const hasFailure = [state.installState, state.testState, state.lintState, state.buildState].includes("failed"); - if (!hasCompletedStage(state, "run-test")) return blocked(state, commandFor(event.type), "至少要先執行 test gate,才能彙總 required check。 "); + if (!hasCompletedStage(state, "install-dependencies")) return blocked(state, commandFor(event.type), "至少要先執行 install gate,才能彙總 required check。 "); if (!hasFailure && !hasCompletedStage(state, "run-build")) return blocked(state, commandFor(event.type), "test、lint、build 尚未全部完成,不能提前發布 required check。 "); const requiredCheck = hasFailure ? "failed" : "passed"; return accepted( @@ -395,7 +404,7 @@ export function runCicdEvent(current: CicdLabState, event: CicdLabEvent): CicdEv if (scenario.id === "pull-request-green") { Object.assign(nextState, completionAfterGreen(nextState)); if (nextState.regressionVerified && scenarioIds.every((scenarioId) => nextState.completedScenarioIds.includes(scenarioId))) { - nextState.lastFeedback = "三個 CI/CD scenario 與 reset regression 都完成;CI/CD Lab 完成。"; + nextState.lastFeedback = "四個 CI/CD scenario 與 reset regression 都完成;CI/CD Lab 完成。"; } } return {