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/cicd-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 教學邊界
Expand Down Expand Up @@ -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
```
Expand All @@ -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。 |

Expand Down Expand Up @@ -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 與第一次一致。
Expand All @@ -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。
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/topics/cicd/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/topics/cicd/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" },
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/topics/cicd/lab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 3 additions & 2 deletions frontend/src/topics/cicd/lab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
}
Expand Down Expand Up @@ -124,7 +125,7 @@ export function CicdLab({ onComplete }: { onComplete?: () => void }) {

{completed ? (
<TopicCompletionCard
title="三個 CI/CD scenarios 與 replay 都完成了。"
title="四個 CI/CD scenarios 與 replay 都完成了。"
description="你已驗證 workflow input、ordered gates、failure boundary、required check 與 merge gate;CI/CD Lab 完成。"
onReset={reset}
/>
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/topics/cicd/simulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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);

Expand All @@ -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,
Expand All @@ -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",
]);
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/topics/cicd/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
Loading