CI is the practice where developers regularly integrate their code into a shared repository.
Each time code is pushed, automated builds and tests are run to ensure that new changes do not break the existing codebase.
The types of tests that can be included in CI are:
Unit Tests
Integration Tests
Styling Checks
Linting Checks
Security Checks
If any test fails, the build is marked as broken, and the developer is notified.
Advantages of CI:
Automates the testing and review process.
Reduces the need for manual checks, such as code formatting or running tests locally.
Helps maintain a healthy and stable codebase by catching issues early.
Internal CI at Boot.dev:
Example: Boot.dev runs CI checks every time a new pull request is opened.
The CI process includes:
Unit Tests: Runs Go tests with the command go test.
Formatting Checks: Ensures the code is formatted correctly using go fmt.
This automation streamlines part of the code review process, focusing on logical and architectural reviews.
GitHub Actions for CI:
GitHub Actions is a CI/CD tool provided by GitHub that uses YAML files to define workflows.
A typical CI setup involves creating a .github/workflows directory in the repository.
Inside this directory, a workflow file (e.g., ci.yml) is added to define what actions should be performed when certain events occur (e.g., opening a pull request).
name: cion: pull_request: branches: [main]jobs: tests: name: Tests runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.23.0' - name: Force Failure run: (exit 1)
Explanation:
name: Names the workflow as ci.
on: Specifies the event that triggers the workflow (pull_request to the main branch).
jobs: Defines a set of tasks (jobs) that will be run.
runs-on: Specifies the environment (ubuntu-latest) to run the job on.
steps: Defines the sequence of commands to execute.
Check out code: Retrieves the code from the repository using actions/checkout@v4.
Set up Go: Installs the specified Go version using actions/setup-go@v5.
Force Failure: Runs exit 1, which will cause the step and workflow to fail (for testing purposes).
Success and Failure in GitHub Actions:
A step “succeeds” if it exits with a status code of 0.
A step “fails” if it exits with a status code other than 0 (typically 1).
The Force Failure step (run: (exit 1)) is intentionally designed to fail, demonstrating how CI handles test failures.
Testing the CI Workflow:
After creating the workflow file, push the changes to a remote branch.
Open a pull request to the main branch.
GitHub Actions will automatically run the defined ci workflow.
Because of the Force Failure step, the workflow will fail, confirming that the CI setup is correctly configured.
Conclusion:
This basic CI setup is a starting point for building more complex workflows.
Additional tests, linters, and code quality checks can be added as more steps in the steps section.
CI is essential for maintaining a robust development process by automating repetitive tasks and providing quick feedback to developers.
Breakdown of GitHub Actions Concepts
Workflows:
A workflow is a series of automated tasks defined using a .yml or .yaml file located in the .github/workflows directory.
Workflows are triggered by events such as a push, pull_request, or any other event that occurs in the GitHub repository.
In the example workflow file:
name: CIon: pull_request: branches: [main]
name: Specifies the name of the workflow (CI in this case).
on: Defines the event that triggers the workflow.
pull_request: This workflow is triggered whenever a pull request targeting the main branch is opened, updated (new commits = synchronize), or reopened. A merge is a closed event (with merged == true), which isn’t in the defaults, so to run something on merge use on: push to main.
Workflows can have any name (e.g., ci, build, test), which helps identify the workflow’s purpose.
Jobs:
A workflow is made up of one or more jobs.
A job is a set of steps that run on the same runner (a virtual machine instance provided by GitHub to execute the job).
Jobs are defined under the jobs section in the YAML file.
Each job can be run on a different environment (e.g., Linux, Windows, macOS).
Example of a single job called Tests:
jobs: tests: name: Tests runs-on: ubuntu-latest
name: Specifies the name of the job (Tests in this case).
runs-on: Specifies the operating system for the job (ubuntu-latest in this case).
Multiple jobs can be added to a single workflow to:
Run in parallel (e.g., testing different components simultaneously).
Test on multiple platforms (e.g., Windows, Linux, macOS).
Steps:
A job is made up of a series of steps, where each step performs a specific task.
Steps can:
Run commands.
Execute scripts.
Use actions (reusable components).
Example of steps in a job:
steps: - name: Check out code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.23.0' - name: Force Failure run: (exit 1)
name: A human-readable name for the step.
uses: Refers to a prebuilt GitHub Action to perform a task.
actions/checkout@v4: Checks out the repository’s code to the runner.
actions/setup-go@v5: Sets up the Go environment on the runner.
run: Executes a shell command or script.
run: (exit 1): Runs a command that exits with a status code of 1, causing the step to fail intentionally.
Detailed Breakdown of the Workflow Example:
name: cion: pull_request: branches: [main]jobs: tests: name: Tests runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.23.0' - name: Force Failure run: (exit 1)
Workflow Name:
The workflow is named ci.
It is triggered whenever a pull request is opened or updated in the main branch.
Job: Tests
The workflow contains a single job named Tests.
This job will run on ubuntu-latest, which is the latest Ubuntu distribution available on GitHub’s servers.
Steps in the Tests Job:
Check out code:
Uses the actions/checkout@v4 action to check out the source code from the repository.
Set up Go:
Uses the actions/setup-go@v5 action to set up the Go programming environment.
Specifies the go-version to be 1.23.0.
Force Failure:
Runs the command exit 1, which will always fail the job intentionally.
This is useful for testing whether the CI is correctly catching failures and reporting them.
Understanding Job Success and Failure:
Success: A step succeeds if it exits with a status code of 0.
Failure: A step fails if it exits with a non-zero status code (e.g., 1).
In this example, the Force Failure step will always fail because it explicitly exits with a status code of 1.
Expanding the Workflow:
This workflow can be expanded by adding more jobs and steps such as:
Running additional tests (e.g., unit tests, integration tests).
Performing linting or formatting checks.
Adding deployment steps to automate deployment after the tests pass.
Each new job can have its own set of steps, environment, and conditions for execution.
Conclusion:
This basic CI setup with a single job and a few steps is a foundational structure that can be built upon.
As development progresses, more comprehensive workflows can be created, including multiple jobs for different test cases, environments, and build configurations.
Continuous Deployment (CD) involves automatically deploying code to a production environment after successful testing and building. The objective is to automate the release process, so new changes are immediately deployed to production once merged or pushed to the main branch.
Steps for Setting up the CD Workflow
Create a new workflow file: cd.yml in the .github/workflows directory.
Define the workflow trigger: It should trigger on push events to the main branch.
Create a Deploy job with the following steps:
Checkout code.
Set up the Go toolchain.
Build the application using the scripts/buildprod.sh script.
Sample cd.yml Workflow File
# .github/workflows/cd.ymlname: Continuous Deploymenton: push: branches: [main]jobs: Deploy: name: Deploy runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: "1.23.0" - name: Build the app run: scripts/buildprod.sh
Workflow Explanation
Trigger Configuration:
The workflow triggers on push events to the main branch.
This ensures the deployment workflow runs whenever code is pushed directly to main or merged into main via a pull request.
Deploy Job Setup:
Job Name: Deploy.
Runs on: ubuntu-latest (the latest Ubuntu runner available in GitHub Actions).
Steps:
Check out code: Uses actions/checkout@v4 to clone the repository into the runner.
Set up Go: Uses actions/setup-go@v5 to install the specified Go version.
Build the app: Executes the custom script scripts/buildprod.sh to build the production version of the application.
Note: Despite the job name, this sample only builds. Nothing is pushed or deployed yet. Real CD adds a step that pushes the artifact/image to a registry and a deploy step (using secrets or OIDC credentials).
Running the CD Workflow
Create or update a pull request targeting the main branch.
Once merged into the main branch, the cd.yml workflow should automatically run.
Alternatively, you can push directly to main to trigger the workflow.
Key Differences Between ci.yml and cd.yml
ci.yml: Executes on pull requests and ensures the code changes are tested and formatted correctly before merging.
cd.yml: Executes on push to main to build the code (and, once push + deploy steps are added, ship it to production).
Troubleshooting Tips
Check the scripts/buildprod.sh Path:
Ensure the scripts/buildprod.sh script exists and has executable permissions (chmod +x scripts/buildprod.sh).
Ensure Correct Go Version:
If there are Go version issues, verify the Go version specified in the workflow matches your project’s Go version.
Workflow Debugging:
If the workflow fails, click on the Actions tab in GitHub, navigate to the cd workflow run, and review the logs for any error messages.
Notes
Always review the deployment process and environment configurations to avoid unexpected downtime.
Consider adding deployment to a staging environment before pushing directly to production.
This setup should help automate the deployment of your Notely project efficiently. Let me know if you’d like to expand the workflow to include additional deployment steps, such as Docker builds or Kubernetes deployments!
Automated code formatting is a great way to keep your code consistent and readable. It helps avoid arguments about code style, a phenomenon often referred to as “bikeshedding”.
Why Use Automated Code Formatting?
Consistency: Ensures that code is uniformly styled throughout the project.
Readability: Properly formatted code is easier to read and understand.
Focus: Allows developers to focus on actual code logic and functionality instead of debating over style issues.
Personal Anecdote
Many developers prefer to enforce automated code formatting from the start of a project. For example, in the Go programming language, a built-in, opinionated code formatter called go fmt is widely used. Most serious Go projects use this formatter to maintain consistency across the codebase.
Example: Unformatted vs. Formatted Code
Here’s an example of technically valid, but poorly formatted Go code:
func main() { fmt.Println("hello world!") }
Formatted correctly, it would look like this:
func main() { fmt.Println("hello world!")}
Using go fmt
The go fmt command is a built-in Go tool that automatically formats Go code. Since it is part of the Go toolchain, you don’t need to install anything additional to use it.
Command Syntax
To format a Go file or an entire project, simply run:
go fmt ./...
This command formats all .go files in the current directory and its subdirectories.
VS Code Integration
Many developers configure their code editors to format code automatically on save. For example, in VS Code, you can set up auto-formatting for Go (or any other language) to ensure your code always follows the proper style conventions.
Benefits of Auto-Formatting on Save
Saves Time: Developers don’t have to manually run formatting commands.
Reduces Errors: Automatically corrects minor formatting mistakes.
Prevents Style Debates: Eliminates subjective discussions around code styling.
Application in Projects
In most projects, formatting is enforced as part of the Continuous Integration (CI) pipeline, where code is checked and formatted before merging into the main branch. This ensures that all code meets the project’s styling guidelines.
Incorporating automated formatting tools is a recommended practice, whether you’re working alone or in a team environment, as it promotes cleaner and more maintainable codebases.
Checking Code Formatting
Automated code formatting tools are essential for maintaining code consistency and readability across various programming languages. However, some formatting tools may not indicate formatting issues via their exit status.
Ensuring Proper Formatting in CI
To ensure that your code is properly formatted, you can use a command that checks whether any files need formatting. If the output is empty, it indicates that the code is correctly formatted.
Example Command
Here’s a generic way to check formatting in many programming environments:
test -z "$(<formatter_command>)"
For Go, that’s:
test -z "$(gofmt -l .)"
Breakdown of the Command
<formatter_command>: This command runs the formatting tool in list mode on the current directory and all its subdirectories (e.g. gofmt -l .). If any files need formatting, the tool will print their names without changing them. Don’t use go fmt here: it rewrites the files (so the check would always pass), and ./... is go tool syntax that gofmt doesn’t accept.
**”(<formattercommand>)"∗∗:The‘()syntax is used for command substitution. It runs the command inside the parentheses and replaces$()with its output. Keep the quotes: unquoted, two or more file names become separate arguments andtest` fails with “too many arguments”.
test -z ”$(<formatter_command>)”: The test command checks if the output from the formatting tool is an empty string:
-z option returns 0 (true) if the string is empty, indicating that the code is formatted correctly.
Returns 1 (false) if there are any files that need formatting.
Practical Steps
Edit a File: Make an intentional formatting mistake in your code to test the formatting check.
Run the Formatting Check: Execute the formatting check command:
test -z "$(<formatter_command>)"
Check the Exit Code: Use the following command to print the exit code of the last command:
echo $?
You should see 1, indicating that the code is not formatted properly.
Fix Formatting: Run the formatting command again to automatically fix the formatting:
go fmt ./... # or: gofmt -w .
Re-check the Formatting: Run the formatting check command again:
test -z "$(<formatter_command>)"echo $?
This time, you should see 0, confirming that the code is now properly formatted.
Conclusion
Implementing code formatting checks as part of the CI/CD pipeline helps maintain code quality and consistency across different programming languages. Automating this process can significantly improve the code review process and reduce formatting-related discussions among team members.
Linting is the process of analyzing code for potential errors, style violations, and other issues. It helps maintain code quality, consistency, and readability across a codebase. Linters are tools that automatically check code for common problems and enforce coding standards.
Linting
Definition: Linting refers to the analysis of source code to detect potential errors, bugs, stylistic errors, and suspicious constructs. Linters provide warnings or errors for potentially problematic code, helping developers maintain code quality.
Key Differences from Code Formatting
Code Formatting: Deals with the aesthetic appearance of the code, including aspects like whitespace, indentation, and line length.
Linting: Focuses on analyzing code for functional issues and potential bugs, offering insights into code quality.
Popular Linters
Each programming language typically has its own set of linters. Common examples include:
JavaScript/TypeScript: ESLint
Python: Flake8, Pylint
Java: Checkstyle, PMD
Go: Staticcheck (recommended over Golint)
Installation
Installing a Linter: Installation methods may vary based on the language and linter being used. Common approaches include:
Package managers (e.g., npm for JavaScript, pip for Python)
Direct installation via command line
Example for Go’s Staticcheck:
go install honnef.co/go/tools/cmd/staticcheck@latest
or
brew install staticcheck
Running the Linter
Command to Run: Typically, you run the linter from the command line within your project directory.
Example for Go:
staticcheck ./...
Check for Issues: If there are any issues in your code, the linter will output warnings or errors, often detailing the nature of the problem.
Environment Configuration: Ensure your development environment is configured to recognize the linter, which may involve adjusting environment variables (e.g., PATH).
Example Issue Detection
Introduce a common code issue, such as an unused function, to verify that the linter detects it.
Example of an unused function:
func unused() { // this function does nothing // and is called nowhere}
Upon running the linter, expect to see a warning or error indicating the issue, such as:
func unused is unused (U1000)
Conclusion
Linting is an essential practice in software development, helping to ensure code quality and maintainability. Integrating linting into your development workflow can save time and reduce the likelihood of introducing bugs into your codebase.
CI Linting
Objective: Integrate linting checks into the CI pipeline to ensure code quality by automatically identifying and reporting issues during the build process.
Steps to Integrate Linting in CI
Install the Linter:
The CI runner may not have the required linter installed, so add a step in your CI configuration to install the linter specific to your programming language before executing any linting commands.
Example for Go:
- name: Install staticcheck run: go install honnef.co/go/tools/cmd/staticcheck@latest
Example for JavaScript (ESLint):
- name: Install ESLint run: npm install eslint
Example for Python (Flake8):
- name: Install Flake8 run: pip install flake8
Run Linting After Formatting:
After performing any code formatting checks (e.g., using prettier for JavaScript or black for Python), add a step to run the linter. This ensures both formatting and linting issues are caught in the CI pipeline.
Example for Go:
- name: Run staticcheck run: staticcheck ./...
Example for JavaScript:
- name: Run ESLint run: npx eslint .
ESLint v9+ uses flat config and removed --ext; which files get linted (e.g. **/*.{js,jsx}) is set by files in eslint.config.js.
Example for Python:
- name: Run Flake8 run: flake8 .
Commit and Push Changes:
Introduce a known issue (like an unused variable or function) to verify that the CI workflow correctly identifies and reports the problem. Commit and push these changes to your branch with an open pull request.
Verify CI Workflow Failure:
After pushing, check that the CI workflow fails as expected due to the linting error detected by the linter.
Fix the Issue:
Resolve the linting errors by modifying the code, and then commit and push your changes again. The CI workflow should now pass without any errors.
Alternative Integration Methods:
Consider using official GitHub Actions or other CI/CD tools specific to your programming language. These can simplify the integration process and help manage updates.
Conclusion
Integrating linting checks into your CI pipeline is crucial for maintaining high code quality across programming languages. Automating the detection of potential issues reduces manual review workloads and ensures adherence to established coding standards. By following these steps, you can effectively incorporate linting into your development workflow regardless of the programming language.
Overview
Security checks in Continuous Integration (CI) are automated processes used to identify potential security vulnerabilities in the codebase. They are an essential part of the DevSecOps approach, ensuring that code is scanned for security issues during the development process itself. Static security analysis tools can identify problems like hardcoded secrets, unhandled errors, vulnerable dependencies, and more before code is deployed.
Popular Security Check Tools Across Different Languages
While the provided example uses Gosec for Go, various security tools are available for different programming languages:
1. Install Gosec
To install Gosec, use the following command:
go install github.com/securego/gosec/v2/cmd/gosec@latest
2. Run Gosec on the Codebase
To run Gosec and analyze the entire codebase, execute:
gosec ./...
Expected Output
When you run Gosec, you might see a list of security warnings, such as unhandled errors or potential vulnerabilities. It’s okay if there are some findings; the objective is to gradually address them and improve security.
Adding Security Checks to CI Workflows
Integrating Security Checks in CI Pipelines
To make security checks part of your CI/CD process, follow these steps:
Install the Security Tool
In the relevant job step, install the security tool if it’s not built into the runner. For example:
- name: Install Gosec run: go install github.com/securego/gosec/v2/cmd/gosec@latest
Run Security Analysis
After installing the tool, add a step to run the security analysis:
- name: Run Gosec run: gosec ./...
Handle Results
CI tools like GitHub Actions or Jenkins can be configured to fail the build if security issues are found. Alternatively, they can just raise a warning depending on the severity.
Best Practices for Security Checks in CI/CD
Run Security Scans Early and Often
Running scans only at the end of the development process can lead to missed issues. Integrate security scans from the start to catch issues as code is developed.
Set a Baseline
If the current codebase has many security findings, set a baseline to prevent new vulnerabilities from being introduced while you work on fixing existing ones.
Use a Combination of Tools
One tool might not catch everything. Consider using multiple tools for broader coverage (e.g., combining static analysis, dynamic analysis, and dependency scanning).
Incorporate Security Tests in Your Development Process
Security shouldn’t be an afterthought. Incorporate security practices and tools in the local development environment as well to prevent issues from being committed in the first place.
Takeaway
Static security checks are an important step in ensuring the safety of your codebase, and they should be a routine part of the CI/CD pipeline. Implementing automated tools like Gosec (or other language-specific tools) ensures potential vulnerabilities are caught and fixed early, making your application more robust and secure.
Unit testing involves testing individual units or components of a software application in isolation.
A “unit” could be a function, method, or module that performs a specific task.
Unit tests are used to validate that each part of the software behaves as expected under various scenarios, such as typical inputs, edge cases, or invalid inputs.
2. Purpose of Adding Unit Tests to CI Pipelines:
Early Detection: Helps identify and fix bugs early in the development cycle.
Automation: Automated testing ensures consistency and saves time.
Code Quality: Encourages good coding practices by ensuring new code doesn’t introduce regressions.
Confidence in Changes: Provides confidence that code changes will not break existing functionality.
3. Steps to Implement Unit Testing:
Identify the Functionality to Test:
Choose functions, modules, or components that perform a critical task.
Ensure the tests cover typical cases, edge cases, and potential failure scenarios.
Create a Test File or Module:
Follow naming conventions that are recognized by your testing framework (e.g., file names ending in _test, or using specific folders like tests/).
Use the standard testing framework of your language or third-party libraries as needed.
Write the Tests:
Structure each test function to check a specific aspect of the code.
Use assertions to validate expected outcomes.
For example:
Positive Cases: Ensure correct output for valid inputs.
Negative Cases: Check how the code handles invalid or missing inputs.
Run Tests Locally:
Before pushing changes, run all tests in your local environment to ensure they pass.
Example commands:
For Python: pytest
For JavaScript: npm test
For Java: mvn test
4. Integrating Unit Tests into a CI/CD Pipeline:
Modify your CI configuration file (e.g., .yml, .json, etc.) to include a step for running tests.
Example of a CI Configuration:
name: CI Pipelineon: pull_request: branches: [main]jobs: run-tests: name: Run Unit Tests runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up Environment run: | # Commands to set up language runtime, dependencies, etc. # Example: For Python: python -m venv venv && venv/bin/pip install -r requirements.txt # Example: For JavaScript: npm install - name: Run Unit Tests run: | # Run the test command for your language # Example: For Python: venv/bin/python -m pytest # Example: For JavaScript: npm test
Explanation:
Trigger: The CI pipeline is triggered on a pull request to the main branch.
Job Definition: The pipeline has a single job called run-tests.
Steps:
Check out the repository code.
Set up the runtime environment (e.g., install dependencies).
Each run: step is a new shell, so source venv/bin/activate doesn’t carry over to the next step. Call venv/bin/python directly (or activate and test in the same step), or use actions/setup-python.
Run unit tests using the appropriate command for the language or framework.
5. Handling Test Results:
If any test fails, the CI job will fail, and the developer will be notified.
Review the logs for detailed error messages and fix the failing tests.
Once the issues are resolved, push the changes, and the tests will re-run automatically.
6. Common Best Practices for Unit Testing:
Isolate Tests: Each test should be independent, without relying on external systems (e.g., databases, APIs) unless necessary.
Use Mocks/Stubs: Use mock data or stubs to simulate dependencies.
Write Descriptive Test Names: Clearly indicate what each test is checking.
Aim for High Test Coverage: Ensure that a significant portion of the code is covered by tests.
Key Takeaways
Implementing unit tests in a CI pipeline helps catch bugs early and automates the testing process.
Write tests that cover a range of scenarios, ensuring code quality and reliability.
Use CI/CD tools to automatically run these tests for each pull request, creating a robust development workflow.
Code Coverage
Code coverage is a measure of how much of your code is being tested. It’s a controversial metric, but I’ll try to provide a balanced take on… at least as balanced as I can be with my personal biases.
For example, if you have 1000 lines of code in your project, and you have tests that cover the logic in 500 of those lines, then we’d say that you have 50% code coverage.
Why is code coverage controversial?
One reason is that it’s quite possible to have 100% code coverage and still have bugs in your code. It’s also possible to have 0% code coverage and have a bug-free application. Unit tests help us find bugs and codify the expected behavior of individual units of code, but they don’t guarantee that there are no bugs.
My personal take is that it’s really hard to say “20% is bad, 80% is good”. I think some functions and methods are more important to have unit tests for than others. For example, I don’t love the idea of unit testing external systems, like databases.
I think that’s a better use case for integration tests.
What does this mean to you?
As a junior developer, you should know what code coverage is, and you should be amicable to the code coverage requirements of the company you work for. You’ll certainly develop your own opinions (and should politely vocalize them) about its efficacy over time, but it’s important to be a team player and not rock the boat too much when you’re just starting out and lack your own experience.
README Badge for Tests
One cool feature on GitHub is that you can add a dynamic badge to your README.md file that shows the status of your tests.
It’s a great way to show off that your code is well-tested, and that the tests are passing without users having to go check the Actions tab on GitHub.
Assignment
Add a badge to the top of your README.md file that shows the status of your tests.
Dynamic Badge URL
The syntax for the URL of the dynamically generated image is: