30 Days of DevOps — a series by @syssignals Every article is a working project. Every command is verified. No fluff.
The problem nobody talks about
Your CI/CD pipeline is fast. Your tests pass. Your Dockerfiles are clean.
But every deployment is still a negotiation. Developers are stepping on each other. Hotfixes break features. The main branch is a graveyard of half-merged work and nobody quite knows what’s in prod.
The bottleneck isn’t your tools. It’s your branching strategy.
Most teams inherit a branching strategy by accident — someone set it up years ago, nobody questioned it, and now it’s load-bearing. Changing it feels risky, so nobody does.
This article gives you two battle-tested strategies — GitFlow and Trunk-Based Development — with complete setup, real configs, and the decision framework to know which one fits your team. By the end, you’ll have a production-ready repository template with enforced workflow, automated hooks, and PR automation you can use immediately.
What you’ll build
A fully configured Git repository with:
- Branch protection rules enforced via GitHub API
- Husky + lint-staged pre-commit hooks (lint, format, commit message validation)
- Conventional Commits enforcement with commitlint
- PR template with checklist
- GitFlow and Trunk-Based branch models implemented side by side
- A reusable
repo-template/folder you cangit cloneinto any new project
Estimated time: 45 minutes
Skill level: Beginner to intermediate
All commands tested on: Ubuntu 22.04, macOS 14 Sonoma, Git 2.43+
Prerequisites
- Git 2.30+ installed (
git --version) - Node.js 18+ installed (
node --version) — needed for Husky and commitlint - A GitHub account with a personal access token (classic) scoped to
repo ghCLI installed (optional but recommended — install: https://cli.github.com)jqinstalled (sudo apt install jq/brew install jq)
Part 1: Understanding the two strategies
Before touching a terminal, you need to understand what you’re choosing between and why it matters for DevOps.
GitFlow
Introduced by Vincent Driessen in 2010. Designed for teams with scheduled releases and multiple versions in production simultaneously.
Branch structure:
main ← production code only. Tagged at every release.
develop ← integration branch. All features merge here.
feature/* ← one branch per feature. Branches off develop.
release/* ← release stabilization. Branches off develop.
hotfix/* ← emergency fixes. Branches off main.
%%{init: {'gitGraph': {'rotateCommitLabel': true}}}%%
gitGraph
commit id: "init" tag: "v0.9.0"
branch develop
checkout develop
commit id: "setup"
branch feature/auth
checkout feature/auth
commit id: "scaffold"
commit id: "tests"
checkout develop
merge feature/auth id: "auth merged"
branch release/1.0.0
checkout release/1.0.0
commit id: "bump v1.0.0"
checkout main
merge release/1.0.0 tag: "v1.0.0"
checkout develop
merge release/1.0.0
checkout main
branch hotfix/1.0.1
checkout hotfix/1.0.1
commit id: "null fix"
checkout main
merge hotfix/1.0.1 tag: "v1.0.1"
checkout develop
merge hotfix/1.0.1
Lifecycle of a feature:
git checkout develop
git checkout -b feature/user-authentication
# ... work ...
git checkout develop
git merge --no-ff feature/user-authentication
git branch -d feature/user-authentication
Lifecycle of a release:
git checkout develop
git checkout -b release/1.4.0
# ... bug fixes and version bumps only ...
git checkout main
git merge --no-ff release/1.4.0
git tag -a v1.4.0 -m "Release 1.4.0"
git checkout develop
git merge --no-ff release/1.4.0
git branch -d release/1.4.0
When GitFlow works:
- Mobile apps with App Store review cycles
- SaaS products with enterprise clients on specific versions
- Teams with QA cycles that require a code freeze period
- Regulated industries with formal release gates
When GitFlow fails:
- Continuous deployment pipelines (release branches block CD)
- Small teams (overhead kills velocity)
- Microservices (coordination across repos becomes impossible)
Trunk-Based Development (TBD)
The model used by Google, Meta, Netflix, and most high-performing DevOps teams. All developers commit directly to main (the “trunk”) or use very short-lived feature branches (< 2 days).
Branch structure:
main ← the trunk. Always deployable. CI runs on every commit.
feature/* ← optional. Max lifetime: 2 days. Merged via PR.
%%{init: {'gitGraph': {'rotateCommitLabel': true}}}%%
gitGraph
commit id: "feat: login"
commit id: "fix: auth"
branch feature/dashboard
checkout feature/dashboard
commit id: "dashboard WIP"
checkout main
commit id: "chore: deps"
merge feature/dashboard id: "flag: off"
commit id: "feat: search"
commit id: "perf: cache"
commit id: "release" tag: "v1.1.0"
The key practices that make TBD work:
- Feature flags — incomplete features are deployed but hidden behind a flag
- Branch by abstraction — large refactors use an abstraction layer, not a long-lived branch
- Pair programming — reduces the need for long review cycles
- Comprehensive CI — every commit triggers full test suite
- Small commits — changes that can be reviewed in under 10 minutes
When TBD works:
- Continuous deployment (deploy multiple times per day)
- Teams with high test coverage (>80%)
- Microservices with independent deployment
- Teams with strong DevOps culture (shared ownership of main)
When TBD fails:
- Low test coverage (broken main blocks everyone)
- Large distributed teams without feature flag infrastructure
- Compliance requirements that mandate release gates
Which one should you choose?
flowchart TD
A([Start]) --> B{Multiple versions\nin prod?}
B -->|Yes| C([GitFlow])
B -->|No| D{Deploy multiple\ntimes per day?}
D -->|Yes| E([Trunk-Based Dev])
D -->|No| F{Test coverage\nabove 80%?}
F -->|Yes| E
F -->|No| G{Regulated or\nformal QA gate?}
G -->|Yes| C
G -->|No| H([TBD + build\ncoverage first])
style C fill:#2d5a1b,color:#fff
style E fill:#1b3a5a,color:#fff
style H fill:#1b3a5a,color:#fff
Part 2: Set up the repository
Step 1: Initialize the repository
# Create project directory
mkdir devops-git-template && cd devops-git-template
# Initialize git
git init
git checkout -b main
# Create base structure
mkdir -p .github/workflows .github/ISSUE_TEMPLATE src tests scripts
touch README.md .gitignore .env.example
Create a solid .gitignore that covers most DevOps projects:
cat > .gitignore << 'EOF'
# Environment and secrets
.env
.env.local
.env.*.local
*.pem
*.key
*.p12
*.pfx
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dist/
build/
.npm
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.venv/
venv/
.pytest_cache/
# Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!*.tfvars.example
.terraform.lock.hcl
# Docker
.dockerignore
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# Build artifacts
*.log
*.tmp
*.bak
coverage/
.nyc_output/
EOF
Step 2: Initialize Node.js for tooling
npm init -y
Step 3: Install and configure Husky
Husky intercepts Git hooks and lets you run scripts before commits and pushes.
# Install Husky and lint-staged
npm install --save-dev husky lint-staged
# Initialize Husky (creates .husky/ directory)
npx husky init
# Verify .husky/ was created
ls -la .husky/
Expected output:
total 16
drwxr-xr-x 3 user user 4096 Jan 15 10:23 .
drwxr-xr-x 12 user user 4096 Jan 15 10:23 ..
-rwxr-xr-x 1 user user 29 Jan 15 10:23 pre-commit
Step 4: Install and configure commitlint
commitlint enforces the Conventional Commits specification. This gives you a machine-readable commit history that tools like semantic-release and changelog generators can parse.
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Create the commitlint config:
cat > commitlint.config.js << 'EOF'
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
// Type must be one of the following
'type-enum': [
2,
'always',
[
'feat', // New feature
'fix', // Bug fix
'docs', // Documentation only
'style', // Formatting, no logic change
'refactor', // Code change that is neither fix nor feature
'perf', // Performance improvement
'test', // Adding or correcting tests
'build', // Build system or dependencies
'ci', // CI/CD configuration
'chore', // Maintenance tasks
'revert', // Reverts a previous commit
'hotfix', // Emergency fix (GitFlow specific)
'release', // Release commit
],
],
// Subject line max length
'subject-max-length': [2, 'always', 100],
// Subject must not end with a period
'subject-full-stop': [2, 'never', '.'],
// Body must have blank line before it
'body-leading-blank': [2, 'always'],
// Footer must have blank line before it
'footer-leading-blank': [2, 'always'],
},
};
EOF
Add the commit-msg hook:
cat > .husky/commit-msg << 'EOF'
#!/bin/sh
npx --no -- commitlint --edit "$1"
EOF
chmod +x .husky/commit-msg
Step 5: Install and configure ESLint + Prettier
npm install --save-dev eslint prettier eslint-config-prettier
# Create ESLint config
cat > .eslintrc.js << 'EOF'
module.exports = {
env: {
node: true,
es2021: true,
},
extends: ['eslint:recommended', 'prettier'],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'no-console': 'warn',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-var': 'error',
'prefer-const': 'error',
},
};
EOF
# Create Prettier config
cat > .prettierrc << 'EOF'
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
EOF
Step 6: Configure lint-staged
lint-staged runs linters only on staged files — not the entire codebase. This makes pre-commit hooks fast enough that developers won’t disable them.
Add to package.json:
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg['lint-staged'] = {
'*.{js,ts,jsx,tsx}': ['eslint --fix', 'prettier --write'],
'*.{json,md,yml,yaml}': ['prettier --write'],
'*.sh': ['shellcheck']
};
pkg.scripts = {
...pkg.scripts,
'prepare': 'husky',
'lint': 'eslint . --ext .js,.ts',
'lint:fix': 'eslint . --ext .js,.ts --fix',
'format': 'prettier --write .',
'format:check': 'prettier --check .'
};
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
console.log('package.json updated');
"
Update the pre-commit hook:
cat > .husky/pre-commit << 'EOF'
#!/bin/sh
npx lint-staged
EOF
chmod +x .husky/pre-commit
Here’s what happens on every git commit with this setup:
On commit:
flowchart LR
A([git commit]) --> B[pre-commit hook]
B --> C{lint-staged\npasses?}
C -->|Fail| D([Blocked — fix lint])
C -->|Pass| E[commit-msg hook]
E --> F{commitlint\npasses?}
F -->|Fail| G([Blocked — fix message])
F -->|Pass| H([Commit created])
On push:
flowchart LR
A([git push]) --> B[pre-push hook]
B --> C{Protected\nbranch?}
C -->|Yes| D([Blocked — open a PR])
C -->|No| E([Push succeeds])
Step 7: Create the PR template
mkdir -p .github
cat > .github/pull_request_template.md << 'EOF'
## Summary
<!-- What does this PR do? One paragraph max. -->
## Type of change
- [ ] `feat` — New feature
- [ ] `fix` — Bug fix
- [ ] `docs` — Documentation update
- [ ] `refactor` — Code refactor (no functional change)
- [ ] `ci` — CI/CD changes
- [ ] `chore` — Maintenance
- [ ] `hotfix` — Emergency production fix
## Related issues
Closes #<!-- issue number -->
## How to test
<!-- Step-by-step instructions for reviewers to test this change -->
1.
2.
3.
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have run `npm run lint` and there are no errors
- [ ] I have added or updated tests
- [ ] I have updated documentation if needed
- [ ] My commits follow the Conventional Commits format
- [ ] I have checked this branch is up to date with the target branch
- [ ] This PR is < 400 lines of change (if not, explain why)
## Screenshots / output (if applicable)
<!-- Paste terminal output or screenshots here -->
## Notes for reviewers
<!-- Anything the reviewer should pay special attention to -->
EOF
Step 8: Create GitHub Actions CI workflow
cat > .github/workflows/ci.yml << 'EOF'
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: $-$
cancel-in-progress: true
jobs:
lint:
name: Lint & Format
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Check formatting
run: npm run format:check
commitlint:
name: Validate commit messages
runs-on: ubuntu-22.04
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Validate PR commits
run: |
npx commitlint \
--from $ \
--to $ \
--verbose
test:
name: Tests
runs-on: ubuntu-22.04
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test --if-present
EOF
Every PR triggers this pipeline before anyone can merge:
flowchart TD
A([PR opened]) --> B[lint]
A --> C[commitlint]
A --> D[test]
B --> E{All checks\npassed?}
C --> E
D --> E
E -->|No| F([PR blocked])
E -->|Yes| G([Awaiting review])
G --> H([Merged to main])
Part 3: Set up GitFlow on a real repository
Step 1: Install git-flow
# Ubuntu / Debian
sudo apt-get install git-flow -y
# macOS
brew install git-flow-avh
# Verify
git flow version
Expected output:
1.12.3 (AVH Edition)
Step 2: Initialize git-flow
# Run inside your repo directory
git flow init -d
The -d flag accepts all defaults. This creates and configures:
Branch name for production releases: main
Branch name for "next release" development: develop
Feature branches: feature/
Bugfix branches: bugfix/
Release branches: release/
Hotfix branches: hotfix/
Support branches: support/
Version tag prefix: v
Verify both branches exist:
git branch -a
Expected output:
* develop
main
Step 3: Work through the full GitFlow lifecycle
Create and finish a feature:
# Start a feature
git flow feature start user-authentication
# You are now on branch: feature/user-authentication
# Verify
git branch --show-current
Expected output:
feature/user-authentication
# Simulate some work
mkdir -p src/auth
cat > src/auth/index.js << 'EOF'
const authenticate = (username, password) => {
// Authentication logic would go here
return { user: username, token: 'jwt-token-placeholder' };
};
module.exports = { authenticate };
EOF
# Stage and commit with conventional commit format
git add src/auth/index.js
git commit -m "feat(auth): add authentication module scaffold"
# Finish the feature — merges to develop with --no-ff
git flow feature finish user-authentication
Expected output:
Branches 'develop' and 'feature/user-authentication' have diverged.
Updating 9f3e2a1..c4b8d2f
Fast-forward (no commit created; -m option ignored)
src/auth/index.js | 6 ++++++
1 file changed, 6 insertions(+)
Deleted branch feature/user-authentication (was c4b8d2f).
Summary of actions:
- The feature branch 'feature/user-authentication' was merged into 'develop'
- Feature branch 'feature/user-authentication' has been locally deleted
- You are now on branch 'develop'
Create and finish a release:
# Start a release from develop
git flow release start 1.0.0
# Bump version in package.json
npm version 1.0.0 --no-git-tag-version
git add package.json
git commit -m "release: bump version to 1.0.0"
# Finish the release
# This: merges to main, tags v1.0.0, merges back to develop
git flow release finish '1.0.0'
You’ll be prompted for a tag message. Enter: Release v1.0.0
Verify the tag was created:
git tag -l
Expected output:
v1.0.0
Simulate a hotfix:
# Start hotfix from main
git flow hotfix start 1.0.1
cat >> src/auth/index.js << 'EOF'
// Hotfix: prevent null username crash
const safeAuthenticate = (username, password) => {
if (!username || !password) throw new Error('Credentials required');
return authenticate(username, password);
};
module.exports = { authenticate, safeAuthenticate };
EOF
git add src/auth/index.js
git commit -m "fix(auth): prevent null username crash in authenticate"
# Finish hotfix — merges to main AND develop
git flow hotfix finish '1.0.1'
Part 4: Set up Trunk-Based Development
TBD requires discipline more than tooling. The tooling enforces the discipline.
Step 1: Protect the main branch
If you’re using the gh CLI:
# Set your repo name
REPO="your-github-username/devops-git-template"
gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
/repos/${REPO}/branches/main/protection \
--input - << 'EOF'
{
"required_status_checks": {
"strict": true,
"contexts": ["lint", "test", "commitlint"]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true,
"require_code_owner_reviews": false
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"required_linear_history": true
}
EOF
Expected output:
{
"url": "https://api.github.com/repos/your-username/devops-git-template/branches/main/protection",
"required_status_checks": {
"strict": true,
...
}
}
What each rule does:
| Rule | What it enforces |
|---|---|
required_status_checks |
CI must pass before merge |
strict: true |
Branch must be up to date with main before merge |
enforce_admins: true |
Rules apply to repo admins too — no exceptions |
required_approving_review_count: 1 |
At least 1 human approval required |
dismiss_stale_reviews: true |
New commits invalidate previous approvals |
allow_force_pushes: false |
Nobody can rewrite main history |
required_linear_history: true |
Enforces squash or rebase merges — no merge commits |
Step 2: Create a CODEOWNERS file
CODEOWNERS automatically assigns reviewers based on which files are changed.
cat > .github/CODEOWNERS << 'EOF'
# Global owners — review everything
* @your-github-username
# CI/CD changes require DevOps team review
.github/ @your-github-username
*.yml @your-github-username
Dockerfile* @your-github-username
terraform/ @your-github-username
# Auth module — security-sensitive
src/auth/ @your-github-username
EOF
Step 3: Feature flag scaffold for TBD
In Trunk-Based Development, you merge incomplete features to main behind a feature flag. Here’s a minimal implementation:
cat > src/feature-flags.js << 'EOF'
/**
* Feature flag system for Trunk-Based Development
* Flags are read from environment variables.
* In production, use a service like LaunchDarkly, Flagsmith, or Unleash.
*/
const flags = {
// Format: FEATURE_FLAG_<NAME>=true|false
NEW_DASHBOARD: process.env.FEATURE_FLAG_NEW_DASHBOARD === 'true',
BETA_SEARCH: process.env.FEATURE_FLAG_BETA_SEARCH === 'true',
USER_AUTHENTICATION_V2: process.env.FEATURE_FLAG_USER_AUTH_V2 === 'true',
};
const isEnabled = (flagName) => {
if (!(flagName in flags)) {
console.warn(`[FeatureFlag] Unknown flag: ${flagName}`);
return false;
}
return flags[flagName];
};
module.exports = { isEnabled, flags };
EOF
Example usage in application code:
const { isEnabled } = require('./feature-flags');
const getAuthHandler = () => {
if (isEnabled('USER_AUTHENTICATION_V2')) {
return require('./auth/v2'); // New implementation, hidden behind flag
}
return require('./auth/v1'); // Stable implementation
};
Step 4: Add a pre-push hook to block direct pushes to main
cat > .husky/pre-push << 'EOF'
#!/bin/sh
CURRENT_BRANCH=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')
PROTECTED_BRANCHES="^(main|master|develop)$"
if echo "$CURRENT_BRANCH" | grep -qE "$PROTECTED_BRANCHES"; then
echo "⛔ Direct push to '$CURRENT_BRANCH' is not allowed."
echo " Create a feature branch and open a pull request."
echo ""
echo " git checkout -b feature/your-feature-name"
exit 1
fi
exit 0
EOF
chmod +x .husky/pre-push
Test it:
# Should be blocked if you're on main
git push origin main
Expected output:
⛔ Direct push to 'main' is not allowed.
Create a feature branch and open a pull request.
git checkout -b feature/your-feature-name
error: failed to push some refs to 'origin'
Part 5: The reusable template
Package everything into a template structure others can use:
# Create the template structure
mkdir -p repo-template/.github/workflows
mkdir -p repo-template/.husky
mkdir -p repo-template/src
# Copy all config files
cp .gitignore repo-template/
cp .eslintrc.js repo-template/
cp .prettierrc repo-template/
cp commitlint.config.js repo-template/
cp package.json repo-template/
cp .github/pull_request_template.md repo-template/.github/
cp .github/CODEOWNERS repo-template/.github/
cp .github/workflows/ci.yml repo-template/.github/workflows/
cp .husky/pre-commit repo-template/.husky/
cp .husky/pre-push repo-template/.husky/
cp .husky/commit-msg repo-template/.husky/
cp src/feature-flags.js repo-template/src/
# Create a setup script so others can bootstrap quickly
cat > repo-template/setup.sh << 'EOF'
#!/bin/bash
set -euo pipefail
echo "Setting up repository from syssignals DevOps template..."
# Install dependencies
npm install
# Initialize Husky
npm run prepare
# Verify hooks are executable
chmod +x .husky/pre-commit .husky/pre-push .husky/commit-msg
echo "Setup complete."
echo ""
echo "Next steps:"
echo " 1. Update CODEOWNERS with your GitHub username"
echo " 2. Set branch protection rules on GitHub (see README)"
echo " 3. Run 'npm run lint' to verify ESLint is working"
echo " 4. Make your first commit: git commit -m 'chore: initial repository setup'"
EOF
chmod +x repo-template/setup.sh
How it works under the hood
Why Husky intercepts hooks:
Git stores hooks in .git/hooks/ — a directory that is never committed or shared. Husky solves this by adding a prepare npm script that installs scripts into .git/hooks/ when anyone runs npm install. The hooks in .husky/ are committed to the repo and become the source of truth. When a developer clones the repo and runs npm install, Husky automatically installs the hooks.
Why lint-staged and not full linting:
Running ESLint on your entire codebase before every commit is slow. On a large project it can take 30+ seconds. Developers start using git commit --no-verify to skip it. lint-staged runs linters only on the files you’ve staged with git add — typically 1–5 files. The hook completes in under 2 seconds, which developers will actually tolerate.
Why conventional commits matter for DevOps:
Conventional commits aren’t just a style guide. Tools like semantic-release parse commit messages to determine the next version number (feat = minor bump, fix = patch bump, BREAKING CHANGE = major bump), auto-generate changelogs, and publish packages — all automatically in CI. Enforcing the format from day one means you can adopt these tools later without reformatting history.
Why --no-ff in GitFlow:
The --no-ff flag (no fast-forward) forces Git to create a merge commit even when the history is linear. Without it, Git can fast-forward the branch pointer and you lose the context that these commits belonged to a feature branch. With --no-ff, the graph always shows which commits were part of which feature — invaluable for git bisect and debugging.
Common errors and fixes
Error 1: husky: command not found
.husky/pre-commit: line 1: husky: command not found
Cause: Husky wasn’t installed or npm install wasn’t run after cloning.
Fix:
npm install
npm run prepare
Error 2: commitlint fails with Your commit message has an error
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
Cause: Commit message doesn’t follow Conventional Commits format.
Fix: Use the correct format:
# Wrong
git commit -m "added login feature"
# Correct
git commit -m "feat(auth): add login form with JWT support"
Error 3: git flow command not found after install
git: 'flow' is not a git command
Cause: On some systems git-flow installs as a separate binary not recognized by git.
Fix:
# Ubuntu: install the AVH edition explicitly
sudo apt-get remove git-flow
sudo apt-get install git-flow -y
# macOS: use Homebrew AVH edition
brew uninstall git-flow
brew install git-flow-avh
# Verify
which git-flow
git flow version
Error 4: Branch protection API returns 403
{
"message": "Not Found",
"documentation_url": "..."
}
Cause: Your GitHub token doesn’t have repo scope, or the repo is private and the token lacks access.
Fix:
# Verify your token has the right scopes
gh auth status
# Re-authenticate with correct scopes
gh auth login --scopes repo,workflow
Error 5: lint-staged fails with No staged files match
✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
This is actually not an error — it means lint-staged ran but found no matching files in your staged changes. If you staged a .sh file and configured shellcheck, but shellcheck isn’t installed, you’ll see:
# Install shellcheck
sudo apt-get install shellcheck # Ubuntu
brew install shellcheck # macOS
What’s next — extend this project
Make it production-ready:
-
Add
semantic-releaseto automate version bumps and changelog generation from your conventional commits. Configure it in.releaserc.jsonand add areleasejob to your CI workflow. -
Add
dangerfor automated PR review — it can enforce PR size limits, check that tests were added with features, and post comments when the PR template isn’t filled out. -
Integrate
git-secrets(AWS) orgitleaksto scan commits for accidentally committed secrets before they reach GitHub.
Series recap — Day 1
You now have:
- A Git repository with enforced workflow (pre-commit, pre-push, commit-msg hooks)
- Conventional Commits enforced at the hook level and validated in CI
- A PR template that enforces review discipline
- Branch protection rules that make it physically impossible to push broken code to main
- A complete understanding of when to use GitFlow vs Trunk-Based Development
- A reusable
repo-template/folder you can drop into any project
The foundation is set. Everything built in the next 29 articles will use this repository structure.
Day 2 preview
Day 2: Dockerize any application the right way — multi-stage builds and best practices
We’ll take a Node.js application from a 1.2GB naive image to a 45MB hardened production image. Multi-stage builds, non-root users, BuildKit cache mounts, and Docker Scout vulnerability scanning.
This is Day 1 of the 30 Days of DevOps series.
Follow @syssignals on X — Day 2 drops tomorrow.
Found an error? Drop a reply on X and I’ll fix it and credit you.