Training
GitHub Actions
Chaos Creeps In
Even the best teams drift. Rules are forgotten, naming conventions slip, and documentation falls behind. Left unchecked, every project trends toward disorder. Chaos doesn’t arrive suddenly — it creeps in quietly with each rushed commit and skipped review.
Automation Restores Order
GitHub Actions transforms your rules into code. Every push and pull request becomes an opportunity to check structure, test logic, and protect quality automatically. With automation guarding the boundaries, consistency stops depending on memory and starts depending on design.
Introduction & Setup
Chaos Creeps In
Entropy is real. Repos grow, rules drift, quality slips. Left alone, small inconsistencies become real problems.
Automation Restores Order
Consistent, automatic checks keep work tidy, repeatable, and easy to teach. GitHub Actions is your lightweight rule engine that runs when things change.
Training Goals
-
Understand what GitHub Actions is and why it is useful
-
See how automation can respond when changes happen in a repository
-
Create and run a simple workflow that performs an automatic task
-
Recognize how automation supports consistency, organization, and reproducibility
GitHub Actions lets you define small, repeatable chores that run for you when something happens in your repository. For example, when someone pushes a change or opens a pull request, your workflow can check spelling, lint Markdown, or build a site. You describe the chore in a short YAML file in .github/workflows/. GitHub hosts the work for you. You get results right on the pull request, which encourages teams to keep standards high without extra meetings or manual checks.
Training Resources
This single page is intended to jump-start your exploration of GitHub Actions. To do the training, fork the following repository:
#!/bin/bash
# ---------------------------------------------------------------------
# Clone your own fork of the GitHub Actions training repository
# Repository: https://github.com/cropandsoil/github-actions-training
# ---------------------------------------------------------------------
# 1. Set your GitHub username (edit this line)
GITHUB_USER="your-github-username"
# 2. Define repo name and URLs
REPO_NAME="github-actions-training"
FORK_URL="https://github.com/${GITHUB_USER}/${REPO_NAME}.git"
UPSTREAM_URL="https://github.com/cropandsoil/${REPO_NAME}.git"
# 3. Choose or create a working directory
cd ~/projects 2>/dev/null || mkdir -p ~/projects && cd ~/projects
# 4. Clone your fork
echo "Cloning your fork..."
git clone "$FORK_URL"
cd "$REPO_NAME" || exit 1
# 5. Confirm that origin points to your fork
echo "Checking remote origin:"
git remote -v
# 6. (Optional) Add the main class repository as 'upstream'
echo "Adding upstream (optional)..."
git remote add upstream "$UPSTREAM_URL" 2>/dev/null || echo "Upstream already set."
# 7. Make a quick test edit (optional)
# echo "# test edit" >> README.md
# 8. Commit and push to trigger GitHub Actions in your fork
# git add README.md
# git commit -m "Trigger GitHub Actions test run"
# git push origin main
# ---------------------------------------------------------------------
# Done! Any push to your fork will now trigger GitHub Actions.
# ---------------------------------------------------------------------
[/showhide]What Are GitHub Actions?
GitHub Actions lets you automate work in your repository when something happens. You describe a workflow in YAML, save it under .github/workflows/, and GitHub runs it in a clean environment. The results appear as checks on commits and pull requests so teams get fast, repeatable feedback.
An example of how this works:
- You push a change or open a pull request (an event).
- A workflow listens for that event and starts a job on a runner.
- The job executes a series of steps, each step runs an action or a shell command.
- The run produces logs, checks, and optional artifacts you can download.
[showhide type="doc_events" hidden="yes" more_text="Show GitHub Actions Event Types" less_text="Hide GitHub Actions Event Types"]
There are lots of other GitHub-native events that can trigger workflows besides cron and push/PR.
Here’s a quick map of the major categories with examples (all supported in on:):
-
Repo/branch/tag lifecycle:
create,delete,fork,release,registry_package,page_build. -
Issues & discussions:
issues,issue_comment,discussion,discussion_comment. You can also filter to specific activity types withtypes:. -
Pull-request ecosystem:
pull_request(and activity types),pull_request_review,pull_request_review_comment,pull_request_target(special security considerations). -
Checks & statuses:
check_run,check_suite,status(useful for reacting to external CI results). -
Security & dependency signals:
code_scanning_alert,secret_scanning_alert,dependabot_alert,dependency_review. -
Deployments & environments:
deployment,deployment_status,workflow_run(trigger after another workflow finishes). -
Pages & wiki:
page_build,gollum(wiki edits). -
Stars/watch:
watch(stars), which can be handy for small automations. -
Manual/custom/system-to-system:
-
workflow_dispatch(manual runs with inputs), -
repository_dispatch(external systems ping your workflow with a custom payload), -
workflow_call(reusable workflow invoked by another workflow).[/showhide]
-
 Core Vocabulary (learn these first)
