1. A clean runner tests committed assumptions
The local museum gate passes. That result depends on one checkout, environment, and set of installed tools. Continuous integration starts a new automated run from committed files and a declared workflow.
Before writing YAML, answer these questions:
Which repository event should start the quality check?
What must the runner install before it can run project commands?
How does a failed command stop the job?
Which local and CI commands must remain identical?
What hidden local assumption can a clean environment expose?
Create .github/workflows/quality.yml:
name : Quality
on :
pull_request :
push :
jobs :
quality :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-python@v5
with :
python-version : "3.10"
- name : Install project and quality tools
run : python -m pip install -e ".[dev]"
- name : Lint
run : ruff check src tests
- name : Check formatting
run : ruff format --check src tests
- name : Check types
run : mypy src
- name : Run tests
run : pytest -q
The file describes one workflow named Quality :
on declares events;
jobs contains independently scheduled work;
quality is a machine ID for one job;
runs-on chooses a hosted runner image;
steps execute in order within that job;
uses invokes a reusable action; and
run invokes a shell command.
A real project must declare its development tools in a way the install command can resolve. If the project uses Poetry, a lock file, or another environment manager, use its documented install command rather than copying .[dev] blindly.
A Git event creates a clean runner that reconstructs the project before executing the gate.
flowchart LR
A[Push or pull request] --> B[Workflow]
B --> C[Clean runner]
C --> D[Checkout]
D --> E[Set up Python]
E --> F[Install project]
F --> G[Run quality commands]
G --> H[Shared pass or failure]
2. Order steps by their dependencies
The linter cannot inspect source before checkout. Tests cannot import the project before installation. Put each prerequisite before its consumer:
check out the commit/ref associated with the run;
select a supported Python interpreter;
install the declared project and development dependencies;
run fast static checks;
run the behavior suite; and
build documentation/package only if those artifacts belong to this gate.
A workflow run uses the workflow version stored in the associated commit or Git reference. Uncommitted local fixes do not reach it. That is a feature: CI asks whether committed state is self-contained.
A nonzero exit status is the gate signal
Ruff, MyPy, and pytest exit zero on success and nonzero on a detected problem or execution failure. GitHub Actions marks a normal run step failed when its command exits nonzero.
This command destroys that signal:
If pytest exits 1, the shell runs true, which exits 0. The step looks green while tests are red. Likewise, an accidental continue-on-error: true changes a required gate into informational output.
Use non-blocking steps only when failure is intentionally advisory and clearly labeled. This unit’s quality commands are gates, so let them fail.
Checkpoint: workflow anatomy
{
const scripts = Array . from (
document . querySelectorAll ("script.fcpython-ojs-quiz-config" )
);
const script = scripts. find (
(node) => node. dataset . fcpythonRendered !== "true"
);
if (! script) {
return html `<div class="fcpython-quiz fcpython-quiz-warning">
Quiz configuration was not found.
</div>` ;
}
script. dataset . fcpythonRendered = "true" ;
const quiz = JSON . parse (script. textContent );
const container = html `<div class="fcpython-quiz"></div>` ;
const title = document . createElement ("h3" );
title. textContent = quiz. title ;
container. appendChild (title);
const instructions = document . createElement ("p" );
instructions. textContent = quiz. instructions ;
container. appendChild (instructions);
const progress = document . createElement ("div" );
progress. className = "fcpython-quiz-progress" ;
const counter = document . createElement ("p" );
counter. className = "fcpython-quiz-counter" ;
counter. setAttribute ("aria-live" , "polite" );
progress. appendChild (counter);
const tabs = document . createElement ("div" );
tabs. className = "fcpython-quiz-steps" ;
tabs. setAttribute ("role" , "tablist" );
tabs. setAttribute ("aria-label" , "Quiz questions" );
progress. appendChild (tabs);
container. appendChild (progress);
const questions = document . createElement ("div" );
questions. className = "fcpython-quiz-questions" ;
const feedbackNodes = [];
const questionPanels = [];
const stepButtons = [];
let currentQuestion = 0 ;
function setStepStatus (questionIndex, status) {
const step = stepButtons[questionIndex];
const question = quiz. questions [questionIndex];
const statuses = {
answered : "answered" ,
correct : "correct" ,
incorrect : "incorrect" ,
unanswered : "not answered" ,
};
step. classList . remove (
"is-answered" ,
"is-correct" ,
"is-incorrect" ,
"is-unanswered"
);
if (status) {
step. classList . add (`is- ${ status} ` );
}
const statusLabel = status ? `, ${ statuses[status]} ` : "" ;
step. setAttribute (
"aria-label" ,
`Question ${ questionIndex + 1 } : ${ question. prompt }${ statusLabel} `
);
}
function showQuestion (questionIndex, focusPanel = false ) {
currentQuestion = Math . max (
0 ,
Math . min (questionIndex, quiz. questions . length - 1 )
);
questionPanels. forEach ((panel, index) => {
panel. hidden = index !== currentQuestion;
});
stepButtons. forEach ((step, index) => {
const isCurrent = index === currentQuestion;
step. classList . toggle ("is-current" , isCurrent);
step. setAttribute ("aria-selected" , String (isCurrent));
step. tabIndex = isCurrent ? 0 : - 1 ;
});
counter. textContent = `Question ${ currentQuestion + 1 } of ${ quiz. questions . length } ` ;
const selected = questionPanels[currentQuestion]. querySelector (
"input[type='radio']:checked"
);
const isLastQuestion = currentQuestion === quiz. questions . length - 1 ;
previous. hidden = currentQuestion === 0 ;
next. hidden = isLastQuestion || ! selected;
check. hidden = ! isLastQuestion;
if (focusPanel) {
questionPanels[currentQuestion]. focus ();
}
}
quiz. questions . forEach ((question, questionIndex) => {
const tabId = ` ${ quiz. id } -question-tab- ${ questionIndex + 1 } ` ;
const panelId = ` ${ quiz. id } -question-panel- ${ questionIndex + 1 } ` ;
const step = document . createElement ("button" );
step. type = "button" ;
step. className = "fcpython-quiz-step" ;
step. id = tabId;
step. textContent = String (questionIndex + 1 );
step. setAttribute ("role" , "tab" );
step. setAttribute ("aria-controls" , panelId);
step. setAttribute ("aria-selected" , "false" );
step. tabIndex = - 1 ;
step. addEventListener ("click" , () => showQuestion (questionIndex));
step. addEventListener ("keydown" , (event ) => {
let destination = null ;
if (event . key === "ArrowRight" ) {
destination = (questionIndex + 1 ) % quiz. questions . length ;
} else if (event . key === "ArrowLeft" ) {
destination =
(questionIndex - 1 + quiz. questions . length ) % quiz. questions . length ;
} else if (event . key === "Home" ) {
destination = 0 ;
} else if (event . key === "End" ) {
destination = quiz. questions . length - 1 ;
}
if (destination !== null ) {
event . preventDefault ();
showQuestion (destination);
stepButtons[destination]. focus ();
}
});
stepButtons. push (step);
tabs. appendChild (step);
setStepStatus (questionIndex, "" );
const panel = document . createElement ("div" );
panel. className = "fcpython-quiz-panel" ;
panel. id = panelId;
panel. setAttribute ("role" , "tabpanel" );
panel. setAttribute ("aria-labelledby" , tabId);
panel. tabIndex = - 1 ;
const fieldset = document . createElement ("fieldset" );
fieldset. className = "fcpython-quiz-question" ;
const legend = document . createElement ("legend" );
legend. textContent = question. prompt ;
fieldset. appendChild (legend);
question. options . forEach ((option, optionIndex) => {
const label = document . createElement ("label" );
label. className = "fcpython-quiz-option" ;
const input = document . createElement ("input" );
input. type = "radio" ;
input. name = ` ${ quiz. id } - ${ question. id } ` ;
input. value = String (optionIndex);
input. addEventListener ("change" , () => {
setStepStatus (questionIndex, "answered" );
feedbackNodes[questionIndex]. textContent = "" ;
feedbackNodes[questionIndex]. className = "fcpython-quiz-feedback" ;
score. textContent = "" ;
showQuestion (questionIndex);
});
const text = document . createElement ("span" );
text. textContent = option;
label. appendChild (input);
label. appendChild (text);
fieldset. appendChild (label);
});
const feedback = document . createElement ("p" );
feedback. className = "fcpython-quiz-feedback" ;
feedback. setAttribute ("aria-live" , "polite" );
feedbackNodes. push (feedback);
fieldset. appendChild (feedback);
panel. appendChild (fieldset);
questionPanels. push (panel);
questions. appendChild (panel);
});
container. appendChild (questions);
const actions = document . createElement ("div" );
actions. className = "fcpython-quiz-actions" ;
const previous = document . createElement ("button" );
previous. type = "button" ;
previous. className = "fcpython-quiz-secondary" ;
previous. textContent = "Previous" ;
previous. addEventListener ("click" , () => {
showQuestion (currentQuestion - 1 , true );
});
const next = document . createElement ("button" );
next. type = "button" ;
next. textContent = "Next question" ;
next. addEventListener ("click" , () => {
showQuestion (currentQuestion + 1 , true );
});
const check = document . createElement ("button" );
check. type = "button" ;
check. textContent = "Check answers" ;
const reset = document . createElement ("button" );
reset. type = "button" ;
reset. className = "fcpython-quiz-secondary fcpython-quiz-reset" ;
reset. textContent = "Reset" ;
const score = document . createElement ("p" );
score. className = "fcpython-quiz-score" ;
score. setAttribute ("aria-live" , "polite" );
check. addEventListener ("click" , () => {
let correctCount = 0 ;
let firstQuestionToReview = null ;
quiz. questions . forEach ((question, questionIndex) => {
const selected = container. querySelector (
`input[name=" ${ quiz. id } - ${ question. id } "]:checked`
);
const feedback = feedbackNodes[questionIndex];
if (! selected) {
feedback. textContent = "Choose an answer before checking." ;
feedback. className = "fcpython-quiz-feedback" ;
setStepStatus (questionIndex, "unanswered" );
if (firstQuestionToReview === null ) {
firstQuestionToReview = questionIndex;
}
return ;
}
const selectedIndex = Number (selected. value );
if (selectedIndex === question. answer_index ) {
correctCount += 1 ;
feedback. textContent = `✅ Correct. ${ question. explanation } ` ;
feedback. className = "fcpython-quiz-feedback is-correct" ;
setStepStatus (questionIndex, "correct" );
} else {
const answer = question. options [question. answer_index ];
feedback. textContent = `❌ Not yet. Correct answer: ${ answer} . ${ question. explanation } ` ;
feedback. className = "fcpython-quiz-feedback is-incorrect" ;
setStepStatus (questionIndex, "incorrect" );
if (firstQuestionToReview === null ) {
firstQuestionToReview = questionIndex;
}
}
});
score. textContent = `Score: ${ correctCount} / ${ quiz. questions . length } ` ;
if (firstQuestionToReview !== null ) {
showQuestion (firstQuestionToReview, true );
}
});
reset. addEventListener ("click" , () => {
container. querySelectorAll ("input[type='radio']" ). forEach ((input) => {
input. checked = false ;
});
feedbackNodes. forEach ((feedback) => {
feedback. textContent = "" ;
feedback. className = "fcpython-quiz-feedback" ;
});
stepButtons. forEach ((_, questionIndex) => {
setStepStatus (questionIndex, "" );
});
score. textContent = "" ;
showQuestion (0 , true );
});
actions. appendChild (previous);
actions. appendChild (next);
actions. appendChild (check);
actions. appendChild (reset);
container. appendChild (actions);
container. appendChild (score);
showQuestion (0 );
return container;
}
3. Make local and CI commands one contract
Drift creates confusing states:
ruff check src
ruff check src tests
Test lint failures appear only after push
mypy src
no MyPy step
CI accepts type gaps local contributors reject
pytest 9 from lock
unbounded latest pytest
Same commit can receive different behavior
formatter rewrites
formatter check-only
Fine when the distinction is documented
Write the authoritative check-only sequence in the README, task runner, or a small script that both environments call. Direct commands are easiest for this small project:
ruff check src tests
ruff format --check src tests
mypy src
pytest -q
The local pre-commit repair loop may run ruff format in place. The final local verification and CI must use --check. That is intentional behavior, not drift.
Pin enough state to reproduce the result
A clean environment still changes over time if it installs unconstrained latest tools. Use the project’s chosen dependency record and review updates. This can be a lock file, exact constraints, or a controlled range plus regular testing.
Actions also have versions. A tag such as actions/checkout@v4 tracks a major release. Security-sensitive projects may pin an immutable commit SHA and use an update process. Foundations learners need to recognize that an action is executable dependency code, not memorize one universal pinning policy.
Do not duplicate policy in the workflow
Keep rule selection, target Python, source paths, and MyPy strict settings in pyproject.toml. The workflow should call tools; it should not contain a second copy of every rule flag. One committed configuration reduces disagreement among editor, local command, hook, and CI.
4. Classify the failing stage before editing source
A red workflow is not always a code defect. Start at the earliest failed step and classify it.
Source-quality failure
Run ruff check src tests
F821 Undefined name `rank_attempt`
Found 1 error.
Error: Process completed with exit code 1.
Reproduce locally from the project root:
Read the rule and repair the source or a justified policy boundary.
Installation failure
Run python -m pip install -e ".[dev]"
ERROR: Directory '.' is not installable. Neither 'setup.py' nor 'pyproject.toml' found.
Do not change Python logic. The runner is in the wrong directory or the project metadata was not committed. Compare checkout paths and working-directory.
Environment/version failure
SyntaxError: Pattern matching is only supported in Python 3.10 and greater
If a matrix job selected Python 3.9 while the project requires 3.10, align the matrix with the declared support range. If the project promises 3.9, the source is incompatible. The contract determines the repair.
Configuration failure
A malformed YAML file may prevent the workflow from starting, so no shell step exists to inspect. Use an editor/YAML parser, compare indentation, and read the workflow annotation. A correctly parsed workflow can still contain a wrong key or expression; consult the provider’s syntax reference.
Test failure only on CI
Compare:
Python and dependency versions;
operating system and path case;
locale, timezone, environment variables, and clock;
files present in Git versus only in the local working tree; and
test order or leaked global state.
Do not label CI “random.” State the observed difference and reduce it through a controlled local reproduction or a more explicit fixture.
Checkpoint: diagnose the clean run
{
const scripts = Array . from (
document . querySelectorAll ("script.fcpython-ojs-quiz-config" )
);
const script = scripts. find (
(node) => node. dataset . fcpythonRendered !== "true"
);
if (! script) {
return html `<div class="fcpython-quiz fcpython-quiz-warning">
Quiz configuration was not found.
</div>` ;
}
script. dataset . fcpythonRendered = "true" ;
const quiz = JSON . parse (script. textContent );
const container = html `<div class="fcpython-quiz"></div>` ;
const title = document . createElement ("h3" );
title. textContent = quiz. title ;
container. appendChild (title);
const instructions = document . createElement ("p" );
instructions. textContent = quiz. instructions ;
container. appendChild (instructions);
const progress = document . createElement ("div" );
progress. className = "fcpython-quiz-progress" ;
const counter = document . createElement ("p" );
counter. className = "fcpython-quiz-counter" ;
counter. setAttribute ("aria-live" , "polite" );
progress. appendChild (counter);
const tabs = document . createElement ("div" );
tabs. className = "fcpython-quiz-steps" ;
tabs. setAttribute ("role" , "tablist" );
tabs. setAttribute ("aria-label" , "Quiz questions" );
progress. appendChild (tabs);
container. appendChild (progress);
const questions = document . createElement ("div" );
questions. className = "fcpython-quiz-questions" ;
const feedbackNodes = [];
const questionPanels = [];
const stepButtons = [];
let currentQuestion = 0 ;
function setStepStatus (questionIndex, status) {
const step = stepButtons[questionIndex];
const question = quiz. questions [questionIndex];
const statuses = {
answered : "answered" ,
correct : "correct" ,
incorrect : "incorrect" ,
unanswered : "not answered" ,
};
step. classList . remove (
"is-answered" ,
"is-correct" ,
"is-incorrect" ,
"is-unanswered"
);
if (status) {
step. classList . add (`is- ${ status} ` );
}
const statusLabel = status ? `, ${ statuses[status]} ` : "" ;
step. setAttribute (
"aria-label" ,
`Question ${ questionIndex + 1 } : ${ question. prompt }${ statusLabel} `
);
}
function showQuestion (questionIndex, focusPanel = false ) {
currentQuestion = Math . max (
0 ,
Math . min (questionIndex, quiz. questions . length - 1 )
);
questionPanels. forEach ((panel, index) => {
panel. hidden = index !== currentQuestion;
});
stepButtons. forEach ((step, index) => {
const isCurrent = index === currentQuestion;
step. classList . toggle ("is-current" , isCurrent);
step. setAttribute ("aria-selected" , String (isCurrent));
step. tabIndex = isCurrent ? 0 : - 1 ;
});
counter. textContent = `Question ${ currentQuestion + 1 } of ${ quiz. questions . length } ` ;
const selected = questionPanels[currentQuestion]. querySelector (
"input[type='radio']:checked"
);
const isLastQuestion = currentQuestion === quiz. questions . length - 1 ;
previous. hidden = currentQuestion === 0 ;
next. hidden = isLastQuestion || ! selected;
check. hidden = ! isLastQuestion;
if (focusPanel) {
questionPanels[currentQuestion]. focus ();
}
}
quiz. questions . forEach ((question, questionIndex) => {
const tabId = ` ${ quiz. id } -question-tab- ${ questionIndex + 1 } ` ;
const panelId = ` ${ quiz. id } -question-panel- ${ questionIndex + 1 } ` ;
const step = document . createElement ("button" );
step. type = "button" ;
step. className = "fcpython-quiz-step" ;
step. id = tabId;
step. textContent = String (questionIndex + 1 );
step. setAttribute ("role" , "tab" );
step. setAttribute ("aria-controls" , panelId);
step. setAttribute ("aria-selected" , "false" );
step. tabIndex = - 1 ;
step. addEventListener ("click" , () => showQuestion (questionIndex));
step. addEventListener ("keydown" , (event ) => {
let destination = null ;
if (event . key === "ArrowRight" ) {
destination = (questionIndex + 1 ) % quiz. questions . length ;
} else if (event . key === "ArrowLeft" ) {
destination =
(questionIndex - 1 + quiz. questions . length ) % quiz. questions . length ;
} else if (event . key === "Home" ) {
destination = 0 ;
} else if (event . key === "End" ) {
destination = quiz. questions . length - 1 ;
}
if (destination !== null ) {
event . preventDefault ();
showQuestion (destination);
stepButtons[destination]. focus ();
}
});
stepButtons. push (step);
tabs. appendChild (step);
setStepStatus (questionIndex, "" );
const panel = document . createElement ("div" );
panel. className = "fcpython-quiz-panel" ;
panel. id = panelId;
panel. setAttribute ("role" , "tabpanel" );
panel. setAttribute ("aria-labelledby" , tabId);
panel. tabIndex = - 1 ;
const fieldset = document . createElement ("fieldset" );
fieldset. className = "fcpython-quiz-question" ;
const legend = document . createElement ("legend" );
legend. textContent = question. prompt ;
fieldset. appendChild (legend);
question. options . forEach ((option, optionIndex) => {
const label = document . createElement ("label" );
label. className = "fcpython-quiz-option" ;
const input = document . createElement ("input" );
input. type = "radio" ;
input. name = ` ${ quiz. id } - ${ question. id } ` ;
input. value = String (optionIndex);
input. addEventListener ("change" , () => {
setStepStatus (questionIndex, "answered" );
feedbackNodes[questionIndex]. textContent = "" ;
feedbackNodes[questionIndex]. className = "fcpython-quiz-feedback" ;
score. textContent = "" ;
showQuestion (questionIndex);
});
const text = document . createElement ("span" );
text. textContent = option;
label. appendChild (input);
label. appendChild (text);
fieldset. appendChild (label);
});
const feedback = document . createElement ("p" );
feedback. className = "fcpython-quiz-feedback" ;
feedback. setAttribute ("aria-live" , "polite" );
feedbackNodes. push (feedback);
fieldset. appendChild (feedback);
panel. appendChild (fieldset);
questionPanels. push (panel);
questions. appendChild (panel);
});
container. appendChild (questions);
const actions = document . createElement ("div" );
actions. className = "fcpython-quiz-actions" ;
const previous = document . createElement ("button" );
previous. type = "button" ;
previous. className = "fcpython-quiz-secondary" ;
previous. textContent = "Previous" ;
previous. addEventListener ("click" , () => {
showQuestion (currentQuestion - 1 , true );
});
const next = document . createElement ("button" );
next. type = "button" ;
next. textContent = "Next question" ;
next. addEventListener ("click" , () => {
showQuestion (currentQuestion + 1 , true );
});
const check = document . createElement ("button" );
check. type = "button" ;
check. textContent = "Check answers" ;
const reset = document . createElement ("button" );
reset. type = "button" ;
reset. className = "fcpython-quiz-secondary fcpython-quiz-reset" ;
reset. textContent = "Reset" ;
const score = document . createElement ("p" );
score. className = "fcpython-quiz-score" ;
score. setAttribute ("aria-live" , "polite" );
check. addEventListener ("click" , () => {
let correctCount = 0 ;
let firstQuestionToReview = null ;
quiz. questions . forEach ((question, questionIndex) => {
const selected = container. querySelector (
`input[name=" ${ quiz. id } - ${ question. id } "]:checked`
);
const feedback = feedbackNodes[questionIndex];
if (! selected) {
feedback. textContent = "Choose an answer before checking." ;
feedback. className = "fcpython-quiz-feedback" ;
setStepStatus (questionIndex, "unanswered" );
if (firstQuestionToReview === null ) {
firstQuestionToReview = questionIndex;
}
return ;
}
const selectedIndex = Number (selected. value );
if (selectedIndex === question. answer_index ) {
correctCount += 1 ;
feedback. textContent = `✅ Correct. ${ question. explanation } ` ;
feedback. className = "fcpython-quiz-feedback is-correct" ;
setStepStatus (questionIndex, "correct" );
} else {
const answer = question. options [question. answer_index ];
feedback. textContent = `❌ Not yet. Correct answer: ${ answer} . ${ question. explanation } ` ;
feedback. className = "fcpython-quiz-feedback is-incorrect" ;
setStepStatus (questionIndex, "incorrect" );
if (firstQuestionToReview === null ) {
firstQuestionToReview = questionIndex;
}
}
});
score. textContent = `Score: ${ correctCount} / ${ quiz. questions . length } ` ;
if (firstQuestionToReview !== null ) {
showQuestion (firstQuestionToReview, true );
}
});
reset. addEventListener ("click" , () => {
container. querySelectorAll ("input[type='radio']" ). forEach ((input) => {
input. checked = false ;
});
feedbackNodes. forEach ((feedback) => {
feedback. textContent = "" ;
feedback. className = "fcpython-quiz-feedback" ;
});
stepButtons. forEach ((_, questionIndex) => {
setStepStatus (questionIndex, "" );
});
score. textContent = "" ;
showQuestion (0 , true );
});
actions. appendChild (previous);
actions. appendChild (next);
actions. appendChild (check);
actions. appendChild (reset);
container. appendChild (actions);
container. appendChild (score);
showQuestion (0 );
return container;
}
5. Use a small version matrix for a real support claim
A library that claims Python 3.10–3.13 support should execute tests on those versions. A matrix avoids copying the job:
jobs :
quality :
runs-on : ubuntu-latest
strategy :
fail-fast : false
matrix :
python-version : [ "3.10" , "3.11" , "3.12" , "3.13" ]
steps :
- uses : actions/checkout@v4
- uses : actions/setup-python@v5
with :
python-version : ${{ matrix.python-version }}
- name : Install project
run : python -m pip install -e ".[dev]"
- name : Run quality gate
run : |
ruff check src tests
ruff format --check src tests
mypy src
pytest -q
This produces one job variant per value. fail-fast: false lets all variants report, which helps distinguish “source fails everywhere” from “only the oldest supported interpreter fails.”
Do not add operating systems, dependency combinations, and dozens of versions without a support question. Every matrix dimension multiplies cost and output. The Midnight Museum exercise can start with Python 3.10; the course repository’s real CI uses a supported-version matrix.
Separate steps or one gate step?
Separate named steps show exactly which tool failed and can make logs easier to scan. One script reduces command duplication if local and CI call the same script. Both are valid:
for a small lesson, separate steps reveal the chain;
for a mature project, one versioned quality task can keep environments aligned while still printing each command.
Choose the design that makes the contract and failure visible.
6. State what a passing workflow proves
A green workflow demonstrates that the workflow version associated with the run completed its selected jobs and steps successfully in their declared runner environments. It does not prove:
every possible input works;
every behavior has a test;
a security vulnerability is absent;
a contributor ran local hooks;
production matches the runner; or
the code is easy to maintain.
CI reduces dependence on hidden local state and gives collaborators a shared result. Its strength comes from a clear, relevant gate—not from the color green alone.
Checkpoint: CI scope and design
{
const scripts = Array . from (
document . querySelectorAll ("script.fcpython-ojs-quiz-config" )
);
const script = scripts. find (
(node) => node. dataset . fcpythonRendered !== "true"
);
if (! script) {
return html `<div class="fcpython-quiz fcpython-quiz-warning">
Quiz configuration was not found.
</div>` ;
}
script. dataset . fcpythonRendered = "true" ;
const quiz = JSON . parse (script. textContent );
const container = html `<div class="fcpython-quiz"></div>` ;
const title = document . createElement ("h3" );
title. textContent = quiz. title ;
container. appendChild (title);
const instructions = document . createElement ("p" );
instructions. textContent = quiz. instructions ;
container. appendChild (instructions);
const progress = document . createElement ("div" );
progress. className = "fcpython-quiz-progress" ;
const counter = document . createElement ("p" );
counter. className = "fcpython-quiz-counter" ;
counter. setAttribute ("aria-live" , "polite" );
progress. appendChild (counter);
const tabs = document . createElement ("div" );
tabs. className = "fcpython-quiz-steps" ;
tabs. setAttribute ("role" , "tablist" );
tabs. setAttribute ("aria-label" , "Quiz questions" );
progress. appendChild (tabs);
container. appendChild (progress);
const questions = document . createElement ("div" );
questions. className = "fcpython-quiz-questions" ;
const feedbackNodes = [];
const questionPanels = [];
const stepButtons = [];
let currentQuestion = 0 ;
function setStepStatus (questionIndex, status) {
const step = stepButtons[questionIndex];
const question = quiz. questions [questionIndex];
const statuses = {
answered : "answered" ,
correct : "correct" ,
incorrect : "incorrect" ,
unanswered : "not answered" ,
};
step. classList . remove (
"is-answered" ,
"is-correct" ,
"is-incorrect" ,
"is-unanswered"
);
if (status) {
step. classList . add (`is- ${ status} ` );
}
const statusLabel = status ? `, ${ statuses[status]} ` : "" ;
step. setAttribute (
"aria-label" ,
`Question ${ questionIndex + 1 } : ${ question. prompt }${ statusLabel} `
);
}
function showQuestion (questionIndex, focusPanel = false ) {
currentQuestion = Math . max (
0 ,
Math . min (questionIndex, quiz. questions . length - 1 )
);
questionPanels. forEach ((panel, index) => {
panel. hidden = index !== currentQuestion;
});
stepButtons. forEach ((step, index) => {
const isCurrent = index === currentQuestion;
step. classList . toggle ("is-current" , isCurrent);
step. setAttribute ("aria-selected" , String (isCurrent));
step. tabIndex = isCurrent ? 0 : - 1 ;
});
counter. textContent = `Question ${ currentQuestion + 1 } of ${ quiz. questions . length } ` ;
const selected = questionPanels[currentQuestion]. querySelector (
"input[type='radio']:checked"
);
const isLastQuestion = currentQuestion === quiz. questions . length - 1 ;
previous. hidden = currentQuestion === 0 ;
next. hidden = isLastQuestion || ! selected;
check. hidden = ! isLastQuestion;
if (focusPanel) {
questionPanels[currentQuestion]. focus ();
}
}
quiz. questions . forEach ((question, questionIndex) => {
const tabId = ` ${ quiz. id } -question-tab- ${ questionIndex + 1 } ` ;
const panelId = ` ${ quiz. id } -question-panel- ${ questionIndex + 1 } ` ;
const step = document . createElement ("button" );
step. type = "button" ;
step. className = "fcpython-quiz-step" ;
step. id = tabId;
step. textContent = String (questionIndex + 1 );
step. setAttribute ("role" , "tab" );
step. setAttribute ("aria-controls" , panelId);
step. setAttribute ("aria-selected" , "false" );
step. tabIndex = - 1 ;
step. addEventListener ("click" , () => showQuestion (questionIndex));
step. addEventListener ("keydown" , (event ) => {
let destination = null ;
if (event . key === "ArrowRight" ) {
destination = (questionIndex + 1 ) % quiz. questions . length ;
} else if (event . key === "ArrowLeft" ) {
destination =
(questionIndex - 1 + quiz. questions . length ) % quiz. questions . length ;
} else if (event . key === "Home" ) {
destination = 0 ;
} else if (event . key === "End" ) {
destination = quiz. questions . length - 1 ;
}
if (destination !== null ) {
event . preventDefault ();
showQuestion (destination);
stepButtons[destination]. focus ();
}
});
stepButtons. push (step);
tabs. appendChild (step);
setStepStatus (questionIndex, "" );
const panel = document . createElement ("div" );
panel. className = "fcpython-quiz-panel" ;
panel. id = panelId;
panel. setAttribute ("role" , "tabpanel" );
panel. setAttribute ("aria-labelledby" , tabId);
panel. tabIndex = - 1 ;
const fieldset = document . createElement ("fieldset" );
fieldset. className = "fcpython-quiz-question" ;
const legend = document . createElement ("legend" );
legend. textContent = question. prompt ;
fieldset. appendChild (legend);
question. options . forEach ((option, optionIndex) => {
const label = document . createElement ("label" );
label. className = "fcpython-quiz-option" ;
const input = document . createElement ("input" );
input. type = "radio" ;
input. name = ` ${ quiz. id } - ${ question. id } ` ;
input. value = String (optionIndex);
input. addEventListener ("change" , () => {
setStepStatus (questionIndex, "answered" );
feedbackNodes[questionIndex]. textContent = "" ;
feedbackNodes[questionIndex]. className = "fcpython-quiz-feedback" ;
score. textContent = "" ;
showQuestion (questionIndex);
});
const text = document . createElement ("span" );
text. textContent = option;
label. appendChild (input);
label. appendChild (text);
fieldset. appendChild (label);
});
const feedback = document . createElement ("p" );
feedback. className = "fcpython-quiz-feedback" ;
feedback. setAttribute ("aria-live" , "polite" );
feedbackNodes. push (feedback);
fieldset. appendChild (feedback);
panel. appendChild (fieldset);
questionPanels. push (panel);
questions. appendChild (panel);
});
container. appendChild (questions);
const actions = document . createElement ("div" );
actions. className = "fcpython-quiz-actions" ;
const previous = document . createElement ("button" );
previous. type = "button" ;
previous. className = "fcpython-quiz-secondary" ;
previous. textContent = "Previous" ;
previous. addEventListener ("click" , () => {
showQuestion (currentQuestion - 1 , true );
});
const next = document . createElement ("button" );
next. type = "button" ;
next. textContent = "Next question" ;
next. addEventListener ("click" , () => {
showQuestion (currentQuestion + 1 , true );
});
const check = document . createElement ("button" );
check. type = "button" ;
check. textContent = "Check answers" ;
const reset = document . createElement ("button" );
reset. type = "button" ;
reset. className = "fcpython-quiz-secondary fcpython-quiz-reset" ;
reset. textContent = "Reset" ;
const score = document . createElement ("p" );
score. className = "fcpython-quiz-score" ;
score. setAttribute ("aria-live" , "polite" );
check. addEventListener ("click" , () => {
let correctCount = 0 ;
let firstQuestionToReview = null ;
quiz. questions . forEach ((question, questionIndex) => {
const selected = container. querySelector (
`input[name=" ${ quiz. id } - ${ question. id } "]:checked`
);
const feedback = feedbackNodes[questionIndex];
if (! selected) {
feedback. textContent = "Choose an answer before checking." ;
feedback. className = "fcpython-quiz-feedback" ;
setStepStatus (questionIndex, "unanswered" );
if (firstQuestionToReview === null ) {
firstQuestionToReview = questionIndex;
}
return ;
}
const selectedIndex = Number (selected. value );
if (selectedIndex === question. answer_index ) {
correctCount += 1 ;
feedback. textContent = `✅ Correct. ${ question. explanation } ` ;
feedback. className = "fcpython-quiz-feedback is-correct" ;
setStepStatus (questionIndex, "correct" );
} else {
const answer = question. options [question. answer_index ];
feedback. textContent = `❌ Not yet. Correct answer: ${ answer} . ${ question. explanation } ` ;
feedback. className = "fcpython-quiz-feedback is-incorrect" ;
setStepStatus (questionIndex, "incorrect" );
if (firstQuestionToReview === null ) {
firstQuestionToReview = questionIndex;
}
}
});
score. textContent = `Score: ${ correctCount} / ${ quiz. questions . length } ` ;
if (firstQuestionToReview !== null ) {
showQuestion (firstQuestionToReview, true );
}
});
reset. addEventListener ("click" , () => {
container. querySelectorAll ("input[type='radio']" ). forEach ((input) => {
input. checked = false ;
});
feedbackNodes. forEach ((feedback) => {
feedback. textContent = "" ;
feedback. className = "fcpython-quiz-feedback" ;
});
stepButtons. forEach ((_, questionIndex) => {
setStepStatus (questionIndex, "" );
});
score. textContent = "" ;
showQuestion (0 , true );
});
actions. appendChild (previous);
actions. appendChild (next);
actions. appendChild (check);
actions. appendChild (reset);
container. appendChild (actions);
container. appendChild (score);
showQuestion (0 );
return container;
}
7. Lab: make the remote gate match the local gate
Start with this incomplete workflow:
name : Quality
on :
pull_request :
jobs :
quality :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-python@v5
with :
python-version : "3.10"
- name : Install
run : python -m pip install -e ".[dev]"
- name : Quality
run : |
ruff check src || true
pytest -q
Complete these tasks:
remove the hidden-success behavior;
make Ruff inspect src and tests;
add Ruff format check, strict MyPy, and pytest;
keep project policy in pyproject.toml;
name steps so the first failed tool is visible;
compare each CI command with the final local command sequence;
explain how the workflow would reveal an uncommitted local fixture; and
diagnose three supplied cases: missing metadata, one Python-version failure, and a Ruff rule failure.
If you have a GitHub repository, you can push a branch and inspect the real run. If not, retain the authored YAML, parse it locally, and annotate the supplied transcripts. The assessed artifact and reasoning are the same.
Hint A: preserve exit status
Remove || true. Put each required command in a normal run step so a nonzero status fails the job.
Hint B: use check-only formatting
CI should call ruff format --check src tests; it should not rewrite runner files that cannot be committed back to the branch.
Hint C: compare the four commands
The intended sequence is Ruff lint, Ruff format check, MyPy on src, and pytest. Ensure the same paths appear in the local acceptance gate.
Show one complete workflow
name : Quality
on :
pull_request :
push :
jobs :
quality :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-python@v5
with :
python-version : "3.10"
- name : Install project and quality tools
run : python -m pip install -e ".[dev]"
- name : Lint
run : ruff check src tests
- name : Check formatting
run : ruff format --check src tests
- name : Check types
run : mypy src
- name : Run tests
run : pytest -q
The install command must match the project’s actual dependency declaration. A passing local gate immediately before push and this clean runner result provide two complementary environment checks.
Key points
CI reconstructs committed project state on a declared runner, exposing hidden local files, dependencies, paths, and environment assumptions.
Order checkout, interpreter setup, installation, and checks by dependency.
Required commands must preserve nonzero exit status; do not hide failures with || true or accidental non-blocking settings.
Keep local and CI check-only commands aligned and project policy in committed configuration.
Diagnose the earliest failed stage as source, configuration, installation, or environment evidence before editing application code.
A version matrix should test a real support claim, and a green result remains bounded by the workflow that actually ran.
Back to top