-
Workflow: YAML file that defines when to run and what to do.
-
Event: Trigger that starts a workflow (e.g.,
push,pull_request,schedule). -
Job: A group of steps that runs on a runner (can run in parallel or sequence).
-
Step: A single action or shell command inside a job.
-
Action: Reusable unit of work (e.g.,
actions/checkout@v4). -
Runner / Runner image: The VM that executes your job (e.g.,
ubuntu-latest). -
Check: Pass/fail status shown on commits and pull requests.
-
Artifact: Files saved from a run (reports, build outputs).
-
Secrets: Encrypted values available to jobs without exposing them in logs.
-
Permissions: Repo access granted to the workflow via the
permissions:block.
Useful next:
-
Context: Read-only data for expressions (e.g.,
github.ref,github.actor). -
Expression: Logic inside
${{ }}for conditions/inputs (e.g.,if:). -
Environment variables (
env:): Key–value pairs available to steps. -
Matrix: Run the same job across variants (e.g., multiple Node versions).
-
Cache: Saved dependencies to speed up builds.
-
Concurrency: Prevent or cancel overlapping runs of the same workflow.
Learning by Example
Use these small workflows to learn by doing. Each exercise takes just minutes.
Hello Workflow
[showhide type="doc_hello" hidden="yes" more_text="Show Hello Workflow Details" less_text="Hide Hello Workflow Details"]
Learning goal: see a workflow fire from a simple commit and locate the run output.
What this teaches:
- Where workflows live in the repo and how
on:triggers work - How jobs and steps appear in the Actions UI
- How to read the run log and find the commit SHA
What to notice:
- The file path
.github/workflows/hello.ymlis what makes it a workflow - The runner image is a clean VM for every run
- The
${{ github.sha }}context gives traceability
Common pitfalls:
- Pushing to a branch that is not matched by the trigger
Expecting environment to persist between runs
Go further:
- Add a second step that prints
${{ github.ref }}and${{ github.actor }}to learn contexts
Try it: edit a line in the workflow, commit, then open the Actions tab to see the run.
[/showhide]
name: Hello Workflow
on:
push:
branches: [ main ]
jobs:
hello:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Say hello
run: |
echo "Hello from GitHub Actions. Commit SHA: ${{ github.sha }}"
[/showhide]Markdown Lint
[showhide type="doc_markdown_lint" hidden="yes" more_text="Show Markdown Lint Details" less_text="Hide Markdown Lint Details"]
Learning goal: experience a fast feedback loop on pull requests.
What this teaches:
- Pull request triggers and check results on the PR page
- Reusable marketplace actions with pinned versions
- Team norms enforced as automation, not nagging
What to notice:
- The
paths:filter saves minutes by running only for docs changes - Failing checks block merges when branch protection is enabled
Common pitfalls:
- Forgetting to pin action versions to a major version
- Large repos without
paths:filters that run more than needed
Go further:
Add a .markdownlint.json to tune rules
[/showhide]
name: Markdown Lint
on:
pull_request:
paths:
- '**/*.md'
jobs:
mdlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@v18
with:
globs: "**/*.md"
[/showhide]Permissions Minimum
[showhide type="doc_permissions_minimum" hidden="yes" more_text="Show Permissions Minimum Details" less_text="Hide Permissions Minimum Details"]
Learning goal: set least-privilege permissions at the workflow level.
What this teaches:
- The
permissions:block and why it matters for security - Grant write only when a job must write to issues or PRs
- How permissions apply by default to all jobs in the file
What to notice:
contents: readis usually enough for CI that only checks code- Jobs needing to comment on PRs require
pull-requests: write
Common pitfalls:
- Adding a wide write permission globally when only one job needs it
- Confusing job-level permissions with workflow-level defaults
Go Further:
- Compare with a version that tries to write a comment without the right scope
[/showhide]
name: Permissions Example
on:
pull_request:
permissions:
contents: read
pull-requests: write # only when you need to comment or set statuses
jobs:
demo:
runs-on: ubuntu-latest
steps:
- run: echo "Least privilege illustrated"
[/showhide]Python Tests
[showhide type="doc_python_tests" hidden="yes" more_text="Show Python Tests Details" less_text="Hide Python Tests Details"]
Learning goal: set up Python, cache dependencies, and run tests.
What this teaches:
- Using
actions/setup-pythonand language-specific cache - The value of a lockfile for reproducible installs
- Where to read test failures in logs
What to notice:
- Cache is keyed by Python version and dependency state
pytest -qkeeps logs readable for beginners
Common pitfalls:
- Forgetting to include a
requirements.txtor using floating dependencies - Cache keyed too broadly so stale packages leak across changes
Go further:
- Add coverage with
pytest-covand upload a small HTML report as an artifact
[/showhide]
name: Python Tests
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- run: pytest -q
[/showhide]Node CI
[showhide type="doc_node_ci" hidden="yes" more_text="Show Node CI Details" less_text="Hide Node CI Details"]
Learning goal: use Node with cache, tests, and linting when present.
What this teaches:
actions/setup-nodewith automatic npm cache- Optional steps guarded by scripts that may or may not exist
- How lint and unit tests complement each other
What to notice:
npm ciuses the lockfile for deterministic installs- Lint often finds issues before tests do, which saves review time
Common pitfalls:
- Running
npm installinstead ofnpm ciin CI - Forgetting to commit
package-lock.json
Go further:
- Add a matrix to test Node 18 and 20 for compatibility
[/showhide]
name: Node CI
on:
pull_request:
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test --if-present
- run: npm run lint --if-present
[/showhide]Artifact Demo
[showhide type="doc_artifact_demo" hidden="yes" more_text="Show Artifact Demo Details" less_text="Hide Artifact Demo Details"]
Learning goal: produce and keep a file from a run for later download.
What this teaches:
- When artifacts are useful, for example reports or build outputs
- How to control retention and name artifacts clearly
- How artifacts appear in the run summary
What to notice:
- Artifacts are scoped to a run, not to the repository
- Small artifacts are ideal for homework checks or grading evidence
Common pitfalls:
- Uploading enormous folders without retention limits
- Assuming artifacts are permanent
Go further:
- Attach a short Markdown report generated by the job and compare versions across runs
[/showhide]
name: Artifact Demo
on:
push:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate report
run: |
mkdir -p reports
echo "Run $GITHUB_RUN_NUMBER on $GITHUB_REF" > reports/summary.txt
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: run-report
path: reports/
retention-days: 7
[/showhide]Run Only When Files Change
[showhide type="doc_run_only" hidden="yes" more_text="Show Run Only When Files Change Details" less_text="Hide Run Only When Files Change Details"]
Learning goal: reduce CI noise by running only when relevant files have changed.
What this teaches:
- The concept of selective execution based on changed paths
- The trade-off between completeness and speed
- How to expose the changed file list in logs for debugging
What to notice:
- The action outputs lists you can branch on with
if: - You can whitelist and blacklist patterns at the same time
Common pitfalls:
- Forgetting that renames and deletions also affect change sets
- Over-restricting patterns and skipping valuable checks
Go further:
- Combine with artifact upload to produce a report only for docs edits
[/showhide]
name: Run Only When Files Change
on:
pull_request:
permissions:
contents: read
jobs:
selective:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: changed
uses: tj-actions/changed-files@v45
- name: List changed Markdown files
if: steps.changed.outputs.any_changed == 'true'
run: |
echo "All changed: ${{ steps.changed.outputs.all_changed_files }}"
echo "Changed .md: ${{ steps.changed.outputs.all_modified_files }}"
[/showhide]PR Labeler
[showhide type="doc_pr_labeler" hidden="yes" more_text="Show PR Labeler Details" less_text="Hide PR Labeler Details"]
Learning goal: automate triage using file-based labels.
What this teaches:
pull_request_targetversuspull_requestand why the former is used here- How labeling enables filtered views and rules in repositories
- Minimal write permissions for pull requests
What to notice:
- Label rules live in
.github/labeler.ymlwhich can evolve over time - Labels are a low-friction way to route reviews in larger classes or teams
Common pitfalls:
- Using
pull_request_targetto run untrusted code. This workflow uses a trusted action with a limited scope - Too many labels that become noise
Go further:
- Add an assignment rule that pings a maintainer for certain paths
[/showhide]
# SPDX-License-Identifier: Apache-2.0
name: PR Labeler
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler.yml
[/showhide]Reusable Markdown Lint (callee)
[showhide type="doc_reusable_workflow_callee" hidden="yes" more_text="Show Reusable Workflow (callee) Details" less_text="Hide Reusable Workflow (callee) Details"]
Learning goal: define a shared workflow that other repos can call.
What this teaches:
workflow_callwith typed inputs and safe defaults- The idea of centralizing a rule once and reusing everywhere
- Clear interfaces for repeatable automation
What to notice:
- Inputs with defaults allow call sites to be very short
- The callee can evolve under version control like any other code
Common pitfalls:
- Forgetting to mark the file with
on: workflow_call - Tight coupling to a specific repository layout
Go further:
- Add an output that returns a summary string the caller can post as a comment
[/showhide]
name: Reusable Markdown Lint
on:
workflow_call:
inputs:
globs:
type: string
required: false
default: "**/*.md"
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DavidAnson/markdownlint-cli2-action@v18
with:
globs: ${{ inputs.globs }}
[/showhide]Call Reusable Markdown Lint (caller)
[showhide type="doc_call_reusable_markdown_lint" hidden="yes" more_text="Show Call Reusable Markdown Lint (caller) Details" less_text="Hide Call Reusable Markdown Lint (caller) Details"]
Learning goal: consume a shared workflow by path and ref.
What this teaches:
- Referencing another workflow with
uses:and a commit, tag, or branch - Passing inputs through to the callee
- The importance of version pinning for stability
What to notice:
- The caller is short and easy to read
- The ref can be a tag for a stable teaching release
Common pitfalls:
- Using a moving branch for uses: and getting unexpected changes
- Incorrect path to the callee file
Go further:
- Call the same callee twice with different inputs to compare results
[/showhide]
name: Call Reusable Lint
on:
pull_request:
permissions:
contents: read
jobs:
docs:
uses: cropandsoil/github-actions-training/.github/workflows/reusable-lint.yml@main
with:
globs: "**/*.md"
[/showhide]Scheduled Maintenance
[showhide type="doc_scheduled_maintenance" hidden="yes" more_text="Show Scheduled Maintenance Details" less_text="Hide Scheduled Maintenance Details"]
Learning goal: run on a clock and by hand, independent of code changes.
What this teaches:
- Cron scheduling in UTC and manual
workflow_dispatch - Lightweight housekeeping jobs that create a visible trace
- How to log timestamps for audit
What to notice:
- Cron syntax is terse, include a comment with the human time
- Scheduled jobs are ideal for reminders and cleanups
Common pitfalls:
- Assuming local time rather than UTC in cron expressions
- Forgetting to test with
workflow_dispatch
Go further:
- Add a matrix of times or days to compare queue wait times
[/showhide]
name: Scheduled Maintenance
on:
schedule:
- cron: '0 9 * * 1' # Mondays 09:00 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
housekeeping:
runs-on: ubuntu-latest
steps:
- run: echo "Weekly job ran at ${{ github.run_started_at }}"
[/showhide]Inactivity Watchdog
[showhide type="doc_inactivity_watchdog" hidden="yes" more_text="Show Inactivity Watchdog Details" less_text="Hide Inactivity Watchdog Details"]
Learning goal: detect silence and notify maintainers without a code push.
What this teaches:
- Scheduled jobs that call the GitHub API using
actions/github-script - Conditional steps based on calculated outputs
- Optional email via SMTP secrets and a record kept in issues
What to notice:
- The check is based on
pushed_atwhich includes merges into the default branch - The job writes a label and updates a single open reminder issue
Common pitfalls:
- Missing SMTP secrets, which is fine because the issue reminder still works
- Forgetting to grant
issues: writewhen creating or updating issues
Go further:
- Generalize the email recipient list using a repository secret instead of a hardcoded address
[/showhide]
# .github/workflows/inactivity-watchdog.yml
# SPDX-License-Identifier: Apache-2.0
name: Inactivity Watchdog
on:
schedule:
- cron: "7 12 * * *" # every day at 12:07 UTC
workflow_dispatch: # allow manual runs
permissions:
contents: read
issues: write
env:
MAINTAINER_EMAIL: maintainer@example.com # <-- set to the primary maintainer
INACTIVITY_DAYS: "7"
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Compute days since last push
id: since
uses: actions/github-script@v7
with:
script: |
// Language: JavaScript (Node.js via actions/github-script)
const {owner, repo} = context.repo;
const {data: r} = await github.rest.repos.get({owner, repo});
const pushed = new Date(r.pushed_at); // last push to default branch
const now = new Date();
const days = Math.floor((now - pushed) / (1000*60*60*24));
core.info(`Last push: ${pushed.toISOString()} (${days} days ago)`);
core.setOutput("days", String(days));
core.setOutput("stale", days >= Number(process.env.INACTIVITY_DAYS) ? "true" : "false");
# Optional email step (requires SMTP secrets). Skips cleanly if not configured.
- name: Send inactivity email
if: steps.since.outputs.stale == 'true' && env.MAINTAINER_EMAIL != ''
uses: dawidd6/action-send-mail@v3
with:
server_address: ${{ secrets.SMTP_HOST }} # e.g., smtp.office365.com
server_port: ${{ secrets.SMTP_PORT }} # e.g., 587
username: ${{ secrets.SMTP_USERNAME }}
password: ${{ secrets.SMTP_PASSWORD }}
subject: "Repo inactive for ${{ steps.since.outputs.days }} days: ${{ github.repository }}"
to: ${{ env.MAINTAINER_EMAIL }}
from: "${{ secrets.FROM_NAME }} <${{ secrets.FROM_EMAIL }}>"
secure: true
priority: low
body: |
Heads up: ${{ github.repository }} has had no pushes or merges
for ${{ steps.since.outputs.days }} days.
Last push timestamp (UTC): ${{ github.event.repository.pushed_at || 'checked via API' }}
Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Always create or update a reminder issue so there is a record, even without SMTP
- name: Open or update reminder issue
if: steps.since.outputs.stale == 'true'
uses: actions/github-script@v7
with:
script: |
const title = "Inactivity reminder: no pushes in {{DAYS}} days"
.replace("{{DAYS}}", "${{ steps.since.outputs.days }}");
const label = "inactivity-reminder";
// ensure label exists
try {
await github.rest.issues.getLabel({ ...context.repo, name: label });
} catch {
await github.rest.issues.createLabel({ ...context.repo, name: label, color: "ededed" });
}
// find an open reminder
const {data: issues} = await github.rest.issues.listForRepo({
...context.repo, state: "open", labels: label
});
if (issues.length) {
await github.rest.issues.createComment({
...context.repo, issue_number: issues[0].number,
body: `Still inactive at ${new Date().toISOString()} (run ${{ github.run_id }}).`
});
} else {
await github.rest.issues.create({
...context.repo,
title, labels: [label],
body: `This repository has had no pushes or merges for **${{ steps.since.outputs.days }}** days.`
});
}
[/showhide]ADR Policy Check (advanced)
Purpose: discuss policy as code outside class time. Because this is an advanced topic and the implementation is long, inspect the file via the GitHub repository.
Try it: skim the structure and talk about when to gate merges on policy.
Save Time with Free Marketplace GitHub Actions
-
actions/checkout: Fetches your repository for the run.
-
actions/setup-node: Installs Node.js with optional dependency caching.
-
actions/setup-python: Installs Python with optional pip caching.
-
actions/cache: General-purpose caching to speed up builds.
-
actions/upload-artifact: Uploads build outputs or reports from a run.
-
actions/download-artifact: Downloads artifacts in later jobs or workflows.
-
DavidAnson/markdownlint-cli2-action: Lints Markdown for consistency and clarity.
-
github/codeql-action: Code scanning for security vulnerabilities.
-
actions/dependency-review-action: Flags vulnerable dependencies in pull requests.
-
tj-actions/changed-files: Detects changed paths to run only what matters.
-
actions/labeler: Auto-labels pull requests based on file changes.
-
peter-evans/create-pull-request: Opens PRs from automation tasks.
-
softprops/action-gh-release: Creates GitHub Releases and uploads assets.
-
googleapis/release-please-action: Automates releases and changelogs.
-
docker/build-push-action: Builds and optionally pushes Docker images.
-
actions/stale: Marks and closes inactive issues or PRs.
-
dawidd6/action-send-mail: Sends email via SMTP from workflows.
-
codecov/codecov-action: Uploads coverage reports to Codecov.
-
actions/upload-pages-artifact: Packages a static site for Pages deployment.
-
actions/deploy-pages: Deploys the Pages artifact to the
github-pagesenvironment.
GitHub Action Ideas
TODO expiry nudge
Add a comment when a TODO older than 30 days is found in a PR diff.
Triggers: pull_request. Hints: scan changed files, parse dates, post comment. Permissions: pull-requests write.
Conventional commits gate
Fail the check if the PR title is not a valid Conventional Commit.
Triggers: pull_request. Hints: regex on title, short error message with examples.
PR size budget
Warn when a PR adds more than N lines. Encourage smaller reviews.
Triggers: pull_request. Hints: use changed-files to get stats, post a summary.
Enforce CODEOWNERS review
Block merge if paths owned by a team lack an approving review
Triggers: pull_request. Hints: read CODEOWNERS, compare reviewers. Pair with branch protection.
Docs spell check on demand
Run spell check only when .md files change.
Triggers: pull_request with paths: filter. Hints: reusable action, custom dictionary file.
Lint workflow YAML
Validate files in .github/workflows/ with actionlint.
Triggers: pull_request. Hints: actionlint action or container, fail fast with clear output.
License header audit
Fail if modified files are missing an SPDX header.
Triggers: pull_request. Hints: grep each changed file, allow a skip pattern, show a quick fix.
Coverage threshold
Upload coverage and fail if total falls below a target.
Triggers: push and pull_request. Hints: parse coverage summary, job outputs for pass or fail.
Dependency risk check
Run dependency review and fail on known vulnerabilities above a chosen severity.
Triggers: pull_request. Hints: use actions/dependency-review-action, link to the report.
Nightly smoke test
Run a minimal test suite at 02:00 UTC even if no code changed.
Triggers: schedule. Hints: short test command, artifact with timestamp, alert only on failure.
Get Free Actions
Browse curated, no-cost Actions to lint, test, build, and publish. Start here when you want a ready-made building block.
Actions Cheat Sheet
Concise quick reference for syntax, triggers, contexts, and common job patterns. Handy printable one-pager.
Starter Workflows
Drop-in CI templates from GitHub. Good patterns for Node, Python, Docker, Pages, and more to save you time.