<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://steve-kaschimer.github.io">
  <title>Steve Kaschimer - Tech Notes</title>
  <subtitle>DevOps, Security, and Development insights</subtitle>
  <link href="https://steve-kaschimer.github.io/feed/" rel="self"/>
  <link href="https://steve-kaschimer.github.io/"/>
  <updated>2026-05-08T00:00:00Z</updated>
  <id>https://steve-kaschimer.github.io/</id>
  <author>
    <name>Steve Kaschimer</name>
  </author>
  <entry>
    <title>Why GitHub is the DevSecOps Platform of Choice</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-10-27-why-github-is-the-devsecops-platform-of-choice/"/>
    <updated>2025-10-27T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-10-27-why-github-is-the-devsecops-platform-of-choice/</id>
    <content xml:lang="en" type="html">&lt;p&gt;In the evolving landscape of software development, DevSecOps has emerged as a critical discipline - one that integrates security into every phase of the software delivery lifecycle. As organizations strive to ship faster without compromising safety, the tools we choose become more than just enablers - they shape our workflows, our culture, and ultimately, our outcomes.&lt;/p&gt;
&lt;p&gt;Among the many platforms available, GitHub stands out. Once known primarily as a code hosting service, GitHub has matured into a robust ecosystem that supports the full spectrum of DevSecOps practices. For architects and engineers tasked with embedding security into development pipelines, GitHub offers a compelling blend of automation, visibility, and developer-first design.&lt;/p&gt;
&lt;p&gt;This post explores why GitHub is increasingly becoming the platform of choice for DevSecOps professionals, and how it can help teams move from theory to practice.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The DevSecOps Imperative&lt;/h2&gt;
&lt;p&gt;DevSecOps isn’t just a buzzword. It’s a response to real-world challenges. Traditional security models often treated security as a gatekeeper, bolted onto the end of the development process. This led to delays, friction between teams, and vulnerabilities slipping through the cracks.&lt;/p&gt;
&lt;p&gt;DevSecOps flips that model. It embeds security into every stage of development, from code commit to deployment. It encourages collaboration between developers, security engineers, and operations teams. And it relies heavily on automation to ensure that security checks are consistent, scalable, and fast.&lt;/p&gt;
&lt;p&gt;But implementing DevSecOps is easier said than done. Tool sprawl, lack of integration, and resistance to change are common hurdles. That’s where platform choice becomes critical and why GitHub deserves a closer look.&lt;/p&gt;
&lt;h2&gt;GitHub’s Strengths for DevSecOps&lt;/h2&gt;
&lt;p&gt;GitHub’s appeal lies in its ability to meet developers where they already are. It’s the default platform for millions of developers, which means DevSecOps initiatives don’t have to fight for adoption. Instead, they can build on existing habits and workflows.&lt;/p&gt;
&lt;p&gt;Here are some of the key reasons GitHub excels as a DevSecOps platform:&lt;/p&gt;
&lt;h3&gt;Developer Familiarity&lt;/h3&gt;
&lt;p&gt;GitHub is already deeply embedded in the daily routines of most development teams. Pull requests, issues, and discussions are part of the rhythm. This familiarity reduces the learning curve and makes it easier to introduce security practices without disrupting productivity.&lt;/p&gt;
&lt;h3&gt;Built-in Automation with GitHub Actions&lt;/h3&gt;
&lt;p&gt;GitHub Actions allows teams to automate everything from builds and tests to security scans and compliance checks. Workflows can be triggered on pull requests, commits, or scheduled intervals, making it easy to enforce security policies continuously.&lt;/p&gt;
&lt;p&gt;Whether you’re running SAST tools, checking for secrets, or validating infrastructure-as-code, GitHub Actions provides a flexible and native way to integrate these steps into your pipeline.&lt;/p&gt;
&lt;h3&gt;Native Security Tooling&lt;/h3&gt;
&lt;p&gt;GitHub has invested heavily in security features that align with DevSecOps principles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CodeQL&lt;/strong&gt;: A powerful static analysis engine that lets you write custom queries to detect vulnerabilities in code.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secret Scanning&lt;/strong&gt;: Automatically detects credentials and tokens committed to repositories.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependency Review&lt;/strong&gt;: Highlights changes to dependencies in pull requests and flags known vulnerabilities.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security Overview&lt;/strong&gt;: Provides a centralized dashboard for tracking vulnerabilities across repositories.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tools are tightly integrated into the GitHub experience, reducing the need for external platforms and making security more accessible to developers.&lt;/p&gt;
&lt;h3&gt;Auditability and Traceability&lt;/h3&gt;
&lt;p&gt;Every action on GitHub, from commits to workflow runs, is logged and traceable. This makes it easier to meet compliance requirements, conduct forensic analysis, and demonstrate accountability.&lt;/p&gt;
&lt;h3&gt;Open Source Ecosystem&lt;/h3&gt;
&lt;p&gt;GitHub’s open nature allows teams to leverage community tools while maintaining enterprise-grade controls. Whether you’re integrating with Snyk, Trivy, or custom linters, GitHub’s extensibility supports a wide range of security use cases.&lt;/p&gt;
&lt;h2&gt;Real-World Use Cases&lt;/h2&gt;
&lt;p&gt;Let’s look at how GitHub supports DevSecOps in practice.&lt;/p&gt;
&lt;h3&gt;Automating Security Checks&lt;/h3&gt;
&lt;p&gt;A DevSecOps team might use GitHub Actions to run CodeQL scans on every pull request. If a vulnerability is detected, the workflow can block the merge and notify the developer with actionable feedback. This ensures that security is enforced without manual intervention.&lt;/p&gt;
&lt;h3&gt;Managing Secrets&lt;/h3&gt;
&lt;p&gt;GitHub’s secret scanning can detect exposed credentials in real time. Combined with environment secrets and access controls, teams can reduce the risk of accidental leaks and enforce secure handling of sensitive data.&lt;/p&gt;
&lt;h3&gt;Dependency Hygiene&lt;/h3&gt;
&lt;p&gt;With dependency review and Dependabot alerts, teams can stay ahead of known vulnerabilities in third-party packages. These features integrate directly into pull requests, making it easy to assess risk before merging.&lt;/p&gt;
&lt;p&gt;These examples aren’t hypothetical. They’re part of the daily workflow for many DevSecOps teams using GitHub.&lt;/p&gt;
&lt;h2&gt;Common Pitfalls and How GitHub Helps&lt;/h2&gt;
&lt;p&gt;No platform is perfect, and GitHub is no exception. But many of the common challenges in DevSecOps are mitigated by GitHub’s design.&lt;/p&gt;
&lt;h3&gt;Security vs. Speed&lt;/h3&gt;
&lt;p&gt;One of the biggest concerns is that security slows down delivery. GitHub’s automation features help strike a balance. Security checks run in parallel with development, and issues are surfaced early when they’re easier to fix.&lt;/p&gt;
&lt;h3&gt;Tool Fragmentation&lt;/h3&gt;
&lt;p&gt;Managing multiple tools across different platforms can be a nightmare. GitHub consolidates many security functions into a single interface, reducing complexity and improving visibility.&lt;/p&gt;
&lt;h3&gt;Lack of Visibility&lt;/h3&gt;
&lt;p&gt;Security teams often struggle to see what’s happening in development. GitHub’s dashboards, logs, and integrations provide a clear view of code changes, workflow runs, and security alerts.&lt;/p&gt;
&lt;h2&gt;Strategic Considerations&lt;/h2&gt;
&lt;p&gt;For organizations considering GitHub as a DevSecOps platform, there are a few strategic questions to address:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Do you need GitHub Advanced Security?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;While many features are available for free, GAS unlocks deeper capabilities like custom CodeQL queries and enterprise-wide security insights.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;How does GitHub align with compliance needs?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;GitHub’s audit logs, access controls, and workflow automation can support compliance frameworks like SOC 2, ISO 27001, and NIST.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Can GitHub scale across teams?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;With organization-level policies, reusable workflows, and role-based access, GitHub supports DevSecOps at scale.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Closing Thoughts&lt;/h2&gt;
&lt;p&gt;DevSecOps is no longer optional. It’s a &lt;strong&gt;necessity&lt;/strong&gt;. As threats evolve and delivery cycles accelerate, security must be built into the fabric of development. GitHub offers a platform that supports this vision, combining developer-first design with powerful security tooling.&lt;/p&gt;
&lt;p&gt;For DevSecOps architects and engineers, GitHub isn’t just a place to host code. It’s a strategic enabler of secure, scalable, and efficient software delivery.&lt;/p&gt;
&lt;p&gt;If you haven’t explored GitHub’s security features recently, now is a good time to dive in. Start small, automate what you can, and build a culture where security is everyone’s responsibility.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Need help? Ask me!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Why GitHub is a strong platform choice for DevSecOps teams - built-in automation, native security tooling, and auditability.</summary>
    <category term="devsecops"/>
    <category term="github"/>
    <category term="devops"/>
  </entry>
  <entry>
    <title>5 Tailwind CSS Tips for Better Productivity</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-10-29-tailwind-css-tips/"/>
    <updated>2025-10-29T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-10-29-tailwind-css-tips/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Tailwind CSS has revolutionized the way I write CSS. Here are five tips that have significantly improved my workflow.&lt;/p&gt;
&lt;h2&gt;1. Use @apply for Repeated Patterns&lt;/h2&gt;
&lt;p&gt;While Tailwind promotes utility-first CSS, sometimes you have patterns that repeat. Use &lt;code&gt;@apply&lt;/code&gt; to create reusable components:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.btn {
  @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
}

.btn-primary {
  @apply bg-blue-600 hover:bg-blue-700 text-white;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. Leverage the JIT Compiler&lt;/h2&gt;
&lt;p&gt;The Just-In-Time compiler generates styles on-demand, giving you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Faster build times&lt;/li&gt;
&lt;li&gt;Smaller file sizes&lt;/li&gt;
&lt;li&gt;Arbitrary values: &lt;code&gt;w-[347px]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. Create Custom Utilities&lt;/h2&gt;
&lt;p&gt;Extend Tailwind with your own utilities in &lt;code&gt;tailwind.config.js&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          500: &#39;#3B82F6&#39;,
          600: &#39;#2563EB&#39;,
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. Use Dark Mode Variants&lt;/h2&gt;
&lt;p&gt;Tailwind makes dark mode incredibly easy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div class=&amp;quot;bg-white dark:bg-gray-900 text-gray-900 dark:text-white&amp;quot;&amp;gt;
  Content that adapts to theme
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. Install the Tailwind CSS IntelliSense Extension&lt;/h2&gt;
&lt;p&gt;If you&#39;re using VS Code, this extension is a must-have. It provides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Autocomplete for class names&lt;/li&gt;
&lt;li&gt;Linting and validation&lt;/li&gt;
&lt;li&gt;Hover previews of CSS values&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;These tips have made working with Tailwind even more enjoyable. The framework&#39;s flexibility allows you to build beautiful, responsive designs quickly.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;What are your favorite Tailwind tips? Let me know!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Boost your productivity with these practical Tailwind CSS tips and tricks. Learn how to write cleaner, more maintainable utility-first CSS.</summary>
    <category term="tailwind-css"/>
    <category term="eleventy"/>
    <category term="developer-productivity"/>
  </entry>
  <entry>
    <title>Getting Started with Eleventy</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-10-30-getting-started-with-eleventy/"/>
    <updated>2025-10-30T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-10-30-getting-started-with-eleventy/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Eleventy (or 11ty) is a fantastic static site generator that&#39;s simple, flexible, and incredibly fast. If you&#39;re looking to build a blog, documentation site, or any static website, Eleventy is an excellent choice.&lt;/p&gt;
&lt;h2&gt;Why Eleventy?&lt;/h2&gt;
&lt;p&gt;Here are some reasons why I love working with Eleventy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Simple &amp;amp; Flexible&lt;/strong&gt;: Works with multiple template languages&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fast Build Times&lt;/strong&gt;: Incredibly quick, even for large sites&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No Client-Side JavaScript Required&lt;/strong&gt;: Pure static HTML by default&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Great Documentation&lt;/strong&gt;: Easy to learn and well-documented&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Active Community&lt;/strong&gt;: Lots of plugins and starter templates available&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Basic Setup&lt;/h2&gt;
&lt;p&gt;Getting started with Eleventy is straightforward. Here&#39;s a quick overview:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install Eleventy
npm install @11ty/eleventy

# Create a simple template
echo &#39;# Hello World&#39; &amp;gt; index.md

# Run Eleventy
npx @11ty/eleventy --serve
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s it! You now have a working Eleventy site.&lt;/p&gt;
&lt;h2&gt;Key Concepts&lt;/h2&gt;
&lt;h3&gt;Layouts&lt;/h3&gt;
&lt;p&gt;Layouts are templates that wrap your content. They&#39;re perfect for creating consistent page structures.&lt;/p&gt;
&lt;h3&gt;Collections&lt;/h3&gt;
&lt;p&gt;Collections let you group related content together. For a blog, you&#39;d typically have a &amp;quot;posts&amp;quot; collection.&lt;/p&gt;
&lt;h3&gt;Filters&lt;/h3&gt;
&lt;p&gt;Filters transform data in your templates. For example, formatting dates or truncating text.&lt;/p&gt;
&lt;h2&gt;Next Steps&lt;/h2&gt;
&lt;p&gt;Now that you know the basics, here are some things to explore:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Add styling&lt;/strong&gt; with your favorite CSS framework&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Create custom filters&lt;/strong&gt; for your specific needs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explore plugins&lt;/strong&gt; to extend functionality&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deploy&lt;/strong&gt; to GitHub Pages, Netlify, or Vercel&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Eleventy strikes a perfect balance between simplicity and power. It gets out of your way and lets you focus on creating content.&lt;/p&gt;
&lt;p&gt;Happy building!&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Need help? Ask me!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Eleventy is a simpler static site generator. Learn why it&#39;s great for building fast, modern websites and how to get started with your first project.</summary>
    <category term="eleventy"/>
    <category term="developer-productivity"/>
  </entry>
  <entry>
    <title>Secrets Management on GitHub: Best Practices and Pitfalls</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-11-10-secrets-management-on-github-best-practices-and-pitfalls/"/>
    <updated>2025-11-05T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-11-10-secrets-management-on-github-best-practices-and-pitfalls/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Secrets are the lifeblood of modern applications. API keys, database credentials, encryption tokens - these tiny strings unlock access to critical systems and sensitive data. But when secrets are mishandled, they become one of the fastest paths to a breach. In fact, exposed credentials are among the most common causes of security incidents today.&lt;/p&gt;
&lt;p&gt;If you’ve ever seen a developer hardcode an API key into a config file or commit a password to a public repository, you know how easy it is for secrets to leak. And once they’re out, attackers don’t need to break encryption or exploit zero-days. They simply use the keys you left behind.&lt;/p&gt;
&lt;p&gt;This article dives deep into how GitHub helps you manage secrets securely, what best practices you should adopt, and the pitfalls that can derail even well-intentioned teams. We’ll cover secret scanning, environment variables, and strategies for secure storage, all through the lens of real-world DevSecOps challenges.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why Secrets Management Matters&lt;/h2&gt;
&lt;p&gt;Secrets are everywhere in modern software. They connect microservices, authenticate APIs, and enable cloud deployments. But the convenience of secrets comes with risk. When credentials are embedded in source code, they often end up in version control systems, which are designed to preserve history forever. That means even if you remove a secret later, it can still be retrieved from old commits.&lt;/p&gt;
&lt;p&gt;Attackers know this. Automated bots constantly scan public repositories for exposed keys. If they find one, they can exploit it within minutes, sometimes before you even realize it’s there. The consequences range from unauthorized access to full-blown data breaches, and the cost of remediation skyrockets when secrets are compromised in production environments.&lt;/p&gt;
&lt;p&gt;Managing secrets properly isn’t just a technical best practice; it’s a compliance requirement. Frameworks like SOC 2, PCI DSS, and ISO 27001 mandate secure handling of sensitive information. Hardcoding credentials violates these standards and can lead to regulatory penalties.&lt;/p&gt;
&lt;h2&gt;The GitHub Landscape for Secrets Management&lt;/h2&gt;
&lt;p&gt;GitHub has evolved beyond being a code hosting platform. It now offers a suite of features designed to help teams detect, prevent, and manage secrets securely. These include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Secret Scanning&lt;/strong&gt;: GitHub automatically scans repositories for patterns that match known credential formats. If it finds something suspicious, it alerts you immediately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environment Secrets&lt;/strong&gt;: GitHub Actions allows you to store secrets at the repository, organization, or environment level. These secrets are encrypted and injected into workflows at runtime.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependabot Alerts&lt;/strong&gt;: While primarily focused on dependency vulnerabilities, Dependabot complements secret scanning by reducing the risk of compromised libraries that might expose secrets indirectly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let’s break these down and see how they fit into a secure development workflow.&lt;/p&gt;
&lt;h2&gt;Secret Scanning: Your First Line of Defense&lt;/h2&gt;
&lt;p&gt;Secret scanning is GitHub’s proactive approach to preventing leaks. It works by analyzing commits for patterns that resemble credentials, such as API keys, tokens, and passwords, and flags them before they become a problem.&lt;/p&gt;
&lt;p&gt;When secret scanning is enabled, GitHub checks every push to your repository. If it detects a secret, it sends an alert to repository administrators and, in some cases, automatically notifies the service provider so they can revoke the compromised key.&lt;/p&gt;
&lt;p&gt;This feature is particularly powerful for public repositories, where exposure can lead to immediate exploitation. But it’s equally valuable for private repos, because insider mistakes are just as dangerous as external threats.&lt;/p&gt;
&lt;p&gt;The key to making secret scanning effective is enabling it across all repositories—not just the ones you think are sensitive. Secrets have a way of showing up in unexpected places, like test scripts or temporary configuration files.&lt;/p&gt;
&lt;h2&gt;Environment Secrets: Secure Injection for Workflows&lt;/h2&gt;
&lt;p&gt;GitHub Actions introduced a game-changing feature for secrets management: environment secrets. Instead of hardcoding credentials into workflow files, you store them securely in GitHub’s encrypted vault. At runtime, these secrets are injected into the workflow as environment variables.&lt;/p&gt;
&lt;p&gt;This approach solves two major problems. First, it keeps secrets out of version control, so they’re never exposed in commits. Second, it allows you to rotate credentials without modifying workflow files, reducing operational friction.&lt;/p&gt;
&lt;p&gt;Secrets can be scoped at different levels:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Repository-level&lt;/strong&gt;: Accessible to workflows in a single repository.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Organization-level&lt;/strong&gt;: Shared across multiple repositories, ideal for enterprise environments.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environment-level&lt;/strong&gt;: Tied to specific deployment environments like staging or production, adding an extra layer of control.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When using environment secrets, it’s critical to follow the principle of least privilege. Only grant workflows access to the secrets they need, and avoid overloading a single environment with unrelated credentials.&lt;/p&gt;
&lt;h2&gt;Dependabot: Keeping Dependencies Secure&lt;/h2&gt;
&lt;p&gt;While Dependabot isn’t a secrets management tool in the strict sense, it plays a critical role in reducing the risk of compromised credentials through vulnerable dependencies. Secrets often interact with third-party libraries such as SDKs, API clients, or infrastructure modules, and if those libraries contain security flaws, your secrets can be exposed indirectly.&lt;/p&gt;
&lt;p&gt;Dependabot continuously monitors your project’s dependencies for known vulnerabilities. When it detects an issue, it automatically opens a pull request with the recommended version upgrade. This proactive approach ensures that the libraries handling your secrets remain secure and up to date.&lt;/p&gt;
&lt;p&gt;Including Dependabot in your security strategy is about &lt;strong&gt;defense in depth&lt;/strong&gt;. Even if you manage secrets perfectly, a vulnerable dependency can undermine your efforts. By automating dependency updates, you reduce the attack surface and strengthen the overall integrity of your workflows.&lt;/p&gt;
&lt;h2&gt;Common Pitfalls in Secrets Management&lt;/h2&gt;
&lt;p&gt;Even with GitHub’s tooling, secrets management can go wrong. One of the most common mistakes is assuming that private repositories are inherently safe. They’re not. Insider threats, misconfigured permissions, and accidental sharing can all lead to exposure.&lt;/p&gt;
&lt;p&gt;Another pitfall is neglecting to rotate secrets. Credentials that never change become ticking time bombs. If a secret is compromised and you don’t rotate it promptly, attackers can maintain access indefinitely.&lt;/p&gt;
&lt;p&gt;Teams also struggle with visibility. Secrets often sprawl across multiple repositories, environments, and cloud services. Without centralized tracking, it’s easy to lose control. GitHub provides some visibility through its security dashboard, but for large organizations, integrating with a dedicated secrets manager like HashiCorp Vault or AWS Secrets Manager is essential.&lt;/p&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;Best Practices for Secure Secrets Management&lt;/h2&gt;
&lt;p&gt;The foundation of secure secrets management is simple: &lt;strong&gt;never store credentials in source code&lt;/strong&gt;. But that&#39;s just the beginning. A mature approach includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Enabling secret scanning on &lt;strong&gt;all&lt;/strong&gt; repositories.&lt;/li&gt;
&lt;li&gt;Using &lt;strong&gt;environment secrets&lt;/strong&gt; for workflows instead of hardcoding values.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rotating credentials&lt;/strong&gt; regularly and automating the process where possible.&lt;/li&gt;
&lt;li&gt;Limiting access based on &lt;strong&gt;least privilege principles&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auditing secret usage&lt;/strong&gt; and reviewing logs for anomalies.&lt;/li&gt;
&lt;li&gt;Integrating GitHub with &lt;strong&gt;external secret managers&lt;/strong&gt; (such as &lt;a href=&quot;https://www.hashicorp.com/en/products/vault&quot;&gt;Hashicorp Vault&lt;/a&gt; or &lt;a href=&quot;https://azure.microsoft.com/en-us/products/key-vault&quot;&gt;Azure KeyVault&lt;/a&gt;) for enterprise-scale control.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These practices don’t just reduce risk, they make compliance easier and improve operational resilience.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;The Future of Secrets Management on GitHub&lt;/h2&gt;
&lt;p&gt;As software supply chain attacks become more sophisticated, secrets management will continue to evolve. GitHub is already experimenting with advanced features like push protection, which blocks commits containing secrets before they even reach the repository.&lt;/p&gt;
&lt;p&gt;Looking ahead, expect tighter integration between GitHub and cloud providers, automated secret rotation, and AI-driven anomaly detection. The goal is to make secrets management seamless, so developers can focus on building features without compromising security.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Closing Thoughts&lt;/h2&gt;
&lt;p&gt;Secrets are powerful... and dangerous. Managing them securely is one of the most important responsibilities in modern software development. GitHub provides strong tools to help, but technology alone isn’t enough. It takes discipline, clear policies, and a culture that treats security as a shared responsibility.&lt;/p&gt;
&lt;p&gt;Start by enabling secret scanning, move your credentials into environment secrets, and adopt a rotation strategy. From there, integrate with external managers and automate wherever possible. The sooner you take these steps, the less likely you are to wake up to a breach caused by a forgotten API key in a commit from six months ago.&lt;/p&gt;
&lt;p&gt;Security isn’t about perfection. It’s about reducing risk. And with GitHub’s capabilities, you have everything you need to make secrets management a strength, not a vulnerability.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Need help securing your secrets? Ask me!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Learn how to securely manage secrets on GitHub using secret scanning, environment variables, and best practices to prevent credential leaks and security breaches.</summary>
    <category term="security"/>
    <category term="github"/>
    <category term="devsecops"/>
  </entry>
  <entry>
    <title>Security as Code with GitHub Actions: Automating DevSecOps</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-11-03-security-as-code-making-it-real-with-github-actions/"/>
    <updated>2025-11-10T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-11-03-security-as-code-making-it-real-with-github-actions/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Security as Code is more than a buzzword. It’s a practical approach to embedding security into the development lifecycle. Instead of treating security as a separate process, we codify policies, checks, and controls so they run automatically alongside builds and deployments. For DevSecOps professionals, this is the foundation of scalable, repeatable security.&lt;/p&gt;
&lt;p&gt;GitHub Actions makes this vision achievable. By leveraging workflows, you can integrate security checks into CI/CD pipelines without slowing down delivery. In this post, we’ll explore what Security as Code means, why it matters, and how to implement it using GitHub Actions.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why Security as Code Matters&lt;/h2&gt;
&lt;p&gt;Traditional security practices often rely on manual reviews and ad-hoc scans. These approaches don’t scale in modern development environments where teams push code multiple times a day. Security as Code solves this by:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Automating enforcement&lt;/strong&gt;: Policies and checks run consistently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reducing human error&lt;/strong&gt;: Less reliance on manual steps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Improving speed&lt;/strong&gt;: Security becomes part of the pipeline, not a bottleneck.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enhancing visibility&lt;/strong&gt;: Logs and reports are centralized and auditable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For DevSecOps engineers, this approach aligns perfectly with the “shift-left” philosophy. that is, catching issues early when they’re cheaper and easier to fix.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“&lt;strong&gt;Shift-left&lt;/strong&gt;” is a software development principle that moves critical activities, like testing and security, earlier in the lifecycle. Instead of waiting until code is complete or deployed to check for vulnerabilities, teams integrate these checks during development. The goal is simple: catch issues sooner, fix them faster, and reduce risk. By shifting security left, DevSecOps teams prevent costly late-stage fixes and make security a natural part of coding, not an afterthought.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;GitHub Actions: The Engine Behind Security Automation&lt;/h2&gt;
&lt;p&gt;GitHub Actions is a workflow automation tool built into GitHub. It allows you to define jobs triggered by events like pushes, pull requests, or scheduled intervals. For security, this means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Running &lt;strong&gt;static analysis&lt;/strong&gt; on every commit.
&lt;ul&gt;
&lt;li&gt;Static analysis examines source code without executing it, looking for patterns that indicate potential bugs, vulnerabilities, or compliance issues.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Scanning for &lt;strong&gt;secrets and credentials&lt;/strong&gt; before merging.&lt;/li&gt;
&lt;li&gt;Enforcing &lt;strong&gt;dependency checks&lt;/strong&gt; to prevent vulnerable packages.&lt;/li&gt;
&lt;li&gt;Validating &lt;strong&gt;infrastructure-as-code&lt;/strong&gt; for compliance.
&lt;ul&gt;
&lt;li&gt;such as: no large VMs, resources created in the correct region, affixing tags to each resource, etc.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Key Features for Security&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Reusable Workflows&lt;/strong&gt;: Share security workflows across repositories.
One of the most powerful features of GitHub Actions is the ability to create reusable workflows. Instead of duplicating security checks in every repository, you can define a single workflow in a central location and reference it across multiple projects. This approach ensures consistency, reduces maintenance overhead, and accelerates adoption of security best practices.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Best Practice&lt;/em&gt;: Combine reusable workflows with organization-level policies to enforce usage across teams. This ensures security automation is embedded in the development process.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Marketplace Actions&lt;/strong&gt;: Integrate tools like Snyk, Trivy, and Checkov.
One of GitHub Actions’ biggest strengths is its Marketplace, which hosts thousands of pre-built actions created by GitHub and the community. For DevSecOps engineers, this means you don’t have to reinvent the wheel because security tools are ready to plug into your workflows.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Best Practice&lt;/em&gt;: Combine multiple Marketplace actions in a single workflow to cover different layers (dependency, containers, IaC, etc.) to ensure comprehensive coverage without adding complexity&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Matrix Builds&lt;/strong&gt;: Test security across multiple environments.
Matrix builds in GitHub Actions allow you to run the same job across multiple configurations (i.e. operating systems, language versions, dependency sets, etc.) in parallel. For DevSecOps, this is a game-changer because vulnerabilities often surface only under certain conditions.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Best Practice&lt;/em&gt;: Combine matrix builds with Reusable workflows for consistency, Marketplace actions for specialized scans, and  fail-fast strategies so a critical vulnerability halts the pipeline immediately.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Implementing Security as Code with GitHub Actions&lt;/h2&gt;
&lt;p&gt;Here’s a practical example of a workflow that runs CodeQL and secret scanning on every pull request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Security Checks
on:
  pull_request:
    branches: [ main ]
jobs:
  codeql-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: github/codeql-action/init@v2
        with:
          languages: javascript
      - uses: github/codeql-action/analyze@v2

  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: github/secret-scanning-action@v1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This workflow ensures that every pull request undergoes static analysis and secret scanning before merging.&lt;/p&gt;
&lt;h2&gt;Best Practices&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Start Small&lt;/strong&gt;: Begin with one or two critical checks, then expand.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail Fast&lt;/strong&gt;: Configure workflows to block merges on high-severity findings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use Reusable Components&lt;/strong&gt;: Standardize workflows across teams.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitor and Iterate&lt;/strong&gt;: Review logs and metrics regularly.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Common Challenges&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;False Positives&lt;/strong&gt;: Tune your tools to reduce noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Developer Resistance&lt;/strong&gt;: Communicate the benefits and provide quick fixes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance Impact&lt;/strong&gt;: Optimize workflows to run in parallel.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Closing Thoughts&lt;/h2&gt;
&lt;p&gt;Security as Code isn’t optional. It’s &lt;strong&gt;essential&lt;/strong&gt; for modern software delivery. GitHub Actions provides the flexibility and power to make it real. By automating security checks, you can reduce risk, improve compliance, and keep development moving at full speed.&lt;/p&gt;
&lt;p&gt;Start small, iterate, and share your workflows. The sooner you embed security into your pipelines, the stronger your software supply chain becomes.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Need help? Ask me!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Learn how to implement Security as Code using GitHub Actions. Explore reusable workflows, Marketplace integrations, matrix builds, and best practices for embedding security into CI/CD pipelines.</summary>
    <category term="github-actions"/>
    <category term="devsecops"/>
    <category term="ci-cd"/>
  </entry>
  <entry>
    <title>Shift Left Without Slowing Down: DevSecOps Pipeline Design</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-11-17-shift-left-without-slowing-down/"/>
    <updated>2025-11-17T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-11-17-shift-left-without-slowing-down/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Modern software delivery is a race against time. Teams push code faster than ever, deploying multiple times a day to meet customer demands. But speed without security is a recipe for disaster. Vulnerabilities introduced early in development can cascade into production, where they’re exponentially harder and more expensive to fix. That’s why the principle of “shift left” has become a cornerstone of DevSecOps.&lt;/p&gt;
&lt;p&gt;Shifting left means moving security checks earlier in the development lifecycle, embedding them into the same workflows that developers use every day. It’s a powerful idea, but it comes with a challenge: how do you integrate security without slowing down the pipeline? Developers want velocity. Security teams want control. The goal is to design a pipeline that satisfies both.&lt;/p&gt;
&lt;p&gt;This article explores how to achieve that balance using GitHub as the foundation. We’ll look at the philosophy behind shift left, the practical steps to embed security into CI/CD, and the strategies that keep your pipeline fast while making it secure.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why Shift Left Matters&lt;/h2&gt;
&lt;p&gt;Traditional security models treated security as a gatekeeper. Code would flow through development and testing, and only at the end, right before deployment, would security teams step in. This approach worked when release cycles were measured in months. It doesn’t work in a world of continuous delivery.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://steve-kaschimer.github.io/images/posts/2025-11-17-shift-left.png&quot; alt=&quot;shift left&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Late-stage security checks create bottlenecks. They force developers to rework code they wrote weeks ago, slowing releases and creating friction between teams. Worse, they allow vulnerabilities to linger until the last possible moment, increasing the risk of exposure.&lt;/p&gt;
&lt;p&gt;Shift left flips the model. Instead of waiting until the end, security becomes part of the development process. Vulnerability scans run on every pull request. Secrets are checked before they hit the repository. Infrastructure-as-code is validated before provisioning resources. The result is fewer surprises, faster remediation, and a culture where security is everyone’s responsibility.&lt;/p&gt;
&lt;h2&gt;The Fear of Slowing Down&lt;/h2&gt;
&lt;p&gt;If shift left is so effective, why do some teams resist it? The answer is simple: performance anxiety. Developers worry that adding security checks will make pipelines sluggish. Security teams worry that developers will bypass controls to keep things moving.&lt;/p&gt;
&lt;p&gt;The truth is, poorly implemented security can slow things down. If scans take 30 minutes to run or generate endless false positives, developers will see security as an obstacle, not an enabler. That’s why pipeline design matters. The goal isn’t just to add security, it’s to integrate it intelligently so it complements speed rather than killing it.&lt;/p&gt;
&lt;h2&gt;Designing a DevSecOps Pipeline on GitHub&lt;/h2&gt;
&lt;p&gt;GitHub provides a rich ecosystem for building secure pipelines without sacrificing agility. At the heart of this is GitHub Actions, which allows you to automate workflows triggered by events like pushes, pull requests, or scheduled intervals.&lt;/p&gt;
&lt;p&gt;A well-designed pipeline starts with a clear separation of concerns. Security checks should run where they make sense, and they should run in parallel whenever possible. For example, static analysis can run alongside unit tests, while dependency checks can execute independently of build steps.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://steve-kaschimer.github.io/images/posts/2025-11-17-devsecops-pipeline-architecture.png&quot; alt=&quot;devsecops pipeline architecture&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The key is modularity. Instead of one monolithic workflow that does everything, break your pipeline into smaller jobs. Each job handles a specific responsibility (build, test, scan) and runs concurrently. This approach minimizes bottlenecks and makes troubleshooting easier.&lt;/p&gt;
&lt;h2&gt;Embedding Security Without Friction&lt;/h2&gt;
&lt;p&gt;The first step is to identify which security controls belong in the pipeline. At a minimum, you want static analysis, secret scanning, and dependency checks. These are lightweight and can run quickly on every pull request.&lt;/p&gt;
&lt;p&gt;Static analysis tools like CodeQL examine source code for vulnerabilities without executing it. They’re ideal for catching issues early, and when configured properly, they add only a few minutes to the pipeline. Secret scanning prevents accidental exposure of credentials, and GitHub provides this natively. Dependency checks, powered by tools like Dependabot, ensure that third-party libraries remain secure.&lt;/p&gt;
&lt;p&gt;For heavier scans, like container image analysis or infrastructure compliance, you can schedule them to run nightly or on merge to main. This keeps pull request workflows lean while still providing comprehensive coverage.&lt;/p&gt;
&lt;h2&gt;Parallelization and Caching: The Unsung Heroes&lt;/h2&gt;
&lt;p&gt;One of the easiest ways to keep pipelines fast is to run jobs in parallel. GitHub Actions supports matrix builds, which allow you to test across multiple environments simultaneously. This is particularly useful for security because vulnerabilities can be environment-specific.&lt;/p&gt;
&lt;p&gt;Caching is another performance booster. Many security tools rely on large databases of vulnerability signatures. By caching these between runs, you avoid downloading them every time, shaving minutes off your workflow.&lt;/p&gt;
&lt;h2&gt;Handling False Positives&lt;/h2&gt;
&lt;p&gt;Nothing kills developer trust faster than noisy security alerts. If every pull request triggers a dozen false positives, developers will tune out. The solution is tuning. Configure your tools to focus on high-severity issues and suppress rules that don’t apply to your codebase.&lt;/p&gt;
&lt;p&gt;It’s also important to provide actionable feedback. A vague “security issue detected” message isn’t helpful. Developers need context about what’s wrong, why it matters, and how to fix it. GitHub’s integration with CodeQL and other tools makes this possible by surfacing detailed findings directly in pull requests.&lt;/p&gt;
&lt;h2&gt;Culture Is the Glue&lt;/h2&gt;
&lt;p&gt;Technology alone won’t make shift left successful. You need a culture that values security as much as speed. That means involving developers in the process, explaining why controls exist, and celebrating wins when vulnerabilities are caught early.&lt;/p&gt;
&lt;p&gt;Security champions (developers who advocate for best practices) can help bridge the gap between teams. Training sessions, documentation, and clear communication go a long way toward making security feel like a shared goal rather than an imposed burden.&lt;/p&gt;
&lt;h2&gt;A Sample Pipeline Design&lt;/h2&gt;
&lt;p&gt;Imagine a pipeline that runs on every pull request. It starts by checking out the code and running unit tests. In parallel, it launches three security jobs: static analysis with CodeQL, secret scanning, and dependency checks. Each job runs independently, and the workflow is configured to fail fast if a critical vulnerability is found.&lt;/p&gt;
&lt;p&gt;On merge to main, the pipeline triggers additional jobs: container image scanning with Trivy and infrastructure compliance checks with Checkov. These heavier scans run asynchronously, so they don’t block developers waiting for feedback on their pull requests.&lt;/p&gt;
&lt;p&gt;The result is a pipeline that enforces security without slowing development. Developers get quick feedback on critical issues, and security teams get the assurance that controls are in place.&lt;/p&gt;
&lt;p&gt;You can find some examples below&lt;/p&gt;
&lt;h3&gt;Pull Request workflow - fast feedback, parallel security&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &#39;.github/workflows/pr-pipeline.yml&#39;&lt;/p&gt;
&lt;p&gt;{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: PR Pipeline (Fast Feedback)

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
    branches: [main]
  workflow_dispatch:

# Prevent redundant runs on the same PR head sha
concurrency:
  group: pr-${{ github.ref }}-${{ github.head_ref }}
  cancel-in-progress: true

permissions:
  contents: read
  actions: read
  security-events: write   # for CodeQL to upload SARIF
  pull-requests: write     # to annotate PRs with findings
  id-token: write          # optional: for OIDC to cloud scanners (if needed)

env:
  NODE_VERSION: &#39;20&#39;
  # Example registry mirror settings (adjust to your org)
  # NPM_REGISTRY: &#39;https://registry.npmjs.org&#39;

jobs:
  build_and_test:
    name: Build &amp;amp; Unit Tests (matrix)
    runs-on: ubuntu-latest
    timeout-minutes: 20
    strategy:
      fail-fast: true
      matrix:
        node: [18, 20]
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: &#39;npm&#39;

      - name: Install deps
        run: npm ci

      - name: Unit tests
        run: npm test -- --ci --reporter=junit
      # Optionally upload coverage/test reports to your system

  codeql:
    name: Static Analysis (CodeQL)
    runs-on: ubuntu-latest
    timeout-minutes: 25
    permissions:
      contents: read
      security-events: write
      actions: read
    steps:
      - uses: actions/checkout@v4

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: javascript # add more e.g., javascript,python,java,go,cpp,csharp
          queries: +security-and-quality

      - name: Autobuild
        uses: github/codeql-action/autobuild@v3

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
        with:
          category: &#39;/language:javascript&#39;

  dependency_review:
    name: Dependency Checks (PR Diff)
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - name: Dependency Review
        uses: actions/dependency-review-action@v4
        with:
          fail-on-severity: critical
          comment-summary-in-pr: true

  secrets_scan:
    name: Secret Scanning (Push Protection Guide)
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      # Native GitHub Secret Scanning runs automatically on Advanced Security-enabled repos.
      # This step enforces a quick pre-commit/PR check with gitleaks as a complement (optional).
      - name: Run Gitleaks
        uses: zricethezav/gitleaks-action@v2
        with:
          args: detect --no-git -v --redact
      # Note: enable &amp;quot;Push Protection&amp;quot; in repo/org settings to block secrets before they land.

  # Optional: run lightweight container scan on PRs, keep it fast
  trivy_pr:
    name: Container Scan (Trivy)
    runs-on: ubuntu-latest
    timeout-minutes: 10
    needs: [build_and_test]
    steps:
      - uses: actions/checkout@v4

      - name: Build app image (local)
        run: |
          docker build -t app:${{ github.sha }} .

      - name: Cache Trivy DB
        uses: actions/cache@v4
        with:
          path: ~/.cache/trivy
          key: trivy-db-${{ runner.os }}-${{ hashFiles(&#39;**/Dockerfile&#39;) }}
          restore-keys: |
            trivy-db-${{ runner.os }}-

      - name: Scan image with Trivy (critical only)
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:${{ github.sha }}
          severity: CRITICAL,HIGH
          exit-code: &#39;1&#39;
          ignore-unfixed: true
          vuln-type: &#39;os,library&#39;

  # Keep IaC checks in PR but quick
  checkov_pr:
    name: IaC Compliance (Checkov)
    runs-on: ubuntu-latest
    timeout-minutes: 8
    steps:
      - uses: actions/checkout@v4
      - name: Run Checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: .
          quiet: true
          soft_fail: false
          framework: terraform,kubernetes,cloudformation,arm

  # Gate: if any critical job fails, whole PR is blocked (default behavior)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why this works for speed + security&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Jobs &lt;strong&gt;run in parallel&lt;/strong&gt; (build/tests, CodeQL, dependency review, secrets, light Trivy, Checkov).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Matrix&lt;/strong&gt; ensures cross-version coverage without serial runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caching&lt;/strong&gt; speeds Trivy DB and Node modules.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail on severity&lt;/strong&gt; and &lt;strong&gt;exit codes&lt;/strong&gt; keep signal strong and avoid noisy false positives.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Main branch workflow - heavier scans on merge&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &#39;.github/workflows/main-security.yml&#39;&lt;/p&gt;
&lt;p&gt;{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Main Branch Security (Heavier Coverage)

on:
  push:
    branches: [main]
  schedule:
    - cron: &amp;quot;17 2 * * *&amp;quot;   # nightly deeper scan (UTC)
  workflow_dispatch:

concurrency:
  group: main-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read
  security-events: write
  actions: read
  id-token: write

jobs:
  build_release_artifacts:
    name: Build Release Artifacts
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: &#39;20&#39;
          cache: &#39;npm&#39;
      - run: npm ci
      - run: npm run build
      - name: Archive build
        uses: actions/upload-artifact@v4
        with:
          name: app-build
          path: dist/

  trivy_image_scan:
    name: Container Image Scan (Trivy - full)
    runs-on: ubuntu-latest
    needs: build_release_artifacts
    steps:
      - uses: actions/checkout@v4

      - name: Build production image
        run: |
          docker build -t app:release .

      - name: Cache Trivy DB
        uses: actions/cache@v4
        with:
          path: ~/.cache/trivy
          key: trivy-db-${{ runner.os }}-${{ github.sha }}
          restore-keys: |
            trivy-db-${{ runner.os }}-

      - name: Trivy scan (fail on High/Critical)
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:release
          severity: CRITICAL,HIGH
          exit-code: &#39;1&#39;
          ignore-unfixed: false
          format: &#39;sarif&#39;
          output: &#39;trivy-results.sarif&#39;

      - name: Upload SARIF to code scanning
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

  checkov_full:
    name: IaC Compliance (Checkov - full)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Checkov (report + fail on high)
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: .
          quiet: true
          soft_fail: false
          skip_check: CKV_SECRET_1  # example of tuning; adjust to your baseline
      - name: Upload Checkov results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: checkov-report
          path: results_json/*.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;h3&gt;Optional: Reusable workflow for org-wide consistency&lt;/h3&gt;
&lt;p&gt;If you manage many repos, create a &lt;strong&gt;reusable workflow&lt;/strong&gt; and call it from each repo.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &#39;.github/workflows/reusable-security.yml&#39;&lt;/p&gt;
&lt;p&gt;{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Reusable Security
on:
  workflow_call:
    inputs:
      languages:
        required: false
        type: string
        default: &#39;javascript&#39;
    secrets:
      token:
        required: false

jobs:
  codeql:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with:
          languages: ${{ inputs.languages }}
          queries: +security-and-quality
      - uses: github/codeql-action/autobuild@v3
      - uses: github/codeql-action/analyze@v3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;p&gt;Then invoke it:&lt;/p&gt;
&lt;p&gt;{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  security:
    uses: your-org/your-repo/.github/workflows/reusable-security.yml@main
    with:
      languages: &#39;javascript,python&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;p&gt;Additional settings that will provide more options for protection and performance:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Push Protection &amp;amp; Secret Scanning:&lt;/strong&gt; Enable at the org/repo level to block secrets before they land; use a lightweight PR scanner as a safety net.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tuning &amp;amp; Noise Reduction:&lt;/strong&gt; Set &#39;fail-on-severity&#39;, &#39;ignore-unfixed&#39;, and &#39;skip_check&#39; to align with your baseline; revisit quarterly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parallelization:&lt;/strong&gt; Keep PR feedback fast by running security jobs concurrently and shifting heavier scans to &#39;push&#39;/&#39;schedule&#39;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Least Privilege:&lt;/strong&gt; Use minimal &#39;permissions&#39; and OIDC (&#39;id-token&#39;) for cloud scanners instead of long‑lived secrets.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Looking Ahead&lt;/h2&gt;
&lt;p&gt;Shift left isn’t a one-time project. It’s an ongoing evolution. As threats change and tools improve, your pipeline will need to adapt. GitHub is investing heavily in security features like push protection, which blocks commits containing secrets before they even hit the repository. Expect more automation, better integrations, and smarter alerts in the future.&lt;/p&gt;
&lt;p&gt;The goal is simple: make security invisible. When developers don’t have to think about it (because it’s baked into their workflows) you’ve achieved true DevSecOps.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;Balancing speed and security isn’t easy, but it’s possible. By designing pipelines that integrate security intelligently, you can shift left without slowing down. Start small, iterate often, and keep the conversation open between development and security teams.&lt;/p&gt;
&lt;p&gt;In the end, the fastest pipeline isn’t the one that skips security. It’s the one that makes security seamless.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Need help shifting left? Contact me!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@!slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Learn how to securely manage secrets on GitHub using secret scanning, environment variables, and best practices to prevent credential leaks and security breaches.</summary>
    <category term="devsecops"/>
    <category term="ci-cd"/>
    <category term="devops"/>
  </entry>
  <entry>
    <title>CodeQL Deep Dive: Static Analysis for DevSecOps Engineers</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-11-24-codeql-deep-dive-static-analysis-for-devops-engineers/"/>
    <updated>2025-11-24T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-11-24-codeql-deep-dive-static-analysis-for-devops-engineers/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Modern software development moves at breakneck speed. Continuous integration and continuous delivery (CI/CD) pipelines have transformed how teams build and ship applications, enabling rapid iteration and frequent releases. But with this velocity comes risk. Vulnerabilities can slip through unnoticed, and if they make it into production, the cost of remediation skyrockets, not just in dollars, but in reputation and trust.&lt;/p&gt;
&lt;p&gt;This is where static analysis becomes indispensable. Among the tools available today, &lt;strong&gt;CodeQL&lt;/strong&gt; stands out as a game-changer for DevSecOps engineers. It’s not just another scanner; it’s a query engine for your code. CodeQL allows you to treat your codebase like a database, asking sophisticated questions about patterns, flows, and behaviors that might indicate security flaws. In this deep dive, we’ll explore what makes CodeQL unique, how it works under the hood, how you can customize it to fit your organization’s needs, and how to integrate it seamlessly into your workflows.&lt;/p&gt;
&lt;p&gt;By the end of this article, you’ll understand why CodeQL is more than a tool. It’s a mindset shift for secure development.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;&lt;strong&gt;What Is CodeQL and Why Does It Matter?&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;CodeQL is GitHub’s semantic code analysis engine. Unlike traditional static analysis tools that rely on predefined rules and pattern matching, CodeQL converts your source code into a relational database. Every function, variable, class, and dependency becomes part of a structured schema. This means you can write queries to search for vulnerabilities, design flaws, or even coding style violations, similar to how you write queries for SQL.&lt;/p&gt;
&lt;p&gt;Why is this approach powerful? Because vulnerabilities often share structural similarities. For example, SQL injection vulnerabilities typically involve unsanitized user input flowing into a database query. With CodeQL, you can express this concept as a query and apply it across your entire codebase. Instead of scanning for hardcoded patterns, you’re analyzing relationships and data flows, which makes detection far more accurate and adaptable.&lt;/p&gt;
&lt;p&gt;For DevSecOps engineers, this flexibility is gold. It allows you to go beyond generic checks and tailor security analysis to your application’s architecture, coding standards, and threat model.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;How CodeQL Works Behind the Scenes&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://steve-kaschimer.github.io/images/posts/2025-11-24-codeql-architecture.png&quot; alt=&quot;codeql architecture&quot; /&gt;&lt;/p&gt;
&lt;p&gt;To appreciate CodeQL’s capabilities, it helps to understand its workflow. When you run CodeQL, three major steps occur:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1: Code Extraction&lt;/strong&gt;
CodeQL parses your source code and builds a database that represents the code’s abstract syntax tree (AST), control flow, and data flow. This database is language-specific, and CodeQL supports a wide range of languages including JavaScript, Python, Java, Go, C#, and C/C++.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2: Query Execution&lt;/strong&gt;
Queries are written in CodeQL’s own language, which borrows concepts from logic programming and relational algebra. These queries operate on the database created in Step 1. For example, you might write a query to find all functions that concatenate user input into SQL statements without sanitization.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 3: Results and Reporting&lt;/strong&gt;
The results of these queries are returned in SARIF (Static Analysis Results Interchange Format), which integrates seamlessly with GitHub’s code scanning alerts. This means developers see actionable findings directly in their pull requests, complete with explanations and remediation guidance.&lt;/p&gt;
&lt;p&gt;This architecture makes CodeQL incredibly versatile. You’re not limited to the queries GitHub provides. You can write your own, combine them, and even share them across teams.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;The Query Language: Your Superpower&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;At the heart of CodeQL is its query language. If you’ve ever written SQL, you’ll feel at home, but CodeQL is designed for code analysis, not relational data. A typical query consists of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Imports&lt;/strong&gt;: Specify the language libraries you need (e.g., &lt;code&gt;import javascript&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Predicates&lt;/strong&gt;: Define conditions that match certain code elements.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Select statements&lt;/strong&gt;: Determine what results to return and how to annotate them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here’s a simple example that detects hardcoded AWS access keys in JavaScript:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ql&quot;&gt;import javascript

from Literal l
where l.getValue().matches(&amp;quot;AKIA[0-9A-Z]{16}&amp;quot;)
select l, &amp;quot;Possible AWS Access Key detected.&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This query imports the JavaScript library, iterates over all literals, and flags any that match the regex for AWS keys. It’s concise, expressive, and easy to adapt.&lt;/p&gt;
&lt;p&gt;But CodeQL can do much more. You can write queries that track data flow across functions, identify tainted inputs, and detect complex vulnerability patterns. For instance, finding SQL injection risks involves tracing user input from its source to a sink (e.g., a database call) without proper sanitization. CodeQL’s libraries provide built-in predicates for common sources and sinks, making these queries easier to write.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;Customizing Queries for Your Organization&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Out-of-the-box, CodeQL includes thousands of queries covering common vulnerabilities and best practices. But every organization has unique requirements. Maybe you have internal APIs that require special handling, or coding standards that go beyond what generic queries enforce. Customization is where CodeQL shines.&lt;/p&gt;
&lt;p&gt;You can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Extend existing queries by adding conditions or exceptions.&lt;/li&gt;
&lt;li&gt;Write new queries for project-specific risks.&lt;/li&gt;
&lt;li&gt;Suppress false positives by refining predicates.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example, suppose your team uses a custom sanitization function called &lt;code&gt;sanitizeInput&lt;/code&gt;. You can modify the standard SQL injection query to treat calls to this function as safe. This reduces noise and builds developer trust.&lt;/p&gt;
&lt;p&gt;Testing custom queries is straightforward with the CodeQL CLI. You can run queries locally against your codebase, iterate quickly, and then integrate them into your CI/CD pipeline once validated.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://steve-kaschimer.github.io/images/posts/2025-11-24-query-lifecycle.png&quot; alt=&quot;query lifecycle&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;Integrating CodeQL into Your Workflows&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Static analysis is most effective when it’s automated and continuous. GitHub Actions makes CodeQL integration seamless. Here’s a sample workflow you can use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: CodeQL Analysis

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: &#39;0 2 * * 0&#39;

jobs:
  analyze:
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with:
          languages: javascript,python
      - uses: github/codeql-action/autobuild@v3
      - uses: github/codeql-action/analyze@v3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This workflow runs CodeQL on every push and pull request to &lt;code&gt;main&lt;/code&gt;, plus a scheduled weekly scan. It initializes CodeQL, builds the project, and analyzes the code. Results appear in GitHub’s Security tab and as annotations in pull requests.&lt;/p&gt;
&lt;p&gt;For larger projects, consider splitting workflows into modular jobs and using caching to speed up builds. You can also configure fail-on-severity thresholds to block merges when critical vulnerabilities are detected.&lt;/p&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h3&gt;&lt;strong&gt;Best Practices for CodeQL Adoption&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Integrating CodeQL is just the beginning. To maximize its value:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Run scans early and often. Pull request analysis provides fast feedback and prevents vulnerabilities from entering the main branch.&lt;/li&gt;
&lt;li&gt;Tune queries to reduce false positives. Developer trust is essential because noisy alerts lead to alert fatigue.&lt;/li&gt;
&lt;li&gt;Combine CodeQL with other security checks like secret scanning and dependency review for layered defense.&lt;/li&gt;
&lt;li&gt;Educate developers on interpreting CodeQL findings. The more they understand the “why” behind alerts, the more likely they are to fix issues promptly.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h3&gt;&lt;strong&gt;Advanced Use Cases&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;CodeQL isn’t limited to security. You can use it for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Code quality enforcement&lt;/strong&gt;: Detect anti-patterns or deprecated APIs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compliance checks&lt;/strong&gt;: Ensure code adheres to regulatory requirements.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Architecture analysis&lt;/strong&gt;: Identify cyclic dependencies or excessive coupling.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These use cases make CodeQL a versatile tool for both security and engineering excellence.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;The Future of CodeQL&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;GitHub continues to invest heavily in CodeQL. Expect improvements in query packs, language support, and performance. Features like push protection and deeper integration with GitHub Advanced Security will make secure development even more frictionless.&lt;/p&gt;
&lt;p&gt;For DevSecOps engineers, mastering CodeQL is a career-defining skill. It empowers you to move beyond reactive scanning and embrace proactive, intelligent security.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;&lt;strong&gt;Final Thoughts&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Static analysis is no longer optional. It’s a necessity in modern software delivery. CodeQL offers a unique approach that combines precision, flexibility, and automation. By understanding how it works, customizing queries, and integrating it into your workflows, you can elevate your security posture without sacrificing speed.&lt;/p&gt;
&lt;p&gt;Start small. Enable CodeQL on a critical repository, experiment with queries, and iterate. Over time, you’ll build a library of custom checks that reflect your organization’s priorities. And as you do, you’ll transform security from a bottleneck into a seamless part of development.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Need help getting your CodeQL just right? Contact me!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Master CodeQL&#39;s query-based static analysis by treating your codebase as a database. Learn to write custom queries, integrate with CI/CD pipelines, and detect vulnerabilities with precision.</summary>
    <category term="security"/>
    <category term="devsecops"/>
    <category term="github"/>
  </entry>
  <entry>
    <title>DevOps Culture: What It Is, Why It Exists, and Why It Matters</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-12-01-devops-culture/"/>
    <updated>2025-12-01T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-12-01-devops-culture/</id>
    <content xml:lang="en" type="html">&lt;p&gt;DevOps has become one of the most talked-about concepts in modern software delivery. It’s often associated with automation tools, CI/CD pipelines, and cloud-native architectures. But the truth is, DevOps isn’t primarily about technology. It’s about culture. Without cultural transformation, even the most advanced tools will fail to deliver the promised benefits.&lt;/p&gt;
&lt;p&gt;So, what exactly is DevOps culture? Why did it emerge? Why should organizations care? And perhaps most importantly, how do we build it? This article dives deep into these questions, drawing on real-world examples and lessons learned from enterprise transformations, including insights from projects.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;What Is DevOps Culture?&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;DevOps culture is more than a set of practices. It’s a mindset that transforms how organizations build and deliver software. At its core, DevOps culture breaks down silos between development, operations, and security teams, fostering collaboration and shared responsibility across the entire software delivery lifecycle. Instead of developers writing code and tossing it over the wall to operations, DevOps encourages everyone involved, including developers, testers, security engineers, and operations, to work toward a common goal: delivering reliable, secure software quickly and efficiently.&lt;/p&gt;
&lt;p&gt;To understand DevOps culture, it helps to look at the Three Ways described in &lt;strong&gt;The Phoenix Project&lt;/strong&gt;, which serve as guiding principles for high-performing technology organizations:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The First Way: Flow&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Flow is about creating a fast, smooth movement of work from development to operations and ultimately to the customer. It emphasizes systems thinking, or viewing the entire value stream as one continuous system rather than isolated silos. Practices like reducing batch sizes, limiting work in progress, and eliminating bottlenecks help accelerate delivery while improving quality. In a DevOps culture, flow ensures that ideas move quickly from concept to production without unnecessary friction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Second Way: Feedback&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Feedback is the lifeblood of continuous improvement. The Second Way focuses on shortening and amplifying feedback loops so problems are detected and corrected early. Automated testing, continuous integration, proactive monitoring, and regular retrospectives create a two-way exchange of insights between development and operations. This principle reinforces shared responsibility and helps teams learn from each other, preventing defects from cascading downstream.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Third Way: Continuous Learning and Experimentation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The Third Way promotes a culture of continual learning and innovation. It encourages teams to take calculated risks, experiment, and learn from failures without fear of blame. Practices like blameless post-mortems, dedicated time for experimentation, and open knowledge sharing make improvement part of everyday work. This principle ensures that organizations adapt quickly to change and continuously evolve their capabilities.&lt;/p&gt;
&lt;p&gt;Together, these Three Ways form the backbone of DevOps culture. They shift the focus from isolated tasks to holistic outcomes, from rigid processes to adaptive learning, and from siloed accountability to shared ownership. When these principles are embraced, DevOps becomes more than a methodology. It becomes a cultural movement that drives speed, quality, and resilience.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;Why Does DevOps Culture Exist?&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;The roots of DevOps culture lie in the shortcomings of traditional software development models. Waterfall methodologies, with their rigid phases and long release cycles, were ill-suited for a world where customer expectations change overnight. Agile development addressed part of the problem by speeding up coding and testing, but it often left operations behind. The result? Faster development paired with slow, painful deployments.&lt;/p&gt;
&lt;p&gt;The 2009 &amp;quot;&lt;strong&gt;10+ Deploys Per Day&lt;/strong&gt;&amp;quot; talk by John Allspaw and Paul Hammond at the Velocity conference is widely considered the spark that ignited the DevOps movement. At the time, Flickr was deploying code to production more than 10 times per day, which was revolutionary when most companies were doing quarterly or monthly releases. The talk challenged the conventional wisdom that development and operations had inherently conflicting goals. Instead of accepting the &amp;quot;wall of confusion&amp;quot; between Dev (who wanted to move fast and ship features) and Ops (who wanted stability and minimal change), Allspaw and Hammond demonstrated how their teams collaborated through shared tools, shared metrics, and shared responsibility. They showed that with the right culture and automation, velocity and stability weren&#39;t trade-offs, but rather they reinforced each other.&lt;/p&gt;
&lt;p&gt;The key insight was that deploying frequently actually &lt;em&gt;reduces&lt;/em&gt; risk because each change is smaller, easier to test, and faster to roll back if needed. Their approach included automated testing, one-step builds and deploys, feature flags for safer releases, shared metrics visible to everyone, and most importantly, a culture of mutual respect and trust between developers and operations. The talk resonated so deeply because it offered a practical alternative to the status quo, proving that cross-functional collaboration, automation, and continuous delivery weren&#39;t just theoretical ideals. They were achievable realities. This presentation became the blueprint for what would soon be formalized as the DevOps movement, influencing countless organizations to rethink how they deliver software.&lt;/p&gt;
&lt;p&gt;DevOps emerged as the bridge between Agile and operational excellence. It extended the principles of iteration and feedback beyond coding to include deployment, monitoring, and incident response. Organizations realized that speed without stability was a recipe for disaster. DevOps culture exists to align innovation with reliability, enabling teams to deliver value continuously without sacrificing quality.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;Why Should We Care About DevOps Culture?&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Culture drives behavior, and behavior drives outcomes. You can implement every automation tool on the market, but if your teams don’t collaborate, share responsibility, and embrace continuous improvement, you’ll never achieve true DevOps maturity.&lt;/p&gt;
&lt;p&gt;DevOps culture matters because it impacts every metric that matters to the business:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Time-to-market&lt;/strong&gt;: Faster releases mean quicker response to customer needs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quality&lt;/strong&gt;: Shared responsibility reduces defects and improves reliability.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Employee engagement&lt;/strong&gt;: Teams that collaborate and learn together are more motivated.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Business value&lt;/strong&gt;: Efficient delivery translates to competitive advantage and profitability.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;strong&gt;How Do We Get There?&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Building a DevOps culture isn&#39;t about buying a tool or adopting a framework. It&#39;s about changing mindsets and behaviors through deliberate practices, organizational design, and measured progress. Here&#39;s a comprehensive roadmap for cultural transformation:&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;Specific Practices That Enable DevOps Culture&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Infrastructure as Code (IaC)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Infrastructure as Code treats infrastructure provisioning like software development, with version control, code reviews, and automated testing. Instead of manually configuring servers through GUI consoles or ad-hoc scripts, teams define infrastructure declaratively in files that can be reviewed, tested, and deployed consistently.&lt;/p&gt;
&lt;p&gt;For example, using Terraform, you might define an Azure Kubernetes Service cluster like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;azurerm_kubernetes_cluster&amp;quot; &amp;quot;main&amp;quot; {
  name                = &amp;quot;prod-aks-cluster&amp;quot;
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  dns_prefix          = &amp;quot;prodaks&amp;quot;
  
  default_node_pool {
    name       = &amp;quot;default&amp;quot;
    node_count = 3
    vm_size    = &amp;quot;Standard_D2_v2&amp;quot;
  }
  
  identity {
    type = &amp;quot;SystemAssigned&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This approach makes infrastructure changes transparent, auditable, and repeatable. When operations engineers and developers collaborate on IaC, they build shared understanding of both application and infrastructure requirements. Code reviews become opportunities for knowledge transfer. Automated testing catches configuration drift before it reaches production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Shift-Left Security Practices&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Shift-left security means integrating security checks early in the development pipeline rather than treating security as a gate before production. This includes static application security testing (SAST) in CI pipelines, dependency scanning for vulnerable packages, container image scanning, and infrastructure security validation.&lt;/p&gt;
&lt;p&gt;For instance, integrating GitHub Advanced Security into your CI/CD pipeline automatically scans for secrets, detects vulnerable dependencies, and runs CodeQL queries on every pull request. Developers get immediate feedback about security issues when the fix is cheapest and easiest. Security teams define policies as code, like &amp;quot;no critical vulnerabilities in production&amp;quot; or &amp;quot;all secrets must be stored in Azure Key Vault,&amp;quot; and automation enforces them consistently.&lt;/p&gt;
&lt;p&gt;The cultural shift here is critical: security isn&#39;t something done &lt;em&gt;to&lt;/em&gt; developers; it&#39;s something done &lt;em&gt;with&lt;/em&gt; them. Security engineers become enablers rather than gatekeepers, providing tools, training, and guardrails that help developers ship secure code confidently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Observability and Monitoring Strategies&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Observability goes beyond traditional monitoring. While monitoring tells you &lt;em&gt;what&lt;/em&gt; is wrong (CPU usage is high, error rate increased), observability helps you understand &lt;em&gt;why&lt;/em&gt; by providing insights into system behavior through logs, metrics, traces, and events.&lt;/p&gt;
&lt;p&gt;A mature observability strategy includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Structured logging&lt;/strong&gt; with correlation IDs to trace requests across distributed systems&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Distributed tracing&lt;/strong&gt; to visualize request flows and identify bottlenecks (using tools like Jaeger or Azure Application Insights)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Metrics dashboards&lt;/strong&gt; that show business KPIs alongside technical metrics&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Proactive alerting&lt;/strong&gt; based on SLOs (Service Level Objectives) rather than arbitrary thresholds&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blameless postmortems&lt;/strong&gt; that treat incidents as learning opportunities&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When developers have access to production metrics and logs, they understand how their code performs in the real world. When operations teams understand application architecture and business context, they can prioritize incidents effectively. Shared observability creates shared responsibility.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ChatOps and Communication Patterns&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;ChatOps brings operational work into chat platforms like Slack or Microsoft Teams, making actions transparent and collaborative. Instead of operations engineers deploying code through opaque terminal sessions, deployments happen via chat commands visible to the entire team.&lt;/p&gt;
&lt;p&gt;For example, a deployment might look like: &lt;code&gt;/deploy api-service v2.3.1 to production&lt;/code&gt; executed in a Slack channel. The bot responds with deployment status, runs automated tests, and notifies the team when complete. If issues arise, the entire team sees the context and can collaborate on resolution in the same thread.&lt;/p&gt;
&lt;p&gt;This transparency breaks down information silos. Junior engineers learn by observing how seniors troubleshoot issues. Product managers understand operational challenges. Security teams can audit actions without requesting logs. ChatOps doesn&#39;t just automate tasks; it democratizes knowledge.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;Team Topologies: Organizing for Flow&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;DevOps culture requires deliberate organizational design. The book &lt;em&gt;Team Topologies&lt;/em&gt; by Matthew Skelton and Manuel Pais provides a framework for structuring teams to optimize flow and minimize cognitive load.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stream-Aligned Teams&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;These are cross-functional product teams aligned to a single value stream (a product, service, or user journey). A stream-aligned team includes developers, testers, operations expertise, and sometimes designers or data analysts. They own their service end-to-end, from code to production. For example, a &amp;quot;Checkout Service Team&amp;quot; owns everything related to the checkout experience: backend APIs, frontend components, database schemas, infrastructure, and monitoring.&lt;/p&gt;
&lt;p&gt;This structure eliminates handoffs and waiting. The team can move quickly because they don&#39;t depend on separate operations or QA teams to progress. They feel ownership because they&#39;re accountable for outcomes, not just outputs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Platform Teams&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Platform teams build internal products that reduce cognitive load for stream-aligned teams. They provide self-service capabilities like CI/CD pipelines, infrastructure templates, observability tooling, and developer portals. A good platform team treats other engineering teams as customers, focusing on developer experience and ease of use.&lt;/p&gt;
&lt;p&gt;For instance, a platform team might create a &amp;quot;golden path&amp;quot; deployment pipeline where stream-aligned teams can deploy containerized applications to Kubernetes with a single YAML file, while the platform handles secrets management, network policies, monitoring setup, and compliance checks automatically.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Enabling Teams&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Enabling teams help stream-aligned teams adopt new technologies and practices. They&#39;re specialists (security engineers, SREs, data engineers) who embed temporarily with product teams to transfer knowledge. Unlike traditional centralized teams that do work &lt;em&gt;for&lt;/em&gt; others, enabling teams work &lt;em&gt;with&lt;/em&gt; others to build capability.&lt;/p&gt;
&lt;p&gt;For example, an enabling team might help a product team adopt observability practices by pairing on instrumentation code, explaining tracing concepts, and setting up dashboards. After a few weeks, the product team has the skills to continue independently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Complicated Subsystem Teams&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;These teams handle complex technical domains that require specialized expertise, like machine learning models, payment processing, or compliance engines. They provide services to stream-aligned teams through well-defined APIs.&lt;/p&gt;
&lt;p&gt;The key principle is &lt;strong&gt;team interaction modes&lt;/strong&gt;: collaboration (working together), X-as-a-Service (consuming through APIs), and facilitation (helping others learn). Clear interaction modes prevent teams from stepping on each other&#39;s toes and reduce cognitive overload.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;Transformation Roadmap: From Assessment to Optimization&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;DevOps transformation isn&#39;t a big-bang change. It&#39;s a phased journey that respects organizational constraints while driving continuous improvement.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://steve-kaschimer.github.io/images/posts/2025-12-01-transformation-roadmap.png&quot; alt=&quot;transformation roadmap&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1: Assessment (2-4 weeks)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Start by understanding your current state. Conduct interviews with developers, operations, security, and business stakeholders. Map your value streams: how does code move from idea to production? Identify bottlenecks, waste, and cultural friction points.&lt;/p&gt;
&lt;p&gt;Measure baseline metrics: How often do you deploy? What&#39;s your lead time from commit to production? What percentage of deployments cause incidents? How long does it take to recover from failures? These become your benchmarks for improvement.&lt;/p&gt;
&lt;p&gt;Assess organizational readiness. Who are your potential champions? What&#39;s leadership&#39;s appetite for change? What constraints (regulatory, technical, political) will you face? Create a stakeholder map and change management strategy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2: Pilot (3-6 months)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Select one stream-aligned team (ideally working on a non-critical but meaningful product) to pilot DevOps practices. This team becomes your laboratory for experimentation and your showcase for success.&lt;/p&gt;
&lt;p&gt;Provide this team with support: automation tools, training, time to refactor, and executive air cover to take calculated risks. Help them implement continuous integration, automated testing, and deployment automation. Introduce infrastructure as code. Set up observability. Establish metrics dashboards.&lt;/p&gt;
&lt;p&gt;Document everything: what worked, what didn&#39;t, and what you learned. Run retrospectives. Share progress through demos and internal blog posts. The goal is to build a proven model and create advocates who can help spread practices to other teams.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 3: Scale (6-18 months)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;With a successful pilot, begin scaling practices across the organization. This isn&#39;t about mandating tools; it&#39;s about sharing patterns, providing platforms, and building momentum.&lt;/p&gt;
&lt;p&gt;Form a platform team to codify lessons learned from the pilot into reusable services. Create documentation, runbooks, and training materials. Establish communities of practice where practitioners share knowledge. Identify and empower champions in each department.&lt;/p&gt;
&lt;p&gt;Roll out changes incrementally. Start with teams that are ready and willing. Let success stories drive adoption. Provide enabling team support to teams that need extra help. Measure progress against DORA metrics and celebrate improvements publicly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 4: Optimize (Ongoing)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;DevOps transformation never &amp;quot;finishes.&amp;quot; Optimization is continuous. Regularly revisit metrics and identify new bottlenecks. Experiment with advanced practices like chaos engineering, feature flags, and progressive delivery.&lt;/p&gt;
&lt;p&gt;Invest in organizational learning. Run internal conferences. Encourage teams to attend external conferences and bring back ideas. Create time and space for innovation. Most importantly, maintain the cultural practices that got you here: blameless postmortems, cross-functional collaboration, and psychological safety.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;Change Management Tactics: Building Momentum&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Cultural change is hard because it threatens the status quo. People fear losing status, competence, or control. Here&#39;s how to overcome resistance:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Start with Why&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Connect DevOps transformation to business outcomes people care about. For executives, emphasize competitive advantage and faster time-to-market. For engineers, highlight reduced toil and more interesting work. For operations, emphasize stability through automation and reduced burnout. Make the case compelling and personal.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build Champions&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Identify influential people at every level who believe in the vision. These aren&#39;t necessarily managers. They&#39;re people others trust and respect. Empower them with resources, training, and visibility. Let them tell the story authentically.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Create Quick Wins&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;People need to see progress quickly. Choose visible pain points with achievable solutions. Automate a painful manual process. Reduce deployment time from hours to minutes. Fix a longstanding monitoring gap. Document the improvement and share it widely. Small wins build confidence that larger changes are possible.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Provide Psychological Safety&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Fear kills transformation. If people are punished for failures or blamed for outages, they&#39;ll stick to safe, slow processes. Leaders must model vulnerability, admit their own mistakes, and celebrate learning from failures. Make it safe to experiment, to ask questions, and to challenge assumptions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Make the Transition Easy&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Reduce friction wherever possible. Provide training before expecting new skills. Offer pairing and mentoring. Create clear documentation. Build self-service tools. Don&#39;t expect people to figure it out alone.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;Metrics That Matter: DORA Metrics Explained&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;The DevOps Research and Assessment (DORA) team identified four key metrics that distinguish elite performers from low performers. These metrics should guide your transformation:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deployment Frequency&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;How often does your organization deploy code to production? Elite teams deploy multiple times per day. Low performers deploy monthly or less. Higher deployment frequency indicates that your teams can deliver value quickly and respond rapidly to feedback.&lt;/p&gt;
&lt;p&gt;To improve deployment frequency, reduce batch sizes (smaller pull requests, feature flags), automate testing and deployment, and eliminate manual approval gates that don&#39;t add value.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lead Time for Changes&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;How long does it take for a commit to reach production? Elite teams measure lead time in hours. Low performers measure it in months. Short lead times mean faster feedback cycles and reduced risk per deployment.&lt;/p&gt;
&lt;p&gt;To improve lead time, identify and eliminate bottlenecks in your delivery pipeline. Common culprits include slow test suites, manual handoffs, and infrequent merge cycles. Visualize your value stream and optimize the slowest steps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mean Time to Recovery (MTTR)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When incidents occur, how quickly can you restore service? Elite teams recover in under an hour. Low performers take more than a week. Fast recovery requires excellent observability, practiced incident response, and the ability to roll back or roll forward quickly.&lt;/p&gt;
&lt;p&gt;To improve MTTR, invest in monitoring and alerting, practice incident response through game days, automate rollback procedures, and conduct blameless postmortems that focus on system improvements rather than individual blame.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Change Failure Rate&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What percentage of deployments cause production incidents? Elite teams have change failure rates under 15%. Low performers are above 45%. Lower change failure rates indicate better quality practices and effective feedback loops.&lt;/p&gt;
&lt;p&gt;To improve change failure rate, strengthen automated testing (unit, integration, contract, and end-to-end tests), implement progressive delivery techniques (canary deployments, blue-green deployments), and use feature flags to decouple deployment from release.&lt;/p&gt;
&lt;p&gt;These four metrics provide a balanced view of software delivery performance. Track them visibly, review them regularly, and use them to guide improvement experiments. But remember: metrics are means to an end, not the end itself. The goal is better outcomes for customers and teams, not just better numbers.&lt;/p&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;p&gt;This is a lot of information to digest. Just remember,&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Start with collaboration&lt;/strong&gt;. Encourage developers and operations to work together from the beginning of a project. Create cross-functional teams that share ownership of outcomes. This was a key takeaway from the a recent project, where teams learned to align backlog management with deployment strategies, reducing friction between roles.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Invest in automation&lt;/strong&gt;, but pair it with &lt;strong&gt;process improvement&lt;/strong&gt;. Automate repetitive tasks like builds, tests, and deployments to free up time for innovation. Use metrics and monitoring to create feedback loops that inform decisions and drive continuous improvement.&lt;/p&gt;
&lt;p&gt;Most importantly, &lt;strong&gt;lead by example&lt;/strong&gt;. Culture change starts at the &lt;strong&gt;top&lt;/strong&gt;. Leaders must champion collaboration, transparency, and learning. Celebrate successes, learn from failures, and make DevOps a shared responsibility across the organization.&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;&lt;strong&gt;What Are the Benefits?&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;The benefits of DevOps culture are well-documented and measurable. Organizations that embrace it see:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Faster delivery cycles.&lt;/li&gt;
&lt;li&gt;Improved software quality.&lt;/li&gt;
&lt;li&gt;Greater agility in responding to market changes.&lt;/li&gt;
&lt;li&gt;Higher employee satisfaction.&lt;/li&gt;
&lt;li&gt;Increased ROI through efficiency and innovation.&lt;/li&gt;
&lt;li&gt;Increased Customer satisfaction&lt;/li&gt;
&lt;li&gt;Accelereated innovation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Companies that adopt DevOps practices report significant reductions in lead time, deployment frequency, and mean time to recovery. They also experience fewer failures and faster resolution when issues occur. These aren’t just numbers, they represent real competitive advantage.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;What Are the Downsides?&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;DevOps culture isn’t a silver bullet. It requires investment in tools, training, and time. It can be challenging to overcome resistance to change, especially in organizations with entrenched silos. There’s also a risk of burnout if teams interpret “continuous delivery” as “never stop working.”&lt;/p&gt;
&lt;p&gt;Another downside is the complexity of scaling DevOps across large enterprises. Aligning multiple teams, standardizing processes, and maintaining governance without stifling agility can be difficult. But these challenges are surmountable with the right strategy and leadership commitment.&lt;/p&gt;
&lt;p&gt;We also often see several common anti-patterns emerge when introducing a DevOps culture to an organization:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;em&gt;The &amp;quot;DevOps Team&amp;quot; Anti-Pattern&lt;/em&gt;
Organizations create a separate &amp;quot;DevOps team&amp;quot; that sits between development and operations, essentially &lt;strong&gt;adding another silo&lt;/strong&gt; instead of breaking them down. This team becomes a new bottleneck, handling deployments and infrastructure requests while developers and ops remain isolated. Real DevOps means cross-functional collaboration, not a new middle layer.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Rebrand Without Reform&lt;/em&gt;
The operations team gets renamed to &amp;quot;DevOps Engineers&amp;quot; or &amp;quot;Site Reliability Engineers,&amp;quot; but &lt;strong&gt;nothing actually changes&lt;/strong&gt;. They still work in isolation, receive work via tickets, and maintain the same adversarial relationship with developers. It&#39;s a cosmetic change that preserves the old culture while claiming transformation.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Automation Without Collaboration&lt;/em&gt;
Teams invest heavily in CI/CD pipelines, infrastructure as code, and monitoring tools, but developers and operations &lt;strong&gt;still don&#39;t talk to each other&lt;/strong&gt;. Automated deployments fail because ops wasn&#39;t consulted on infrastructure requirements. Alerts fire constantly because developers don&#39;t understand operational concerns. Tools don&#39;t fix broken relationships.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&amp;quot;You Build It, You Run It&amp;quot; Without Support&lt;/em&gt;
Organizations push operational responsibility to developers without providing training, access, or support. Developers get paged at 3 AM for production issues they don&#39;t know how to debug. &lt;strong&gt;This isn&#39;t empowerment, it&#39;s abdication&lt;/strong&gt;. Real DevOps means shared responsibility with proper enablement.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Speed Without Safety&lt;/em&gt;
Teams focus obsessively on deployment frequency while ignoring quality, security, and stability. They &lt;strong&gt;ship broken code faster&lt;/strong&gt;, rack up technical debt, and burn out from constant firefighting. DevOps is about sustainable velocity, not just moving fast.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Metrics Theater&lt;/em&gt;
Organizations track deployment frequency and lead time but don&#39;t use them to drive improvement. &lt;strong&gt;Metrics become performative checkboxes&lt;/strong&gt; rather than feedback mechanisms. Teams game the numbers (deploying trivial changes to boost frequency) while real problems persist.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Tool Sprawl&lt;/em&gt;
The organization adopts every trendy DevOps tool - Jenkins, GitLab CI, CircleCI, Kubernetes, Terraform, Ansible, Prometheus, Grafana, Datadog - without standardization or strategy. &lt;strong&gt;Teams spend more time integrating tools than delivering value&lt;/strong&gt;. DevOps requires thoughtful tooling, not a collection of shiny objects.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Security as an Afterthought&lt;/em&gt;
&amp;quot;DevOps&amp;quot; pipelines deploy code rapidly but &lt;strong&gt;security reviews still happen at the end&lt;/strong&gt;, creating a bottleneck. DevSecOps means security is integrated from the start, meaning threat modeling in design, automated security testing in CI/CD, and security champions embedded in teams.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Agile Dev, Waterfall Ops&lt;/em&gt;
Development teams work in two-week sprints, but operations still requires three-month lead times for infrastructure provisioning. The &lt;strong&gt;&amp;quot;agile transformation&amp;quot; stops at the deployment boundary&lt;/strong&gt;. Real DevOps extends agility through the entire value stream.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Blame Culture in Disguise&lt;/em&gt;
Despite talk of blameless postmortems, &lt;strong&gt;incidents still result in finger-pointing&lt;/strong&gt; and CYA behavior. Engineers fear making changes because failures are punished. Psychological safety is lip service, not reality. DevOps requires genuine trust and learning from failures.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;These anti-patterns share a common theme: focusing on superficial changes (tools, titles, processes) while avoiding the hard work of cultural transformation: building trust, breaking down silos, fostering collaboration, and creating shared responsibility.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;Common Objections and How to Address Them&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;When proposing DevOps cultural transformation, you&#39;ll inevitably encounter resistance. Here are the most common objections and practical ways to address them:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;We&#39;re too regulated for DevOps&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Regulation doesn&#39;t prevent DevOps. In fact, heavily regulated industries like finance and healthcare have successfully adopted DevOps practices. The key is &lt;strong&gt;automated compliance&lt;/strong&gt;. Infrastructure as code, automated testing, and audit trails actually make compliance &lt;em&gt;easier&lt;/em&gt; by creating repeatable, documented processes. Organizations like Capital One and Nationwide Insurance are proof that DevOps and regulation coexist successfully. Shift your conversation from &amp;quot;can we?&amp;quot; to &amp;quot;how do we automate compliance checks into our pipelines?&amp;quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;Our legacy systems can&#39;t support this&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Legacy systems are a reason &lt;em&gt;to&lt;/em&gt; adopt DevOps, not a reason to avoid it. You don&#39;t need to rewrite everything. Start by applying DevOps principles to deployment processes, monitoring, and incident response for existing systems. Use &lt;strong&gt;strangler fig patterns&lt;/strong&gt; to gradually modernize while maintaining stability. Many organizations run containerized microservices alongside mainframes. The goal isn&#39;t technology replacement; it&#39;s improving how you deliver value regardless of the underlying tech stack.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;Developers don&#39;t want operational responsibilities&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This objection often stems from misunderstanding what shared responsibility means. DevOps doesn&#39;t expect developers to become sysadmins overnight. It means &lt;strong&gt;providing developers with self-service platforms, observability tools, and operational expertise&lt;/strong&gt;. Embed operations engineers into development teams to transfer knowledge. Start with on-call rotations for high-severity issues only, with proper training and escalation paths. Most developers appreciate understanding how their code runs in production. It makes them better engineers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;We don&#39;t have time for cultural change&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is the most dangerous objection because it confuses urgency with importance. The reality is you&#39;re &lt;em&gt;already&lt;/em&gt; paying the cost of poor culture through slow delivery, frequent outages, and low morale. Cultural transformation doesn&#39;t require stopping work. It happens incrementally. Start with &lt;strong&gt;small experiments&lt;/strong&gt;: one cross-functional team, one automated deployment pipeline, one blameless postmortem. Demonstrate value quickly and build momentum. The question isn&#39;t whether you have time for change. It&#39;s whether you can afford to keep doing things the old way.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;Actionable Insights from Enterprise Projects&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;From internal initiatives and recent project work, several lessons stand out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Start small&lt;/strong&gt;. Pilot DevOps practices in one team before scaling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Focus on outcomes&lt;/strong&gt;, not tools. Tools &lt;em&gt;enable&lt;/em&gt; culture. They don’t &lt;em&gt;create&lt;/em&gt; it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Measure what matters&lt;/strong&gt;. Track deployment frequency, lead time, and recovery time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Invest in people&lt;/strong&gt;. Training and communication are as important as automation.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;&lt;strong&gt;Final Thoughts&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;DevOps culture is the foundation of modern software delivery. It’s what turns automation into acceleration and collaboration into innovation. Without it, tools are just tools, and processes are just paperwork.&lt;/p&gt;
&lt;p&gt;Building this culture takes time, effort, and leadership. But the payoff (faster delivery, better quality, happier teams, and stronger business outcomes) is worth every step.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Need help building or changing culture? I can help!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>DevOps culture prioritizes collaboration and shared responsibility over tools and automation. Learn why cultural transformation is essential for faster delivery, better quality, and sustainable software development practices.</summary>
    <category term="devops"/>
  </entry>
  <entry>
    <title>DevSecOps Metrics That Matter: What to Measure, How to Track It in GitHub, and Why It Matters</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-12-08-devsecops-metrics-that-matter/"/>
    <updated>2025-12-08T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-12-08-devsecops-metrics-that-matter/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Modern software delivery is a balancing act. Teams strive to move fast, but every shortcut can introduce risk. DevSecOps exists to resolve that tension by embedding security into development workflows without slowing innovation. Yet there’s a catch: you can’t improve what you don’t measure. Metrics are the compass that keeps your DevSecOps journey on course.&lt;/p&gt;
&lt;p&gt;The challenge isn’t data scarcity. GitHub and other platforms generate plenty of signals. The challenge is knowing which metrics matter, how to track them effectively, and why they’re worth your attention. In this post, we’ll explore the essential DevSecOps metrics, show how to capture them using GitHub’s capabilities, and explain why these numbers should influence decisions across your organization.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why Metrics Matter in DevSecOps&lt;/h2&gt;
&lt;p&gt;Metrics aren’t about policing teams or assigning blame. They’re about creating feedback loops that drive improvement. When developers and security teams see clear, actionable data, they can make better decisions, automate guardrails, and reduce friction. Without metrics, DevSecOps becomes a slogan rather than a practice.&lt;/p&gt;
&lt;p&gt;The most impactful metrics align three outcomes: &lt;strong&gt;velocity to value&lt;/strong&gt;, &lt;strong&gt;risk reduction&lt;/strong&gt;, and &lt;strong&gt;operational reliability&lt;/strong&gt;. If you measure only speed, you risk cutting corners. If you measure only security, you risk slowing delivery to a crawl. The goal is balance, that is, fast, safe, and resilient software delivery.&lt;/p&gt;
&lt;h2&gt;The Core Delivery Signals&lt;/h2&gt;
&lt;p&gt;High-performing teams track a handful of delivery metrics that reveal how efficiently and safely code moves from idea to production. These are often called DORA metrics, and they’ve become the gold standard for assessing software delivery performance.&lt;/p&gt;
&lt;h3&gt;Deployment Frequency&lt;/h3&gt;
&lt;p&gt;Frequent deployments in small batches reduce risk and accelerate feedback. In GitHub, you can track this by querying deployment events tied to protected environments.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Deployment frequency for production environment (last 30 days)
gh api /repos/&amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt;/deployments &#92;
  -F environment=production &#92;
  --jq &#39;[.[] | select(.created_at &amp;gt; (now - 2592000 | todate))] | length&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Benchmarks:&lt;/strong&gt; Elite performers deploy &lt;strong&gt;multiple times per day&lt;/strong&gt; (on-demand deployment). High performers deploy &lt;strong&gt;between once per day and once per week&lt;/strong&gt;. Medium performers deploy &lt;strong&gt;between once per week and once per month&lt;/strong&gt;. Low performers deploy &lt;strong&gt;less than once per month&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common Pitfalls:&lt;/strong&gt; Counting every commit to any branch inflates your numbers without measuring actual production deployment. Measuring deployments to test or staging environments instead of production gives false signals. Including automated dependency updates or infrastructure-only changes that don&#39;t deliver user value skews the metric.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How to Improve:&lt;/strong&gt; Reduce batch size by breaking large features into smaller, independently deployable increments. Automate the entire deployment pipeline to eliminate manual handoffs and approval gates that don&#39;t add value. Use feature flags to decouple deployment from release, allowing you to deploy code to production safely without immediately exposing it to users. Establish trunk-based development practices with short-lived branches to reduce integration complexity.&lt;/p&gt;
&lt;h3&gt;Lead Time for Changes&lt;/h3&gt;
&lt;p&gt;Shorter lead times indicate healthy pipelines and fewer bottlenecks. GitHub’s GraphQL API lets you correlate commit timestamps with pull request merges and deployment events.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;{
  repository(owner: &amp;quot;&amp;lt;org&amp;gt;&amp;quot;, name: &amp;quot;&amp;lt;repo&amp;gt;&amp;quot;) {
    pullRequests(last: 10, states: MERGED) {
      nodes {
        title
        createdAt
        mergedAt
        commits(first: 1) {
          nodes {
            commit {
              oid
              authoredDate
            }
          }
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Change Failure Rate&lt;/h3&gt;
&lt;p&gt;Tag deployment statuses and link them to incident issues or rollback workflows in GitHub Actions.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        run: ./scripts/deploy.sh
      - name: Emit Deployment Status
        if: always()
        run: |
          jq -n --arg status &amp;quot;${{ job.status }}&amp;quot; &#92;
                --arg dt &amp;quot;$(date -Iseconds)&amp;quot; &#92;
                &#39;{status: $status, timestamp: $dt}&#39; &amp;gt; deploy.json
      - uses: actions/upload-artifact@v4
        with:
          name: deploy-meta
          path: deploy.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Benchmarks:&lt;/strong&gt; Elite performers maintain a change failure rate of &lt;strong&gt;0-15%&lt;/strong&gt; (meaning 85%+ of deployments succeed without causing incidents or requiring rollback). High performers experience &lt;strong&gt;16-30%&lt;/strong&gt; failure rates. Medium performers see &lt;strong&gt;31-45%&lt;/strong&gt; failures. Low performers exceed &lt;strong&gt;45%&lt;/strong&gt; failure rates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common Pitfalls:&lt;/strong&gt; Defining &amp;quot;failure&amp;quot; inconsistently across teams makes comparison meaningless. Some teams count any rollback as failure; others only count customer-impacting incidents. Excluding specific types of changes (configuration updates, database migrations, infrastructure changes) provides an artificially optimistic picture. Not tracking near-misses (issues caught in production monitoring before customer impact) misses opportunities for improvement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How to Improve:&lt;/strong&gt; Strengthen your automated testing strategy across the pyramid: unit tests for fast feedback on logic, integration tests for component interactions, contract tests for API compatibility, and end-to-end tests for critical user journeys. Implement progressive delivery techniques like canary deployments (route a small percentage of traffic to new versions), blue-green deployments (maintain parallel environments for instant rollback), and feature flags (enable features gradually for specific user cohorts). Establish comprehensive monitoring with Service Level Indicators (SLIs) that detect degradation before customers notice. Conduct blameless postmortems after failures to identify systemic improvements rather than individual blame.&lt;/p&gt;
&lt;h3&gt;Mean Time to Restore&lt;/h3&gt;
&lt;p&gt;GitHub issues and deployment logs provide the timestamps you need to calculate MTTR.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Benchmarks:&lt;/strong&gt; Elite performers restore service in &lt;strong&gt;less than one hour&lt;/strong&gt;. High performers recover &lt;strong&gt;in less than one day&lt;/strong&gt;. Medium performers require &lt;strong&gt;between one day and one week&lt;/strong&gt;. Low performers take &lt;strong&gt;more than one week&lt;/strong&gt; to restore service after an incident.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common Pitfalls:&lt;/strong&gt; Starting the clock when someone begins working on the problem rather than when the incident actually occurred understates your true MTTR. Stopping the clock when a fix is deployed rather than when service is fully restored to customers gives false confidence. Excluding incidents that resolve themselves (transient failures, auto-scaling responses) or only counting &amp;quot;major&amp;quot; incidents creates blind spots.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How to Improve:&lt;/strong&gt; Invest in observability to detect issues faster. Structured logging with correlation IDs, distributed tracing across services, and real-time dashboards showing business and technical metrics reduce time to detection. Practice incident response through game days and chaos engineering experiments so teams know their playbooks when real incidents occur. Automate rollback procedures so reverting to known-good states takes seconds, not hours. Reduce deployment size and complexity so understanding the blast radius of changes is straightforward. Establish clear escalation paths and on-call rotations with runbooks that guide responders through common scenarios. Most importantly, conduct blameless postmortems that focus on improving systems rather than punishing individuals. Psychological safety is essential for honest learning.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Security Metrics That Drive Action&lt;/h2&gt;
&lt;p&gt;Velocity is only half the story. DevSecOps is about embedding security into the development process, and that means measuring how effectively you identify and remediate risks. GitHub Advanced Security (GHAS) offers powerful signals here.&lt;/p&gt;
&lt;h3&gt;Open Vulnerabilities and Aging&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Code scanning alerts by severity and age
gh api /repos/&amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt;/code-scanning/alerts &#92;
  --jq &#39;.[] | {rule_id, severity, created_at, dismissed_at, fixed_at}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Industry Benchmarks:&lt;/strong&gt; High-performing teams maintain &lt;strong&gt;fewer than 10 critical vulnerabilities&lt;/strong&gt; open at any time and resolve critical findings within &lt;strong&gt;24-48 hours&lt;/strong&gt;. Medium performers may carry 10-50 open critical issues with resolution times of 1-2 weeks. Low performers accumulate hundreds of open vulnerabilities with remediation measured in months.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What &amp;quot;Good&amp;quot; Looks Like:&lt;/strong&gt; Your critical and high-severity vulnerability count trends downward over time. No critical vulnerability remains open longer than your SLA (typically 7 days). You have zero known vulnerabilities older than 90 days. Your backlog of medium and low-severity findings decreases quarter over quarter, indicating you&#39;re not just fixing new issues but addressing technical debt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common Measurement Challenges:&lt;/strong&gt; False positives inflate your numbers and erode trust in scanning tools; invest time tuning rules and suppressing noise. Not all vulnerabilities are exploitable in your context; consider exploitability and reachability analysis rather than counting every theoretical issue. Alert fatigue sets in when teams see hundreds of findings; prioritize ruthlessly by severity, exploitability, and business impact.&lt;/p&gt;
&lt;h3&gt;Time to Remediate&lt;/h3&gt;
&lt;p&gt;Track created and resolved timestamps on alerts to measure how quickly vulnerabilities are fixed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Industry Benchmarks:&lt;/strong&gt; Elite security programs remediate &lt;strong&gt;critical vulnerabilities within 24 hours&lt;/strong&gt; and high-severity issues within &lt;strong&gt;7 days&lt;/strong&gt;. Medium and low-severity findings should be addressed within &lt;strong&gt;30 and 90 days&lt;/strong&gt; respectively. Organizations with mature DevSecOps practices often achieve median remediation times under 5 days for all severities.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What &amp;quot;Good&amp;quot; Looks Like:&lt;/strong&gt; Your remediation time consistently meets or beats your internal SLAs. The time-to-fix decreases as your team builds muscle memory and automation around common vulnerability patterns. You measure time from discovery to deployed fix, not just time to code commit. You differentiate between remediation (actually fixing the vulnerability) and mitigation (implementing compensating controls), tracking both separately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common Measurement Challenges:&lt;/strong&gt; Disagreement about when the clock starts: is it when the scanner first detects the issue, when a ticket is created, or when a human triages it? Ambiguity about when it stops: when code is merged, when it&#39;s deployed to production, or when the scanner confirms the fix? Dismissed or &amp;quot;won&#39;t fix&amp;quot; vulnerabilities skew averages if not handled separately. Dependency vulnerabilities where you&#39;re waiting for upstream maintainers require different measurement approaches than code you control.&lt;/p&gt;
&lt;h3&gt;Dependency Health&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Dependabot alerts aging
gh api /repos/&amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt;/dependabot/alerts &#92;
  --jq &#39;.[] | {package: .dependency.package.name, severity, created_at, dismissed_at}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Industry Benchmarks:&lt;/strong&gt; Organizations with strong supply chain security maintain &lt;strong&gt;zero critical dependency vulnerabilities&lt;/strong&gt; in production code and keep &lt;strong&gt;95%+ of dependencies up to date&lt;/strong&gt; within one major version of current releases. They track dependency age and proactively update libraries before vulnerabilities are announced. A healthy dependency refresh rate is &lt;strong&gt;monthly for patch updates&lt;/strong&gt; and &lt;strong&gt;quarterly for minor version updates&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What &amp;quot;Good&amp;quot; Looks Like:&lt;/strong&gt; Your dependency alert count trends toward zero over time. You have automated processes (like Dependabot) that propose updates regularly, and your team merges them quickly. You maintain an inventory of all dependencies including transitive ones. Critical dependencies have identified maintainers and fallback plans if projects are abandoned. You&#39;ve eliminated dependencies with known vulnerabilities older than 30 days.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common Measurement Challenges:&lt;/strong&gt; Transitive dependencies (dependencies of your dependencies) are invisible to many teams but represent significant risk. Not all updates are straightforward: breaking changes require testing and refactoring effort that&#39;s hard to predict. Alert fatigue when automated tools propose dozens of updates weekly; teams need filtering and prioritization logic. License compliance issues get conflated with security issues, creating confusion about what needs immediate action.&lt;/p&gt;
&lt;h3&gt;Secret Exposure Prevention&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Secret scanning alerts
gh api /repos/&amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt;/secret-scanning/alerts &#92;
  --jq &#39;.[] | {secret_type, state, created_at, resolved_at}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Industry Benchmarks:&lt;/strong&gt; Best-in-class organizations maintain &lt;strong&gt;zero exposed secrets&lt;/strong&gt; in their repositories at any given time. When secrets are accidentally committed, they&#39;re &lt;strong&gt;revoked within 1 hour&lt;/strong&gt; and rotated immediately. The occurrence rate should trend toward zero as teams adopt secret management solutions and pre-commit hooks. Organizations with mature secret hygiene see &lt;strong&gt;fewer than 1 secret exposure per 1000 commits&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What &amp;quot;Good&amp;quot; Looks Like:&lt;/strong&gt; You have automated secret scanning on every push, with immediate notifications to committers and security teams. Exposed secrets are automatically revoked through integration with secret management platforms (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault). Your team uses environment variables, secrets management tools, and encrypted configuration files instead of hardcoding credentials. Developers are trained to recognize secrets and use tooling (like git-secrets or detect-secrets) locally before pushing code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common Measurement Challenges:&lt;/strong&gt; False positives from test credentials, dummy API keys, and string patterns that look like secrets but aren&#39;t. Historical secrets in old commits that can&#39;t be removed without rewriting git history, creating tension between security and traceability. Secrets in configuration files that change format or location, requiring constant tuning of detection rules. Third-party integrations that generate tokens automatically, creating alert noise if not properly categorized. Determining when a secret was truly exposed (commit time, push time, or PR merge time) affects measurement and response urgency.&lt;/p&gt;
&lt;h2&gt;Why These Numbers Matter to the Business&lt;/h2&gt;
&lt;p&gt;Deployment frequency and lead time show whether your investment in automation and CI/CD is paying off. Change failure rate and MTTR reveal the true cost of speed and the resilience of your systems. Vulnerability aging and remediation time demonstrate security posture and compliance readiness. Dependency health and secret scanning metrics protect against supply chain attacks and catastrophic breaches.&lt;/p&gt;
&lt;p&gt;For executives, these numbers translate into risk and cost. Faster recovery means less downtime and happier customers. Shorter lead times mean quicker delivery of features and revenue opportunities. For security leaders, remediation metrics provide evidence of policy adherence and help prioritize resources. For developers, clear feedback loops reduce friction and make security part of the daily workflow rather than an afterthought.&lt;/p&gt;
&lt;h2&gt;Building a Governance Framework Around Metrics&lt;/h2&gt;
&lt;p&gt;Collecting data is not enough. Enterprises need a governance model that defines who owns these metrics, how often they’re reviewed, and what actions follow. Successful organizations establish oversight domains (platform teams, security councils, centers of excellence) and create a cadence for reviewing risk and reliability trends.&lt;/p&gt;
&lt;p&gt;Here’s an example of a nightly export workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/security-export.yml
on:
  schedule:
    - cron: &amp;quot;0 2 * * *&amp;quot;
jobs:
  export:
    runs-on: ubuntu-latest
    permissions:
      security-events: read
      contents: read
    steps:
      - name: Export code scanning alerts
        run: gh api /repos/$ORG/$REPO/code-scanning/alerts &amp;gt; code-alerts.json
      - name: Export dependabot alerts
        run: gh api /repos/$ORG/$REPO/dependabot/alerts &amp;gt; dep-alerts.json
      - name: Export secret scanning alerts
        run: gh api /repos/$ORG/$REPO/secret-scanning/alerts &amp;gt; secret-alerts.json
      - uses: actions/upload-artifact@v4
        with:
          name: security-alerts
          path: &amp;quot;*.json&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Putting It All Together&lt;/h2&gt;
&lt;p&gt;DevSecOps is not a destination; it’s a continuous improvement journey. Metrics are the map that keeps you on course. By focusing on a handful of meaningful signals, such as deployment frequency, lead time, change failure rate, MTTR, vulnerability remediation, dependency health, and secret exposure, you can balance speed and security without sacrificing either.&lt;/p&gt;
&lt;p&gt;GitHub makes it possible to track these metrics without adding friction. With built-in dashboards, APIs, and automation workflows, you can turn raw data into actionable insights. The challenge is cultural: using metrics to drive learning and improvement, not blame. When teams see metrics as a tool for empowerment, DevSecOps becomes more than a buzzword, it becomes a competitive advantage.&lt;/p&gt;
&lt;h3&gt;Next Steps for Readers&lt;/h3&gt;
&lt;p&gt;Start small. Pick two or three metrics that matter most to your organization and implement the queries and workflows shared here. Build a central repository for data exports and dashboards. Establish a monthly review cadence with platform and security teams. Over time, expand your coverage and automate more of the process. The payoff is worth it: faster delivery, stronger security, and greater confidence in every release.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Need help understanding your metrics or putting together meaningful reports to help you take you DevSecOps game from good to great? Email me!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Learn the essential DevSecOps metrics, how to track them using GitHub APIs and workflows, and why they matter for balancing speed, security, and reliability.</summary>
    <category term="devsecops"/>
    <category term="devops"/>
  </entry>
  <entry>
    <title>GitHub Advanced Security: What You Get and How to Use It</title>
    <link href="https://steve-kaschimer.github.io/posts/2025-12-15-github-advanced-security/"/>
    <updated>2025-12-15T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2025-12-15-github-advanced-security/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Security is no longer an afterthought in modern software development. With the rise of DevSecOps, security practices are woven into every stage of the development lifecycle. GitHub, as one of the most widely used platforms for code collaboration, has stepped up its game with &lt;strong&gt;GitHub Advanced Security (GHAS)&lt;/strong&gt;, a suite of premium features designed to help teams identify, prevent, and remediate vulnerabilities before they reach production.&lt;/p&gt;
&lt;p&gt;If you’re a DevOps practitioner new to GitHub Advanced Security, this guide will walk you through what GHAS offers, why it matters, and how to use its features effectively. By the end, you’ll understand how to integrate these tools into your workflow and elevate your security posture without slowing down development.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why GitHub Advanced Security matters&lt;/h2&gt;
&lt;p&gt;Traditional security models often rely on periodic audits or post-release vulnerability scans. These approaches are reactive and costly. DevSecOps flips the script by embedding security checks into the development pipeline, catching issues early when they’re cheaper and easier to fix.&lt;/p&gt;
&lt;p&gt;GitHub Advanced Security is built on this principle. It provides automated, developer-friendly tools that surface security risks directly in your repositories. Instead of waiting for a penetration test or a compliance review, your team can address problems as part of everyday coding.&lt;/p&gt;
&lt;h2&gt;What’s Included in GitHub Advanced Security?&lt;/h2&gt;
&lt;p&gt;GHAS is not just one feature. Instead, it&#39;s a collection of capabilities designed to tackle different aspects of application security. The four pillars you&#39;ll work with are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Code Scanning (powered by CodeQL)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secret Scanning&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependency Review&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security Overview&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these plays a unique role in safeguarding your codebase. Let&#39;s break them down.&lt;/p&gt;
&lt;h3&gt;Code Scanning: Find Vulnerabilities in Your Code&lt;/h3&gt;
&lt;p&gt;Code Scanning is GitHub&#39;s flagship static application security testing (SAST) tool, powered by &lt;strong&gt;CodeQL&lt;/strong&gt;, a semantic code analysis engine. Unlike simple pattern-matching tools that look for suspicious strings, CodeQL understands the structure and flow of your code. It can trace how data moves through your application, identify where user input enters the system, and detect when that untrusted data reaches a dangerous sink without proper sanitization.&lt;/p&gt;
&lt;h4&gt;How CodeQL Works&lt;/h4&gt;
&lt;p&gt;CodeQL treats your code as a database. It builds a semantic model of your entire codebase, including control flow, data flow, and the relationships between functions and variables. You then query this database using a declarative language to find patterns that represent vulnerabilities.&lt;/p&gt;
&lt;p&gt;For example, CodeQL can detect SQL injection by identifying code paths where:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User input enters the system (source)&lt;/li&gt;
&lt;li&gt;That data flows through the application (data flow analysis)&lt;/li&gt;
&lt;li&gt;The data is used to construct a SQL query without sanitization (sink)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This approach catches vulnerabilities that simpler tools miss, including complex multi-step exploits where tainted data passes through several functions before reaching a vulnerable point.&lt;/p&gt;
&lt;h4&gt;What CodeQL Catches&lt;/h4&gt;
&lt;p&gt;CodeQL comes with hundreds of built-in queries covering the most critical security issues across multiple languages (JavaScript/TypeScript, Python, Java, C#, C/C++, Go, Ruby):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Injection Flaws&lt;/strong&gt;: SQL injection, command injection, LDAP injection, XPath injection&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-Site Scripting (XSS)&lt;/strong&gt;: Reflected, stored, and DOM-based XSS&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Path Traversal&lt;/strong&gt;: Directory traversal and arbitrary file access&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authentication Issues&lt;/strong&gt;: Hardcoded credentials, weak crypto, insecure random number generation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authorization Bypasses&lt;/strong&gt;: Missing access controls, IDOR vulnerabilities&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resource Management&lt;/strong&gt;: Memory leaks, resource exhaustion, uncontrolled recursion&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cryptographic Issues&lt;/strong&gt;: Weak algorithms, improper key management, insufficient entropy&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Enabling Code Scanning&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Using the GitHub UI&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to your repository.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Security → Code scanning&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Set up code scanning&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;CodeQL Analysis&lt;/strong&gt; and select &lt;strong&gt;Default&lt;/strong&gt; or &lt;strong&gt;Advanced&lt;/strong&gt; setup.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The default setup automatically configures CodeQL for your detected languages and runs on every push and pull request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Using GitHub Actions (Advanced)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For more control, create &lt;code&gt;.github/workflows/codeql.yml&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: &amp;quot;CodeQL&amp;quot;
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]
  schedule:
    - cron: &#39;0 6 * * 1&#39;  # Weekly scan on Mondays

jobs:
  analyze:
    name: Analyze
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: read
      security-events: write

    strategy:
      matrix:
        language: [ &#39;javascript&#39;, &#39;python&#39; ]

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: ${{ matrix.language }}
          queries: security-extended  # Include additional security queries

      - name: Autobuild
        uses: github/codeql-action/autobuild@v3

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
        with:
          category: &amp;quot;/language:${{ matrix.language }}&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;h4&gt;Custom CodeQL Queries&lt;/h4&gt;
&lt;p&gt;Beyond the built-in queries, you can write custom queries tailored to your organization&#39;s specific security requirements. For example, you might want to flag usage of deprecated internal APIs or enforce that certain sensitive functions are always called with specific security parameters.&lt;/p&gt;
&lt;p&gt;Here&#39;s a simple custom query that finds direct use of &lt;code&gt;eval()&lt;/code&gt; in JavaScript:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ql&quot;&gt;import javascript

from CallExpr call
where call.getCalleeName() = &amp;quot;eval&amp;quot;
select call, &amp;quot;Direct use of eval() is dangerous and should be avoided.&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To use custom queries, add them to your repository in a &lt;code&gt;.github/codeql/queries&lt;/code&gt; directory and reference them in your workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Initialize CodeQL
  uses: github/codeql-action/init@v3
  with:
    languages: javascript
    queries: ./.github/codeql/queries
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;What Good Coverage Looks Like&lt;/h4&gt;
&lt;p&gt;High-performing teams using Code Scanning typically see:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;90%+ of repositories&lt;/strong&gt; with Code Scanning enabled&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Critical and high-severity alerts&lt;/strong&gt; resolved within 7 days&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;False positive rate below 10%&lt;/strong&gt; (achieved through query tuning)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weekly or bi-weekly scans&lt;/strong&gt; on active branches, plus scans on every PR&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero critical vulnerabilities&lt;/strong&gt; in production code paths&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Teams often start with the default query suite and gradually expand to &lt;code&gt;security-extended&lt;/code&gt; or &lt;code&gt;security-and-quality&lt;/code&gt; as they mature.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why Organizations Choose GHAS: Real-World Impact&lt;/h2&gt;
&lt;p&gt;Before diving into setup and configuration, let&#39;s look at how real organizations use GitHub Advanced Security and the tangible value it delivers. These examples illustrate why GHAS has become essential for DevSecOps teams.&lt;/p&gt;
&lt;h3&gt;Case Study: Catching Leaked AWS Credentials Before Exploitation&lt;/h3&gt;
&lt;p&gt;A fintech startup building a payment processing platform accidentally committed AWS access keys to their public repository. Within minutes of the commit, GitHub&#39;s Secret Scanning detected the credentials and sent alerts to both the repository maintainers and the security team.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Response:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The security team received an immediate notification via Slack integration&lt;/li&gt;
&lt;li&gt;They revoked the exposed AWS credentials through their AWS account within 15 minutes&lt;/li&gt;
&lt;li&gt;They rotated all related secrets and updated the application configuration&lt;/li&gt;
&lt;li&gt;They implemented a pre-commit hook using &lt;code&gt;git-secrets&lt;/code&gt; to prevent future incidents&lt;/li&gt;
&lt;li&gt;The entire incident was resolved in under an hour, before any external party could exploit the credentials&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;The Impact:&lt;/strong&gt; Without Secret Scanning, those credentials could have remained exposed for days or weeks. The company estimated this early detection saved them from potential unauthorized AWS charges (potentially tens of thousands of dollars) and regulatory compliance issues related to PCI-DSS.&lt;/p&gt;
&lt;h3&gt;Case Study: Supply Chain Attack Prevention Through Dependency Review&lt;/h3&gt;
&lt;p&gt;A healthcare SaaS company using GHAS received a Dependabot alert about a critical vulnerability in a popular logging library they used. The vulnerability (CVE-2021-44228, Log4Shell) had a CVSS score of 10.0 and was being actively exploited in the wild.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Response:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Dependency Review flagged the vulnerable version in all pull requests attempting to merge code&lt;/li&gt;
&lt;li&gt;The platform team created a dedicated task force to assess impact across 200+ repositories&lt;/li&gt;
&lt;li&gt;Using the Security Overview dashboard, they identified 47 repositories using the vulnerable version&lt;/li&gt;
&lt;li&gt;They used GitHub&#39;s bulk operations API to create automated pull requests with the patched version&lt;/li&gt;
&lt;li&gt;Within 72 hours, 45 of 47 repositories were patched and deployed&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;The Impact:&lt;/strong&gt; The centralized visibility through Security Overview turned what could have been a months-long remediation effort into a coordinated 3-day sprint. Their competitors without similar tooling took an average of 3-6 weeks to fully remediate.&lt;/p&gt;
&lt;h3&gt;Enterprise Migration Strategy: From Manual Reviews to Automated Security&lt;/h3&gt;
&lt;p&gt;A global enterprise with 500+ repositories and 200+ developers was struggling with their manual security review process. Security reviews were creating a bottleneck, with a median 5-day wait time before security approval. Developers saw security as an impediment rather than an enabler.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Transformation:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Phase 1 (Month 1-2)&lt;/strong&gt;: Enabled Code Scanning on 10 pilot repositories representing different tech stacks (Node.js, Python, Java, .NET). Tuned false positive rates to below 15%.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 2 (Month 3-4)&lt;/strong&gt;: Rolled out Secret Scanning and Dependabot alerts to all 500 repositories. Integrated alerts with their existing ticketing system (Jira) for tracking.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 3 (Month 5-6)&lt;/strong&gt;: Implemented branch protection rules requiring passing Code Scanning and Dependency Review checks before merge. Reduced manual security reviews from 100% to only high-risk changes (infrastructure changes, authentication modifications, API design changes).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 4 (Month 7-8)&lt;/strong&gt;: Established security champions program with two developers per team trained on GHAS. Created internal documentation and runbooks for common alert types.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;The Impact:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Median time to security approval dropped from 5 days to 4 hours&lt;/li&gt;
&lt;li&gt;Critical vulnerability detection increased by 300% (from catching ~25% to ~75% based on penetration test results)&lt;/li&gt;
&lt;li&gt;Developer satisfaction with security processes increased from 2.1/5 to 4.3/5&lt;/li&gt;
&lt;li&gt;Security team shifted focus from manual code review to threat modeling and security architecture&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;The ROI Case: GHAS Cost vs. Breach Cost&lt;/h3&gt;
&lt;p&gt;GitHub Advanced Security costs approximately $49 per active committer per month. For a team of 50 developers, that&#39;s $29,400 per year. This investment must be weighed against security risks:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cost of a security breach:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Average data breach cost&lt;/strong&gt;: $4.45 million (IBM 2023 Cost of a Data Breach Report)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regulatory fines&lt;/strong&gt;: GDPR fines up to €20 million or 4% of annual revenue; HIPAA fines up to $1.5 million per violation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reputational damage&lt;/strong&gt;: Customer churn typically 5-10% after a public breach&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incident response costs&lt;/strong&gt;: $245 per hour for forensics, $500-$1,000 per hour for specialized consultants&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Legal costs&lt;/strong&gt;: Average $1.2 million for breach-related litigation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Break-even analysis:&lt;/strong&gt; If GHAS prevents even one moderate security incident (estimated cost $150,000 in remediation, notification, and regulatory response), it pays for itself 5x over for a 50-person team.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Additional value beyond breach prevention:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Velocity preservation&lt;/strong&gt;: Automated security checks don&#39;t slow developers down like manual reviews do&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Developer empowerment&lt;/strong&gt;: Immediate, actionable feedback rather than abstract security guidelines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compliance evidence&lt;/strong&gt;: Auditors love documented, automated security controls&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Insurance benefits&lt;/strong&gt;: Some cyber insurance providers offer premium reductions for organizations with SAST/DAST tooling&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For most organizations shipping customer-facing applications, the question isn&#39;t whether GHAS is worth the cost, but whether they can afford not to have it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Getting Started: Enabling Core Features&lt;/h2&gt;
&lt;p&gt;The case studies above demonstrate GHAS&#39;s value, but how do you actually implement it? This section walks through enabling each core feature. The key principle: start simple with basic enablement, prove value quickly, then expand with advanced configuration.&lt;/p&gt;
&lt;h3&gt;Secret Scanning: Stop Leaks Before They Happen&lt;/h3&gt;
&lt;p&gt;Secrets, such as API keys, tokens and passwords, are the crown jewels of your application. Accidentally committing them to a repository can lead to catastrophic breaches. GitHub’s Secret Scanning feature helps prevent this.&lt;/p&gt;
&lt;h4&gt;How It Works&lt;/h4&gt;
&lt;p&gt;Secret Scanning automatically scans your commits for patterns that match known secret formats. This includes credentials for cloud providers, database connection strings, and more. When it detects a secret, it alerts you so you can revoke and rotate it immediately.&lt;/p&gt;
&lt;h4&gt;Enabling Secret Scanning&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Using the GitHub UI&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to your repository.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Settings → Code security and analysis&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Secret scanning&lt;/strong&gt;, click &lt;strong&gt;Enable&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Using GitHub API&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl &#92;
  -X PATCH &#92;
  -H &amp;quot;Accept: application/vnd.github+json&amp;quot; &#92;
  -H &amp;quot;Authorization: Bearer YOUR_TOKEN&amp;quot; &#92;
  https://api.github.com/repos/OWNER/REPO &#92;
  -d &#39;{&amp;quot;security_and_analysis&amp;quot;:{&amp;quot;secret_scanning&amp;quot;:{&amp;quot;status&amp;quot;:&amp;quot;enabled&amp;quot;}}}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace &lt;code&gt;OWNER&lt;/code&gt; and &lt;code&gt;REPO&lt;/code&gt; with your repository details.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Dependency Review: Know What You’re Shipping&lt;/h3&gt;
&lt;p&gt;Modern applications rely heavily on third-party libraries. While this accelerates development, it also introduces risk. Vulnerabilities in dependencies can become entry points for attackers. Dependency Review helps you manage this risk by providing visibility into changes to your dependency graph.&lt;/p&gt;
&lt;h4&gt;How Dependency Review Works&lt;/h4&gt;
&lt;p&gt;Dependency Review integrates with pull requests to show you exactly what dependencies are being added, removed, or updated. It displays:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;New dependencies&lt;/strong&gt; introduced in the PR&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Known vulnerabilities&lt;/strong&gt; in those dependencies (powered by GitHub Advisory Database)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;License information&lt;/strong&gt; to catch licensing issues before merge&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependency graph changes&lt;/strong&gt; showing direct and transitive dependencies&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When you open a pull request that modifies a manifest file (&lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;requirements.txt&lt;/code&gt;, &lt;code&gt;pom.xml&lt;/code&gt;, &lt;code&gt;Gemfile&lt;/code&gt;, etc.), Dependency Review automatically generates a comparison showing the security impact.&lt;/p&gt;
&lt;h4&gt;Understanding Dependabot vs. Dependency Review&lt;/h4&gt;
&lt;p&gt;These two features work together but serve different purposes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dependabot Alerts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Continuously monitors your existing dependencies&lt;/li&gt;
&lt;li&gt;Notifies you when vulnerabilities are discovered in dependencies you&#39;re already using&lt;/li&gt;
&lt;li&gt;Generates automated pull requests to update vulnerable dependencies&lt;/li&gt;
&lt;li&gt;Runs on a schedule (daily checks)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Dependency Review:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Runs on pull requests before code is merged&lt;/li&gt;
&lt;li&gt;Prevents new vulnerable dependencies from being introduced&lt;/li&gt;
&lt;li&gt;Blocks merges based on configurable severity thresholds&lt;/li&gt;
&lt;li&gt;Provides just-in-time security feedback during development&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Think of Dependabot as your continuous monitoring system and Dependency Review as your gate keeper.&lt;/p&gt;
&lt;h4&gt;Enabling Dependency Review&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Using the GitHub UI&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Settings → Code security and analysis&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Dependency review&lt;/strong&gt;, click &lt;strong&gt;Enable&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;Example Workflow with License Controls&lt;/h4&gt;
&lt;p&gt;You can enforce dependency review checks using GitHub Actions with additional license compliance:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Dependency Review
on: [pull_request]
jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
      - name: Dependency Review
        uses: actions/dependency-review-action@v3
        with:
          fail-on-severity: high
          deny-licenses: GPL-3.0, AGPL-3.0
          allow-licenses: MIT, Apache-2.0, BSD-3-Clause
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Understanding the Dependency Graph&lt;/h4&gt;
&lt;p&gt;The dependency graph visualizes all packages your project depends on, distinguishing between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Direct dependencies:&lt;/strong&gt; Packages explicitly declared in your manifest files&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transitive dependencies:&lt;/strong&gt; Dependencies of your dependencies&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Most vulnerabilities (80-90%) exist in transitive dependencies, making the graph view essential for understanding your complete security exposure. The graph also helps identify which direct dependency is pulling in a problematic transitive dependency, making it easier to address the issue.&lt;/p&gt;
&lt;h4&gt;Prioritizing Dependency Updates&lt;/h4&gt;
&lt;p&gt;Not all vulnerabilities require immediate action. Use these criteria to prioritize:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Priority&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;CVSS Score&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Characteristics&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Action Timeline&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Immediate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;9.0-10.0&lt;/td&gt;
&lt;td&gt;Active exploits, network-accessible, no auth required&lt;/td&gt;
&lt;td&gt;24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;High&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.0-8.9&lt;/td&gt;
&lt;td&gt;Exploitable with user interaction or limited scope&lt;/td&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Medium&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4.0-6.9&lt;/td&gt;
&lt;td&gt;Requires specific conditions or configuration&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Low&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.1-3.9&lt;/td&gt;
&lt;td&gt;Difficult to exploit or minimal impact&lt;/td&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;To check if a vulnerability has known exploits, query the GitHub Advisory Database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;query {
  securityVulnerabilities(first: 1, ecosystem: NPM, package: &amp;quot;lodash&amp;quot;) {
    nodes {
      advisory {
        summary
        severity
        cvss {
          score
        }
        references {
          url
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cross-reference with the CISA KEV (Known Exploited Vulnerabilities) catalog and EPSS (Exploit Prediction Scoring System) scores for additional context.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Security Overview: Your Command Center&lt;/h3&gt;
&lt;p&gt;Managing security across multiple repositories can feel overwhelming. Security Overview provides a centralized dashboard for your organization’s security posture. It aggregates alerts from Secret Scanning, Dependabot, and Code Scanning, giving you a bird’s-eye view of risks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Accessing Security Overview&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Navigate to:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Organization Settings → Security → Security Overview&lt;/strong&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Advanced Configuration &amp;amp; Customization&lt;/h2&gt;
&lt;p&gt;Now that you have the basics running, it&#39;s time to tailor GHAS to your organization&#39;s specific needs. The default configurations provide solid coverage, but customization unlocks the full power of GHAS for your unique security requirements and development workflows.&lt;/p&gt;
&lt;h3&gt;Custom Secret Patterns for Internal Tokens&lt;/h3&gt;
&lt;p&gt;GitHub&#39;s Secret Scanning includes patterns for hundreds of popular services (AWS, Azure, GitHub tokens, Stripe keys, etc.), but your organization likely has internal secrets that don&#39;t match public patterns. You can define custom patterns to detect these.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Creating a Custom Pattern:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Organization Settings → Code security and analysis → Secret scanning&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;New pattern&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Define your pattern using regular expressions&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Example: Internal API Token Pattern&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-regex&quot;&gt;company_api_key_[a-zA-Z0-9]{32}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Example: Database Connection String&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-regex&quot;&gt;Server=.+;Database=.+;User Id=.+;Password=.+;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Custom patterns support:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Test strings&lt;/strong&gt; to validate your regex before publishing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dry run mode&lt;/strong&gt; to see what would be detected without generating alerts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;False positive suppression&lt;/strong&gt; through comment annotations in code&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Configuring Severity Thresholds and Alert Routing&lt;/h3&gt;
&lt;p&gt;Not all alerts require the same urgency. You can configure how alerts are prioritized and who receives notifications based on severity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Branch Protection Rules Tied to Security:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/settings.yml (using probot/settings)
branches:
  - name: main
    protection:
      required_status_checks:
        strict: true
        contexts:
          - &amp;quot;CodeQL Analysis&amp;quot;
          - &amp;quot;Dependency Review&amp;quot;
          - &amp;quot;Secret Scanning Check&amp;quot;
      required_pull_request_reviews:
        required_approving_review_count: 1
        dismiss_stale_reviews: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Alert Routing with GitHub Actions:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Route different severity levels to different channels:&lt;/p&gt;
&lt;p&gt;{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Security Alert Router
on:
  code_scanning_alert:
    types: [created, reopened]
jobs:
  route-alert:
    runs-on: ubuntu-latest
    steps:
      - name: Route Critical Alerts
        if: github.event.alert.rule.severity == &#39;critical&#39;
        run: |
          curl -X POST ${{ secrets.PAGERDUTY_WEBHOOK }} &#92;
            -H &amp;quot;Content-Type: application/json&amp;quot; &#92;
            -d &#39;{&amp;quot;severity&amp;quot;:&amp;quot;critical&amp;quot;,&amp;quot;summary&amp;quot;:&amp;quot;Critical security alert in ${{ github.repository }}&amp;quot;}&#39;
      
      - name: Route High Alerts
        if: github.event.alert.rule.severity == &#39;high&#39;
        run: |
          curl -X POST ${{ secrets.SLACK_SECURITY_CHANNEL }} &#92;
            -H &amp;quot;Content-Type: application/json&amp;quot; &#92;
            -d &#39;{&amp;quot;text&amp;quot;:&amp;quot;High severity alert: ${{ github.event.alert.rule.description }}&amp;quot;}&#39;
      
      - name: Route Medium/Low Alerts
        if: github.event.alert.rule.severity == &#39;medium&#39; || github.event.alert.rule.severity == &#39;low&#39;
        run: |
          gh issue create &#92;
            --title &amp;quot;Security Alert: ${{ github.event.alert.rule.description }}&amp;quot; &#92;
            --label security,automated &#92;
            --body &amp;quot;Alert details: ${{ github.event.alert.html_url }}&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;h3&gt;Integrating with Jira and ServiceNow&lt;/h3&gt;
&lt;p&gt;For enterprises with existing ticketing systems, you can automatically create tickets for security alerts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Jira Integration Example:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Create Jira Ticket for Security Alerts
on:
  code_scanning_alert:
    types: [created]
jobs:
  create-jira-ticket:
    runs-on: ubuntu-latest
    steps:
      - name: Create Jira Issue
        uses: atlassian/gajira-create@v3
        with:
          project: SECURITY
          issuetype: Bug
          summary: &amp;quot;[${{ github.event.alert.rule.severity }}] ${{ github.event.alert.rule.description }}&amp;quot;
          description: |
            Alert detected in repository ${{ github.repository }}
            Severity: ${{ github.event.alert.rule.severity }}
            File: ${{ github.event.alert.instances[0].location.path }}
            Line: ${{ github.event.alert.instances[0].location.start_line }}
            
            GitHub Alert: ${{ github.event.alert.html_url }}
          fields: &#39;{&amp;quot;priority&amp;quot;: {&amp;quot;name&amp;quot;: &amp;quot;${{ github.event.alert.rule.severity == &#39;critical&#39; &amp;amp;&amp;amp; &#39;Highest&#39; || &#39;High&#39; }}&amp;quot;}}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;h3&gt;Setting Up Security Policies at Organization Level&lt;/h3&gt;
&lt;p&gt;Instead of configuring security settings repository-by-repository, you can establish organization-wide policies that apply to all repositories (or specific subsets).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Organization Security Policy (&lt;code&gt;SECURITY.md&lt;/code&gt; in &lt;code&gt;.github&lt;/code&gt; repo):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;# Security Policy

## Reporting a Vulnerability

Report vulnerabilities to security@company.com or through our private disclosure program at https://hackerone.com/company

## Security Scanning Requirements

All repositories must have:
- Code Scanning enabled with at least weekly scans
- Secret Scanning enabled with push protection
- Dependabot alerts enabled with auto-merge for patch updates

## Remediation SLAs

- **Critical vulnerabilities**: 24 hours
- **High vulnerabilities**: 7 days
- **Medium vulnerabilities**: 30 days
- **Low vulnerabilities**: 90 days

## Branch Protection

Production branches (`main`, `production`) must:
- Require passing Code Scanning and Dependency Review
- Require at least one approval from CODEOWNERS
- Prohibit force pushes
- Require signed commits
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Enforcing Policies with GitHub API:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Enable GHAS features for all repos in an organization
for repo in $(gh repo list myorg --json name --jq &#39;.[].name&#39;); do
  gh api -X PATCH /repos/myorg/$repo &#92;
    -f security_and_analysis[secret_scanning][status]=enabled &#92;
    -f security_and_analysis[secret_scanning_push_protection][status]=enabled &#92;
    -f security_and_analysis[dependabot_security_updates][status]=enabled
done
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Customizing CodeQL Queries&lt;/h3&gt;
&lt;p&gt;You can adjust which CodeQL queries run to balance security coverage with false positive rates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Query Suites:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;default&lt;/code&gt;: Standard security queries, good balance&lt;/li&gt;
&lt;li&gt;&lt;code&gt;security-extended&lt;/code&gt;: Additional security queries, more comprehensive but higher false positive rate&lt;/li&gt;
&lt;li&gt;&lt;code&gt;security-and-quality&lt;/code&gt;: Security plus code quality checks&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Custom Query Configuration (&lt;code&gt;.github/codeql/codeql-config.yml&lt;/code&gt;):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: &amp;quot;Custom CodeQL Config&amp;quot;
queries:
  - uses: security-extended
  - uses: ./.github/codeql/custom-queries

query-filters:
  - exclude:
      id: js/incomplete-sanitization
  - exclude:
      tags:
        - experimental

paths-ignore:
  - &amp;quot;**/*.test.js&amp;quot;
  - &amp;quot;**/vendor/**&amp;quot;
  - &amp;quot;**/node_modules/**&amp;quot;

paths:
  - &amp;quot;src/**&amp;quot;
  - &amp;quot;lib/**&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Managing False Positives&lt;/h3&gt;
&lt;p&gt;False positives are inevitable with any security tool. The key is having a systematic process for handling them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dismissing Alerts with Reason Tracking:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Dismiss a false positive via API with reason
gh api -X PATCH /repos/OWNER/REPO/code-scanning/alerts/ALERT_NUMBER &#92;
  -f state=dismissed &#92;
  -f dismissed_reason=&amp;quot;false positive&amp;quot; &#92;
  -f dismissed_comment=&amp;quot;This regex pattern only matches internal test data, not user input&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Common Dismissal Reasons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;False positive&lt;/strong&gt;: The tool incorrectly identified an issue&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Won&#39;t fix&lt;/strong&gt;: The issue is real but accepted risk (document why!)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Used in tests&lt;/strong&gt;: The code only runs in test environments&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Best Practices:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Require a comment explaining every dismissal&lt;/li&gt;
&lt;li&gt;Review dismissed alerts quarterly to ensure decisions still make sense&lt;/li&gt;
&lt;li&gt;Track dismissal rates by team to identify training opportunities&lt;/li&gt;
&lt;li&gt;Use suppressions in code for persistent false positives:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# github/codeql: disable sql-injection
# This query is safe because user_input is validated against whitelist
query = f&amp;quot;SELECT * FROM users WHERE role = &#39;{user_input}&#39;&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Integrating GHAS into CI/CD Workflows&lt;/h2&gt;
&lt;p&gt;Configuration alone isn&#39;t enough—security checks must be enforced in your development workflow. This section shows how to weave GHAS into CI/CD pipelines, transforming security from optional to mandatory. By shifting security left, you catch issues in pull requests before they reach production.&lt;/p&gt;
&lt;h3&gt;Enforcing Secret Scanning in CI/CD&lt;/h3&gt;
&lt;p&gt;Block merges when secret scanning detects exposed credentials:&lt;/p&gt;
&lt;p&gt;{% raw %}
{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Block Merge on Secret Alerts
on: [pull_request]
jobs:
  check-secrets:
    runs-on: ubuntu-latest
    steps:
      - name: Check for Secret Scanning Alerts
        run: |
          alerts=$(gh api repos/$GITHUB_REPOSITORY/secret-scanning/alerts --jq &#39;.[] | select(.state==&amp;quot;open&amp;quot;)&#39;)
          if [ -n &amp;quot;$alerts&amp;quot; ]; then
            echo &amp;quot;Open secret scanning alerts detected. Failing build.&amp;quot;
            exit 1
          fi
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}
{% endraw %}&lt;/p&gt;
&lt;h3&gt;Enforcing Dependency Review in CI/CD&lt;/h3&gt;
&lt;p&gt;Prevent vulnerable dependencies from being merged:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Dependency Review
on: [pull_request]
jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
      - name: Dependency Review
        uses: actions/dependency-review-action@v3
        with:
          fail-on-severity: high
          deny-licenses: GPL-3.0, AGPL-3.0
          allow-licenses: MIT, Apache-2.0, BSD-3-Clause
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Enforcing Code Scanning in CI/CD&lt;/h3&gt;
&lt;p&gt;Block pull request merges when Code Scanning detects critical or high-severity issues:&lt;/p&gt;
&lt;p&gt;{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Enforce Code Scanning
on: [pull_request]
jobs:
  check-code-scanning:
    runs-on: ubuntu-latest
    steps:
      - name: Check for Critical Alerts
        run: |
          alerts=$(gh api repos/$GITHUB_REPOSITORY/code-scanning/alerts &#92;
            --jq &#39;[.[] | select(.state==&amp;quot;open&amp;quot; and (.rule.severity==&amp;quot;critical&amp;quot; or .rule.severity==&amp;quot;high&amp;quot;))] | length&#39;)
          if [ &amp;quot;$alerts&amp;quot; -gt 0 ]; then
            echo &amp;quot;Critical or high-severity code scanning alerts detected. Failing build.&amp;quot;
            exit 1
          fi
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;h3&gt;Branch Protection Rules&lt;/h3&gt;
&lt;p&gt;Configure branch protection to require passing security checks before merge:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub UI:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Settings → Branches&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Add a branch protection rule for &lt;code&gt;main&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Enable &amp;quot;Require status checks to pass before merging&amp;quot;&lt;/li&gt;
&lt;li&gt;Select your security workflows (Code Scanning, Dependency Review, Secret Scanning)&lt;/li&gt;
&lt;li&gt;Enable &amp;quot;Require branches to be up to date before merging&amp;quot;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Using GitHub API:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -X PUT &#92;
  -H &amp;quot;Accept: application/vnd.github+json&amp;quot; &#92;
  -H &amp;quot;Authorization: Bearer $GITHUB_TOKEN&amp;quot; &#92;
  https://api.github.com/repos/OWNER/REPO/branches/main/protection &#92;
  -d &#39;{
    &amp;quot;required_status_checks&amp;quot;: {
      &amp;quot;strict&amp;quot;: true,
      &amp;quot;contexts&amp;quot;: [&amp;quot;CodeQL&amp;quot;, &amp;quot;Dependency Review&amp;quot;, &amp;quot;Secret Scanning&amp;quot;]
    },
    &amp;quot;enforce_admins&amp;quot;: true,
    &amp;quot;required_pull_request_reviews&amp;quot;: {
      &amp;quot;required_approving_review_count&amp;quot;: 1
    }
  }&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Best Practices for Long-Term Success&lt;/h2&gt;
&lt;p&gt;With GHAS enabled and integrated into your CI/CD pipeline, focus shifts to operational excellence. These practices help teams maintain security effectiveness over time.&lt;/p&gt;
&lt;h3&gt;Establish Clear Remediation SLAs&lt;/h3&gt;
&lt;p&gt;Security alerts are only valuable if teams act on them. Establish service level agreements (SLAs) for remediation based on severity:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Critical&lt;/strong&gt;: 24 hours - These represent actively exploitable vulnerabilities or exposed secrets&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;High&lt;/strong&gt;: 7 days - Serious vulnerabilities that could lead to compromise&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Medium&lt;/strong&gt;: 30 days - Issues that increase attack surface but aren&#39;t immediately exploitable&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Low&lt;/strong&gt;: 90 days - Code quality or defense-in-depth improvements&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Track compliance with these SLAs and surface teams that consistently miss targets. This isn&#39;t about punishment; it&#39;s about identifying training needs or resource constraints.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example SLA Dashboard Query:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Get all open high/critical alerts older than 7 days
gh api /repos/OWNER/REPO/code-scanning/alerts &#92;
  --jq &#39;.[] | select(.state==&amp;quot;open&amp;quot; and (.rule.severity==&amp;quot;critical&amp;quot; or .rule.severity==&amp;quot;high&amp;quot;) and (now - (.created_at | fromdateiso8601) &amp;gt; 604800)) | {number, severity: .rule.severity, age: ((now - (.created_at | fromdateiso8601)) / 86400 | floor)}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Handle False Positives Systematically&lt;/h3&gt;
&lt;p&gt;False positives erode trust in security tools. When developers see too many incorrect alerts, they start ignoring all alerts, including real ones.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strategies to manage false positives:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Tune your queries&lt;/em&gt;: Start with default CodeQL queries, then gradually add security-extended queries as your team gains expertise&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Use path filters&lt;/em&gt;: Exclude test code, vendor libraries, and generated files from scanning&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Document dismissals&lt;/em&gt;: Require a clear explanation for every dismissed alert&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Review dismissals quarterly&lt;/em&gt;: Ensure past decisions still make sense&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Create custom suppressions&lt;/em&gt;: For persistent false positives, use in-code suppressions with explanatory comments&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Acceptable false positive rate:&lt;/em&gt; Aim for under 10%. If you&#39;re above 20%, invest time in tuning queries and training your team on what constitutes a real vulnerability.&lt;/p&gt;
&lt;h3&gt;Run Security Checks Efficiently&lt;/h3&gt;
&lt;p&gt;Security scans can slow down your CI/CD pipeline if not configured properly. Here are strategies to keep things fast:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Parallel Execution:&lt;/strong&gt;
{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  security:
    strategy:
      matrix:
        check: [code-scanning, secret-scanning, dependency-review]
    runs-on: ubuntu-latest
    steps:
      - name: Run ${{ matrix.check }}
        run: ./scripts/${{ matrix.check }}.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Caching:&lt;/strong&gt;
{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Cache CodeQL
  uses: actions/cache@v3
  with:
    path: ~/.codeql
    key: codeql-${{ runner.os }}-${{ hashFiles(&#39;**/codeql-config.yml&#39;) }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Incremental Analysis:&lt;/strong&gt;
Only scan changed files on pull requests:
{% raw %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Get changed files
  id: changed-files
  run: |
    echo &amp;quot;files=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | tr &#39;&#92;n&#39; &#39; &#39;)&amp;quot; &amp;gt;&amp;gt; $GITHUB_OUTPUT

- name: Run CodeQL on changed files
  if: steps.changed-files.outputs.files != &#39;&#39;
  run: codeql analyze --sarif-category=pr --paths=${{ steps.changed-files.outputs.files }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% endraw %}&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Benchmark:&lt;/strong&gt; Well-configured GHAS scans should add no more than 5-10 minutes to your CI/CD pipeline for most repositories.&lt;/p&gt;
&lt;h3&gt;Integrate Alerts with Communication Channels&lt;/h3&gt;
&lt;p&gt;Developers are most likely to act on security alerts when they see them in their existing workflows. Don&#39;t expect them to regularly check a dashboard.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Slack Integration:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Notify Slack on Critical Alert
  if: github.event.alert.rule.severity == &#39;critical&#39;
  run: |
    curl -X POST -H &#39;Content-type: application/json&#39; &#92;
    --data &#39;{&amp;quot;text&amp;quot;:&amp;quot;🚨 Critical security alert in ${{ github.repository }}: ${{ github.event.alert.rule.description }}&#92;nView: ${{ github.event.alert.html_url }}&amp;quot;}&#39; &#92;
    ${{ secrets.SLACK_WEBHOOK }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Microsoft Teams Integration:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Notify Teams
  uses: toko-bifrost/ms-teams-deploy-card@master
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    webhook-uri: ${{ secrets.TEAMS_WEBHOOK }}
    card-layout-start: cozy
    show-on-start: false
    show-on-exit: true
    custom-facts: |
      - name: Severity
        value: ${{ github.event.alert.rule.severity }}
      - name: Rule
        value: ${{ github.event.alert.rule.description }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;PagerDuty for Critical Issues:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Page on-call for critical vulnerability
  if: github.event.alert.rule.severity == &#39;critical&#39;
  run: |
    curl -X POST https://events.pagerduty.com/v2/enqueue &#92;
      -H &#39;Content-Type: application/json&#39; &#92;
      -d &#39;{
        &amp;quot;routing_key&amp;quot;: &amp;quot;${{ secrets.PAGERDUTY_ROUTING_KEY }}&amp;quot;,
        &amp;quot;event_action&amp;quot;: &amp;quot;trigger&amp;quot;,
        &amp;quot;payload&amp;quot;: {
          &amp;quot;summary&amp;quot;: &amp;quot;Critical vulnerability in ${{ github.repository }}&amp;quot;,
          &amp;quot;severity&amp;quot;: &amp;quot;critical&amp;quot;,
          &amp;quot;source&amp;quot;: &amp;quot;GitHub Advanced Security&amp;quot;
        }
      }&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Build a Security Metrics Dashboard&lt;/h3&gt;
&lt;p&gt;Track your security posture over time with key metrics:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alert volume trends&lt;/strong&gt;: Are new alerts decreasing as your code improves?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Remediation time by severity&lt;/strong&gt;: Are you meeting your SLAs?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;False positive rate&lt;/strong&gt;: Is your tuning working?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Coverage metrics&lt;/strong&gt;: What percentage of repositories have GHAS enabled?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alert aging&lt;/strong&gt;: How many alerts are older than 90 days?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example: Query for metrics collection&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Collect security metrics across all repos

ORG=&amp;quot;your-org&amp;quot;
OUTPUT=&amp;quot;security-metrics-$(date +%Y-%m-%d).json&amp;quot;

echo &amp;quot;{&amp;quot; &amp;gt; $OUTPUT
echo &amp;quot;  &#92;&amp;quot;timestamp&#92;&amp;quot;: &#92;&amp;quot;$(date -Iseconds)&#92;&amp;quot;,&amp;quot; &amp;gt;&amp;gt; $OUTPUT
echo &amp;quot;  &#92;&amp;quot;repositories&#92;&amp;quot;: [&amp;quot; &amp;gt;&amp;gt; $OUTPUT

for repo in $(gh repo list $ORG --json name --jq &#39;.[].name&#39;); do
  echo &amp;quot;    {&amp;quot; &amp;gt;&amp;gt; $OUTPUT
  echo &amp;quot;      &#92;&amp;quot;name&#92;&amp;quot;: &#92;&amp;quot;$repo&#92;&amp;quot;,&amp;quot; &amp;gt;&amp;gt; $OUTPUT
  
  # Code scanning alerts by severity
  critical=$(gh api /repos/$ORG/$repo/code-scanning/alerts --jq &#39;[.[] | select(.state==&amp;quot;open&amp;quot; and .rule.severity==&amp;quot;critical&amp;quot;)] | length&#39;)
  high=$(gh api /repos/$ORG/$repo/code-scanning/alerts --jq &#39;[.[] | select(.state==&amp;quot;open&amp;quot; and .rule.severity==&amp;quot;high&amp;quot;)] | length&#39;)
  
  # Secret scanning alerts
  secrets=$(gh api /repos/$ORG/$repo/secret-scanning/alerts --jq &#39;[.[] | select(.state==&amp;quot;open&amp;quot;)] | length&#39;)
  
  # Dependabot alerts
  deps=$(gh api /repos/$ORG/$repo/dependabot/alerts --jq &#39;[.[] | select(.state==&amp;quot;open&amp;quot;)] | length&#39;)
  
  echo &amp;quot;      &#92;&amp;quot;code_scanning&#92;&amp;quot;: {&#92;&amp;quot;critical&#92;&amp;quot;: $critical, &#92;&amp;quot;high&#92;&amp;quot;: $high},&amp;quot; &amp;gt;&amp;gt; $OUTPUT
  echo &amp;quot;      &#92;&amp;quot;secret_scanning&#92;&amp;quot;: $secrets,&amp;quot; &amp;gt;&amp;gt; $OUTPUT
  echo &amp;quot;      &#92;&amp;quot;dependabot&#92;&amp;quot;: $deps&amp;quot; &amp;gt;&amp;gt; $OUTPUT
  echo &amp;quot;    },&amp;quot; &amp;gt;&amp;gt; $OUTPUT
done

echo &amp;quot;  ]&amp;quot; &amp;gt;&amp;gt; $OUTPUT
echo &amp;quot;}&amp;quot; &amp;gt;&amp;gt; $OUTPUT
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Provide Developer Training&lt;/h3&gt;
&lt;p&gt;The most sophisticated security tools are useless if developers don&#39;t understand them. Invest in training:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Onboarding for New Developers:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;30-minute GHAS overview session&lt;/li&gt;
&lt;li&gt;Hands-on lab: trigger an alert, triage it, fix it, verify resolution&lt;/li&gt;
&lt;li&gt;Documentation on how to dismiss false positives correctly&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Ongoing Education:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Monthly &amp;quot;security office hours&amp;quot; where developers can ask questions&lt;/li&gt;
&lt;li&gt;Quarterly reviews of common vulnerability patterns found in your codebase&lt;/li&gt;
&lt;li&gt;Annual security training with real examples from your organization&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Security Champions Program:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Identify 1-2 developers per team interested in security&lt;/li&gt;
&lt;li&gt;Provide deeper training (OWASP Top 10, threat modeling, secure coding)&lt;/li&gt;
&lt;li&gt;Give them time (20%) to triage alerts and mentor teammates&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Start Small, Scale Gradually&lt;/h3&gt;
&lt;p&gt;Don&#39;t try to enable everything everywhere on day one. Follow a phased approach:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1: &lt;em&gt;Pilot (1-2 months)&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Enable GHAS on 5-10 repositories that represent your tech stack diversity. Focus this phase on tuning the configuration and learning how the tools work in your environment. Gather feedback from developers to understand their experience and identify any friction points.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2: &lt;em&gt;Expand (3-6 months)&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Roll out GHAS to 25% of your repositories, prioritizing those with the highest business impact. During this phase, integrate security checks into your CI/CD pipelines to enforce quality gates. Establish clear remediation SLAs so teams know how quickly they need to address different severity levels of security issues.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 3: &lt;em&gt;Scale (6-12 months)&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Enable GHAS on all active repositories across your organization to achieve full security coverage. Implement branch protection rules that prevent merges when security issues are detected, ensuring no vulnerabilities slip through to production. Enforce compliance through automation by creating organizational policies and using GitHub Actions to maintain consistent security standards across all teams.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 4: &lt;em&gt;Optimize (ongoing)&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Continuously improve your GHAS implementation by reducing false positive rates through better query tuning and path filters. Work to decrease remediation times by streamlining workflows and providing better training to developers. Add custom queries tailored to your organization&#39;s specific risks and coding patterns, ensuring GHAS catches vulnerabilities unique to your technology stack and business domain.&lt;/p&gt;
&lt;h3&gt;Build Effective Security Champions Teams&lt;/h3&gt;
&lt;p&gt;Organizations that succeed with GHAS typically don&#39;t rely solely on a central security team. They establish a &lt;strong&gt;security champions program&lt;/strong&gt; where developers across teams receive additional security training and act as the first line of defense.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Typical Structure:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Central Security Team (2-5 people)&lt;/strong&gt;: Owns security policy, manages GHAS configuration at the organization level, tunes alert rules, conducts security architecture reviews&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security Champions (1-2 per team)&lt;/strong&gt;: Embedded developers with 20% time allocation to security, triage GHAS alerts within their team, provide peer education, participate in security council meetings&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Platform Team&lt;/strong&gt;: Maintains security automation, manages CI/CD security gates, creates shared GitHub Actions for security checks&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Development Teams&lt;/strong&gt;: Own remediation of alerts in their codebases, integrate security checks into their workflows, participate in game days and security training&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This distributed model ensures security knowledge spreads throughout the organization while keeping security experts focused on high-value activities.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Troubleshooting &amp;amp; Common Pitfalls&lt;/h2&gt;
&lt;p&gt;Even with careful planning, you&#39;ll encounter challenges when operating GHAS at scale. Here&#39;s how to address the most common issues teams face.&lt;/p&gt;
&lt;p&gt;Even with the best planning, you&#39;ll encounter challenges when rolling out GHAS. Here are the most common issues and how to address them:&lt;/p&gt;
&lt;h3&gt;Alert Fatigue&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Teams receive hundreds of alerts on day one and become overwhelmed, leading to alerts being ignored entirely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Start with critical and high severity alerts only&lt;/li&gt;
&lt;li&gt;Use the &lt;code&gt;security-severity&lt;/code&gt; filter in CodeQL to focus on high-impact issues&lt;/li&gt;
&lt;li&gt;Implement a phased rollout where you fix existing issues before enabling additional scanning&lt;/li&gt;
&lt;li&gt;Set up alert routing so only relevant teams see their alerts (not organization-wide notifications)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Prevention strategy:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Enable CodeQL with limited severity
- uses: github/codeql-action/init@v3
  with:
    queries: security-extended
    # Only fail on critical/high issues initially
- uses: github/codeql-action/analyze@v3
  with:
    upload: true
    wait-for-processing: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;False Positives Derailing Adoption&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Developers lose trust in the tool when they see too many false positives, especially in legacy codebases.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create a documented process for dismissing alerts (require justification in comments)&lt;/li&gt;
&lt;li&gt;Use CodeQL query exclusions for known false positive patterns specific to your codebase&lt;/li&gt;
&lt;li&gt;Invest time upfront to tune queries before requiring remediation&lt;/li&gt;
&lt;li&gt;Track false positive rates and continuously improve&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Example: Suppress specific CWE in CodeQL config:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: &amp;quot;CodeQL Config&amp;quot;
disable-default-queries: false
queries:
  - uses: security-extended
packs:
  - codeql/javascript-queries
paths-ignore:
  - test/**
  - vendor/**
query-filters:
  - exclude:
      id: js/sql-injection
      problem.severity: warning
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Performance Impact on CI/CD Pipelines&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; CodeQL analysis adds 5-15 minutes to build times, slowing down development velocity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Run CodeQL on scheduled workflows (nightly) rather than on every commit&lt;/li&gt;
&lt;li&gt;Use incremental analysis (only scan changed code) for pull requests&lt;/li&gt;
&lt;li&gt;Run security scans in parallel with other CI jobs, not sequentially&lt;/li&gt;
&lt;li&gt;Use self-hosted runners with better CPU resources for large repositories&lt;/li&gt;
&lt;li&gt;Enable caching for CodeQL databases to speed up subsequent runs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Performance-optimized workflow:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: &amp;quot;CodeQL - Optimized&amp;quot;
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  schedule:
    - cron: &#39;0 2 * * 1&#39;  # Weekly deep scan

jobs:
  analyze:
    runs-on: ubuntu-latest-8-cores  # Use larger runners
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v3
      - uses: github/codeql-action/init@v3
      - uses: github/codeql-action/autobuild@v3
      - uses: github/codeql-action/analyze@v3
        with:
          category: &amp;quot;/language:javascript&amp;quot;
          # Upload results but don&#39;t block PR on scheduled runs
          upload: true
          checkout_path: ${{ github.workspace }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Secret Scanning Revealing Embarrassing Legacy Issues&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Enabling secret scanning exposes years of accumulated secrets in commit history, creating a massive cleanup effort.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use GitHub&#39;s secret scanning push protection to prevent new secrets immediately&lt;/li&gt;
&lt;li&gt;Prioritize active secrets over historical ones (check if tokens still work)&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;git-filter-repo&lt;/code&gt; or BFG Repo-Cleaner to rewrite history for critical secrets&lt;/li&gt;
&lt;li&gt;Accept that some historical secrets may need to remain (if rotated/inactive) rather than rewriting years of history&lt;/li&gt;
&lt;li&gt;Focus remediation efforts on secrets exposed in the last 90 days first&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Quick check if a token is still active:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# For GitHub tokens
curl -H &amp;quot;Authorization: token ghp_xxxxx&amp;quot; https://api.github.com/user

# For AWS keys
aws sts get-caller-identity --profile compromised-key
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Licensing Costs vs. Security Value&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Justifying the per-user cost of GHAS to leadership when ROI isn&#39;t immediately visible.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Start with a pilot on critical repositories to demonstrate value with concrete metrics&lt;/li&gt;
&lt;li&gt;Calculate cost of a breach ($4.45M average) vs. GHAS investment (~$49/user/month = $588/year)&lt;/li&gt;
&lt;li&gt;Track time saved by preventing vulnerabilities from reaching production&lt;/li&gt;
&lt;li&gt;Measure reduction in post-production security incidents&lt;/li&gt;
&lt;li&gt;Document compliance benefits (SOC 2, ISO 27001 require security scanning)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;ROI Calculation Example:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Team of 50 developers: 50 × $49/month = $2,450/month = $29,400/year&lt;/li&gt;
&lt;li&gt;One prevented breach (MTTR from 48 hours to 4 hours saves $183K in incident response)&lt;/li&gt;
&lt;li&gt;Prevented vulnerabilities reaching production: 12 critical issues caught in PR = $500K+ saved&lt;/li&gt;
&lt;li&gt;Compliance audit time reduced: 40 hours saved = $8K&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Net benefit: $661K/year vs. $29K investment = 22x ROI&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Dependency Scanning Overhead on Large Monorepos&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Dependency Review on monorepos with 50+ manifest files takes too long and creates noise.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;paths&lt;/code&gt; filters in workflows to only scan changed directories&lt;/li&gt;
&lt;li&gt;Implement matrix strategies to scan different ecosystems in parallel&lt;/li&gt;
&lt;li&gt;Configure &lt;code&gt;allow-licenses&lt;/code&gt; to reduce license violation noise&lt;/li&gt;
&lt;li&gt;Use Dependabot groups to batch related updates rather than individual PRs&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Integrating GHAS with Your Broader Security Ecosystem&lt;/h2&gt;
&lt;p&gt;GHAS shouldn&#39;t operate in isolation. Modern security requires a layered approach where multiple tools complement each other. Here&#39;s how GHAS fits into your broader security strategy:&lt;/p&gt;
&lt;h3&gt;Complementing Commercial SAST/SCA Tools&lt;/h3&gt;
&lt;p&gt;If you already use tools like Snyk, Aqua Security, or Checkmarx, GHAS doesn&#39;t replace them—it complements them:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CodeQL (GHAS) strengths:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Deep semantic analysis of first-party code&lt;/li&gt;
&lt;li&gt;Native GitHub integration with no third-party API dependencies&lt;/li&gt;
&lt;li&gt;Customizable queries for organization-specific patterns&lt;/li&gt;
&lt;li&gt;Free for public repositories&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Commercial tool strengths:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Broader language support (Snyk supports 10+ more languages)&lt;/li&gt;
&lt;li&gt;Container and infrastructure-as-code scanning&lt;/li&gt;
&lt;li&gt;Advanced license compliance management&lt;/li&gt;
&lt;li&gt;Dedicated support and consulting&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Best practice:&lt;/strong&gt; Use GHAS as your primary gate in the CI/CD pipeline for fast feedback, and run commercial tools on a nightly schedule for comprehensive coverage. Configure both to write to your centralized security dashboard.&lt;/p&gt;
&lt;h3&gt;Exporting to SIEM and Analytics Platforms&lt;/h3&gt;
&lt;p&gt;Send GHAS alert data to your Security Information and Event Management (SIEM) system for centralized monitoring:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example: Export to Splunk&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Export GHAS alerts to Splunk HEC endpoint

ORG=&amp;quot;your-org&amp;quot;
SPLUNK_HEC_TOKEN=&amp;quot;your-token&amp;quot;
SPLUNK_URL=&amp;quot;https://splunk.company.com:8088/services/collector&amp;quot;

# Fetch all code scanning alerts
gh api &amp;quot;/orgs/$ORG/code-scanning/alerts&amp;quot; --paginate | &#92;
jq -c &#39;.[] | {
  time: .created_at,
  source: &amp;quot;github_ghas&amp;quot;,
  sourcetype: &amp;quot;code_scanning&amp;quot;,
  event: {
    repo: .repository.full_name,
    severity: .rule.severity,
    rule_id: .rule.id,
    state: .state,
    url: .html_url
  }
}&#39; | &#92;
while read -r event; do
  curl -k &amp;quot;$SPLUNK_URL&amp;quot; &#92;
    -H &amp;quot;Authorization: Splunk $SPLUNK_HEC_TOKEN&amp;quot; &#92;
    -d &amp;quot;$event&amp;quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Building Custom Dashboards with GitHub API&lt;/h3&gt;
&lt;p&gt;GHAS provides robust REST and GraphQL APIs for building custom security dashboards:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example: GraphQL query for organization-wide security posture&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;query OrgSecurityPosture($org: String!) {
  organization(login: $org) {
    repositories(first: 100) {
      nodes {
        name
        vulnerabilityAlerts(first: 10, states: OPEN) {
          totalCount
          nodes {
            securityVulnerability {
              severity
              package { name }
            }
          }
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use this data to create real-time dashboards in Grafana, Datadog, or your internal portal showing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Alert trends over time&lt;/li&gt;
&lt;li&gt;Repository risk scores&lt;/li&gt;
&lt;li&gt;Remediation velocity by team&lt;/li&gt;
&lt;li&gt;Compliance coverage metrics&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Integrating with Policy-as-Code Frameworks&lt;/h3&gt;
&lt;p&gt;Combine GHAS with Open Policy Agent (OPA) or Conftest to enforce security policies:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example: OPA policy requiring zero critical vulnerabilities&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rego&quot;&gt;package github.security

deny[msg] {
  input.code_scanning_alerts[_].severity == &amp;quot;critical&amp;quot;
  input.code_scanning_alerts[_].state == &amp;quot;open&amp;quot;
  msg := &amp;quot;Deployment blocked: Critical security vulnerabilities must be resolved&amp;quot;
}

deny[msg] {
  input.secret_scanning_alerts[_].state == &amp;quot;open&amp;quot;
  msg := &amp;quot;Deployment blocked: Active secrets detected&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enforce this policy in your deployment pipeline before promoting to production.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;If you&#39;re serious about DevSecOps, GitHub Advanced Security is a must-have. It empowers developers to take ownership of security without sacrificing speed. Start small by enabling Secret Scanning on a few repositories, experiment with Dependency Review, and explore Security Overview. As you gain confidence, scale these practices across your organization.&lt;/p&gt;
&lt;p&gt;Security isn’t a destination; it’s a journey. With GHAS, you have the tools to make that journey smoother, safer, and more efficient.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Need help on your GitHub Journey? Ask me!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>GitHub Advanced Security provides integrated tools like secret scanning, dependency review, and security dashboards to help DevSecOps teams embed proactive security checks into their development and CI/CD workflows.</summary>
    <category term="security"/>
    <category term="devsecops"/>
    <category term="github"/>
  </entry>
  <entry>
    <title>GitHub Actions: Reusable Workflows vs. Composite Actions — Know the Difference</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-03-13-github-actions-reusable-workflows-vs-composite-actions/"/>
    <updated>2026-03-13T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-03-13-github-actions-reusable-workflows-vs-composite-actions/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Every team that grows past a handful of GitHub Actions workflows eventually hits the same wall: duplicated YAML, copy-pasted step sequences, a &lt;code&gt;deploy&lt;/code&gt; job that lives in six repositories. The solution is obvious — abstract the common pieces. GitHub gives you two tools to do that: &lt;strong&gt;reusable workflows&lt;/strong&gt; and &lt;strong&gt;composite actions&lt;/strong&gt;. The docs present them as siblings. They&#39;re not. They operate at different levels of the execution model, enforce different scoping rules, and fail in different ways when you use them outside their intended purpose.&lt;/p&gt;
&lt;p&gt;Most of the bugs I&#39;ve seen come from one pattern: a developer reads about both abstractions, picks the one that looks right, and discovers the hard way that secrets don&#39;t arrive, matrix values vanish, or a branch protection rule silently stops enforcing. This post walks through three concrete failure scenarios — real YAML, real error behavior — and ends with a decision framework you can apply without rereading the docs.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What Each One Actually Is&lt;/h2&gt;
&lt;p&gt;Before the failure scenarios, a precise definition of each mechanism. The marketing framing (&amp;quot;reuse your workflows!&amp;quot;) is accurate but useless for debugging.&lt;/p&gt;
&lt;h3&gt;Reusable Workflows&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;A reusable workflow is a complete workflow file that runs as its &lt;strong&gt;own job&lt;/strong&gt; (or set of jobs) inside the calling workflow run. It is invoked at the &lt;code&gt;jobs:&lt;/code&gt; level using &lt;code&gt;uses:&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# caller.yml
jobs:
  test:
    uses: ./.github/workflows/run-tests.yml
    with:
      node-version: &amp;quot;20&amp;quot;
    secrets: inherit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The called file must declare &lt;code&gt;on: workflow_call:&lt;/code&gt;. It runs on its own runner, in its own environment, with its own job context. From GitHub&#39;s perspective — and from branch protection&#39;s perspective — it appears as a separate job in the workflow run, with its own status check named &lt;code&gt;&amp;lt;calling-job&amp;gt; / &amp;lt;reusable-job&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Composite Actions&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;A composite action is a reusable sequence of &lt;strong&gt;steps&lt;/strong&gt; that runs inside the calling job. It is invoked at the &lt;code&gt;steps:&lt;/code&gt; level using &lt;code&gt;uses:&lt;/code&gt;, just like any other action.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# caller.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: ./.github/actions/setup-node
        with:
          node-version: &amp;quot;20&amp;quot;
      - run: npm test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The called file is an &lt;code&gt;action.yml&lt;/code&gt; that declares &lt;code&gt;runs.using: composite&lt;/code&gt;. Its steps execute inside the calling job, sharing the runner, the workspace, environment variables, and the job context. It is not a separate job. It has no separate status check.&lt;/p&gt;
&lt;p&gt;That structural difference — job vs. steps — is the source of every failure scenario below.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Three Failure Scenarios&lt;/h2&gt;
&lt;h3&gt;1. The Disappearing Secret&lt;/h3&gt;
&lt;p&gt;This is the most common gotcha. A team moves their deployment logic into a composite action and discovers that the secret they need is silently empty at runtime.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The broken setup:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/actions/deploy/action.yml
name: Deploy
description: Deploy to production
runs:
  using: composite
  steps:
    - name: Call deployment API
      shell: bash
      run: |
        curl -sf -X POST &#92;
          -H &amp;quot;Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}&amp;quot; &#92;
          https://api.example.com/deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/release.yml
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;curl&lt;/code&gt; command sends an empty &lt;code&gt;Authorization&lt;/code&gt; header. The API returns a 401. Nothing in the logs explains why — &lt;code&gt;${{ secrets.DEPLOY_TOKEN }}&lt;/code&gt; just evaluates to an empty string inside the composite action because &lt;strong&gt;the secrets context is not available inside composite action YAML&lt;/strong&gt;. Composite actions run within the calling job&#39;s environment, but they don&#39;t inherit the calling job&#39;s secrets context automatically. GitHub explicitly scopes secrets away from composite action definitions to prevent accidental secret forwarding into third-party actions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix — pass it as an input:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/actions/deploy/action.yml
name: Deploy
description: Deploy to production
inputs:
  deploy-token:
    description: API token for the deployment endpoint
    required: true
runs:
  using: composite
  steps:
    - name: Call deployment API
      shell: bash
      env:
        DEPLOY_TOKEN: ${{ inputs.deploy-token }}
      run: |
        curl -sf -X POST &#92;
          -H &amp;quot;Authorization: Bearer ${DEPLOY_TOKEN}&amp;quot; &#92;
          https://api.example.com/deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/release.yml
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/deploy
        with:
          deploy-token: ${{ secrets.DEPLOY_TOKEN }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things changed. First, the composite action declares a &lt;code&gt;deploy-token&lt;/code&gt; input and reads it via &lt;code&gt;inputs.deploy-token&lt;/code&gt;. Second, the calling workflow explicitly passes &lt;code&gt;${{ secrets.DEPLOY_TOKEN }}&lt;/code&gt; via &lt;code&gt;with:&lt;/code&gt;. The secret is now in scope at the call site, where the secrets context &lt;em&gt;is&lt;/em&gt; available, and forwarded as an opaque input value.&lt;/p&gt;
&lt;p&gt;Notice the &lt;code&gt;env:&lt;/code&gt; block in the step definition. Referencing secrets (including values derived from inputs that originally came from secrets) via environment variables rather than inline &lt;code&gt;${{ }}&lt;/code&gt; interpolation is a defense-in-depth practice — it prevents the value from appearing in runner debug logs when step debug logging is enabled.&lt;/p&gt;
&lt;p&gt;If your composite action needs many secrets, the &lt;code&gt;with:&lt;/code&gt; list can get long fast. When that happens, it&#39;s often a signal that a reusable workflow is actually the right tool — it supports &lt;code&gt;secrets: inherit&lt;/code&gt;, which passes all secrets from the calling workflow automatically.&lt;/p&gt;
&lt;h3&gt;2. The Matrix That Won&#39;t Cooperate&lt;/h3&gt;
&lt;p&gt;A developer wants to test their library against three Node.js versions. They already have a reusable workflow for running tests. The natural move seems to be: loop the matrix, call the reusable workflow for each combination. They write this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/ci.yml
jobs:
  test:
    strategy:
      matrix:
        node: [18, 20, 22]
    uses: ./.github/workflows/run-tests.yml
    with:
      node-version: ${{ matrix.node }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This actually works syntactically — &lt;code&gt;matrix.*&lt;/code&gt; is available in the &lt;code&gt;with:&lt;/code&gt; block of a reusable workflow call when the calling job has a &lt;code&gt;strategy.matrix&lt;/code&gt; defined. Each matrix combination triggers a separate invocation of the reusable workflow. So far so good.&lt;/p&gt;
&lt;p&gt;The problem appears in the GitHub Actions UI and in branch protection rules. Each matrix combination produces a set of jobs named:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;test (18) / lint
test (18) / unit-tests
test (20) / lint
test (20) / unit-tests
test (22) / lint
test (22) / unit-tests
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you had a required status check configured as &lt;code&gt;lint&lt;/code&gt; or &lt;code&gt;unit-tests&lt;/code&gt;, it no longer matches anything. The check names now include the calling job name AND the matrix suffix. Your branch protection rule passes vacuously — no check with that name exists, so GitHub considers it satisfied — and you&#39;ve accidentally disabled your quality gate.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Update your required status checks to match the full generated names, or restructure so the matrix lives inside the reusable workflow rather than at the call site:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/run-tests.yml
on:
  workflow_call:

jobs:
  unit-tests:
    strategy:
      matrix:
        node: [18, 20, 22]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci &amp;amp;&amp;amp; npm test
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/ci.yml
jobs:
  test:
    uses: ./.github/workflows/run-tests.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the generated job names are &lt;code&gt;test / unit-tests (18)&lt;/code&gt;, &lt;code&gt;test / unit-tests (20)&lt;/code&gt;, and &lt;code&gt;test / unit-tests (22)&lt;/code&gt;. The required status check &lt;code&gt;test / unit-tests (18)&lt;/code&gt; is predictable and won&#39;t shift when the caller changes. Better yet: you can require just &lt;code&gt;test / unit-tests&lt;/code&gt; and GitHub will wait for all matrix variants to pass.&lt;/p&gt;
&lt;p&gt;A composite action sidesteps this entirely — its steps appear within the parent job, and the job name in branch protection is just the job name. No suffix, no nesting. If you&#39;re not sharing the workflow cross-repo and don&#39;t need secrets isolation, a composite action plus a matrix on the calling job is cleaner.&lt;/p&gt;
&lt;h3&gt;3. The Status Check That Lies&lt;/h3&gt;
&lt;p&gt;This one is the most dangerous because it doesn&#39;t cause a visible failure. It causes a &lt;strong&gt;missing&lt;/strong&gt; failure — a gate you thought was enforcing stops enforcing.&lt;/p&gt;
&lt;p&gt;Suppose you have a workflow with a &lt;code&gt;build&lt;/code&gt; job that your branch protection rules require to pass before merging. The team refactors &lt;code&gt;build&lt;/code&gt; to call a reusable workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Before refactor
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci &amp;amp;&amp;amp; npm run build

# After refactor
jobs:
  build:
    uses: ./.github/workflows/build.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reusable workflow file contains a job named &lt;code&gt;compile&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/build.yml
on:
  workflow_call:

jobs:
  compile:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci &amp;amp;&amp;amp; npm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After the refactor, the workflow run produces a check named &lt;code&gt;build / compile&lt;/code&gt;. The old check named &lt;code&gt;build&lt;/code&gt; no longer exists. GitHub&#39;s required status check for &lt;code&gt;build&lt;/code&gt; now matches nothing, so it&#39;s considered satisfied automatically. Every PR merges regardless of whether the build passes.&lt;/p&gt;
&lt;p&gt;Nobody notices until a broken build ships to production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix has two parts:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;First, update the required status check in branch protection from &lt;code&gt;build&lt;/code&gt; to &lt;code&gt;build / compile&lt;/code&gt; to match the new job name structure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# GitHub API — update required status check
# PATCH /repos/{owner}/{repo}/branches/{branch}/protection
{
  &amp;quot;required_status_checks&amp;quot;: {
    &amp;quot;strict&amp;quot;: true,
    &amp;quot;contexts&amp;quot;: [&amp;quot;build / compile&amp;quot;]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Second, make this explicit in your reusable workflow by naming the job clearly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/build.yml
on:
  workflow_call:

jobs:
  build:          # ← name this to match what branch protection expects
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci &amp;amp;&amp;amp; npm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the reusable workflow job is also named &lt;code&gt;build&lt;/code&gt;, the required check becomes &lt;code&gt;build / build&lt;/code&gt; — redundant but unambiguous. Some teams prefix reusable workflow jobs with &lt;code&gt;rw-&lt;/code&gt; to make it obvious which job names come from reusable workflows.&lt;/p&gt;
&lt;p&gt;Composite actions don&#39;t have this problem. Their steps roll up into the parent job&#39;s status. If you refactor steps into a composite action, the job name in branch protection doesn&#39;t change. This is one of the strongest arguments for composite actions when cross-repo sharing isn&#39;t needed.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Decision Framework&lt;/h2&gt;
&lt;p&gt;Use these rules. They&#39;re opinionated because ambiguity is what causes the bugs described above.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reach for a reusable workflow when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You need secrets to be available inside the abstraction without explicitly passing each one (use &lt;code&gt;secrets: inherit&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;You want the abstraction to appear as its own named job in the workflow UI and in status checks&lt;/li&gt;
&lt;li&gt;The workflow needs to run on a different runner type than the caller (separate &lt;code&gt;runs-on&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;You&#39;re sharing the automation across repositories&lt;/li&gt;
&lt;li&gt;The logic involves multiple jobs with dependencies between them&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Reach for a composite action when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You&#39;re sharing a sequence of steps within the same repository (or same workflow)&lt;/li&gt;
&lt;li&gt;The steps need access to the calling job&#39;s workspace, environment variables, or matrix context&lt;/li&gt;
&lt;li&gt;You want the steps to appear inline in the calling job — same status check, same log view&lt;/li&gt;
&lt;li&gt;You&#39;re building a reusable action you&#39;ll publish to the GitHub Marketplace&lt;/li&gt;
&lt;li&gt;Keeping the calling workflow&#39;s total job count low matters for readability&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One rule of thumb that holds up: if you&#39;re thinking &amp;quot;I want this to look like a step,&amp;quot; use a composite action. If you&#39;re thinking &amp;quot;I want this to look like a job,&amp;quot; use a reusable workflow.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Side-by-Side Reference&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Reusable Workflow&lt;/th&gt;
&lt;th&gt;Composite Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Invoked at&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jobs:&lt;/code&gt; level (&lt;code&gt;uses:&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;steps:&lt;/code&gt; level (&lt;code&gt;uses:&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runs on&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Its own runner&lt;/td&gt;
&lt;td&gt;Calling job&#39;s runner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Appears in UI as&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Separate job(s)&lt;/td&gt;
&lt;td&gt;Steps within calling job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Status check name&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;caller-job&amp;gt; / &amp;lt;rw-job&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same as calling job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Secrets access&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Via &lt;code&gt;secrets:&lt;/code&gt; or &lt;code&gt;secrets: inherit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Must pass via &lt;code&gt;with:&lt;/code&gt; inputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Calling job&#39;s env vars&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not inherited&lt;/td&gt;
&lt;td&gt;Inherited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Calling job&#39;s workspace&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not shared&lt;/td&gt;
&lt;td&gt;Shared&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Matrix context&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not inherited; pass via &lt;code&gt;inputs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inherited (&lt;code&gt;${{ matrix.* }}&lt;/code&gt; works)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cross-repo use&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (if published or referenced by path)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;outputs&lt;/code&gt; support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (workflow-level outputs)&lt;/td&gt;
&lt;td&gt;Yes (action-level outputs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multiple jobs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes, with &lt;code&gt;needs:&lt;/code&gt; chains&lt;/td&gt;
&lt;td&gt;No (steps only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;strategy.matrix&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Definable inside the workflow&lt;/td&gt;
&lt;td&gt;N/A — runs within calling job&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Closing Thoughts&lt;/h2&gt;
&lt;p&gt;Reusable workflows and composite actions are not interchangeable. The GitHub documentation groups them under &amp;quot;reusing workflows&amp;quot; in a way that makes them look like two flavors of the same thing. They&#39;re not. One is a job abstraction; the other is a step abstraction. That difference determines everything: how secrets flow, how status checks are named, how matrix strategies compose, and where logs appear.&lt;/p&gt;
&lt;p&gt;The three failure scenarios in this post — the disappearing secret, the matrix naming problem, and the missing status check — don&#39;t show up as actionable errors. They show up as empty strings, confusing UI, and security gates that quietly stop working. The fix is always the same: understand which layer you&#39;re operating at and choose the abstraction that matches.&lt;/p&gt;
&lt;p&gt;If you&#39;re auditing existing workflows for these issues, start with branch protection. Pull your required status check names, run a recent workflow, and verify every required check name appears somewhere in the checks list. If anything is missing, you&#39;ve found a silent bypass. That&#39;s the one worth fixing first.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Have questions about structuring your GitHub Actions pipelines, or want help auditing your branch protection rules? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Reusable workflows and composite actions solve different problems — understand the secret-passing rules, matrix scoping, and status-check semantics before you pick one.</summary>
    <category term="github-actions"/>
    <category term="ci-cd"/>
    <category term="devops"/>
  </entry>
  <entry>
    <title>Deploying to GitHub Pages with GitHub Actions: Beyond the Defaults</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-03-18-deploying-to-github-pages-beyond-the-defaults/"/>
    <updated>2026-03-18T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-03-18-deploying-to-github-pages-beyond-the-defaults/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Most tutorials for deploying to GitHub Pages start with &lt;code&gt;peaceiris/actions-gh-pages&lt;/code&gt; or the GitHub UI&#39;s auto-generated workflow. Both work. Neither is production-grade. The problems are predictable: every run reinstalls all npm packages from scratch, build artifacts persist indefinitely against your storage quota, and the site goes live on every push to &lt;code&gt;main&lt;/code&gt; with no human gate between &amp;quot;CI passed&amp;quot; and &amp;quot;it&#39;s in front of users.&amp;quot;&lt;/p&gt;
&lt;p&gt;The official &lt;code&gt;actions/deploy-pages&lt;/code&gt; action — introduced in 2022 and now the GitHub-recommended approach — solves most of this. But using it correctly means understanding OIDC token authentication, the artifact lifecycle, and how GitHub Environments create a reviewable deployment gate. This post builds the full production pipeline, step by step, for an Eleventy + Tailwind CSS site.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What the Default Workflow Gets Wrong&lt;/h2&gt;
&lt;p&gt;Before the fix, the failure list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No caching&lt;/strong&gt;: every run reinstalls all npm packages from scratch, adding 60–90 seconds to every deploy&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Broad token permissions&lt;/strong&gt;: classic &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;-based deploys grant write access to the entire repository context; OIDC-based deployment scopes that to the Pages deployment specifically&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No environment protection&lt;/strong&gt;: the site deploys directly on every push to &lt;code&gt;main&lt;/code&gt; — no reviewer gate, no way to stop a bad deploy before it goes live&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Artifact leakage&lt;/strong&gt;: &lt;code&gt;actions/upload-pages-artifact&lt;/code&gt; defaults to a 90-day retention window; a blog with daily publishing accumulates artifacts fast against your GitHub storage quota&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;gh-pages&lt;/code&gt; branch pollution&lt;/strong&gt;: the &lt;code&gt;peaceiris&lt;/code&gt; approach writes a separate &lt;code&gt;gh-pages&lt;/code&gt; branch — another moving part to maintain, rebase on, and reason about when something goes wrong&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;The Build This Pipeline Serves&lt;/h2&gt;
&lt;p&gt;This blog — and the workflow in this post — runs on a specific stack. If you&#39;re on the same one, you can drop this directly into your repo.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Eleventy v2&lt;/strong&gt; (&lt;code&gt;@11ty/eleventy&lt;/code&gt;) — static site generator, outputs to &lt;code&gt;_site/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS v3&lt;/strong&gt; (&lt;code&gt;tailwindcss&lt;/code&gt;) — utility-first CSS, built as a separate step&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;npm-run-all&lt;/code&gt;&lt;/strong&gt; — used to run Eleventy and Tailwind in parallel during development (&lt;code&gt;npm run dev&lt;/code&gt;), sequentially for production&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The relevant scripts from &lt;code&gt;package.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;scripts&amp;quot;: {
    &amp;quot;build&amp;quot;: &amp;quot;npx @11ty/eleventy&amp;quot;,
    &amp;quot;build:css&amp;quot;: &amp;quot;npx tailwindcss -i ./src/styles/input.css -o ./_site/styles/output.css --minify&amp;quot;,
    &amp;quot;deploy&amp;quot;: &amp;quot;npm run build &amp;amp;&amp;amp; npm run build:css&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;deploy&lt;/code&gt; script runs &lt;code&gt;build&lt;/code&gt; first, then &lt;code&gt;build:css&lt;/code&gt;. Order matters here: Eleventy creates the &lt;code&gt;_site/&lt;/code&gt; directory, and &lt;code&gt;build:css&lt;/code&gt; writes its output directly into &lt;code&gt;_site/styles/&lt;/code&gt;. Running them in parallel with &lt;code&gt;npm-run-all --parallel&lt;/code&gt; risks a race condition where Tailwind tries to write before &lt;code&gt;_site/&lt;/code&gt; exists. The &lt;code&gt;deploy&lt;/code&gt; script gets this right — use it instead of calling the steps individually.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 1: Configure GitHub Pages to Use the Actions Source&lt;/h2&gt;
&lt;p&gt;Before any workflow will work, GitHub Pages must be configured to deploy from GitHub Actions rather than from a branch. The default is branch-based (&lt;code&gt;gh-pages&lt;/code&gt;), and &lt;code&gt;actions/deploy-pages&lt;/code&gt; silently does nothing if you&#39;ve left it there.&lt;/p&gt;
&lt;p&gt;Go to &lt;strong&gt;Repository Settings → Pages → Build and deployment → Source&lt;/strong&gt; and select &lt;strong&gt;GitHub Actions&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That&#39;s the only UI change required. Everything else is workflow config.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 2: OIDC Authentication — What It Is and Why It Matters&lt;/h2&gt;
&lt;p&gt;The deployment permissions block that shows up in every &lt;code&gt;deploy-pages&lt;/code&gt; example deserves an explanation, not just a copy-paste:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;permissions:
  contents: read    # Read the repo to build it
  pages: write      # Write to GitHub Pages
  id-token: write   # Request an OIDC token for deployment authentication
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;OIDC&lt;/strong&gt; (OpenID Connect) is the mechanism GitHub Actions uses to issue short-lived, scoped tokens at runtime. When &lt;code&gt;actions/deploy-pages&lt;/code&gt; runs, it requests an OIDC token from GitHub&#39;s identity provider — a token that is scoped specifically to a Pages deployment for this workflow run, on this repository, in this environment. The token expires when the run completes.&lt;/p&gt;
&lt;p&gt;The alternative — using a static &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; or a Personal Access Token stored as a repository secret — grants broader permissions that persist indefinitely, require rotation, and are exposed in your secrets store. With OIDC there is nothing to rotate, nothing to store, and nothing to leak. The &lt;code&gt;id-token: write&lt;/code&gt; permission is what allows the workflow to request this token.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 3: Dependency Caching&lt;/h2&gt;
&lt;p&gt;The single change with the highest return on effort. &lt;code&gt;actions/setup-node&lt;/code&gt; supports built-in npm caching:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: actions/setup-node@v4
  with:
    node-version: &#39;20&#39;
    cache: &#39;npm&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With &lt;code&gt;cache: &#39;npm&#39;&lt;/code&gt;, the action manages a cache keyed on the hash of your &lt;code&gt;package-lock.json&lt;/code&gt;. When the lockfile hasn&#39;t changed — which is true for the vast majority of content-only commits on a blog — the cache is hit and the &lt;code&gt;npm ci&lt;/code&gt; install step takes seconds instead of a minute. When you do update dependencies, the lockfile changes, the cache key changes, and a fresh install populates the new cache.&lt;/p&gt;
&lt;p&gt;For teams with monorepos or custom cache locations, the manual &lt;code&gt;actions/cache@v4&lt;/code&gt; approach gives you full control:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Cache npm dependencies
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles(&#39;package-lock.json&#39;) }}
    restore-keys: |
      ${{ runner.os }}-npm-
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a single-package repo like this one, &lt;code&gt;cache: &#39;npm&#39;&lt;/code&gt; in &lt;code&gt;setup-node&lt;/code&gt; is equivalent and cleaner.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 4: Building the Site&lt;/h2&gt;
&lt;p&gt;The build job checks out the code, installs dependencies with &lt;code&gt;npm ci&lt;/code&gt; (not &lt;code&gt;npm install&lt;/code&gt; — &lt;code&gt;ci&lt;/code&gt; respects the lockfile exactly and fails if it&#39;s out of sync), runs the production build, and uploads the artifact:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;build:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4

    - uses: actions/setup-node@v4
      with:
        node-version: &#39;20&#39;
        cache: &#39;npm&#39;

    - name: Install dependencies
      run: npm ci

    - name: Build Eleventy site and Tailwind CSS
      run: npm run deploy

    - name: Upload Pages artifact
      uses: actions/upload-pages-artifact@v3
      with:
        path: _site
        retention-days: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;retention-days: 1&lt;/code&gt; on the artifact upload is the cleanup fix. The artifact only needs to survive long enough for the &lt;code&gt;deploy&lt;/code&gt; job to consume it in the same workflow run — typically minutes. After that it has no value. The default is 90 days. For a blog with regular publishing, that accumulates fast against your GitHub storage quota. One day is the right number here.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 5: Deploying with Environment Protection&lt;/h2&gt;
&lt;p&gt;The deploy job is where the environment gate comes in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;deploy:
  needs: build
  runs-on: ubuntu-latest
  environment:
    name: github-pages
    url: ${{ steps.deployment.outputs.page_url }}
  permissions:
    pages: write
    id-token: write
  steps:
    - name: Deploy to GitHub Pages
      id: deployment
      uses: actions/deploy-pages@v4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;environment:&lt;/code&gt; block does two things. First, it connects this job to a &lt;strong&gt;GitHub Environment&lt;/strong&gt; — a named deployment target that can be configured with protection rules. Second, the &lt;code&gt;url:&lt;/code&gt; output from &lt;code&gt;actions/deploy-pages&lt;/code&gt; is automatically surfaced in the GitHub UI, linked from the deployment entry in the Actions run.&lt;/p&gt;
&lt;p&gt;The permissions here are scoped to this job only: &lt;code&gt;pages: write&lt;/code&gt; and &lt;code&gt;id-token: write&lt;/code&gt;. The top-level permissions for the workflow are set to &lt;code&gt;contents: read&lt;/code&gt;. The &lt;code&gt;build&lt;/code&gt; job never gets write access to Pages; the &lt;code&gt;deploy&lt;/code&gt; job never gets more than it needs. This is the principle of least privilege applied where it&#39;s cheapest — YAML.&lt;/p&gt;
&lt;h3&gt;Configuring the GitHub Environment&lt;/h3&gt;
&lt;p&gt;The environment protection rules live in the GitHub UI, not the workflow file. Navigate to &lt;strong&gt;Repository Settings → Environments → New environment&lt;/strong&gt; and name it &lt;code&gt;github-pages&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;From there, the two most useful controls:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Required reviewers&lt;/strong&gt;: add one or more people who must approve the deployment before the job proceeds. When a deployment is pending approval, the &lt;code&gt;deploy&lt;/code&gt; job pauses and GitHub sends a notification to the reviewers. The workflow waits — your site doesn&#39;t go live until someone explicitly approves it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deployment branch filter&lt;/strong&gt;: restrict deployments to the &lt;code&gt;main&lt;/code&gt; branch. This prevents accidental deploys from feature branches even if someone triggers a &lt;code&gt;workflow_dispatch&lt;/code&gt; from the wrong ref.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a personal site or solo project, required reviewers may be more friction than value. The deployment branch filter alone is a meaningful improvement — it eliminates the category of &amp;quot;I accidentally ran this from a branch that wasn&#39;t ready.&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Complete Workflow&lt;/h2&gt;
&lt;p&gt;All of it assembled:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Deploy to GitHub Pages

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: &#39;20&#39;
          cache: &#39;npm&#39;

      - name: Install dependencies
        run: npm ci

      - name: Build Eleventy site and Tailwind CSS
        run: npm run deploy

      - name: Upload Pages artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: _site
          retention-days: 1

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    permissions:
      pages: write
      id-token: write
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few design decisions worth calling out explicitly:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two-job structure.&lt;/strong&gt; &lt;code&gt;build&lt;/code&gt; produces the artifact; &lt;code&gt;deploy&lt;/code&gt; consumes it. If &lt;code&gt;build&lt;/code&gt; fails, &lt;code&gt;deploy&lt;/code&gt; never runs — there is no path from a broken build to a live deployment. The jobs are cleanly separated and could run on different runner types if needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;workflow_dispatch&lt;/code&gt;.&lt;/strong&gt; Allows manual triggering from the GitHub Actions UI, useful for redeploying after a config change, an environment tweak, or any situation where you want to redeploy without committing a change to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Top-level &lt;code&gt;permissions: contents: read&lt;/code&gt;.&lt;/strong&gt; This is the floor. Every job in this workflow inherits it unless they declare their own permissions block. The &lt;code&gt;deploy&lt;/code&gt; job adds &lt;code&gt;pages: write&lt;/code&gt; and &lt;code&gt;id-token: write&lt;/code&gt; at the job level — those permissions exist for that job only, not for &lt;code&gt;build&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;npm ci&lt;/code&gt; not &lt;code&gt;npm install&lt;/code&gt;.&lt;/strong&gt; Reproducible installs, lockfile-enforcing. If &lt;code&gt;package-lock.json&lt;/code&gt; diverges from &lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;npm ci&lt;/code&gt; fails loudly instead of silently mutating the install.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;PR Preview Deployments&lt;/h2&gt;
&lt;p&gt;GitHub Pages doesn&#39;t natively support per-PR preview URLs. If that&#39;s a requirement, two options:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cloudflare Pages or Netlify&lt;/strong&gt;: connect your repository and they handle PR preview URLs automatically, with zero workflow changes on your end. Each PR gets its own preview URL, and it tears down when the PR closes. For most teams, this is the right answer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Custom approach within GitHub Pages&lt;/strong&gt;: deploy to a path-prefixed URL per PR number on a separate branch, managed through workflow logic. More engineering work, stays entirely within GitHub, no third-party dependency. Worth it if GitHub Pages is a hard constraint; not worth it otherwise.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;GitHub Pages Deployment Checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Set Pages source to &lt;strong&gt;GitHub Actions&lt;/strong&gt; in Repository Settings — not a branch&lt;/li&gt;
&lt;li&gt;[ ] Use &lt;code&gt;actions/setup-node&lt;/code&gt; with &lt;code&gt;cache: &#39;npm&#39;&lt;/code&gt; — eliminates 60–90 seconds of install time on unchanged deps&lt;/li&gt;
&lt;li&gt;[ ] Run &lt;code&gt;npm ci&lt;/code&gt; not &lt;code&gt;npm install&lt;/code&gt; — reproducible, lockfile-respecting installs; fails loudly on lockfile drift&lt;/li&gt;
&lt;li&gt;[ ] Use &lt;code&gt;npm run deploy&lt;/code&gt; (not parallel dev scripts) — Eleventy must build &lt;code&gt;_site/&lt;/code&gt; before &lt;code&gt;build:css&lt;/code&gt; can write into it&lt;/li&gt;
&lt;li&gt;[ ] Set &lt;code&gt;retention-days: 1&lt;/code&gt; on the Pages artifact — it only needs to survive until the &lt;code&gt;deploy&lt;/code&gt; job runs in the same workflow&lt;/li&gt;
&lt;li&gt;[ ] Set top-level &lt;code&gt;permissions: contents: read&lt;/code&gt;; add &lt;code&gt;pages: write&lt;/code&gt; + &lt;code&gt;id-token: write&lt;/code&gt; only in the &lt;code&gt;deploy&lt;/code&gt; job&lt;/li&gt;
&lt;li&gt;[ ] Create a &lt;code&gt;github-pages&lt;/code&gt; Environment with a deployment branch filter set to &lt;code&gt;main&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] Add required reviewers to the Environment if the site is anything beyond a personal project&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;workflow_dispatch&lt;/code&gt; — allows redeployment without a code change&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;The gap between &amp;quot;it works&amp;quot; and &amp;quot;it&#39;s production-grade&amp;quot; for GitHub Pages is surprisingly small. Caching, least-privilege permissions, a one-day artifact lifecycle, and a deployment environment that can be gated — none of these are complex changes. Together they cut deploy time noticeably, close the OIDC security gap, and give you the ability to stop a bad deploy before it reaches users. For a personal blog or a small team site, this workflow is the right baseline — not over-engineered, but not leaving the obvious improvements on the table either.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions about GitHub Actions deployment pipelines, or want help adapting this for a monorepo or a different static site generator? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>The default GitHub Pages workflow skips caching, leaks artifacts, and has no deployment gate — this post rebuilds it from scratch with OIDC authentication, npm caching, and a reviewer-gated GitHub Environment.</summary>
    <category term="github-actions"/>
    <category term="eleventy"/>
    <category term="ci-cd"/>
  </entry>
  <entry>
    <title>Trunk-Based Development in Practice: What They Don&#39;t Tell You</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-03-20-trunk-based-development-in-practice/"/>
    <updated>2026-03-20T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-03-20-trunk-based-development-in-practice/</id>
    <content xml:lang="en" type="html">&lt;p&gt;The internet has no shortage of &amp;quot;trunk-based development is better than GitFlow&amp;quot; hot takes. They&#39;re not wrong, but they&#39;re not useful either. Teams read the post, nod along, rename their &lt;code&gt;develop&lt;/code&gt; branch to &lt;code&gt;main&lt;/code&gt;, and wonder two sprints later why nothing has changed. The abstract argument isn&#39;t the hard part. The hard part is the prerequisites — the tooling and cultural wiring that has to be in place before TBD actually works. Nobody writes about those.&lt;/p&gt;
&lt;p&gt;So let&#39;s do that instead.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why the Research Points Here&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Trunk-based development (TBD)&lt;/strong&gt; is the practice of integrating code to a shared mainline frequently — at minimum daily, ideally multiple times a day — rather than maintaining long-lived feature or release branches. It sounds simple. The implications are not.&lt;/p&gt;
&lt;p&gt;In &lt;em&gt;Accelerate&lt;/em&gt; (Nicole Forsgren, Jez Humble, Gene Kim), the authors analyzed four years of DORA survey data spanning thousands of organizations and found that trunk-based development is one of a small cluster of technical practices that statistically separates elite software delivery performers from everyone else. Elite performers — the cohort deploying on demand, with lead times under an hour and change failure rates under 15% — almost universally practice TBD. It shows up alongside continuous integration, comprehensive test automation, and loosely coupled architecture as a predictor of both delivery throughput &lt;em&gt;and&lt;/em&gt; stability.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;High performers were more likely to practice trunk-based development, have fewer than three active branches, and merge to trunk daily.&amp;quot; — &lt;em&gt;Accelerate&lt;/em&gt;, Forsgren et al.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The data point that tends to surprise people: TBD is correlated with &lt;em&gt;both&lt;/em&gt; speed and reliability. The instinct is to assume that committing often to a shared branch increases instability. The research says the opposite. Long-lived branches accumulate integration debt that gets paid — with interest — at merge time. The longer you wait to integrate, the more expensive it gets.&lt;/p&gt;
&lt;p&gt;That&#39;s the theory. Here&#39;s what it takes to actually do it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What TBD Actually Requires&lt;/h2&gt;
&lt;h3&gt;Feature Flags as a First-Class Citizen&lt;/h3&gt;
&lt;p&gt;The most common objection to TBD is: &amp;quot;What do we do with work that isn&#39;t ready for production?&amp;quot; The answer is &lt;strong&gt;feature flags&lt;/strong&gt;, and if you don&#39;t have them, you don&#39;t have TBD — you have wishful thinking.&lt;/p&gt;
&lt;p&gt;The model is simple: code that isn&#39;t ready for users still ships to production. It just ships behind a flag that keeps it dark. This decouples &lt;em&gt;deployment&lt;/em&gt; (getting code onto servers) from &lt;em&gt;release&lt;/em&gt; (exposing it to users). Once that mental model clicks, a lot of the fear around TBD dissolves.&lt;/p&gt;
&lt;p&gt;Not all flags are the same. There are three types worth distinguishing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Release toggles&lt;/strong&gt; are long-lived flags that gate an unreleased feature. They&#39;re the most common, and the most abused.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ops toggles&lt;/strong&gt; are runtime switches — circuit breakers, kill switches for expensive features under load. These have a legitimate long lifespan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Experiment toggles&lt;/strong&gt; are A/B test controls. They&#39;re tied to a hypothesis with a defined end date.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A minimal flag pattern doesn&#39;t require LaunchDarkly or a feature management platform. A config value or environment variable will do for early-stage work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// config.ts
export const flags = {
  newCheckoutFlow: process.env.FEATURE_NEW_CHECKOUT === &amp;quot;true&amp;quot;,
};

// checkout.ts
import { flags } from &amp;quot;./config&amp;quot;;

function renderCheckout(user: User) {
  if (flags.newCheckoutFlow) {
    return renderNewCheckout(user);
  }
  return renderLegacyCheckout(user);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The pattern is trivial. The discipline is not. &lt;strong&gt;Flag lifecycle&lt;/strong&gt; is where teams get into trouble. Flags accumulate. Developers ship behind a flag, the feature launches, and the flag never gets removed. Six months later you have 40 flags controlling behavior that shipped a year ago, and nobody is confident about what happens if you toggle one. Treat flags like debt: every flag you create should have a removal ticket filed the day it ships to production. Make &amp;quot;remove old flags&amp;quot; a recurring part of your sprint.&lt;/p&gt;
&lt;h3&gt;Database Migrations Without Long-Lived Branches&lt;/h3&gt;
&lt;p&gt;Schema changes are the hardest part of TBD to get right, and the one most tutorials skip. The problem is classic: you need to rename a column, but the current production code still reads the old column name. If you deploy the migration before the application code, production breaks. If you merge the application code first, it breaks because the column doesn&#39;t exist yet. Long-lived branches &amp;quot;solve&amp;quot; this by bundling both changes together — and that solution is exactly what TBD rules out.&lt;/p&gt;
&lt;p&gt;The answer is the &lt;strong&gt;expand/contract pattern&lt;/strong&gt;, also called parallel change. Instead of making a breaking schema change in one step, you split it into three phases deployed across separate releases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 — Expand:&lt;/strong&gt; Add the new column alongside the old one. Deploy application code that &lt;em&gt;writes to both&lt;/em&gt; and reads from the old column. At this point, both versions of the code are compatible with the schema.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Migration 001: Add the new column (non-breaking)
ALTER TABLE orders ADD COLUMN customer_reference VARCHAR(255);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 — Migrate and cut over:&lt;/strong&gt; Deploy application code that reads from the &lt;em&gt;new&lt;/em&gt; column. Run a backfill to populate the new column for existing rows. Both the old and new column still exist — a rollback is still safe.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Migration 002: Backfill existing rows
UPDATE orders SET customer_reference = order_ref WHERE customer_reference IS NULL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Phase 3 — Contract:&lt;/strong&gt; Once you&#39;re confident the new column is correct and the old column is no longer read anywhere in production code, drop it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Migration 003: Drop the old column (safe to run after code is fully deployed)
ALTER TABLE orders DROP COLUMN order_ref;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is not glamorous, but it is safe. It also means you can deploy at any of these phases independently, which is exactly what TBD demands.&lt;/p&gt;
&lt;h3&gt;The Minimum CI Gate&lt;/h3&gt;
&lt;p&gt;TBD has exactly one non-negotiable: &lt;strong&gt;trunk is always deployable&lt;/strong&gt;. If you can&#39;t guarantee that, the whole model breaks down. The mechanism that enforces it is your CI pipeline.&lt;/p&gt;
&lt;p&gt;Every commit to &lt;code&gt;main&lt;/code&gt; must run your test suite and block merge on failure. That&#39;s table stakes. The less obvious constraint is speed. The target is &lt;strong&gt;under 10 minutes&lt;/strong&gt;. This is not arbitrary. When a pipeline takes 30 minutes, developers stop waiting for it. They queue up another change, or they start multitasking, or they just merge and hope. The feedback loop breaks. Small batches accumulate. You&#39;re back to GitFlow behavior with a different branch name.&lt;/p&gt;
&lt;p&gt;Here&#39;s a minimal GitHub Actions workflow that enforces this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: &amp;quot;20&amp;quot;
          cache: &amp;quot;npm&amp;quot;

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run lint
        run: npm run lint
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;timeout-minutes: 10&lt;/code&gt; is doing real work here — it enforces the discipline in code, not just policy. If your test suite is already over 10 minutes, parallelizing test execution and aggressively culling slow integration tests is the first investment you need to make before TBD is viable.&lt;/p&gt;
&lt;h3&gt;Short-Lived Branches (If You Use Branches at All)&lt;/h3&gt;
&lt;p&gt;TBD does not require that every developer commits directly to &lt;code&gt;main&lt;/code&gt;. Short-lived feature branches with pull requests are fine — and for most teams, preferable. The rule is: &lt;strong&gt;a branch that lives longer than one day is a risk.&lt;/strong&gt; A branch that lives longer than a week is a problem.&lt;/p&gt;
&lt;p&gt;The target is branches that represent a few hours of work, get reviewed, and merge the same day. When a task is genuinely larger than that, the skill to develop is decomposition — breaking the work into independently mergeable slices, each behind a feature flag if needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stacked PRs&lt;/strong&gt; are a technique worth knowing here. Instead of one massive PR that touches the data layer, API layer, and UI, you create three PRs where each one builds on the previous. PR 1 merges first. PR 2 is rebased on top of it. PR 3 is rebased on PR 2. Each is small and reviewable. The stack merges in order over the course of a day. This is how you do large changes without long-lived branches.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;How to Talk Your Team Out of GitFlow&lt;/h2&gt;
&lt;p&gt;Don&#39;t argue abstractions. &amp;quot;Trunk-based development has better research support&amp;quot; will not move anyone who has spent three years on a team where GitFlow worked fine. Argue consequences.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Long-lived branches create merge conflicts.&lt;/strong&gt; Merge conflicts are not a technical nuisance — they are lost time, and they compound. A branch that was one day of work becomes two days when you factor in the merge and the re-testing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitFlow&#39;s release branch is solving the wrong problem.&lt;/strong&gt; The &lt;code&gt;release/2.4.1&lt;/code&gt; branch exists to stabilize code before it ships. TBD solves the same problem differently: with a CI pipeline that keeps main stable, and feature flags that let you exclude unready work. The stabilization is continuous, not batch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The hotfix question.&lt;/strong&gt; Teams always ask this one: &amp;quot;What about hotfixes? We need a way to patch production without shipping everything in develop.&amp;quot; This is a legitimate scenario. TBD handles it better, not worse. If &lt;code&gt;main&lt;/code&gt; is always deployable, a hotfix is just: commit the fix to main, deploy. There&#39;s no &lt;code&gt;hotfix/&lt;/code&gt; branch to create, no cherry-pick into &lt;code&gt;develop&lt;/code&gt;, no cherry-pick into &lt;code&gt;main&lt;/code&gt;. The ceremony GitFlow adds for hotfixes is ceremony that only exists because GitFlow made the process complicated in the first place.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The migration path.&lt;/strong&gt; Don&#39;t try to flip a team from GitFlow to TBD overnight. Start with one metric: branch lifetime. Track how long the average branch lives from creation to merge. Make it visible. Set a goal. Start pushing toward same-day merges. That single habit change will surface all the tooling gaps — missing feature flags, slow pipelines, large PRs — and give you a concrete agenda for fixing them. Branch lifetime is the leading indicator for everything else.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Minimum GitHub Setup for TBD&lt;/h2&gt;
&lt;p&gt;The tooling that enforces TBD practices in GitHub is &lt;strong&gt;branch protection rules&lt;/strong&gt; (or the newer rulesets for organizations). Here&#39;s the minimum configuration that makes the model work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Equivalent repository ruleset (GitHub API / terraform-github-provider)
ruleset:
  name: &amp;quot;Trunk Protection&amp;quot;
  target: branch
  enforcement: active
  conditions:
    ref_include: [&amp;quot;~DEFAULT_BRANCH&amp;quot;]
  rules:
    - type: required_status_checks
      parameters:
        strict_required_status_checks_policy: true  # branch must be up to date
        required_status_checks:
          - context: &amp;quot;CI / test&amp;quot;
          - context: &amp;quot;CI / lint&amp;quot;
    - type: pull_request
      parameters:
        required_approving_review_count: 1
        dismiss_stale_reviews_on_push: true
    - type: non_fast_forward          # no force-pushes to main
    - type: deletion                  # can&#39;t delete main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you prefer GitHub UI, the key settings are: &lt;strong&gt;Require status checks to pass before merging&lt;/strong&gt;, &lt;strong&gt;Require branches to be up to date before merging&lt;/strong&gt;, and &lt;strong&gt;Require a pull request before merging&lt;/strong&gt;. Enable &lt;strong&gt;Automatically delete head branches&lt;/strong&gt; at the repository level to keep the branch list clean.&lt;/p&gt;
&lt;p&gt;One opinion worth taking: &lt;strong&gt;include administrators in the restriction&lt;/strong&gt;. The &amp;quot;bypass for admins&amp;quot; escape hatch gets used. When it does, it undermines the trust the CI gate is supposed to build. If the trunk is always deployable, there&#39;s no reason admins need to bypass it.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;TBD Readiness Checklist&lt;/h2&gt;
&lt;p&gt;Use this to assess whether your team has the prerequisites in place before making the switch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] CI pipeline completes in &lt;strong&gt;under 10 minutes&lt;/strong&gt; — if not, parallelization is the first project&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Feature flags&lt;/strong&gt; exist for in-progress or unreleased work — code ships dark&lt;/li&gt;
&lt;li&gt;[ ] Database migrations follow the &lt;strong&gt;expand/contract pattern&lt;/strong&gt; — no single-step breaking changes&lt;/li&gt;
&lt;li&gt;[ ] Branches are &lt;strong&gt;deleted within 24 hours&lt;/strong&gt; of creation — track this as a team metric&lt;/li&gt;
&lt;li&gt;[ ] Every merge to main &lt;strong&gt;triggers a deployment&lt;/strong&gt; (to at least a staging environment)&lt;/li&gt;
&lt;li&gt;[ ] Developers are comfortable &lt;strong&gt;committing incomplete work behind a flag&lt;/strong&gt; — this is the cultural shift&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If more than two of these are unchecked, start there before changing your branching strategy. The tools have to be in place before the practice is safe.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;The One Thing to Do First&lt;/h2&gt;
&lt;p&gt;TBD isn&#39;t hard because of Git. Git is fine. It&#39;s hard because it exposes every gap in your delivery pipeline and makes every cultural shortcut visible. Teams that succeed treat it as an engineering practice with prerequisites — not a branching strategy you adopt by announcing it in a team meeting.&lt;/p&gt;
&lt;p&gt;If you&#39;re starting from GitFlow, the single change with the most leverage is this: &lt;strong&gt;stop creating branches that last more than a day.&lt;/strong&gt; Not as a rule you enforce immediately, but as a target you start measuring toward. That one constraint will surface the flag infrastructure you need, the pipeline speed you&#39;re missing, and the decomposition skills your team hasn&#39;t had to develop yet. Fix those, and the rest follows.&lt;/p&gt;
&lt;p&gt;The research is clear on where this leads. The path there is less a strategy swap and more an engineering discipline you build one merged PR at a time.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Want to talk through a TBD migration for your team, or figure out where to start? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Trunk-based development promises elite software delivery performance, but most adoption attempts fail on unspoken prerequisites — feature flags, expand/contract migrations, and a CI pipeline that earns trust.</summary>
    <category term="devops"/>
    <category term="ci-cd"/>
    <category term="developer-productivity"/>
  </entry>
  <entry>
    <title>The GitHub Actions `permissions` Block: Principle of Least Privilege for Workflows</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-03-25-github-actions-permissions-block/"/>
    <updated>2026-03-25T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-03-25-github-actions-permissions-block/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Every time a GitHub Actions workflow runs, GitHub provisions a &lt;strong&gt;&lt;code&gt;GITHUB_TOKEN&lt;/code&gt;&lt;/strong&gt; automatically — a short-lived credential scoped to the repository. You don&#39;t create it, rotate it, or store it as a secret. It just appears. What most developers don&#39;t realize is what that token can do by default: write to repository contents, open and merge pull requests, push packages, create deployments, manage releases, and more. All of it, unless you say otherwise. The default exists because GitHub designed it for ease of adoption — get a workflow running without thinking about permissions. That&#39;s reasonable for a first prototype. It&#39;s a real problem for anything that runs in production.&lt;/p&gt;
&lt;p&gt;The attack surface is concrete. A compromised dependency in a build step. A malicious action injected through a supply-chain attack. A command injection vulnerability in an untrusted PR title. Any of these can use the workflow&#39;s default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; to read secrets, push code, or overwrite a release. Not because the workflow was misconfigured. Because the default is permissive and nobody added the &lt;code&gt;permissions&lt;/code&gt; block.&lt;/p&gt;
&lt;p&gt;The fix is three to six lines of YAML. The return on investment is not subtle.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What the Default Permissions Actually Are&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;By default, when the &lt;code&gt;permissions&lt;/code&gt; key is absent from a workflow, GitHub Actions grants write access to most token scopes when the workflow is triggered by an event on the default branch. Workflows triggered by pull requests from forks get read-only by default — but that&#39;s a different default, and it applies only to that specific case.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here are the actual scopes that &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; receives when you don&#39;t specify a &lt;code&gt;permissions&lt;/code&gt; block:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Default (non-fork)&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;actions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Manage workflow runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Create and update check runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Read/write repo contents, create commits and branches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deployments&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Create deployments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id-token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;Request OIDC tokens — must be explicitly opted in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;issues&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Create and update issues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;packages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Push packages to GitHub Packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Manage GitHub Pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pull-requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Open, edit, and merge pull requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repository-projects&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Manage projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;security-events&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Upload SARIF results, manage Dependabot alerts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;statuses&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;Set commit statuses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Notice &lt;code&gt;id-token&lt;/code&gt;: it is the one scope that is &lt;em&gt;not&lt;/em&gt; granted by default. Everything else in this table is write-enabled unless you turn it off. A workflow that runs unit tests needs one of these — &lt;code&gt;checks: write&lt;/code&gt; to post test results, or sometimes nothing at all. It has all of them.&lt;/p&gt;
&lt;p&gt;The practical implication: if your test workflow checks out code, installs dependencies from npm or PyPI, and runs tests, every package in your transitive dependency tree is running code inside a process that holds a token with write access to your repository. That&#39;s the blast radius. It exists whether or not anyone intended it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Workflow-Level vs. Job-Level Permissions&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;permissions&lt;/code&gt; block can appear at two places in a workflow file. Understanding both is necessary to use it correctly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Workflow-level&lt;/strong&gt; permissions sit at the top of the file, under the &lt;code&gt;on:&lt;/code&gt; block. They establish a baseline that every job in the workflow inherits unless a job explicitly overrides them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: CI
on: [push]

permissions:
  contents: read
  checks: write

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci &amp;amp;&amp;amp; npm test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Job-level&lt;/strong&gt; permissions sit inside a specific job and override the workflow baseline for that job only. This lets different jobs in the same workflow operate with different scopes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      checks: write
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pages: write
      id-token: write
    steps:
      - uses: actions/deploy-pages@v4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The correct pattern for any workflow with more than one job — or any workflow where you care about security at all — is to set &lt;code&gt;permissions: {}&lt;/code&gt; at the workflow level and then declare exactly what each job needs at the job level:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: CI
on: [push]

permissions: {}  # zero baseline — every job must declare what it needs

jobs:
  test:
    permissions:
      contents: read
      checks: write
    runs-on: ubuntu-latest
    steps:
      ...

  deploy:
    permissions:
      pages: write
      id-token: write
    runs-on: ubuntu-latest
    steps:
      ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The empty object &lt;code&gt;{}&lt;/code&gt; grants zero permissions. Any job added later starts with nothing and will fail visibly in CI if it uses a token operation it hasn&#39;t been granted. That failure in CI is strictly preferable to silently holding permissions that were never intended.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Three Real Workflow Scenarios&lt;/h2&gt;
&lt;h3&gt;Scenario 1: Run Tests and Post Results&lt;/h3&gt;
&lt;p&gt;A test workflow needs two things: to read the repository code (&lt;code&gt;contents: read&lt;/code&gt;) and to post check results (&lt;code&gt;checks: write&lt;/code&gt;). That&#39;s the complete list.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Test
on: [push, pull_request]

permissions: {}

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      checks: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What this workflow does not have: write access to repository contents, issues, pull requests, packages, or anything else. A compromised dependency in &lt;code&gt;npm ci&lt;/code&gt; or &lt;code&gt;npm test&lt;/code&gt; cannot push a commit, open a PR, or modify a release with this configuration. The blast radius is contained to the job&#39;s declared scope.&lt;/p&gt;
&lt;h3&gt;Scenario 2: Comment on a Pull Request&lt;/h3&gt;
&lt;p&gt;A workflow that posts a comment on a PR — a code coverage summary, a preview URL, a diff report — needs &lt;code&gt;pull-requests: write&lt;/code&gt;. It still does not need &lt;code&gt;contents: write&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Coverage Report
on: pull_request

permissions: {}

jobs:
  coverage:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - run: npm ci &amp;amp;&amp;amp; npm run test:coverage
      - uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: &#39;## Coverage: 94.2%&#39;
            })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing worth calling out explicitly: commenting on a pull request uses &lt;code&gt;pull-requests: write&lt;/code&gt;, not &lt;code&gt;issues: write&lt;/code&gt;. Pull requests and issues share an API in GitHub — a PR is technically an issue — but they are separate &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; scopes. Grant only &lt;code&gt;pull-requests: write&lt;/code&gt;; &lt;code&gt;issues: write&lt;/code&gt; gives the workflow access to create and modify issues across the repository.&lt;/p&gt;
&lt;h3&gt;Scenario 3: Deploy to GitHub Pages with OIDC&lt;/h3&gt;
&lt;p&gt;This scenario requires the most permissions, which makes it the most important one to scope correctly. A misconfigured deploy workflow with an overly broad &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; can modify branches, overwrite releases, or interact with packages — none of which a Pages deployment needs.&lt;/p&gt;
&lt;p&gt;The correct approach splits build and deploy into separate jobs, each with only what it needs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Deploy
on:
  push:
    branches: [main]

permissions: {}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm ci &amp;amp;&amp;amp; npm run build
      - uses: actions/upload-pages-artifact@v3
        with:
          path: _site/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deploy.outputs.page_url }}
    permissions:
      pages: write
      id-token: write
    steps:
      - uses: actions/deploy-pages@v4
        id: deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;id-token: write&lt;/code&gt; scope deserves special attention here. It is the one scope in the permissions table that is &lt;strong&gt;not&lt;/strong&gt; granted by default and must be explicitly declared. It authorizes the workflow to request an OIDC token from GitHub — the short-lived, keyless credential used for authentication with GitHub Pages and cloud providers. Without &lt;code&gt;id-token: write&lt;/code&gt;, OIDC-based deployments fail. The error messages are not always clear about why. When a Pages or cloud deploy workflow silently fails to authenticate, the missing &lt;code&gt;id-token: write&lt;/code&gt; permission is the first thing to check.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The &lt;code&gt;permissions: {}&lt;/code&gt; Pattern — Zero Baseline&lt;/h2&gt;
&lt;p&gt;There are three ways to handle the workflow-level &lt;code&gt;permissions&lt;/code&gt; block, and they are not equivalent:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Inherits GitHub&#39;s permissive defaults — write access to almost everything
name: Dangerous Workflow
on: [push]
# no permissions key

---

# Better — grants read access to all scopes; still broader than necessary
name: Less Dangerous Workflow
on: [push]
permissions: read-all

---

# Correct — jobs declare exactly what they need, nothing is inherited
name: Correct Workflow
on: [push]
permissions: {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;read-all&lt;/code&gt; shorthand is a common stopping point for teams that know they should restrict permissions but aren&#39;t ready to audit each job. It meaningfully reduces the write blast radius. But &lt;code&gt;read&lt;/code&gt; access to &lt;code&gt;contents&lt;/code&gt; still means any step in the workflow can read the full repository source, read secrets exposed as environment variables via &lt;code&gt;env:&lt;/code&gt;, and exfiltrate data to an external endpoint. Read-only is not zero. &lt;code&gt;permissions: {}&lt;/code&gt; is zero.&lt;/p&gt;
&lt;p&gt;The other reason the zero baseline matters: it makes security visible in code review. When a developer adds a new job that calls &lt;code&gt;softprops/action-gh-release&lt;/code&gt; to create a release, and the workflow has &lt;code&gt;permissions: {}&lt;/code&gt; at the top, the CI run will fail immediately with a 403. The review conversation becomes &amp;quot;this job needs &lt;code&gt;contents: write&lt;/code&gt; to create a release — is that the right tool for this workflow?&amp;quot; instead of &amp;quot;the release job works, ship it.&amp;quot; The failure surface in CI is the faster feedback loop.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Organization-Level Defaults&lt;/h2&gt;
&lt;p&gt;Individual workflow &lt;code&gt;permissions&lt;/code&gt; blocks are the most important control — but GitHub also allows setting a default permissions policy at the organization level. Navigate to &lt;strong&gt;Settings → Actions → Workflow permissions&lt;/strong&gt; at the org level and you&#39;ll find two options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&amp;quot;Read and write permissions&amp;quot;&lt;/strong&gt; — the default for most organizations, grants write access to most scopes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;quot;Read repository contents and packages permissions&amp;quot;&lt;/strong&gt; — grants read-only by default&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Set the org default to read-only. This doesn&#39;t replace per-workflow &lt;code&gt;permissions&lt;/code&gt; blocks — those override the org default and should still be explicit — but it reduces the blast radius for any workflow file in any repository in the org that is missing its &lt;code&gt;permissions&lt;/code&gt; block entirely. In a large organization with dozens of repositories and workflows, that gap is not hypothetical.&lt;/p&gt;
&lt;p&gt;For organizations using GitHub Enterprise or GitHub Advanced Security, this setting is often the fastest compliance win available: one checkbox that immediately restricts the default token scope across the entire org, with no workflow changes required.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Auditing Existing Workflows&lt;/h2&gt;
&lt;p&gt;Before adding &lt;code&gt;permissions&lt;/code&gt; blocks to new workflows, it&#39;s worth knowing which existing workflows don&#39;t have them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Manual scan&lt;/strong&gt; — find workflow files with no &lt;code&gt;permissions&lt;/code&gt; key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rL &amp;quot;^permissions:&amp;quot; .github/workflows/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This outputs every workflow file in &lt;code&gt;.github/workflows/&lt;/code&gt; that has no &lt;code&gt;permissions&lt;/code&gt; declaration at all. Each result is a workflow running on GitHub&#39;s permissive defaults.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Using &lt;code&gt;step-security/harden-runner&lt;/code&gt;&lt;/strong&gt; — for determining what permissions a workflow actually uses before committing to a minimal set:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: step-security/harden-runner@v2
  with:
    egress-policy: audit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Harden-runner logs all outbound network calls and the permissions the workflow actually exercises during a run. Run it in audit mode for a few cycles before adding a &lt;code&gt;permissions&lt;/code&gt; block — it tells you the minimal set you need rather than requiring you to read every action&#39;s documentation to figure it out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Using &lt;code&gt;actionlint&lt;/code&gt;&lt;/strong&gt; — static analysis for GitHub Actions workflows:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install and run actionlint
brew install actionlint
actionlint .github/workflows/*.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;actionlint&lt;/code&gt; catches a broad range of workflow issues including type mismatches, invalid expressions, and — with the right configuration — jobs without explicit permission declarations. It&#39;s the fastest way to get a baseline audit across all workflows in a repository.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;Permissions Quick Reference&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Minimum permissions needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Checkout and build&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contents: read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run tests, post check results&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contents: read&lt;/code&gt;, &lt;code&gt;checks: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comment on a PR&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contents: read&lt;/code&gt;, &lt;code&gt;pull-requests: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create a release&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contents: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push to GitHub Packages&lt;/td&gt;
&lt;td&gt;&lt;code&gt;packages: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy to GitHub Pages (OIDC)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pages: write&lt;/code&gt;, &lt;code&gt;id-token: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload SARIF to code scanning&lt;/td&gt;
&lt;td&gt;&lt;code&gt;security-events: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request OIDC token (cloud deploy)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id-token: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Key rules:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Set &lt;code&gt;permissions: {}&lt;/code&gt; at the workflow level as a zero baseline&lt;/li&gt;
&lt;li&gt;Grant only what each job needs, declared at the job level&lt;/li&gt;
&lt;li&gt;Set &amp;quot;Read repository contents&amp;quot; as the org-level default in Actions settings&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id-token: write&lt;/code&gt; is never granted by default — always declare it explicitly&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;step-security/harden-runner&lt;/code&gt; in audit mode to discover actual permissions used before writing your &lt;code&gt;permissions&lt;/code&gt; block&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;grep -rL &amp;quot;^permissions:&amp;quot; .github/workflows/&lt;/code&gt; to find workflows still on GitHub defaults&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;permissions&lt;/code&gt; block is three lines of YAML that meaningfully reduces the attack surface of every workflow that includes it. It doesn&#39;t require a security team, a policy review, or a platform migration. It requires looking at what each job actually does, mapping that to the minimum set of scopes, and writing it down. GitHub&#39;s defaults were designed for ease of adoption — get something running without friction. The &lt;code&gt;permissions&lt;/code&gt; block is how you opt out of that tradeoff once the workflow is running in production. That&#39;s the right time to do it, which means the right time is now.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Want to talk through permissions strategy for your workflows, or work through a permissions audit for your GitHub Actions setup? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>GitHub Actions workflows run with write access to almost every repo scope by default — the permissions block is three lines of YAML that closes that blast radius, and most workflows aren&#39;t using it.</summary>
    <category term="github-actions"/>
    <category term="security"/>
    <category term="devsecops"/>
  </entry>
  <entry>
    <title>Dependabot Advanced: Getting Past the Noise</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-03-27-dependabot-advanced-getting-past-the-noise/"/>
    <updated>2026-03-27T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-03-27-dependabot-advanced-getting-past-the-noise/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Here&#39;s how most Dependabot stories end: the team enables it, a flood of PRs appears, nobody has time to review 40 dependency bumps, the PRs age into staleness, and eventually someone closes them all in bulk and adds Dependabot to the list of things that sounded good in theory. Sometimes they disable it outright. Sometimes they just stop looking.&lt;/p&gt;
&lt;p&gt;The tool isn&#39;t broken. The configuration is. Dependabot out of the box is optimized for coverage — it will find every update and open a PR for it. What it is not optimized for is human attention. The default config fires daily, creates one PR per package per version bump, treats a patch bump to a dev-only type package the same as a major version change to your HTTP client, and sets a low cap on open PRs that triggers a silent failure mode most teams don&#39;t even know exists. Every one of those choices is tunable. Two hours of configuration work will cut your PR volume by 70% while keeping security updates fast and individual. This post walks through exactly how to do that.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What You Get by Default&lt;/h2&gt;
&lt;p&gt;A repo with npm, Docker, and GitHub Actions dependencies needs exactly three lines of configuration to enable Dependabot:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/dependabot.yml (default)
version: 2
updates:
  - package-ecosystem: &amp;quot;npm&amp;quot;
    directory: &amp;quot;/&amp;quot;
    schedule:
      interval: &amp;quot;daily&amp;quot;
  - package-ecosystem: &amp;quot;docker&amp;quot;
    directory: &amp;quot;/&amp;quot;
    schedule:
      interval: &amp;quot;daily&amp;quot;
  - package-ecosystem: &amp;quot;github-actions&amp;quot;
    directory: &amp;quot;/&amp;quot;
    schedule:
      interval: &amp;quot;daily&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a medium-sized project — 50 npm dependencies, two or three Docker base images, a handful of GitHub Actions — the first week will produce somewhere between 20 and 50 PRs. If you haven&#39;t updated dependencies in a few months, that number can spike higher. Each PR is a single package bump, unreviewed, with a title like &lt;code&gt;Bump @types/node from 20.11.0 to 20.11.5&lt;/code&gt; that carries no signal about whether it matters.&lt;/p&gt;
&lt;p&gt;The problem compounds. A daily schedule means any upstream package that releases a new version today will generate a new PR tomorrow. For active ecosystems like npm, that&#39;s not occasional — it&#39;s continuous. &lt;code&gt;eslint&lt;/code&gt;, &lt;code&gt;typescript&lt;/code&gt;, &lt;code&gt;@types/*&lt;/code&gt;, React ecosystem packages — they release constantly. Without grouping or scheduling discipline, you&#39;re running a low-grade interrupt loop that trains your team to ignore the PRs entirely.&lt;/p&gt;
&lt;p&gt;That&#39;s the configuration problem. Here&#39;s how to fix it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Tuning Levers&lt;/h2&gt;
&lt;h3&gt;Scheduling — Weekly Is the Right Default&lt;/h3&gt;
&lt;p&gt;Daily updates are wrong for most teams. Not because freshness doesn&#39;t matter — it does — but because daily creates a pace of review that no team actually sustains. The right default is &lt;strong&gt;weekly&lt;/strong&gt;, and the right day is Monday morning.&lt;/p&gt;
&lt;p&gt;Monday gives your team a clean start with a predictable batch of updates. Friday is actively bad — Dependabot PRs that open on a Friday sit over the weekend and nobody is happy about merging an untested dependency bump before heading out. Monday morning also means the team can merge, run CI, and have time in the same week to deal with anything unexpected.&lt;/p&gt;
&lt;p&gt;One important caveat: &lt;strong&gt;security updates bypass the schedule entirely&lt;/strong&gt;. When Dependabot detects a vulnerability in a dependency, it opens a PR immediately, regardless of what you&#39;ve set for &lt;code&gt;interval&lt;/code&gt;. Switching to weekly does not slow down your response to known CVEs. This distinction matters and it&#39;s one of the most common misconceptions about tuning the schedule.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;schedule:
  interval: &amp;quot;weekly&amp;quot;
  day: &amp;quot;monday&amp;quot;
  time: &amp;quot;09:00&amp;quot;
  timezone: &amp;quot;UTC&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Grouping — The Single Highest-Impact Change&lt;/h3&gt;
&lt;p&gt;Without grouping, every package gets its own PR. With &lt;strong&gt;grouping&lt;/strong&gt;, related packages are bundled into a single PR. This is the lever that most dramatically reduces PR volume.&lt;/p&gt;
&lt;p&gt;The syntax is straightforward. You define named groups with a pattern that matches package names, and optionally constrain them to specific update types:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;groups:
  dev-dependencies:
    dependency-type: &amp;quot;development&amp;quot;
    update-types:
      - &amp;quot;minor&amp;quot;
      - &amp;quot;patch&amp;quot;
  aws-sdk:
    patterns:
      - &amp;quot;@aws-sdk/*&amp;quot;
      - &amp;quot;aws-cdk*&amp;quot;
  eslint-plugins:
    patterns:
      - &amp;quot;eslint*&amp;quot;
      - &amp;quot;@typescript-eslint/*&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this configuration, all your dev dependencies (patch and minor) arrive in a single PR. All your &lt;code&gt;@aws-sdk/*&lt;/code&gt; packages — and there can be dozens — arrive in one PR. All your ESLint toolchain packages arrive together. Instead of 15 PRs for dev tooling updates, you get one.&lt;/p&gt;
&lt;p&gt;Two things worth knowing about how grouping interacts with security updates. First: &lt;strong&gt;security updates are excluded from groups by default&lt;/strong&gt;. When Dependabot opens a PR for a known vulnerability, it opens it as an individual PR regardless of whether the package matches a group. This is the correct behavior — you want security PRs to be fast, individual, and easy to track. Don&#39;t fight this. Second: ungrouped packages still get individual PRs, so you&#39;re not forced to bucket everything — you can be selective about which families you group.&lt;/p&gt;
&lt;p&gt;For GitHub Actions, grouping by patch updates keeps your action version noise low while letting major version changes — which sometimes involve breaking API changes — surface individually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# For the github-actions ecosystem
groups:
  actions-minor-patch:
    update-types:
      - &amp;quot;minor&amp;quot;
      - &amp;quot;patch&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Versioning Strategy&lt;/h3&gt;
&lt;p&gt;Dependabot offers three &lt;strong&gt;versioning strategies&lt;/strong&gt; for npm. The difference matters for lockfile-only projects versus projects that also manage &lt;code&gt;package.json&lt;/code&gt; version ranges:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;lockfile-only&lt;/code&gt;: Only updates &lt;code&gt;package-lock.json&lt;/code&gt; or &lt;code&gt;yarn.lock&lt;/code&gt;. Does not change version ranges in &lt;code&gt;package.json&lt;/code&gt;. Useful if you want strict control over what you&#39;ve declared, but it means Dependabot can&#39;t update packages that don&#39;t already satisfy the current range.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;increase-if-necessary&lt;/code&gt;: Updates the version range in &lt;code&gt;package.json&lt;/code&gt; only when the new version falls outside the current range. This is the right default for most projects — it keeps your declared ranges honest without aggressively bumping them.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;widen&lt;/code&gt;: Widens the version range to include both the old and new version. Creates permissive ranges that can hide what version is actually running.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For most npm projects, &lt;code&gt;increase-if-necessary&lt;/code&gt; is the right call. For GitHub Actions, it&#39;s also the right default — though if you&#39;re serious about supply chain security, pinning Actions to full commit SHAs and using Dependabot to update those pins is a stronger posture (that&#39;s worth its own post).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;versioning-strategy: increase-if-necessary
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Allowed and Ignored Updates&lt;/h3&gt;
&lt;p&gt;Sometimes you explicitly don&#39;t want Dependabot touching a specific package. Maybe you&#39;re in the middle of migrating away from it. Maybe a known-broken major version exists and you&#39;re not ready to upgrade. The &lt;code&gt;ignore&lt;/code&gt; directive handles this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;ignore:
  # Hold on major version bumps for webpack until we migrate config
  - dependency-name: &amp;quot;webpack&amp;quot;
    update-types: [&amp;quot;version-update:semver-major&amp;quot;]
  # This package has a broken v3.x release; skip it entirely for now
  - dependency-name: &amp;quot;some-library&amp;quot;
    versions: [&amp;quot;3.x&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For monorepos with a mix of internal and external packages, &lt;code&gt;allow&lt;/code&gt; lets you whitelist just the external dependencies you actually want Dependabot to manage, which prevents it from opening PRs for internal workspace packages:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Option A: only direct dependencies (production + dev, no transitive)
allow:
  - dependency-type: &amp;quot;direct&amp;quot;

# Option B: only production dependencies (direct + transitive, no dev)
allow:
  - dependency-type: &amp;quot;production&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The PR Limit Silent Failure&lt;/h3&gt;
&lt;p&gt;This one deserves special emphasis because it creates a failure mode most teams don&#39;t know about until they&#39;re already affected.&lt;/p&gt;
&lt;p&gt;Dependabot has an &lt;code&gt;open-pull-requests-limit&lt;/code&gt; that defaults to &lt;strong&gt;5&lt;/strong&gt;. Once 5 Dependabot PRs are open in a repo, Dependabot stops opening new ones — silently. No notification, no warning, no dashboard indicator. If you have 6 open PRs and a new vulnerability is discovered in one of your dependencies, Dependabot will not open a security PR until you reduce your open count below the limit.&lt;/p&gt;
&lt;p&gt;This is the exact opposite of what you want from a security tool.&lt;/p&gt;
&lt;p&gt;The fix is to set the limit explicitly and high enough to accommodate your grouping strategy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;open-pull-requests-limit: 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you&#39;ve enabled grouping, your actual PR count should stay low enough that 10 is comfortable. But set it explicitly regardless — relying on the default means accepting a silent ceiling you might not notice until it matters.&lt;/p&gt;
&lt;h3&gt;Auto-merge for Low-Risk Updates&lt;/h3&gt;
&lt;p&gt;Grouping and scheduling reduce review volume, but the further optimization is &lt;strong&gt;auto-merge&lt;/strong&gt; for updates that are genuinely low-risk. A companion GitHub Actions workflow can merge Dependabot patch and minor PRs automatically once CI passes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/dependabot-automerge.yml
name: Dependabot Auto-merge
on: pull_request

permissions:
  contents: write
  pull-requests: write

jobs:
  auto-merge:
    runs-on: ubuntu-latest
    if: github.actor == &#39;dependabot[bot]&#39;
    steps:
      - name: Fetch Dependabot metadata
        id: metadata
        uses: dependabot/fetch-metadata@v2
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - name: Auto-merge patch and minor updates
        if: |
          steps.metadata.outputs.update-type == &#39;version-update:semver-patch&#39; ||
          steps.metadata.outputs.update-type == &#39;version-update:semver-minor&#39;
        run: gh pr merge --auto --squash &amp;quot;$PR_URL&amp;quot;
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;gh pr merge --auto&lt;/code&gt; queues the merge but does not bypass branch protection. The PR still needs to pass all required status checks before it merges. If CI fails, nothing merges — auto-merge just removes the human step of pressing the button on PRs that would obviously have been approved anyway.&lt;/p&gt;
&lt;p&gt;For teams with strict review requirements, you can scope auto-merge to patch-only and require human review for minor bumps. The key decision is comfort level: patches are usually safe to auto-merge; minor versions occasionally introduce behavioral changes that warrant a look.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Complete Tuned Configuration&lt;/h2&gt;
&lt;p&gt;Here&#39;s the full &lt;code&gt;dependabot.yml&lt;/code&gt; that incorporates all of the above. This is the config I&#39;d start with for a real project and adjust from there:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: &amp;quot;npm&amp;quot;
    directory: &amp;quot;/&amp;quot;
    schedule:
      interval: &amp;quot;weekly&amp;quot;
      day: &amp;quot;monday&amp;quot;
      time: &amp;quot;09:00&amp;quot;
      timezone: &amp;quot;UTC&amp;quot;
    versioning-strategy: increase-if-necessary
    open-pull-requests-limit: 10
    groups:
      dev-dependencies:
        dependency-type: &amp;quot;development&amp;quot;
        update-types:
          - &amp;quot;minor&amp;quot;
          - &amp;quot;patch&amp;quot;
      aws-sdk:
        patterns:
          - &amp;quot;@aws-sdk/*&amp;quot;
          - &amp;quot;aws-cdk*&amp;quot;
      eslint-plugins:
        patterns:
          - &amp;quot;eslint*&amp;quot;
          - &amp;quot;@typescript-eslint/*&amp;quot;
    ignore:
      # Hold major version bumps on webpack pending config migration
      - dependency-name: &amp;quot;webpack&amp;quot;
        update-types: [&amp;quot;version-update:semver-major&amp;quot;]

  - package-ecosystem: &amp;quot;docker&amp;quot;
    directory: &amp;quot;/&amp;quot;
    schedule:
      interval: &amp;quot;weekly&amp;quot;
      day: &amp;quot;monday&amp;quot;
      time: &amp;quot;09:00&amp;quot;
      timezone: &amp;quot;UTC&amp;quot;
    open-pull-requests-limit: 10

  - package-ecosystem: &amp;quot;github-actions&amp;quot;
    directory: &amp;quot;/&amp;quot;
    schedule:
      interval: &amp;quot;weekly&amp;quot;
      day: &amp;quot;monday&amp;quot;
      time: &amp;quot;09:00&amp;quot;
      timezone: &amp;quot;UTC&amp;quot;
    open-pull-requests-limit: 10
    groups:
      actions-minor-patch:
        update-types:
          - &amp;quot;minor&amp;quot;
          - &amp;quot;patch&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What this produces in practice: one grouped PR per week for dev dependencies, one for AWS SDK packages (if applicable), one for ESLint plugins, individual PRs for production dependency minor and major bumps, a grouped PR for action patches and minor versions, individual PRs for action major versions, and immediate individual PRs for any security advisory. The average team running this configuration sees 3–5 Dependabot PRs per week instead of 15–50.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;When to Reach for Renovate Instead&lt;/h2&gt;
&lt;p&gt;Dependabot is the right default. It&#39;s zero-config to enable, deeply integrated with GitHub&#39;s security features, and handles most repos perfectly well with the tuning above. But it has real limitations worth knowing about.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No monorepo workspace awareness.&lt;/strong&gt; Dependabot doesn&#39;t understand npm workspaces natively. It will open PRs for the root &lt;code&gt;package.json&lt;/code&gt; and for each workspace&#39;s &lt;code&gt;package.json&lt;/code&gt; independently, without understanding that some of those packages are internal workspace references that shouldn&#39;t be bumped. Renovate handles workspace topology and won&#39;t create PRs for internal packages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No custom regex versioning.&lt;/strong&gt; Renovate can extract version strings from arbitrary files — a &lt;code&gt;Dockerfile&lt;/code&gt; with a custom &lt;code&gt;ARG VERSION=1.2.3&lt;/code&gt; pattern, a &lt;code&gt;.tool-versions&lt;/code&gt; file, a &lt;code&gt;Makefile&lt;/code&gt; constant. Dependabot is limited to the ecosystems it officially supports. If your infrastructure tooling version lives somewhere outside those ecosystems, Dependabot can&#39;t see it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No Dependency Dashboard.&lt;/strong&gt; Renovate creates a single &amp;quot;Dependency Dashboard&amp;quot; issue in the repo — a living document that shows every pending update, every pending decision, every ignored package, and every rate-limited PR in one place. For large repos, this is dramatically better UX than navigating a list of PRs in varying states of staleness. Dependabot has no equivalent.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;More flexible grouping.&lt;/strong&gt; Dependabot&#39;s grouping handles the common cases well, but Renovate&#39;s grouping rules are more expressive — you can group across ecosystems, apply regex to version strings, and build more complex rules for large monorepos.&lt;/p&gt;
&lt;p&gt;The signal for switching: if you find yourself writing complicated &lt;code&gt;ignore&lt;/code&gt; chains and still fighting the tool, or if your repo is a multi-package workspace, try Renovate. If Dependabot&#39;s grouping handles your repo&#39;s structure and you&#39;re not managing version strings outside supported ecosystems, stay — it&#39;s one YAML file and no additional setup.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;Dependabot Tuning Checklist&lt;/h2&gt;
&lt;p&gt;Apply these today to cut PR volume without slowing down security response:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Switch schedule from &lt;code&gt;daily&lt;/code&gt; to &lt;code&gt;weekly&lt;/code&gt;, targeting Monday morning&lt;/li&gt;
&lt;li&gt;[ ] Add groups for dev dependencies and any major SDK families (AWS SDK, testing frameworks, ESLint)&lt;/li&gt;
&lt;li&gt;[ ] Set &lt;code&gt;open-pull-requests-limit&lt;/code&gt; explicitly to &lt;strong&gt;10 or higher&lt;/strong&gt; — the default 5 creates a silent failure&lt;/li&gt;
&lt;li&gt;[ ] Add the auto-merge workflow for patch and minor updates (gated on CI)&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;ignore&lt;/code&gt; rules for any known-broken version ranges or packages under active migration&lt;/li&gt;
&lt;li&gt;[ ] Verify that security updates are &lt;strong&gt;not&lt;/strong&gt; in groups — they shouldn&#39;t be by default, but confirm it&lt;/li&gt;
&lt;li&gt;[ ] Merge, close, or label all existing stale Dependabot PRs before the new config takes effect&lt;/li&gt;
&lt;li&gt;[ ] Review open Dependabot PRs monthly — a backlog is a signal, not a to-do list&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2&gt;Closing Thoughts&lt;/h2&gt;
&lt;p&gt;The teams that ignore Dependabot PRs aren&#39;t lazy. They&#39;re dealing with a configuration problem that the default setup actively creates. A flood of low-signal PRs trains teams to stop looking, and once that habit forms it takes real effort to undo.&lt;/p&gt;
&lt;p&gt;The tuning described here — weekly schedule, dependency grouping, explicit PR limits, auto-merge for low-risk updates — converts Dependabot from a PR flood into a low-maintenance practice that actually runs in the background of your team&#39;s week. The security updates still arrive immediately. The housekeeping updates arrive in manageable batches. Auto-merge handles the ones that don&#39;t need eyes. The remaining PRs that reach your review queue are the ones that actually warrant attention.&lt;/p&gt;
&lt;p&gt;That&#39;s what a correctly configured Dependabot looks like. It takes about two hours to set up, and it changes the relationship from &amp;quot;thing we&#39;re ignoring&amp;quot; to &amp;quot;thing that quietly keeps our dependencies current.&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Have questions about Dependabot configuration, supply chain security, or whether Renovate is the right call for your repo? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Default Dependabot floods teams with low-signal PRs until they stop merging them — here&#39;s how to tune grouping, scheduling, and auto-merge so dependency updates actually get reviewed.</summary>
    <category term="supply-chain-security"/>
    <category term="github"/>
    <category term="devsecops"/>
  </entry>
  <entry>
    <title>Tailwind CSS v4: What Actually Changed and How to Migrate</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-04-01-tailwind-css-v4-migration/"/>
    <updated>2026-04-01T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-04-01-tailwind-css-v4-migration/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Tailwind v4 isn&#39;t a config syntax refresh with a migration codemod attached. It&#39;s a rewritten engine — &lt;strong&gt;Oxide&lt;/strong&gt;, built in Rust — that changes how configuration works, how CSS is generated, how plugins are authored, and how the CLI operates. The headline benchmarks (full builds 5× faster, incremental builds 100×+ faster) are real, but the migration isn&#39;t purely mechanical. For developers with custom color palettes, class-based dark mode, or typography plugin overrides, there are breaking changes the codemod doesn&#39;t handle.&lt;/p&gt;
&lt;p&gt;This post walks through what actually changed, migrates this blog&#39;s real v3 &lt;code&gt;tailwind.config.js&lt;/code&gt; to v4 line by line, and flags the three breaking changes most likely to catch you off-guard. The migration is manageable — under an hour for a typical Eleventy blog — but you need to know what you&#39;re walking into.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What the Engine Change Means&lt;/h2&gt;
&lt;p&gt;The first thing to understand is that &lt;code&gt;tailwind.config.js&lt;/code&gt; isn&#39;t just changing syntax — it&#39;s going away. Configuration moves into CSS using &lt;code&gt;@theme&lt;/code&gt;, &lt;code&gt;@utility&lt;/code&gt;, and &lt;code&gt;@variant&lt;/code&gt; directives. The JS file is replaced by a CSS entry point that becomes the single source of truth for everything previously split between the config file and your CSS.&lt;/p&gt;
&lt;p&gt;Four changes that affect every project:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No more &lt;code&gt;tailwind.config.js&lt;/code&gt;&lt;/strong&gt;: everything moves to CSS. The &lt;code&gt;@tailwindcss/upgrade&lt;/code&gt; codemod generates a starter &lt;code&gt;@theme&lt;/code&gt; block from your existing config, but complex customizations need manual migration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No more &lt;code&gt;content&lt;/code&gt; array&lt;/strong&gt;: v4 uses automatic content detection. It scans your project for Nunjucks, HTML, Markdown, and JS files automatically. The explicit &lt;code&gt;content: [&#39;./src/**/*.{html,md,njk,js}&#39;]&lt;/code&gt; entry is no longer needed — though if you have non-standard locations or extensions, &lt;code&gt;@source&lt;/code&gt; provides an explicit escape hatch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;@tailwindcss/cli&lt;/code&gt; replaces &lt;code&gt;tailwindcss&lt;/code&gt; for CLI invocations&lt;/strong&gt;: any &lt;code&gt;npx tailwindcss&lt;/code&gt; call in your build scripts becomes &lt;code&gt;npx @tailwindcss/cli&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;@tailwindcss/postcss&lt;/code&gt; replaces &lt;code&gt;tailwindcss&lt;/code&gt;&lt;/strong&gt; as the PostCSS plugin package name, if you&#39;re using PostCSS.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;The Oxide engine is written in Rust. The 5× full build and 100×+ incremental build improvements are from the Tailwind team&#39;s own benchmarks. For an Eleventy site running Tailwind as a separate build step, the incremental build gain is what you&#39;ll feel on every file save during local development.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;The v3 Config: What We&#39;re Starting From&lt;/h2&gt;
&lt;p&gt;This blog&#39;s &lt;code&gt;tailwind.config.js&lt;/code&gt; is a representative v3 config — a custom color scale, class-based dark mode, the typography plugin, and prose variable overrides:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;/** @type {import(&#39;tailwindcss&#39;).Config} */
module.exports = {
  content: [
    &amp;quot;./src/**/*.{html,md,njk,js}&amp;quot;,
  ],
  darkMode: &#39;class&#39;,
  theme: {
    extend: {
      colors: {
        primary: {
          50: &#39;#f0f9ff&#39;,
          100: &#39;#e0f2fe&#39;,
          200: &#39;#bae6fd&#39;,
          300: &#39;#7dd3fc&#39;,
          400: &#39;#38bdf8&#39;,
          500: &#39;#0ea5e9&#39;,
          600: &#39;#0284c7&#39;,
          700: &#39;#0369a1&#39;,
          800: &#39;#075985&#39;,
          900: &#39;#0c4a6e&#39;,
        },
      },
      typography: ({ theme }) =&amp;gt; ({
        DEFAULT: {
          css: {
            &#39;--tw-prose-body&#39;: theme(&#39;colors.gray[700]&#39;),
            &#39;--tw-prose-headings&#39;: theme(&#39;colors.gray[900]&#39;),
            &#39;--tw-prose-links&#39;: theme(&#39;colors.primary[600]&#39;),
            &#39;--tw-prose-bold&#39;: theme(&#39;colors.gray[900]&#39;),
            &#39;--tw-prose-code&#39;: theme(&#39;colors.gray[900]&#39;),
            &#39;--tw-prose-pre-bg&#39;: theme(&#39;colors.gray[100]&#39;),
          },
        },
        invert: {
          css: {
            &#39;--tw-prose-body&#39;: theme(&#39;colors.gray[300]&#39;),
            &#39;--tw-prose-headings&#39;: theme(&#39;colors.white&#39;),
            &#39;--tw-prose-links&#39;: theme(&#39;colors.primary[400]&#39;),
            &#39;--tw-prose-bold&#39;: theme(&#39;colors.white&#39;),
            &#39;--tw-prose-code&#39;: theme(&#39;colors.white&#39;),
            &#39;--tw-prose-pre-bg&#39;: theme(&#39;colors.gray[800]&#39;),
          },
        },
      }),
    },
  },
  plugins: [
    require(&#39;@tailwindcss/typography&#39;),
  ],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the current &lt;code&gt;src/styles/input.css&lt;/code&gt; entry point opens with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@tailwind base;
@tailwind components;
@tailwind utilities;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those three directives are the first thing to replace. Everything else follows from there.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Migrating to v4: Section by Section&lt;/h2&gt;
&lt;h3&gt;The &lt;code&gt;content&lt;/code&gt; array → gone&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// v3 — delete this block entirely
content: [
  &amp;quot;./src/**/*.{html,md,njk,js}&amp;quot;,
],
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Auto-detection in v4 covers Nunjucks, HTML, Markdown, and JS without configuration. For a standard Eleventy project with templates in &lt;code&gt;src/&lt;/code&gt;, nothing else is needed.&lt;/p&gt;
&lt;h3&gt;The &lt;code&gt;theme.extend.colors&lt;/code&gt; block → &lt;code&gt;@theme&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;The custom &lt;code&gt;primary&lt;/code&gt; color scale moves from a JavaScript object to CSS custom properties in an &lt;code&gt;@theme&lt;/code&gt; block inside &lt;code&gt;input.css&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@import &amp;quot;tailwindcss&amp;quot;;

@theme {
  --color-primary-50: #f0f9ff;
  --color-primary-100: #e0f2fe;
  --color-primary-200: #bae6fd;
  --color-primary-300: #7dd3fc;
  --color-primary-400: #38bdf8;
  --color-primary-500: #0ea5e9;
  --color-primary-600: #0284c7;
  --color-primary-700: #0369a1;
  --color-primary-800: #075985;
  --color-primary-900: #0c4a6e;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The naming convention is direct: &lt;code&gt;theme.extend.colors.primary[500]&lt;/code&gt; becomes &lt;code&gt;--color-primary-500&lt;/code&gt;. Every &lt;code&gt;bg-primary-600&lt;/code&gt;, &lt;code&gt;text-primary-400&lt;/code&gt;, and &lt;code&gt;border-primary-600&lt;/code&gt; in the templates continues to work without touching a single template file.&lt;/p&gt;
&lt;h3&gt;The &lt;code&gt;plugins&lt;/code&gt; array → &lt;code&gt;@plugin&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// v3 — remove this
plugins: [
  require(&#39;@tailwindcss/typography&#39;),
],
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* v4 — add to input.css */
@plugin &amp;quot;@tailwindcss/typography&amp;quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;require()&lt;/code&gt; call is replaced by a &lt;code&gt;@plugin&lt;/code&gt; directive. The &lt;code&gt;prose&lt;/code&gt; class, &lt;code&gt;prose-sm&lt;/code&gt;, &lt;code&gt;prose-lg&lt;/code&gt;, &lt;code&gt;prose-invert&lt;/code&gt; — all work identically on the consuming side.&lt;/p&gt;
&lt;h3&gt;Typography theme overrides → direct CSS variables&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;typography&lt;/code&gt; section of the v3 config is the most nuanced part of this migration. Those &lt;code&gt;--tw-prose-*&lt;/code&gt; overrides were resolved at build time using Tailwind&#39;s &lt;code&gt;theme()&lt;/code&gt; function. In v4, the same variables are still supported by &lt;code&gt;@tailwindcss/typography&lt;/code&gt;, but you set them directly in CSS with the resolved hex values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* v4: resolved prose color overrides */
.prose {
  --tw-prose-body: #374151;      /* gray-700 */
  --tw-prose-headings: #111827;  /* gray-900 */
  --tw-prose-links: #0284c7;     /* primary-600 */
  --tw-prose-bold: #111827;
  --tw-prose-code: #111827;
  --tw-prose-pre-bg: #f3f4f6;    /* gray-100 */
}

.prose-invert {
  --tw-prose-body: #d1d5db;      /* gray-300 */
  --tw-prose-headings: #ffffff;
  --tw-prose-links: #38bdf8;     /* primary-400 */
  --tw-prose-bold: #ffffff;
  --tw-prose-code: #ffffff;
  --tw-prose-pre-bg: #1f2937;    /* gray-800 */
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You lose the &lt;code&gt;theme()&lt;/code&gt; indirection, but you gain direct CSS that a browser can read without a build tool.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;darkMode: &#39;class&#39;&lt;/code&gt; → &lt;code&gt;@variant dark&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;This is the breaking change with the most teeth, and the one the codemod silently misses. The &lt;code&gt;darkMode: &#39;class&#39;&lt;/code&gt; option tells v3 to apply dark utilities when a &lt;code&gt;.dark&lt;/code&gt; class is present on a parent element. In v4, that moves to CSS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@variant dark (&amp;amp;:where(.dark, .dark *));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without this line, all the &lt;code&gt;dark:&lt;/code&gt; prefixed classes in the templates — &lt;code&gt;dark:bg-gray-900&lt;/code&gt;, &lt;code&gt;dark:text-gray-100&lt;/code&gt;, &lt;code&gt;dark:prose-invert&lt;/code&gt; — will silently fall back to media-query behavior instead of responding to the &lt;code&gt;.dark&lt;/code&gt; class toggled by JavaScript. The pages will still look fine in a system-level dark mode setting. The bug is invisible unless you test with the actual JS toggle.&lt;/p&gt;
&lt;h3&gt;The migrated &lt;code&gt;input.css&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Putting it together, the v3 entry point&#39;s three directives collapse into a single &lt;code&gt;@import&lt;/code&gt;, and all theme configuration moves in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@import &amp;quot;tailwindcss&amp;quot;;
@plugin &amp;quot;@tailwindcss/typography&amp;quot;;

@variant dark (&amp;amp;:where(.dark, .dark *));

@theme {
  --color-primary-50: #f0f9ff;
  --color-primary-100: #e0f2fe;
  --color-primary-200: #bae6fd;
  --color-primary-300: #7dd3fc;
  --color-primary-400: #38bdf8;
  --color-primary-500: #0ea5e9;
  --color-primary-600: #0284c7;
  --color-primary-700: #0369a1;
  --color-primary-800: #075985;
  --color-primary-900: #0c4a6e;
}

.prose {
  --tw-prose-body: #374151;
  --tw-prose-headings: #111827;
  --tw-prose-links: #0284c7;
  --tw-prose-bold: #111827;
  --tw-prose-code: #111827;
  --tw-prose-pre-bg: #f3f4f6;
}

.prose-invert {
  --tw-prose-body: #d1d5db;
  --tw-prose-headings: #ffffff;
  --tw-prose-links: #38bdf8;
  --tw-prose-bold: #ffffff;
  --tw-prose-code: #ffffff;
  --tw-prose-pre-bg: #1f2937;
}

@layer base {
  /* ... unchanged ... */
}

@layer components {
  /* ... unchanged ... */
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;tailwind.config.js&lt;/code&gt; gets deleted. One fewer JavaScript file in your project root.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Three Breaking Changes Most Likely to Burn You&lt;/h2&gt;
&lt;h3&gt;1. Dark mode configuration&lt;/h3&gt;
&lt;p&gt;Already covered above, but worth stating plainly: &lt;strong&gt;&lt;code&gt;darkMode: &#39;class&#39;&lt;/code&gt; has no automatic equivalent in v4, and the upgrade codemod does not emit the &lt;code&gt;@variant dark&lt;/code&gt; line&lt;/strong&gt;. If you skip it, your dark mode silently switches from class-based to media-query-based — a behavior change that&#39;s invisible in automated tests and only obvious when you manually click the dark mode toggle.&lt;/p&gt;
&lt;p&gt;The fix is one line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@variant dark (&amp;amp;:where(.dark, .dark *));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put it at the top of &lt;code&gt;input.css&lt;/code&gt;, immediately after the &lt;code&gt;@import&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;2. Arbitrary value syntax for CSS variables&lt;/h3&gt;
&lt;p&gt;v4 tightens the arbitrary value parser. The bracket syntax for inline CSS variable references changes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- v3 --&amp;gt;
&amp;lt;div class=&amp;quot;bg-[var(--color-brand)]&amp;quot;&amp;gt;

&amp;lt;!-- v4: CSS variable references use parenthesis syntax --&amp;gt;
&amp;lt;div class=&amp;quot;bg-(--color-brand)&amp;quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;(--variable)&lt;/code&gt; syntax replaces &lt;code&gt;[var(--variable)]&lt;/code&gt; everywhere. If your templates reference CSS variables inline in Tailwind classes — common for dynamic theming or per-component tokens — this is a targeted find-and-replace across your template files. Run a grep for &lt;code&gt;[var(--&lt;/code&gt; before considering the migration done.&lt;/p&gt;
&lt;h3&gt;3. Custom screen breakpoints&lt;/h3&gt;
&lt;p&gt;If your config extends &lt;code&gt;theme.screens&lt;/code&gt;, the breakpoints move to &lt;code&gt;@theme&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// v3 tailwind.config.js
theme: {
  extend: {
    screens: { &#39;3xl&#39;: &#39;1920px&#39; },
  },
},
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* v4 input.css */
@theme {
  --breakpoint-3xl: 1920px;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The subtler issue: v4 adjusts the default breakpoint values slightly. The &lt;code&gt;sm&lt;/code&gt;, &lt;code&gt;md&lt;/code&gt;, &lt;code&gt;lg&lt;/code&gt;, &lt;code&gt;xl&lt;/code&gt;, and &lt;code&gt;2xl&lt;/code&gt; values are close to their v3 equivalents but not identical. If your layout uses responsive utilities like &lt;code&gt;md:grid-cols-2&lt;/code&gt; at precise breakpoints and you care about exact pixel boundaries, check the v4 defaults before declaring the migration complete.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Migration Path&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Step 1: Run the codemod&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx @tailwindcss/upgrade
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Handles: renaming deprecated utilities, generating a starter &lt;code&gt;@theme&lt;/code&gt; block, updating PostCSS config. Does not handle: &lt;code&gt;darkMode: &#39;class&#39;&lt;/code&gt;, typography &lt;code&gt;theme()&lt;/code&gt; overrides, or arbitrary variable syntax.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2: Install v4 packages&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install tailwindcss @tailwindcss/cli @tailwindcss/typography
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 3: Update &lt;code&gt;package.json&lt;/code&gt; build scripts&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The CLI package name changes from &lt;code&gt;tailwindcss&lt;/code&gt; to &lt;code&gt;@tailwindcss/cli&lt;/code&gt;. For this blog, that&#39;s two script entries:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;scripts&amp;quot;: {
    &amp;quot;build:css&amp;quot;: &amp;quot;npx @tailwindcss/cli -i ./src/styles/input.css -o ./_site/styles/output.css --minify&amp;quot;,
    &amp;quot;watch:css&amp;quot;: &amp;quot;npx @tailwindcss/cli -i ./src/styles/input.css -o ./_site/styles/output.css --watch&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt;, &lt;code&gt;dev&lt;/code&gt;, and &lt;code&gt;deploy&lt;/code&gt; scripts are unchanged — only the two that invoke the Tailwind CLI directly need updating.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 4: Update &lt;code&gt;input.css&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Replace the three &lt;code&gt;@tailwind&lt;/code&gt; directives with &lt;code&gt;@import &amp;quot;tailwindcss&amp;quot;&lt;/code&gt;, add &lt;code&gt;@plugin &amp;quot;@tailwindcss/typography&amp;quot;&lt;/code&gt;, move the theme config in, and add the &lt;code&gt;@variant dark&lt;/code&gt; line.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 5: Check for the three breaking changes&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Confirm &lt;code&gt;@variant dark (&amp;amp;:where(.dark, .dark *));&lt;/code&gt; is present&lt;/li&gt;
&lt;li&gt;Grep for &lt;code&gt;[var(--&lt;/code&gt; and update to &lt;code&gt;(--&lt;/code&gt; parenthesis syntax&lt;/li&gt;
&lt;li&gt;Verify any custom breakpoint values against v4 defaults&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Step 6: Verify the build&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run build:css
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the output file size — v4&#39;s dead-code elimination is more aggressive, so the output should be at least as small as v3, typically smaller. If you see deprecation warnings, address those before calling it done.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Build Time: What to Expect&lt;/h2&gt;
&lt;p&gt;For this blog&#39;s stack — Eleventy v2 with a moderate number of Tailwind utility classes — the Rust engine should drop cold build time from roughly 2–4 seconds to under a second, and reduce watch mode latency to something effectively instant.&lt;/p&gt;
&lt;p&gt;The practical impact on the &lt;code&gt;npm run dev&lt;/code&gt; script — which uses &lt;code&gt;npm-run-all --parallel start watch:css&lt;/code&gt; to run Eleventy and Tailwind side by side — is that the &lt;code&gt;watch:css&lt;/code&gt; process stops being something you wait for. The bottleneck shifts fully to Eleventy&#39;s templating and data cascade. That&#39;s exactly where you want it; the CSS layer should be invisible overhead, not a noticeable pause.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;v4 Migration Checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Run &lt;code&gt;npx @tailwindcss/upgrade&lt;/code&gt; first — handles the mechanical parts&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;npm install tailwindcss@next @tailwindcss/cli@next @tailwindcss/typography@next&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] Replace &lt;code&gt;@tailwind base/components/utilities&lt;/code&gt; with &lt;code&gt;@import &amp;quot;tailwindcss&amp;quot;&lt;/code&gt; in &lt;code&gt;input.css&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;@plugin &amp;quot;@tailwindcss/typography&amp;quot;&lt;/code&gt; to &lt;code&gt;input.css&lt;/code&gt; (replaces the &lt;code&gt;plugins&lt;/code&gt; array)&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;@variant dark (&amp;amp;:where(.dark, .dark *));&lt;/code&gt; — &lt;strong&gt;the codemod does not emit this&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;[ ] Move &lt;code&gt;theme.extend.colors&lt;/code&gt; to &lt;code&gt;@theme&lt;/code&gt; CSS custom properties&lt;/li&gt;
&lt;li&gt;[ ] Resolve typography overrides to actual hex values in &lt;code&gt;.prose&lt;/code&gt; and &lt;code&gt;.prose-invert&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] Update build scripts: &lt;code&gt;npx tailwindcss&lt;/code&gt; → &lt;code&gt;npx @tailwindcss/cli&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] Grep for &lt;code&gt;[var(--&lt;/code&gt; and update to &lt;code&gt;(--&lt;/code&gt; parenthesis syntax&lt;/li&gt;
&lt;li&gt;[ ] Check any custom breakpoint values against v4 defaults&lt;/li&gt;
&lt;li&gt;[ ] Delete &lt;code&gt;tailwind.config.js&lt;/code&gt; — if the build passes, you&#39;re done&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;v4 is a better tool. The Rust engine is genuinely faster, the CSS-native config is more coherent than a JavaScript object that mirrors CSS concepts, and automatic content detection eliminates the whole category of &amp;quot;why aren&#39;t my classes generating?&amp;quot; debugging sessions. The migration has real rough edges — the &lt;code&gt;darkMode: &#39;class&#39;&lt;/code&gt; gap and the arbitrary value syntax change are both things the codemod won&#39;t catch for you. But for an Eleventy blog like this one, the full migration runs under an hour. The codemod handles 80% of it; the remaining 20% is a focused search-and-replace and one line of CSS. The result is a faster build, less config to maintain, and one fewer JavaScript file in your project root.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Working through a v4 migration and hitting something this post didn&#39;t cover? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Tailwind CSS v4 ships a Rust-powered engine and CSS-native configuration that replaces tailwind.config.js — this post walks through migrating this blog&#39;s actual v3 config, and flags the three breaking changes most likely to catch you off-guard.</summary>
    <category term="tailwind-css"/>
    <category term="eleventy"/>
  </entry>
  <entry>
    <title>Understanding CVSS Scores: A Practical Guide for Developers</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-04-03-understanding-cvss-scores/"/>
    <updated>2026-04-03T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-04-03-understanding-cvss-scores/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Dependabot fires an alert. It says Critical 9.8. The developer drops everything, merges the patch PR, and marks it done — without reading the advisory, without checking whether the vulnerable package is even reachable in their deployment, without asking whether an exploit exists in the wild. The fire drill takes two hours and disrupts the sprint. Or the opposite happens: after the fifteenth Critical alert this month, the developer dismisses it without reading, and a genuinely exploitable vulnerability sits open in a public-facing API for six weeks. Both failures trace back to the same root cause — treating a CVSS score as a verdict rather than a starting point.&lt;/p&gt;
&lt;p&gt;The score is not a triage decision. It&#39;s a standardized severity estimate calculated against an imaginary worst-case deployment. The number tells you how bad the vulnerability could be in ideal attack conditions. It says nothing about your infrastructure, your network topology, your authentication requirements, or whether a working exploit even exists. Once you understand how the score is constructed, you stop panic-patching on every 9.8 and stop dismissing alerts because you&#39;re fatigued. You read the vector string, check your context, and make a call in two minutes instead of two hours.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What CVSS Actually Is&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;CVSS&lt;/strong&gt; — the &lt;strong&gt;Common Vulnerability Scoring System&lt;/strong&gt; — is a framework maintained by &lt;strong&gt;FIRST&lt;/strong&gt; (Forum of Incident Response and Security Teams) for communicating the characteristics and severity of software vulnerabilities in a standardized, vendor-neutral way. The current version you&#39;ll encounter in practice is &lt;strong&gt;CVSS v3.1&lt;/strong&gt;. A v4.0 spec exists, but the GitHub Advisory Database, NVD (National Vulnerability Database), and most security tooling including Dependabot still report v3.1 scores. That&#39;s what this post covers.&lt;/p&gt;
&lt;p&gt;CVSS defines three metric groups:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Base Metrics&lt;/strong&gt; — the intrinsic characteristics of the vulnerability: how it&#39;s exploited, what it affects, and how severely. This is the number your tooling shows you. It&#39;s static — it doesn&#39;t change based on time, patches, or your environment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Temporal Metrics&lt;/strong&gt; — how the threat landscape has evolved since disclosure: whether exploit code exists publicly, whether a patch or workaround is available. These change over time and can be applied on top of the Base Score to get a more current picture.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environmental Metrics&lt;/strong&gt; — your organization&#39;s specific context: whether the affected component is internet-facing, how much you actually care about confidentiality of that data, what compensating controls you have in place.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;The Base Score answers: &amp;quot;How bad could this be in the worst possible context?&amp;quot; It does not answer: &amp;quot;How bad is this for my application?&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Most tools show only the Base Score because it&#39;s universal — it requires no knowledge of your environment. Environmental and Temporal scores require input your tooling doesn&#39;t have. That makes the Base Score useful for comparison across vulnerabilities and useless as a standalone triage signal. It&#39;s the beginning of the analysis, not the end.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Decoding the Vector String&lt;/h2&gt;
&lt;p&gt;Every CVSS score is accompanied by a &lt;strong&gt;vector string&lt;/strong&gt; — a compact, human-readable encoding of all the metrics that produced the score. If you only take one thing from this post, take this: the vector string is where the real information lives. The number is a summary. The string is the data.&lt;/p&gt;
&lt;p&gt;Here&#39;s a real-world example of a Critical score:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Score: &lt;strong&gt;10.0 Critical&lt;/strong&gt;. This is the ceiling — every metric is at its worst. Here&#39;s what each component means:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Attack Vector&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AV&lt;/td&gt;
&lt;td&gt;N (Network)&lt;/td&gt;
&lt;td&gt;Exploitable remotely over the network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Attack Complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AC&lt;/td&gt;
&lt;td&gt;L (Low)&lt;/td&gt;
&lt;td&gt;No special conditions required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Privileges Required&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PR&lt;/td&gt;
&lt;td&gt;N (None)&lt;/td&gt;
&lt;td&gt;Attacker needs no authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;User Interaction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;N (None)&lt;/td&gt;
&lt;td&gt;No user action needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;S&lt;/td&gt;
&lt;td&gt;C (Changed)&lt;/td&gt;
&lt;td&gt;Exploit crosses security boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Confidentiality&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;H (High)&lt;/td&gt;
&lt;td&gt;Complete data disclosure possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Integrity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;I&lt;/td&gt;
&lt;td&gt;H (High)&lt;/td&gt;
&lt;td&gt;Complete data modification possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Availability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;H (High)&lt;/td&gt;
&lt;td&gt;Complete service disruption possible&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every metric at its most severe produces a 10.0. Now look at what happens when three metrics shift:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:H/I:H/A:H
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Confidentiality, Integrity, and Availability impacts are identical. The potential damage ceiling is the same. But &lt;strong&gt;AC:H&lt;/strong&gt; means the attacker needs specific, non-default conditions to land the exploit — a race condition, a particular configuration, a timing window. &lt;strong&gt;PR:H&lt;/strong&gt; means they need admin-level credentials on the target system first. &lt;strong&gt;UI:R&lt;/strong&gt; means a legitimate user has to take an action — click a link, open a file, trigger a specific code path.&lt;/p&gt;
&lt;p&gt;The score on that second vector drops to 6.4 Medium, despite the same damage potential. That drop reflects how much harder the exploitation chain is in practice. The gap between a 10.0 and a 6.6 isn&#39;t about how bad the impact is — it&#39;s about how accessible the attack path is.&lt;/p&gt;
&lt;p&gt;The two metrics that do the most work in changing real-world exploitability are &lt;strong&gt;AC&lt;/strong&gt; (does this require unusual conditions?) and &lt;strong&gt;PR&lt;/strong&gt; (does the attacker need existing access?). Learn to read those two first.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why the Base Score Lies About Your Risk&lt;/h2&gt;
&lt;p&gt;This is the most important section. The Base Score is calculated against a theoretical target with no defenses and maximum exposure. Your deployment is not that target. The delta between the two is where triage happens.&lt;/p&gt;
&lt;h3&gt;Example A: The Network-Reachable API&lt;/h3&gt;
&lt;p&gt;A Critical 9.8 in an npm package used by your public-facing REST API. The vector shows &lt;code&gt;AV:N/AC:L/PR:N&lt;/code&gt; — network exploitable, no special conditions, no authentication required. Your API is reachable from the internet. The package handles request parsing and runs on every inbound request. The Base Score is accurate here: this is a genuine fire drill. Patch immediately. The theoretical worst case and your actual case are close to the same thing.&lt;/p&gt;
&lt;h3&gt;Example B: The Same CVE in a Build Tool&lt;/h3&gt;
&lt;p&gt;The exact same CVE — same package, same vector string — but this time the package only runs during your local &lt;code&gt;npm run build&lt;/code&gt; step or inside a CI job with no external network exposure. &lt;code&gt;AV:N&lt;/code&gt; in the vector means &amp;quot;network&amp;quot; is the attack vector under ideal conditions. If the package never processes data from an untrusted network source and the machine running it isn&#39;t exposed to one, that attack vector doesn&#39;t apply to your deployment. The 9.8 is still on the advisory. Your actual risk is dramatically lower. This belongs in the next sprint, not in an emergency change window tonight.&lt;/p&gt;
&lt;h3&gt;Example C: The &amp;quot;Critical&amp;quot; Without a Public Exploit&lt;/h3&gt;
&lt;p&gt;A 9.8 Base Score with no known public proof-of-concept. The Temporal metric &lt;strong&gt;Exploit Code Maturity&lt;/strong&gt; would show E:U (Unproven) if applied — but most tooling doesn&#39;t apply Temporal metrics, so you only see the Base Score. Check the advisory References section manually. If no PoC exists, the window of realistic exploitation is much narrower. This doesn&#39;t mean &amp;quot;ignore it&amp;quot; — it means &amp;quot;don&#39;t drop everything at 5pm on a Friday to merge an untested patch.&amp;quot;&lt;/p&gt;
&lt;h3&gt;The Environmental Score: The Fix Nobody Uses&lt;/h3&gt;
&lt;p&gt;CVSS provides &lt;strong&gt;Environmental Metrics&lt;/strong&gt; precisely for this problem. Your organization can configure values for Modified Attack Vector, Modified Confidentiality, and others to reflect the actual deployment context, producing an adjusted score that accurately represents your exposure. An &lt;code&gt;AV:N&lt;/code&gt; vulnerability running inside a network segment with no external access can have its Attack Vector modified to &lt;code&gt;AV:L&lt;/code&gt; in the environmental calculation, producing a score that reflects reality.&lt;/p&gt;
&lt;p&gt;Almost no teams do this because tooling support is inconsistent and the process isn&#39;t automated. Understanding that it exists changes how you read advisories — you know you can mentally apply the same logic even when the tool doesn&#39;t do it for you.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Reading a Real GitHub Advisory&lt;/h2&gt;
&lt;p&gt;When a Dependabot alert fires, the advisory it links to contains more useful information than the score. Here&#39;s how to read it efficiently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The CVSS section&lt;/strong&gt; shows the full vector string, not just the number. Click through to it. The vector string is the data; the number is just a summary. Read the metrics directly rather than trying to reverse-engineer them from the score.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Weaknesses field (CWE)&lt;/strong&gt; tells you the &lt;em&gt;type&lt;/em&gt; of vulnerability — CWE-79 (Cross-Site Scripting), CWE-89 (SQL Injection), CWE-400 (Uncontrolled Resource Consumption). CVSS tells you how severe; CWE tells you what it actually is. This matters for assessing whether your code actually exercises the vulnerable path. A CWE-79 in a server-side rendering library matters a lot if you&#39;re rendering user-supplied content and nothing if you&#39;re using the library in a static site generator that never processes external input.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Affected versions and Patched versions&lt;/strong&gt; are more immediately useful than the score for deciding urgency. If a patched version exists, the question becomes &amp;quot;how hard is this upgrade?&amp;quot; — often the answer is &amp;quot;trivially easy,&amp;quot; and you should just do it regardless of score. If no patched version exists, you need mitigations and monitoring, and that&#39;s true whether the score is 4.0 or 9.8.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The References section&lt;/strong&gt; is where exploit signal lives. Look for links to GitHub repositories, exploit-db entries, proof-of-concept write-ups, or Metasploit modules. A published PoC changes the urgency calculation immediately — regardless of Base Score, the barrier to exploitation just dropped to near-zero for anyone with basic skills.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;A Triage Framework&lt;/h2&gt;
&lt;p&gt;Apply this as a decision sequence, not a scoring rubric. Work through it in order and stop when you have enough signal.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Is the vulnerable package reachable from an untrusted network in production?&lt;/strong&gt; Check your deployment: does this package process data from external sources? If no → deprioritize, schedule for next sprint or next maintenance window. If yes → continue.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Does Attack Complexity require conditions you don&#39;t have?&lt;/strong&gt; An &lt;code&gt;AC:H&lt;/code&gt; vulnerability requires non-default configuration or specific runtime conditions. If your deployment doesn&#39;t match those conditions → reduce urgency. If &lt;code&gt;AC:L&lt;/code&gt; → continue.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Does it require privileges your attack surface doesn&#39;t expose?&lt;/strong&gt; &lt;code&gt;PR:H&lt;/code&gt; means an admin-level authenticated attacker. If your vulnerable endpoint requires authentication and your threat model doesn&#39;t include compromised admin accounts → reduce urgency. If &lt;code&gt;PR:N&lt;/code&gt; → continue.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Is there a known public exploit?&lt;/strong&gt; Check the advisory References section and the CVE detail pages on NVD and Mitre. A published proof-of-concept means treat it as immediate regardless of score. An &lt;code&gt;E:U&lt;/code&gt; Temporal rating (no public exploit) means you have more runway.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Is a patched version available?&lt;/strong&gt; If yes → patch now. Even for lower-urgency vulnerabilities, if the upgrade path is straightforward, just do it. The cost is low and the future you will be grateful. If no → document a mitigation (firewall rule, input validation layer, feature flag) and monitor for patch availability.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;CVSS Quick Reference&lt;/h2&gt;
&lt;p&gt;The metrics that most change real-world exploitability:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AV:N&lt;/strong&gt; = network-exploitable (worst for server apps) — ask whether the package actually processes network input in your deployment&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AV:L&lt;/strong&gt; = local access required — much lower risk for any server-side or cloud-hosted workload&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AC:L&lt;/strong&gt; = no special conditions needed (worst) — the attack path is straightforward&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AC:H&lt;/strong&gt; = requires specific configuration or conditions — assess whether your deployment matches&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR:N&lt;/strong&gt; = no authentication required (worst) — unauthenticated remote exploitation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR:H&lt;/strong&gt; = admin credentials required — material reduction in exploitability&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;S:C&lt;/strong&gt; (Scope Changed) = the exploit crosses security boundaries — container escapes, privilege escalation, cross-tenant impact — always serious regardless of other metrics&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Base Score alone is not a triage decision&lt;/strong&gt; — always check: is the package reachable in production? Is there a public exploit? Is a patch available?&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;CVSS scores are a standardized starting point for a conversation, not the end of one. The number exists to make vulnerabilities comparable across software and vendors. It was never designed to replace context — it was designed to communicate in the absence of it.&lt;/p&gt;
&lt;p&gt;A 9.8 in a package your public API depends on is a fire drill. The same 9.8 in a build-time tool that never processes network input is a scheduled maintenance item. Both are real vulnerabilities. Only one of them should interrupt your day.&lt;/p&gt;
&lt;p&gt;Teams that treat every Critical as a five-alarm emergency burn out and start ignoring alerts. Teams that read the vector string, check their deployment context, and apply the five-step triage sequence above make better decisions faster — and build the kind of judgment that means the actual emergencies get the response they deserve.&lt;/p&gt;
&lt;p&gt;The vector string is eight components. It takes sixty seconds to read. Start there.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Have questions about vulnerability triage, CVSS environmental scoring, or building a security response process that doesn&#39;t burn out your team? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>CVSS scores tell you theoretical worst-case severity, not actual risk to your application — here&#39;s how to read the vector string and triage accurately instead of panic-patching.</summary>
    <category term="security"/>
    <category term="devsecops"/>
    <category term="supply-chain-security"/>
  </entry>
  <entry>
    <title>Generating and Using SBOMs with GitHub Actions</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-04-10-generating-and-using-sboms-with-github-actions/"/>
    <updated>2026-04-10T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-04-10-generating-and-using-sboms-with-github-actions/</id>
    <content xml:lang="en" type="html">&lt;p&gt;The SBOM requirement showed up in a procurement questionnaire. Someone on the team generated one, attached it to a Confluence page, checked the box, and moved on. Six months later a new CVE dropped for a package nobody had heard of. It turned out to be a transitive dependency — the dependency of a dependency — that had been in every release for two years. The Confluence document, already stale the day it was created, couldn&#39;t answer the question that mattered: was the vulnerable version in the build that shipped last week, or the one that shipped the week before? The audit trail was blank. The compliance checkbox was green.&lt;/p&gt;
&lt;p&gt;This is the gap between compliance theater and an actually useful &lt;strong&gt;SBOM&lt;/strong&gt; — &lt;strong&gt;Software Bill of Materials&lt;/strong&gt;. A document filed in a wiki tells you roughly what was on a developer&#39;s machine the day someone decided to run a scan. An SBOM attached to a specific release commit, generated automatically by your CI pipeline, cryptographically signed, and queryable on demand tells you exactly what shipped and when. The difference isn&#39;t philosophical. One is evidence; the other is paperwork. GitHub Actions — specifically &lt;code&gt;anchore/sbom-action&lt;/code&gt; and GitHub&#39;s artifact attestation — makes producing the real version take about fifteen lines of YAML.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What an SBOM Actually Is&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;An SBOM is a machine-readable inventory of every component in your software — direct dependencies, transitive dependencies, their versions, licenses, and known vulnerabilities at the time of build.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The two dominant formats are &lt;strong&gt;SPDX&lt;/strong&gt; (Linux Foundation, widely used in government and enterprise procurement) and &lt;strong&gt;CycloneDX&lt;/strong&gt; (OWASP, richer vulnerability data, better tooling ecosystem). The NTIA minimum elements guidance and Executive Order 14028 are format-agnostic, but in practice CycloneDX has better tooling support for querying and analysis. This post uses &lt;strong&gt;CycloneDX JSON&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A CycloneDX SBOM contains, per component:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Package name and version&lt;/strong&gt; — exactly what was resolved and installed, not what was specified&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PURL&lt;/strong&gt; — a &lt;strong&gt;Package URL&lt;/strong&gt; in the form &lt;code&gt;pkg:npm/lodash@4.17.21&lt;/code&gt; that uniquely identifies the component across ecosystems&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;License&lt;/strong&gt; — often the thing legal is actually asking about&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supplier&lt;/strong&gt; — the entity that published the package&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hashes&lt;/strong&gt; — SHA-256 and SHA-512 digests of the component at the time of inclusion&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The version and hash fields are what make the SBOM meaningful for security response. When a CVE drops, you don&#39;t ask &amp;quot;do we use this package?&amp;quot; — you ask &amp;quot;which of our releases included version X, and is that version still deployed?&amp;quot; The SBOM answers both questions directly.&lt;/p&gt;
&lt;p&gt;The reason transitive dependencies matter more than most developers realize: the majority of documented supply chain attacks target transitive dependencies, not the packages a team explicitly installs. Your &lt;code&gt;package.json&lt;/code&gt; might list twenty direct dependencies. Your resolved dependency tree likely contains several hundred packages. Most of your team can&#39;t name ten of them. The SBOM names all of them.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;An SBOM is a snapshot of your software&#39;s supply chain at a specific point in time. Its value degrades as soon as a dependency changes — which is why generating it at build time, not manually, is the only approach that scales.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;The GitHub Tooling Stack&lt;/h2&gt;
&lt;p&gt;Four components do the work in this post:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;anchore/sbom-action&lt;/code&gt;&lt;/strong&gt; generates CycloneDX or SPDX SBOMs from a source repository, a compiled artifact, or a container image. Under the hood it wraps &lt;strong&gt;Syft&lt;/strong&gt;, Anchore&#39;s open-source SBOM generator. The action handles ecosystem detection automatically — npm, Maven, Go modules, Python, NuGet, and others are all supported without configuration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;actions/attest&lt;/code&gt;&lt;/strong&gt; creates a &lt;strong&gt;sigstore-based attestation&lt;/strong&gt; that cryptographically binds your SBOM file to the specific GitHub Actions workflow run and commit that produced it. The attestation is stored in GitHub&#39;s attestation API, not as a file in your repo. It uses the workflow&#39;s &lt;strong&gt;OIDC identity&lt;/strong&gt; — a short-lived token issued to the specific run — as the signing key, so there&#39;s no long-lived secret to manage and no key rotation story to write.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub Releases&lt;/strong&gt; is where the SBOM gets attached as a named asset. Consumers — security teams, procurement reviewers, downstream pipelines — can retrieve it without cloning the repository.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;gh attestation verify&lt;/code&gt;&lt;/strong&gt; is how any consumer, including your own audit workflow, validates that an SBOM file was produced by the claimed workflow run and hasn&#39;t been tampered with since.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Generating the SBOM: Step by Step&lt;/h2&gt;
&lt;h3&gt;Step 1: Basic SBOM Generation on Release&lt;/h3&gt;
&lt;p&gt;This workflow triggers on any tag matching &lt;code&gt;v*&lt;/code&gt;, generates a CycloneDX JSON SBOM, attests it, and attaches it to the GitHub Release created by the tag push.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Release

on:
  push:
    tags:
      - &#39;v*&#39;

permissions:
  contents: write
  id-token: write
  attestations: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Generate SBOM
        uses: anchore/sbom-action@v0
        with:
          format: cyclonedx-json
          output-file: sbom.cyclonedx.json

      - name: Attest SBOM
        uses: actions/attest@v1
        with:
          subject-path: sbom.cyclonedx.json

      - name: Attach SBOM to release
        uses: softprops/action-gh-release@v2
        with:
          files: sbom.cyclonedx.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The three permissions are not interchangeable defaults — each one does specific work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;contents: write&lt;/code&gt; allows the workflow to create and upload assets to the GitHub Release created by the tag push&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id-token: write&lt;/code&gt; allows the workflow to request an OIDC token from GitHub, which is the signing identity that sigstore uses for the attestation — without this, the attest step fails silently&lt;/li&gt;
&lt;li&gt;&lt;code&gt;attestations: write&lt;/code&gt; allows the workflow to write the attestation record to GitHub&#39;s attestation API&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you&#39;re building a container image alongside the release, &lt;code&gt;anchore/sbom-action&lt;/code&gt; can generate an image SBOM instead by setting &lt;code&gt;image&lt;/code&gt; instead of scanning the source tree:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Generate image SBOM
  uses: anchore/sbom-action@v0
  with:
    image: ghcr.io/your-org/your-image:${{ github.ref_name }}
    format: cyclonedx-json
    output-file: sbom.cyclonedx.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source-tree and image SBOMs answer different questions. The source-tree SBOM reflects what your build process consumed. The image SBOM reflects what ended up in the container, including any OS-level packages installed in the base image. For a complete supply chain picture you want both, attached as separate release assets.&lt;/p&gt;
&lt;h3&gt;Step 2: Validating the Attestation Downstream&lt;/h3&gt;
&lt;p&gt;After the release is created, any consumer can verify the SBOM&#39;s provenance:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gh attestation verify sbom.cyclonedx.json &#92;
  --owner your-org &#92;
  --repo your-repo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What this checks: that the file was signed by a GitHub Actions workflow running in the specified org and repo, using the OIDC identity of the specific workflow run. The attestation record includes the git commit SHA, the workflow file path, and the ref that triggered the run. If the file has been modified since it was attested — even a single byte — verification fails.&lt;/p&gt;
&lt;p&gt;You can add this as a gate in a downstream audit workflow, or run it manually in an incident response scenario to confirm that the SBOM you&#39;re looking at is the one that was produced at release time and hasn&#39;t been manipulated:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Output shows the signer identity, workflow ref, and commit
gh attestation verify sbom.cyclonedx.json &#92;
  --owner your-org &#92;
  --repo your-repo &#92;
  --format json | jq &#39;.verificationResult.statement.predicate&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;The SBOM as a Debugging Tool&lt;/h2&gt;
&lt;p&gt;Compliance is the reason most teams generate an SBOM. Debugging transitive dependency surprises is the reason you&#39;ll be glad you did. Three concrete scenarios:&lt;/p&gt;
&lt;h3&gt;Scenario A: The Mystery Vulnerability&lt;/h3&gt;
&lt;p&gt;Dependabot fires an alert for a package you don&#39;t recognize. You search your &lt;code&gt;package.json&lt;/code&gt; — it&#39;s not there. It&#39;s a transitive dependency. Without the SBOM you trace the tree manually: &lt;code&gt;npm ls &amp;lt;package&amp;gt;&lt;/code&gt;, follow the chain, work out which of your direct dependencies pulled it in, decide whether you can bump that direct dep or need a resolution override.&lt;/p&gt;
&lt;p&gt;With the SBOM, you query it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Find the affected component and its PURL
jq &#39;.components[] | select(.name == &amp;quot;vulnerable-package&amp;quot;) | {name, version, purl}&#39; &#92;
  sbom.cyclonedx.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The PURL tells you the ecosystem, the package registry, the name, and the exact version. From there you know immediately whether the version in the release matches the affected range in the CVE. You&#39;re not guessing based on what&#39;s currently installed — you&#39;re looking at the resolved state at the moment the build ran.&lt;/p&gt;
&lt;h3&gt;Scenario B: License Audit&lt;/h3&gt;
&lt;p&gt;Legal asks whether any GPL-licensed dependencies made it into the product. Without an SBOM this is a manual audit of every package in the tree, opening each one&#39;s LICENSE file or checking the registry. With one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# List all components with GPL licenses
jq &#39;.components[] | select(.licenses[]?.license.id | test(&amp;quot;GPL&amp;quot;; &amp;quot;i&amp;quot;)) | {name, version, licenses}&#39; &#92;
  sbom.cyclonedx.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This runs in seconds and produces an exhaustive list including transitive dependencies that almost certainly weren&#39;t reviewed during the original dependency selection. License compliance failures are disproportionately found in transitive deps — packages that seemed safe because nobody chose them.&lt;/p&gt;
&lt;h3&gt;Scenario C: Point-in-Time Comparison&lt;/h3&gt;
&lt;p&gt;A new CVE drops on a Tuesday. Your current codebase has already been patched — the vulnerable package was bumped in a PR three weeks ago. But you need to know whether the release that&#39;s currently in production, tagged &lt;code&gt;v2.4.1&lt;/code&gt; two months ago, was affected. The SBOM attached to that release tag is the authoritative answer. No guessing from git history, no reconstructing lock files, no hoping that the package manager&#39;s lock file actually reflects what was installed in CI.&lt;/p&gt;
&lt;p&gt;This is the scenario that makes the &amp;quot;attach to every release, don&#39;t let it be ephemeral&amp;quot; rule non-negotiable. An SBOM that lives only in a workflow artifact expires in 90 days by default. One attached to a GitHub Release lives as long as the release does.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;SBOM in the PR Pipeline&lt;/h2&gt;
&lt;p&gt;Generating on release is the baseline. Generating on every PR and diffing the result is the level-up. The goal is to catch unexpected changes in the transitive dependency tree before they merge — the scenario where a direct dependency bump quietly pulls in a new version of a shared transitive dep that nobody reviewed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: SBOM Diff

on:
  pull_request:

permissions:
  contents: read

jobs:
  sbom-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Generate SBOM for PR branch
        uses: anchore/sbom-action@v0
        with:
          format: cyclonedx-json
          output-file: sbom-pr.cyclonedx.json

      - name: Checkout base branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.base_ref }}
          path: base

      - name: Generate SBOM for base branch
        uses: anchore/sbom-action@v0
        with:
          path: base
          format: cyclonedx-json
          output-file: sbom-base.cyclonedx.json

      - name: Diff transitive dependency count
        run: |
          base_count=$(jq &#39;.components | length&#39; sbom-base.cyclonedx.json)
          pr_count=$(jq &#39;.components | length&#39; sbom-pr.cyclonedx.json)
          echo &amp;quot;Base: $base_count components | PR: $pr_count components&amp;quot;
          if [ &amp;quot;$pr_count&amp;quot; -gt &amp;quot;$base_count&amp;quot; ]; then
            echo &amp;quot;::warning::Transitive dependency count increased by $((pr_count - base_count))&amp;quot;
          fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This won&#39;t block PRs by default — it surfaces the signal as a warning annotation. Whether that warning should block merges is a policy call for your team. The point is making the change visible before it ships, not after someone queries the release SBOM in response to an incident.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;SBOM Implementation Checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Generate on every tagged release — not manually, not on demand&lt;/li&gt;
&lt;li&gt;Use CycloneDX JSON format for best tooling and &lt;code&gt;jq&lt;/code&gt; compatibility&lt;/li&gt;
&lt;li&gt;Attest with &lt;code&gt;actions/attest@v1&lt;/code&gt; for cryptographic provenance tied to the specific workflow run&lt;/li&gt;
&lt;li&gt;Attach to GitHub Releases as a named asset (&lt;code&gt;sbom.cyclonedx.json&lt;/code&gt;) so it survives past artifact expiry&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;id-token: write&lt;/code&gt; and &lt;code&gt;attestations: write&lt;/code&gt; permissions — without both, attestation silently fails&lt;/li&gt;
&lt;li&gt;Verify attestation in your audit workflow with &lt;code&gt;gh attestation verify&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Archive SBOMs alongside release artifacts — an SBOM that expires in 90 days can&#39;t answer questions about a release from last year&lt;/li&gt;
&lt;li&gt;Know your transitive dependency count: if you don&#39;t know it, run &lt;code&gt;jq &#39;.components | length&#39; sbom.cyclonedx.json&lt;/code&gt; on your last release&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;The compliance requirement is a forcing function, but treat it as the floor rather than the ceiling. A manually generated SBOM filed in Confluence is compliance theater — it&#39;s a document that describes a state that no longer exists, signed by nobody, attached to nothing. The workflow in this post runs in under two minutes, produces a cryptographically attestable artifact tied to a specific git commit and workflow run, and gives your security team something they can query against a real CVE in a real incident.&lt;/p&gt;
&lt;p&gt;The SBOM is only as useful as it is current. &amp;quot;Current&amp;quot; means generated at build time, on every release, automatically — not whenever someone on the team remembers to run a scanner. The attestation is only as useful as your ability to verify it. The debugging value is only as real as your willingness to actually query the artifact instead of filing it and forgetting it.&lt;/p&gt;
&lt;p&gt;Your transitive dependency tree almost certainly contains packages you&#39;ve never evaluated. The SBOM tells you their names, their versions, and their licenses. It takes one &lt;code&gt;jq&lt;/code&gt; command to find out how many there are. Start there.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Have questions about supply chain security, SBOM tooling, or wiring attestation into your release pipeline? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>GitHub Actions makes generating a cryptographically attested, queryable CycloneDX SBOM on every release straightforward — here&#39;s the complete workflow and why the SBOM is a debugging tool as much as a compliance artifact.</summary>
    <category term="supply-chain-security"/>
    <category term="github-actions"/>
    <category term="devsecops"/>
  </entry>
  <entry>
    <title>GitHub CLI Power User: 10 `gh` Commands That Replace Browser Tabs</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-04-17-github-cli-power-user/"/>
    <updated>2026-04-17T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-04-17-github-cli-power-user/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Most developers have GitHub open in a browser tab permanently. They switch to it to check a PR status, review a diff, watch a failing run, paste in a secret, or find the branch name for an issue. Each of those trips is 30 seconds of context-switching that breaks whatever thread of thought was running in the background. The &lt;strong&gt;&lt;code&gt;gh&lt;/code&gt; CLI&lt;/strong&gt; eliminates most of them — not because it&#39;s clever, but because it puts GitHub&#39;s full API surface in the terminal, where you already are.&lt;/p&gt;
&lt;p&gt;The problem isn&#39;t that people don&#39;t know &lt;code&gt;gh&lt;/code&gt; exists. Most developers have it installed. The problem is that they used &lt;code&gt;gh pr create&lt;/code&gt; once, found it fine, and never went deeper. This post covers the commands that actually change how you work: the ones that replace complete browser workflows rather than just wrapping a single API call.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The 10 Commands&lt;/h2&gt;
&lt;h3&gt;1. &lt;code&gt;gh pr checkout&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; Copying a branch name off the PR page, running &lt;code&gt;git fetch&lt;/code&gt;, running &lt;code&gt;git checkout&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Check out by PR number
gh pr checkout 342

# Check out by URL — works from any directory
gh pr checkout https://github.com/org/repo/pull/342
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The underrated behavior: &lt;code&gt;gh pr checkout&lt;/code&gt; sets up remote tracking automatically. &lt;code&gt;git push&lt;/code&gt; works immediately after without a &lt;code&gt;--set-upstream&lt;/code&gt;. It also handles PRs from forks — no manual remote setup, no fetching from the contributor&#39;s fork URL. If you&#39;ve ever spent three minutes getting a forked PR&#39;s branch onto your machine, this is the command that eliminates that entirely.&lt;/p&gt;
&lt;h3&gt;2. &lt;code&gt;gh pr review&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; Opening the PR in a browser, navigating to the Files tab, writing a review.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Approve with a note
gh pr review 342 --approve --body &amp;quot;LGTM, tested locally&amp;quot;

# Request changes
gh pr review 342 --request-changes --body &amp;quot;See inline comments&amp;quot;

# Leave a comment without a decision
gh pr review 342 --comment --body &amp;quot;One question before I approve&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Approve, request-changes, and comment on the whole PR are fully terminal-native. The one thing that still requires the browser: inline comments on specific file lines. For anything else — including the daily &amp;quot;LGTM&amp;quot; on a PR you&#39;ve reviewed locally — this is faster than a browser tab.&lt;/p&gt;
&lt;h3&gt;3. &lt;code&gt;gh run watch&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; Refreshing the Actions tab to monitor a workflow run in progress.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Watch the most recent run interactively
gh run watch

# Watch a specific run by ID
gh run watch 1234567890

# Exit with the run&#39;s exit code (the flag most people miss)
gh run watch --exit-status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--exit-status&lt;/code&gt; flag is the one worth knowing: it returns a non-zero exit code when the run fails. That makes &lt;code&gt;gh run watch&lt;/code&gt; composable in scripts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gh workflow run deploy.yml &amp;amp;&amp;amp; gh run watch --exit-status &amp;amp;&amp;amp; echo &amp;quot;deployed successfully&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without &lt;code&gt;--exit-status&lt;/code&gt;, the command exits 0 regardless of whether the run passed or failed — which makes it useless in automation. With it, you get a blocking, scriptable workflow monitor.&lt;/p&gt;
&lt;h3&gt;4. &lt;code&gt;gh run rerun&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; Opening a failed run in the browser and clicking &amp;quot;Re-run failed jobs&amp;quot;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Rerun only the failed jobs — not the entire workflow
gh run rerun 1234567890 --failed

# Rerun with step debug logging enabled
gh run rerun 1234567890 --debug
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--debug&lt;/code&gt; flag is the behavior most people don&#39;t know exists. It enables step-level debug logging for the rerun — equivalent to setting &lt;code&gt;ACTIONS_STEP_DEBUG=true&lt;/code&gt; as a repository secret, but without touching your repo settings and without affecting other runs. When a job fails intermittently and you need visibility into exactly what happened, &lt;code&gt;--debug&lt;/code&gt; is the first thing to reach for.&lt;/p&gt;
&lt;h3&gt;5. &lt;code&gt;gh issue develop&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; Manually creating a branch, remembering to include the issue number in the name, hoping you remember it later for the PR description.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Create a branch linked to issue 88 and check it out immediately
gh issue develop 88 --checkout
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The branch name is generated from the issue title and number — something like &lt;code&gt;88-fix-authentication-redirect-loop&lt;/code&gt;. The branch is automatically linked to the issue in the GitHub UI, and when you open a PR from it, the issue is referenced and closed automatically on merge. Use &lt;code&gt;--base&lt;/code&gt; to target a non-default branch. This eliminates an entire class of &amp;quot;I forgot to link the issue&amp;quot; PR comments.&lt;/p&gt;
&lt;h3&gt;6. &lt;code&gt;gh secret set&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; Opening Repository Settings → Secrets and variables → Actions → New repository secret, pasting a value into a browser form field.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Set from a file — value never touches shell history
gh secret set MY_API_KEY &amp;lt; secret.txt

# Pipe directly from a secret manager
aws secretsmanager get-secret-value --secret-id prod/api-key &#92;
  --query SecretString --output text | gh secret set PROD_API_KEY

# Set an environment-scoped secret (not repo-level)
gh secret set DEPLOY_TOKEN --env production

# Set an org-level secret visible to all repos
gh secret set SHARED_TOKEN --org my-org --visibility all
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;Never pass the secret value directly as a flag: &lt;code&gt;gh secret set MY_KEY --body &amp;quot;actual-value&amp;quot;&lt;/code&gt; writes the plaintext value into your shell history. The stdin approach (&lt;code&gt;&amp;lt; secret.txt&lt;/code&gt; or a pipe) keeps the value out of history entirely. This is the default you should build muscle memory around.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h3&gt;7. &lt;code&gt;gh repo create --template&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; Navigating to a template repository, clicking &amp;quot;Use this template&amp;quot;, waiting for the GitHub UI to create the repository, then cloning it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Create a private repo from a template and clone it locally in one step
gh repo create my-new-service &#92;
  --template org/service-template &#92;
  --private &#92;
  --clone
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Combine with a wrapper script in &lt;code&gt;~/scripts/new-service.sh&lt;/code&gt; that pre-fills the standard options for your organization — private visibility, team access, your naming convention. No more clicking through four browser screens for every new repository.&lt;/p&gt;
&lt;h3&gt;8. &lt;code&gt;gh api&lt;/code&gt; with &lt;code&gt;--jq&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; Looking up the GitHub API endpoint, constructing a &lt;code&gt;curl&lt;/code&gt; command, piping to a separate JSON parser.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# List open PRs with review status
gh api /repos/{owner}/{repo}/pulls &#92;
  --jq &#39;.[] | {number, title, user: .user.login, draft: .draft}&#39;

# List org repos sorted by last push, handling pagination automatically
gh api /orgs/my-org/repos &#92;
  --paginate &#92;
  --jq &#39;sort_by(.pushed_at) | reverse | .[] | {name, pushed_at}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things worth knowing: the &lt;code&gt;{owner}&lt;/code&gt; and &lt;code&gt;{repo}&lt;/code&gt; placeholders are filled automatically from the current directory&#39;s git remote — no hardcoding needed. And &lt;code&gt;--paginate&lt;/code&gt; handles multi-page responses transparently, fetching all pages and concatenating the results before piping to &lt;code&gt;--jq&lt;/code&gt;. Any GitHub REST endpoint is reachable this way, which means &lt;code&gt;gh api&lt;/code&gt; is the escape hatch for anything the purpose-built commands don&#39;t cover.&lt;/p&gt;
&lt;h3&gt;9. &lt;code&gt;gh search&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; GitHub&#39;s web search interface, which requires a browser and returns results buried in a UI.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Your open issues across all repos
gh search issues --assignee @me --state open --json number,title,repository

# Open Dependabot PRs in a specific repo
gh search prs &amp;quot;dependabot&amp;quot; --repo org/repo --state open

# Find hardcoded tokens in YAML files
gh search code &amp;quot;GITHUB_TOKEN&amp;quot; --language yaml --repo org/repo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--json&lt;/code&gt; flag outputs machine-readable results composable with &lt;code&gt;jq&lt;/code&gt;. The &lt;code&gt;@me&lt;/code&gt; shorthand resolves to your authenticated GitHub user automatically. For cross-repo issue triage or security audits across an organization, &lt;code&gt;gh search&lt;/code&gt; is considerably faster than assembling a GraphQL query by hand.&lt;/p&gt;
&lt;h3&gt;10. The Standup Script&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Replaces:&lt;/strong&gt; Mentally reconstructing what you worked on yesterday before a standup.&lt;/p&gt;
&lt;p&gt;Save this as &lt;code&gt;~/scripts/standup.sh&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# standup.sh — what did I do yesterday?
YESTERDAY=$(date -d &amp;quot;yesterday&amp;quot; +%Y-%m-%dT%H:%M:%SZ 2&amp;gt;/dev/null &#92;
  || date -v-1d +%Y-%m-%dT%H:%M:%SZ)

echo &amp;quot;=== PRs you reviewed ===&amp;quot;
gh search prs --reviewed-by @me --updated &amp;quot;&amp;gt;$YESTERDAY&amp;quot; &#92;
  --json number,title,repository &#92;
  --jq &#39;.[] | &amp;quot;  #&#92;(.number) &#92;(.title) [&#92;(.repository.name)]&amp;quot;&#39;

echo &amp;quot;&amp;quot;
echo &amp;quot;=== PRs you opened or updated ===&amp;quot;
gh search prs --author @me --updated &amp;quot;&amp;gt;$YESTERDAY&amp;quot; &#92;
  --json number,title,state,repository &#92;
  --jq &#39;.[] | &amp;quot;  #&#92;(.number) [&#92;(.state)] &#92;(.title) [&#92;(.repository.name)]&amp;quot;&#39;

echo &amp;quot;&amp;quot;
echo &amp;quot;=== Issues you were involved in ===&amp;quot;
gh search issues --involves @me --updated &amp;quot;&amp;gt;$YESTERDAY&amp;quot; &#92;
  --json number,title,repository &#92;
  --jq &#39;.[] | &amp;quot;  #&#92;(.number) &#92;(.title) [&#92;(.repository.name)]&amp;quot;&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;date&lt;/code&gt; syntax differs between GNU date (Linux) and BSD date (macOS) — the &lt;code&gt;2&amp;gt;/dev/null || &lt;/code&gt; fallback handles both. Run this every morning before standup: it pulls the previous day&#39;s PR reviews, authored PRs, and issue activity across all your repos without touching a browser.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Shell Aliases Worth Adding&lt;/h2&gt;
&lt;p&gt;A small set of aliases for &lt;code&gt;.bashrc&lt;/code&gt; or &lt;code&gt;.zshrc&lt;/code&gt; that make the most common workflows single-keystrokes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Check out a PR by number
alias prco=&#39;gh pr checkout&#39;

# Watch the latest run on the current branch
alias runwatch=&#39;gh run watch $(gh run list &#92;
  --branch $(git branch --show-current) &#92;
  --limit 1 --json databaseId &#92;
  --jq &amp;quot;.[0].databaseId&amp;quot;)&#39;

# Open the current repo in the browser (for the things that do need the browser)
alias ghopen=&#39;gh repo view --web&#39;

# Create a PR for the current branch, pre-filled from commit messages
alias ghpr=&#39;gh pr create --fill --web&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;runwatch&lt;/code&gt; alias is the most useful: it resolves the latest run ID for the current branch automatically, so you can push a commit and immediately run &lt;code&gt;runwatch&lt;/code&gt; without knowing or caring about run IDs.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;Getting Started: Install and Auth&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Install:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;macOS: &lt;code&gt;brew install gh&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Windows: &lt;code&gt;winget install GitHub.cli&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Debian/Ubuntu: &lt;code&gt;sudo apt install gh&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Authenticate:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gh auth login          # browser flow or token
gh auth switch         # switch between accounts or GitHub Enterprise hosts
gh auth status         # check who you&#39;re authenticated as
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;One rule that matters:&lt;/strong&gt; all &lt;code&gt;gh&lt;/code&gt; commands resolve context from the current directory&#39;s git remote. Run them from inside the repository you want to act on. If you run &lt;code&gt;gh pr list&lt;/code&gt; in the wrong directory, you&#39;ll get the wrong repo&#39;s PRs — and wonder why until you check &lt;code&gt;gh repo view&lt;/code&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;The payoff isn&#39;t any single command. It&#39;s the accumulated effect of eliminating ten context-switches a day — ten times you didn&#39;t reach for the browser, ten times you stayed in the terminal and kept the thread of thought intact. Over a workday that compounds into real, measurable concentration time. The standup script alone saves five minutes of mental reconstruction every morning before you&#39;ve had coffee.&lt;/p&gt;
&lt;p&gt;Start with &lt;code&gt;gh pr checkout&lt;/code&gt; and &lt;code&gt;gh run watch&lt;/code&gt;. Those two commands cover the majority of daily GitHub back-and-forth for most developers. The rest follows naturally once you&#39;ve built the reflex to reach for &lt;code&gt;gh&lt;/code&gt; before reaching for the browser.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Working on developer tooling at your organization, or want to talk through GitHub CLI adoption with your team? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>The gh CLI covers PR reviews, workflow monitoring, secret management, and issue branching entirely from the terminal — here are the 10 commands that eliminate the browser tabs most developers still have open.</summary>
    <category term="github"/>
    <category term="developer-productivity"/>
    <category term="platform-engineering"/>
  </entry>
  <entry>
    <title>Writing Commit Messages That Make Code Review Faster</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-04-24-writing-commit-messages-that-make-code-review-faster/"/>
    <updated>2026-04-24T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-04-24-writing-commit-messages-that-make-code-review-faster/</id>
    <content xml:lang="en" type="html">&lt;p&gt;You open a PR for review. It has twelve commits. The messages read: &amp;quot;fix&amp;quot;, &amp;quot;wip&amp;quot;, &amp;quot;update&amp;quot;, &amp;quot;more fixes&amp;quot;, &amp;quot;actually fix&amp;quot;, &amp;quot;pr feedback&amp;quot;. There is no narrative, no context, no explanation of what was tried and discarded. To understand why any particular line changed, you have to reverse-engineer intent from the diff alone — which is exactly what the commit messages were supposed to make unnecessary. This is a communication failure, and it compounds: bad commit messages make code review slower, make &lt;code&gt;git bisect&lt;/code&gt; a guessing game, make &lt;code&gt;git blame&lt;/code&gt; useless for anything except finding who to ask, and make onboarding new teammates onto a codebase a puzzle instead of a story.&lt;/p&gt;
&lt;p&gt;The fix takes about 60 seconds per commit. Most developers just haven&#39;t been taught the format.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Anatomy of a Good Commit Message&lt;/h2&gt;
&lt;p&gt;Start with a concrete example of the finished product, then take it apart:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;feat(auth): replace session tokens with JWTs

Cookie-based sessions were hitting a scaling wall — the session store
was becoming a bottleneck at ~5k concurrent users. JWTs eliminate the
server-side session lookup entirely.

Considered Redis cluster as an alternative but rejected it: adds
infrastructure complexity and the session store problem recurs at
higher scale. JWTs shift the complexity to token validation, which
is stateless and horizontally scalable.

Breaking change: clients must handle 401 responses by re-authenticating.
Existing sessions are invalidated on deploy.

Closes #412
Co-authored-by: Jamie Lee &amp;lt;jamie@example.com&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Five distinct structural elements. Each one is doing specific work.&lt;/p&gt;
&lt;h3&gt;The subject line&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;50 characters or fewer — hard limit is 72. If your editor shows a ruler, put it there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Imperative mood&lt;/strong&gt;: &amp;quot;add&amp;quot;, &amp;quot;fix&amp;quot;, &amp;quot;remove&amp;quot; — not &amp;quot;added&amp;quot;, &amp;quot;fixed&amp;quot;, &amp;quot;removes&amp;quot;. The convention is to complete the sentence &amp;quot;If applied, this commit will...&amp;quot; — the rest of that sentence is your subject line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Type prefix + scope&lt;/strong&gt;: &lt;code&gt;feat(auth):&lt;/code&gt;, &lt;code&gt;fix(api):&lt;/code&gt;, &lt;code&gt;chore(deps):&lt;/code&gt; — this is &lt;strong&gt;Conventional Commits&lt;/strong&gt;, covered in full below.&lt;/li&gt;
&lt;li&gt;No period at the end. The subject line is a title, not a sentence.&lt;/li&gt;
&lt;li&gt;If you can&#39;t write it in 50 characters, the commit is probably doing too much. That&#39;s information worth acting on.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;The blank line&lt;/h3&gt;
&lt;p&gt;Required. Without it, many git tools — &lt;code&gt;git log --oneline&lt;/code&gt;, &lt;code&gt;git shortlog&lt;/code&gt;, GitHub&#39;s PR commit list — treat the entire message as a single subject. The blank line is not optional punctuation. It is structural.&lt;/p&gt;
&lt;h3&gt;The body&lt;/h3&gt;
&lt;p&gt;This is the part most developers skip and the part that pays the most dividends over time. The body explains &lt;strong&gt;why&lt;/strong&gt;, not what — the diff already shows what changed. Three questions the body should answer:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Why was this change necessary?&lt;/li&gt;
&lt;li&gt;What alternatives were considered and why were they rejected?&lt;/li&gt;
&lt;li&gt;What constraints or tradeoffs shaped the approach?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Wrap at 72 characters. &lt;code&gt;git log&lt;/code&gt; outputs body text at full width in a terminal — unwrapped lines that run past 80 characters make the output unreadable without horizontal scrolling.&lt;/p&gt;
&lt;h3&gt;The footer&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Issue references&lt;/strong&gt;: &lt;code&gt;Closes #412&lt;/code&gt;, &lt;code&gt;Fixes #88&lt;/code&gt;, &lt;code&gt;Resolves #200&lt;/code&gt;, &lt;code&gt;Refs #101&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Co-authors&lt;/strong&gt;: &lt;code&gt;Co-authored-by: Name &amp;lt;email&amp;gt;&lt;/code&gt; — GitHub parses this trailer and credits the contributor in the commit view and contribution graph&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Breaking changes&lt;/strong&gt;: &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; — the Conventional Commits spec; triggers a major version bump in &lt;code&gt;semantic-release&lt;/code&gt; and &lt;code&gt;release-please&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The footer is where metadata lives. Putting &lt;code&gt;Closes #412&lt;/code&gt; in the body instead of the footer works syntactically, but it survives squash-merge and PR description edits more reliably as a footer trailer.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Conventional Commits — The Spec Worth Adopting&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Conventional Commits&lt;/strong&gt; is a specification for commit message format that makes history machine-parseable: &lt;code&gt;&amp;lt;type&amp;gt;(&amp;lt;scope&amp;gt;): &amp;lt;subject&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The common types, and what they mean:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;feat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;New capability or behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fix&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bug fix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Documentation only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;style&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Formatting, whitespace — no logic change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refactor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Code restructuring, no behavior change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Adding or updating tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chore&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maintenance, config, tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ci&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CI/CD pipeline changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;perf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Performance improvement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;revert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reverting a previous commit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The scope in parentheses is optional but useful: &lt;code&gt;feat(auth)&lt;/code&gt;, &lt;code&gt;fix(api)&lt;/code&gt;, &lt;code&gt;chore(deps)&lt;/code&gt;. It narrows where the change lives and makes filtered log queries (&lt;code&gt;git log --grep=&amp;quot;^feat(auth)&amp;quot;&lt;/code&gt;) actually useful.&lt;/p&gt;
&lt;p&gt;Why this matters beyond aesthetics: Conventional Commits is machine-parseable. Tools like &lt;code&gt;semantic-release&lt;/code&gt;, &lt;code&gt;conventional-changelog&lt;/code&gt;, and &lt;code&gt;release-please&lt;/code&gt; read your commit history to determine version bumps and generate changelogs automatically. A &lt;code&gt;feat&lt;/code&gt; commit triggers a minor version bump. A &lt;code&gt;fix&lt;/code&gt; triggers a patch. A commit with &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; in the footer triggers a major. That automation is only possible because the commit messages follow a predictable structure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;fix stuff
update deps
more work on auth
fix tests
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;feat(auth): add JWT refresh token rotation
fix(api): handle null user on profile endpoint
chore(deps): bump axios from 1.6.0 to 1.7.2
test(auth): add coverage for token expiry edge case
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From the &amp;quot;after&amp;quot; log, &lt;code&gt;conventional-changelog&lt;/code&gt; generates:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;## [2.1.0] - 2026-04-24

### Features
- **auth:** add JWT refresh token rotation

### Bug Fixes
- **api:** handle null user on profile endpoint
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Zero manual changelog writing. The history is the changelog, because the commit messages are structured well enough to read programmatically.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Writing the Body — The Why, Not the What&lt;/h2&gt;
&lt;p&gt;The body is where most developers have the most room to improve and the most to gain. Here is the pattern to avoid:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;# Bad — describes what the diff already shows
refactor(db): extract query builder

Moved query building logic from UserRepository into a new
QueryBuilder class. Added methods for filtering and sorting.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That body is worse than no body. It repeats what the diff shows, adds no context, and will tell a future reader nothing they couldn&#39;t have learned from running &lt;code&gt;git diff&lt;/code&gt;. Compare:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;# Good — explains why and what was considered
refactor(db): extract query builder

UserRepository had grown to 400 lines, 60% of which was
query construction logic unrelated to repository concerns.
Extracting QueryBuilder makes each class testable in isolation
and unblocks the planned migration to a read replica (tracked
in #388).

Considered an ORM (Prisma) but deferred: migration cost is
high and the current query patterns don&#39;t justify the
abstraction. Revisit if the read replica migration expands
the query surface significantly.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The test for whether a body is done: could someone who wasn&#39;t in the room understand why this change was made, six months from now, with only this message and the diff? If not, the body isn&#39;t done.&lt;/p&gt;
&lt;p&gt;That test is particularly important for decisions that look arbitrary without context. The rejected Redis cluster alternative in the opening example isn&#39;t there to show off the author&#39;s research — it&#39;s there because the next engineer to touch that code will have the same idea, and they deserve to know it was already considered and why it was rejected. Without that note, the investigation happens again. Bad commit messages bill future engineers for decisions that were already paid for.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Linking Issues and PRs Correctly&lt;/h2&gt;
&lt;p&gt;GitHub parses specific &lt;strong&gt;closing keywords&lt;/strong&gt; in commit messages (and PR descriptions) and acts on them when code lands on the default branch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Closes #123&lt;/code&gt; — closes the issue on merge&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Fixes #123&lt;/code&gt; — closes the issue (alias for Closes)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Resolves #123&lt;/code&gt; — closes the issue (alias for Closes)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Refs #123&lt;/code&gt; — links without closing, for partial work or related issues&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The recommendation: put these in the commit message footer, not the PR description. Here&#39;s why.&lt;/p&gt;
&lt;p&gt;If you use a squash-merge strategy, GitHub uses the PR description as the squash commit message by default. But PR descriptions get edited — the final state of the description may not match what was in the original. Issue references in individual commit messages survive this, and they&#39;re visible in the git history independent of GitHub&#39;s UI.&lt;/p&gt;
&lt;p&gt;For &lt;code&gt;Refs&lt;/code&gt; specifically: use it when a commit is related to an issue but doesn&#39;t fully resolve it. A multi-PR epic might have three commits that each &lt;code&gt;Refs #88&lt;/code&gt; and one final commit that &lt;code&gt;Closes #88&lt;/code&gt;. That gives a clean audit trail of every commit that touched the work.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Enforcing Format with a Commit-Msg Hook&lt;/h2&gt;
&lt;p&gt;A commit message standard that lives only in a team wiki is not a standard. Enforcement needs to be automatic.&lt;/p&gt;
&lt;p&gt;The first layer is a &lt;strong&gt;commit-msg hook&lt;/strong&gt; that runs locally before the commit is accepted:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# .git/hooks/commit-msg
# Enforce Conventional Commits format

commit_regex=&#39;^(feat|fix|docs|style|refactor|test|chore|ci|perf|revert)(&#92;(.+&#92;))?: .{1,72}&#39;

if ! grep -qE &amp;quot;$commit_regex&amp;quot; &amp;quot;$1&amp;quot;; then
  echo &amp;quot;ERROR: Commit message does not follow Conventional Commits format.&amp;quot;
  echo &amp;quot;Expected: &amp;lt;type&amp;gt;(&amp;lt;scope&amp;gt;): &amp;lt;subject&amp;gt;&amp;quot;
  echo &amp;quot;Example:  feat(auth): add JWT refresh token rotation&amp;quot;
  exit 1
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Install it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chmod +x .git/hooks/commit-msg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The problem with a raw &lt;code&gt;.git/hooks/&lt;/code&gt; file: it isn&#39;t committed to the repository and doesn&#39;t automatically apply for new clones. The team-scale solution is &lt;strong&gt;commitlint&lt;/strong&gt; with Husky:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install --save-dev husky @commitlint/cli @commitlint/config-conventional
npx husky init
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;scripts&amp;quot;: {
    &amp;quot;prepare&amp;quot;: &amp;quot;husky&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# .husky/commit-msg
npx --no -- commitlint --edit $1
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// commitlint.config.js (ESM — requires &amp;quot;type&amp;quot;: &amp;quot;module&amp;quot; in package.json)
export default {
  extends: [&#39;@commitlint/config-conventional&#39;]
};

// CommonJS alternative: rename to commitlint.config.cjs and use:
// module.exports = { extends: [&#39;@commitlint/config-conventional&#39;] };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;prepare&lt;/code&gt; script runs on &lt;code&gt;npm install&lt;/code&gt;, so every developer who clones the repository and installs dependencies gets the hook automatically.&lt;/p&gt;
&lt;h3&gt;CI Enforcement&lt;/h3&gt;
&lt;p&gt;The local hook can be bypassed with &lt;code&gt;git commit --no-verify&lt;/code&gt;. For teams where that matters — or for open-source projects where contributors control their own environments — add a CI check that runs on pull requests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Lint Commits
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  commitlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: &#39;20&#39;
      - run: npm ci
      - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;fetch-depth: 0&lt;/code&gt; is required — without it, the shallow clone won&#39;t have the base commit in history, and commitlint can&#39;t compute the range. This catches any commit that bypassed the local hook, and it gives contributors clear feedback in CI before the PR goes to review.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;&lt;code&gt;git notes&lt;/code&gt; — Post-Merge Context Without Rewriting History&lt;/h2&gt;
&lt;p&gt;Sometimes you learn something after a commit merges — a production incident reveals the real cause, a follow-up investigation changes your understanding of a decision. &lt;strong&gt;&lt;code&gt;git notes&lt;/code&gt;&lt;/strong&gt; lets you attach context to an existing commit without amending or rewriting history:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Add a note to the most recent commit
git notes add -m &amp;quot;This introduced a subtle race condition under high load. See incident-2024-11-14 in the runbook.&amp;quot;

# Add a note to a specific commit
git notes add -m &amp;quot;Root cause confirmed in #512. The fix is in abc9876.&amp;quot; abc1234

# View notes in git log
git log --show-notes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The limitation worth knowing upfront: &lt;code&gt;git notes&lt;/code&gt; don&#39;t sync automatically. You have to push and fetch them explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Push notes to the remote
git push origin refs/notes/commits

# Fetch notes from the remote
git fetch origin refs/notes/commits
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That friction makes &lt;code&gt;git notes&lt;/code&gt; most useful for team-internal context in repositories where the note-fetching step can be scripted into onboarding. For open-source projects where contributors won&#39;t have the notes configured, a linked issue comment is a more reliable place for post-merge context. Use &lt;code&gt;git notes&lt;/code&gt; where you control the team&#39;s git workflow; use issue/PR references everywhere else.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;Commit Message Checklist&lt;/h2&gt;
&lt;p&gt;Before every commit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Subject line is &lt;strong&gt;≤ 50 characters&lt;/strong&gt; (hard limit: 72), imperative mood, no trailing period&lt;/li&gt;
&lt;li&gt;[ ] Type prefix matches what changed — &lt;code&gt;feat&lt;/code&gt; for new capability, &lt;code&gt;fix&lt;/code&gt; for bug, &lt;code&gt;chore&lt;/code&gt; for maintenance&lt;/li&gt;
&lt;li&gt;[ ] Body explains &lt;strong&gt;why&lt;/strong&gt;, not what the diff already shows&lt;/li&gt;
&lt;li&gt;[ ] Tradeoffs and rejected alternatives are documented if the decision wasn&#39;t obvious&lt;/li&gt;
&lt;li&gt;[ ] Issue reference is in the footer (&lt;code&gt;Closes #N&lt;/code&gt;, &lt;code&gt;Refs #N&lt;/code&gt;) — not buried in the body&lt;/li&gt;
&lt;li&gt;[ ] If it&#39;s a breaking change: &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; is in the footer&lt;/li&gt;
&lt;li&gt;[ ] If you couldn&#39;t fit the change in one subject line, consider whether the commit should be split&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2&gt;The Asymmetry of the Investment&lt;/h2&gt;
&lt;p&gt;Writing a good commit message costs 60 seconds. Reading a bad one during code review, a &lt;code&gt;git bisect&lt;/code&gt; session, or an incident postmortem costs multiples of that — multiplied by every person who reads it, every time the codebase is touched for as long as it exists. A codebase with good commit messages is a codebase with a searchable, human-readable record of every decision ever made: why the architecture looks the way it does, what was tried and rejected, what constraints shaped each choice.&lt;/p&gt;
&lt;p&gt;That&#39;s useful for reviewers. It&#39;s useful for the new engineer trying to understand a module they&#39;ve never touched. It&#39;s especially useful for the person who wrote the commits six months from now, staring at a line they no longer remember writing, asking themselves why they made a choice they can&#39;t explain.&lt;/p&gt;
&lt;p&gt;The format is learnable in an afternoon. The discipline is a habit built commit by commit. Start with the subject line — type prefix, imperative mood, under 72 characters. Add a body the next time you make a decision that future-you will need to understand. The rest follows.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Working on developer tooling or engineering practices at your organization? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Most commit messages are a form of passive negligence — this post teaches the exact format, body-writing discipline, hook setup, and CI enforcement that turns git log into a searchable record of every decision your team has ever made.</summary>
    <category term="writing-for-engineers"/>
    <category term="developer-productivity"/>
    <category term="devops"/>
  </entry>
  <entry>
    <title>Architecture Decision Records: The 30-Minute Investment That Pays Off for Years</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-05-01-architecture-decision-records/"/>
    <updated>2026-05-01T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-05-01-architecture-decision-records/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Six months into a project, a new engineer asks why the codebase uses library X instead of the obvious choice Y. Nobody remembers. The original decision-maker has left. The Slack thread is gone. The PR description says &amp;quot;initial implementation.&amp;quot; The team spends 45 minutes reconstructing a decision that took 20 minutes to make — and they still aren&#39;t sure they got it right.&lt;/p&gt;
&lt;p&gt;This happens constantly. It is entirely avoidable.&lt;/p&gt;
&lt;p&gt;An &lt;strong&gt;Architecture Decision Record (ADR)&lt;/strong&gt; is a Markdown file that captures a decision, its context, the alternatives considered, and the reasoning. One file. Thirty minutes. Permanent record. A codebase with 20 ADRs is a codebase whose entire architectural history is readable in a &lt;code&gt;docs/&lt;/code&gt; folder without needing to interrogate anyone, reconstruct anything, or trust that the person who made the call is still at the company.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What an ADR Is (and Isn&#39;t)&lt;/h2&gt;
&lt;p&gt;An ADR records &lt;strong&gt;a single architectural decision&lt;/strong&gt; at the moment it was made. It is not a design document, not a post-mortem, not a wiki page that gets updated as the system evolves. The distinction matters because it determines how you use the record.&lt;/p&gt;
&lt;p&gt;The two properties that make ADRs useful are also the two that teams instinctively resist. First: &lt;strong&gt;one decision per file&lt;/strong&gt;. Not &amp;quot;the architecture of the authentication system&amp;quot; — that&#39;s a design document. An ADR is &amp;quot;use JWTs instead of server-side sessions.&amp;quot; Specific, bounded, answerable. Second: &lt;strong&gt;immutable once accepted&lt;/strong&gt;. You do not edit an old ADR to reflect a change in direction. You write a new ADR that supersedes it, and the old one stays in the repo with its status updated. The history is the value.&lt;/p&gt;
&lt;p&gt;The format was coined by Michael Nygard in a &lt;a href=&quot;https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions&quot;&gt;2011 blog post&lt;/a&gt; and later popularized by the &lt;code&gt;adr-tools&lt;/code&gt; CLI project. The exact template has evolved, but the principle hasn&#39;t moved.&lt;/p&gt;
&lt;p&gt;What counts as an architectural decision: anything that affects the structure of the system, is expensive to reverse, or that future maintainers will need to understand in order to make sensible choices. Template engine selection, database schema approach, authentication strategy, monorepo vs. polyrepo, API versioning policy. What doesn&#39;t warrant an ADR: bug fixes, routine implementation choices, minor refactors that don&#39;t change structural constraints.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;An ADR is a snapshot of a decision as it was understood at the time it was made. Its value isn&#39;t that it&#39;s always right — it&#39;s that it&#39;s honest about what was known, what was considered, and what was chosen, so future teams can evaluate whether those conditions still hold.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;The Template&lt;/h2&gt;
&lt;p&gt;The maximalist ADR templates floating around the internet have twelve sections and take longer to fill out than it took to make the decision. This is the version that covers what actually matters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;# ADR-{number}: {Title}

**Date:** YYYY-MM-DD  
**Status:** Proposed | Accepted | Deprecated | Superseded by ADR-{N}  
**Deciders:** {Names or roles of people involved in the decision}

## Context

What is the situation that requires a decision? What constraints or forces are at play?
Describe the problem, not the solution.

## Decision

What was decided? State it clearly in one or two sentences.

## Alternatives Considered

| Option | Pros | Cons |
|--------|------|------|
| Option A | ... | ... |
| Option B | ... | ... |
| Option C | ... | ... |

## Consequences

What becomes easier or harder as a result of this decision?
What follow-up decisions does this enable or require?
What is the cost of reversing this decision if it proves wrong?

## References

- Link to relevant PRs, issues, discussions, external docs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four substantive sections. &lt;strong&gt;Context&lt;/strong&gt; describes the situation and its constraints — it is about the problem, not the solution. If you skip this section, the decision loses its meaning the moment the original conditions change. &lt;strong&gt;Decision&lt;/strong&gt; is one or two sentences stating what was chosen. &lt;strong&gt;Alternatives Considered&lt;/strong&gt; is the table most teams fill out in their heads and never write down — it is the section that prevents the same research from being done twice. &lt;strong&gt;Consequences&lt;/strong&gt; is the section people skip most often and future maintainers value most. It answers the questions that actually come up during maintenance: Is this easy to reverse? What follow-up choices did this lock in? What got harder?&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;A Real-World Example&lt;/h2&gt;
&lt;p&gt;This blog runs on Eleventy, and the template engine choice is exactly the kind of decision that looks arbitrary without context. Here is what ADR-001 for this project would look like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;# ADR-001: Use Nunjucks as the Eleventy Template Engine

**Date:** 2025-10-15  
**Status:** Accepted  
**Deciders:** Steve Kaschimer

## Context

Eleventy supports multiple template languages: Nunjucks, Liquid, Handlebars,
EJS, and plain HTML. The project needs a template language that supports
layouts, includes, macros/partials, and conditional logic. The choice affects
every template file in the project and is expensive to reverse.

## Decision

Use Nunjucks (`.njk`) as the primary template language for all layouts and pages.

## Alternatives Considered

| Option | Pros | Cons |
|--------|------|------|
| Nunjucks | Full-featured (macros, filters, inheritance), mature Eleventy support, familiar to Jinja2 users | Slightly more syntax to learn than Liquid |
| Liquid | Simpler syntax, default in Jekyll (familiar to many) | Fewer features, no macro support, less expressive for complex layouts |
| Handlebars | Familiar to JS developers | Limited built-in helpers, logic-less by design (a constraint here, not a feature) |
| EJS | Pure JavaScript in templates | Mixing logic and markup leads to unmaintainable templates at scale |

## Consequences

- All layout and page files use `.njk` extension
- Eleventy filters and shortcodes are written to work with Nunjucks syntax
- New contributors familiar with Liquid/Jekyll will need a brief orientation
- Migration cost if we switch: high — every template file would need rewriting
- Enables: complex layout inheritance, custom filters, macro-based component patterns

## References

- [Eleventy template language docs](https://www.11ty.dev/docs/languages/)
- PR #3: Initial project scaffold
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every new engineer who touches a template now gets this answer in under two minutes instead of in a 45-minute archaeology session. And when Eleventy ships a compelling new template format — say, WebC — the question &amp;quot;should we reconsider this?&amp;quot; is grounded in the documented reasons the original choice was made, not in whoever happens to be in the room.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Where to Store ADRs&lt;/h2&gt;
&lt;p&gt;ADRs belong in the repository, not in Confluence, not in Notion, not in a separate wiki. When the ADR lives next to the code it governs, it&#39;s reviewable in pull requests, findable from the same search that surfaces source files, and it survives tool migrations. Documentation that drifts away from the code it documents becomes archaeology at a different URL.&lt;/p&gt;
&lt;p&gt;The convention:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;docs/decisions/&lt;/code&gt;&lt;/strong&gt; at the repository root&lt;/li&gt;
&lt;li&gt;Filenames: zero-padded number + kebab-case title — &lt;code&gt;001-use-nunjucks-as-template-engine.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;An index at &lt;strong&gt;&lt;code&gt;docs/decisions/README.md&lt;/code&gt;&lt;/strong&gt; with one-line summaries and status&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The index format:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;# Architecture Decision Records

| # | Title | Status | Date |
|---|-------|--------|------|
| 001 | Use Nunjucks as the Eleventy template engine | Accepted | 2025-10-15 |
| 002 | Deploy to GitHub Pages via GitHub Actions | Accepted | 2025-10-20 |
| 003 | Use Tailwind CSS for styling | Accepted | 2025-10-20 |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The index is the entry point for any engineer who wants to understand why the system looks the way it does. It should be readable top-to-bottom in five minutes. Keep it current as part of the PR that adds or supersedes an ADR.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Deliberation Workflow with GitHub Discussions&lt;/h2&gt;
&lt;p&gt;The ADR template handles the &lt;em&gt;record&lt;/em&gt;. The &lt;em&gt;deliberation&lt;/em&gt; — the conversation before a decision is made — belongs somewhere else. Mixing the two in the same file produces ADRs that are half-deliberation, half-decision, and useful as neither. &lt;strong&gt;GitHub Discussions&lt;/strong&gt; is the right tool for the deliberation phase.&lt;/p&gt;
&lt;p&gt;The workflow:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Engineer opens a Discussion in an &lt;code&gt;Architecture&lt;/code&gt; category (create it if it doesn&#39;t exist) with the draft ADR as the body&lt;/li&gt;
&lt;li&gt;Team comments with concerns, alternative options, data, prior art&lt;/li&gt;
&lt;li&gt;Engineer updates the draft as the conversation converges&lt;/li&gt;
&lt;li&gt;Once consensus is reached, a PR is opened: &lt;code&gt;docs/decisions/005-adopt-jwt-auth.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;PR description includes: &lt;code&gt;Closes discussion #42&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;PR merges; Discussion closes; the decision is now permanent and co-located with code&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This creates a two-layer record. The Discussion holds the deliberation — the messy, non-linear conversation where options were surfaced and rejected. The ADR holds the distilled outcome. Both are searchable in GitHub. Neither requires a separate tool. And critically: the Discussion captures the voices of people who raised concerns that were ultimately rejected, which is often the most valuable thing to know when you revisit the decision two years later.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Linking ADRs from PRs and Commits&lt;/h2&gt;
&lt;p&gt;An ADR sitting in &lt;code&gt;docs/decisions/&lt;/code&gt; and never referenced from the code it governs is a document that will be forgotten. The connections have to be explicit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PR descriptions&lt;/strong&gt;: when a PR implements a decision, reference the ADR directly. &amp;quot;Implements ADR-005. See &lt;code&gt;docs/decisions/005-adopt-jwt-auth.md&lt;/code&gt;.&amp;quot; This makes the PR self-contained — reviewers know where to find the rationale without asking.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Commit message footers&lt;/strong&gt;: for commits that land architectural changes, add &lt;code&gt;Refs docs/decisions/005-adopt-jwt-auth.md&lt;/code&gt; in the trailer block. This connects &lt;code&gt;git blame&lt;/code&gt; output to the ADR. The combination is the complete picture: &lt;code&gt;git blame&lt;/code&gt; tells you who changed the line; the ADR tells you why the approach was chosen in the first place.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Code comments&lt;/strong&gt;: for non-obvious implementation choices, a single-line comment is enough. &lt;code&gt;// See ADR-003 — chosen over alternatives for reasons in docs/decisions/&lt;/code&gt;. Not a comment that explains what the code does — the code does that. A comment that explains why the code is structured this way and where to find the full reasoning.&lt;/p&gt;
&lt;p&gt;The goal is a web of references tight enough that any engineer starting from either the ADR or the code can reach the other within one click.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;How Decisions Evolve — Superseding ADRs&lt;/h2&gt;
&lt;p&gt;Architectural decisions change. The correct response is not to edit the original ADR. It is to write a new one that supersedes it and update the old one&#39;s status field.&lt;/p&gt;
&lt;p&gt;The old ADR: status becomes &lt;code&gt;Superseded by ADR-007&lt;/code&gt;. The new ADR: references the old one in its Context section, explaining what has changed since the original decision was made. Here is what that looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;# ADR-007: Migrate from Nunjucks to WebC for Component-Based Templates

**Date:** 2026-06-01  
**Status:** Accepted  
**Supersedes:** ADR-001

## Context

ADR-001 chose Nunjucks for its maturity and layout inheritance support.
Since that decision, Eleventy introduced WebC — a single-file component
format that eliminates the need for macros and provides scoped CSS and JS
bundling. The project has grown to 15+ reusable components where Nunjucks
macros are showing maintenance friction. The original concern about reversal
cost still applies; this decision should not be made lightly.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a decision changelog. You can trace how the team&#39;s thinking evolved over time, what changed in the environment, and what the cost of each reversal was judged to be. That history is only available because the earlier ADR was never edited — it captured what was true and what was known at the time it was written. The moment you start retroactively updating ADRs to reflect where you ended up, you lose the record of how you got there.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;ADR Quick-Start Checklist&lt;/h2&gt;
&lt;p&gt;To start using ADRs today:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Create &lt;code&gt;docs/decisions/&lt;/code&gt; in your repository root&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;docs/decisions/README.md&lt;/code&gt; — even if the index starts empty&lt;/li&gt;
&lt;li&gt;[ ] Write your first ADR for the most recent significant decision you made — don&#39;t reconstruct the entire project history, start from now&lt;/li&gt;
&lt;li&gt;[ ] Add an ADR pull request template at &lt;code&gt;.github/PULL_REQUEST_TEMPLATE/adr.md&lt;/code&gt; with the four-section structure&lt;/li&gt;
&lt;li&gt;[ ] Establish the norm: any PR that introduces or changes a foundational pattern either references an existing ADR or creates a new one&lt;/li&gt;
&lt;li&gt;[ ] Reference ADRs from PR descriptions and commit footers — the link from code to reasoning is what makes the record useful&lt;/li&gt;
&lt;li&gt;[ ] When a decision changes: update the old ADR&#39;s status field, write a new ADR that supersedes it — never edit the original&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You don&#39;t need a tool to start. &lt;code&gt;adr-tools&lt;/code&gt; (CLI) is useful at scale but not required. A folder and a template are enough.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;The Asymmetry&lt;/h2&gt;
&lt;p&gt;ADRs have almost no cost at the time of writing and asymmetric value over time. The 30 minutes you spend on ADR-001 pays back the first time a new engineer asks &amp;quot;why are we using Nunjucks?&amp;quot; and gets a two-minute answer instead of a 45-minute archaeology session. The payback compounds: a codebase with 20 ADRs is a codebase whose architectural history is readable, searchable, and honest about uncertainty. Not just &amp;quot;what did we decide&amp;quot; but &amp;quot;what did we consider,&amp;quot; &amp;quot;what did we know at the time,&amp;quot; and &amp;quot;what would it cost to change this.&amp;quot;&lt;/p&gt;
&lt;p&gt;That&#39;s not documentation for its own sake. That&#39;s a team that respects the time of every engineer who comes after them — including themselves, six months from now, staring at a decision they no longer remember making.&lt;/p&gt;
&lt;p&gt;The first ADR is the hardest. Write it this week for the last significant decision your team made. Everything after that is just the habit.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Want to talk through documenting architectural decisions at your organization, or building a decision-record practice from scratch? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>Architecture Decision Records are a single Markdown file per decision that eliminates the &#39;why did we build it this way?&#39; archaeology session forever — here is the template, the storage convention, the GitHub workflow, and a real example from this blog&#39;s own stack.</summary>
    <category term="writing-for-engineers"/>
    <category term="developer-productivity"/>
  </entry>
  <entry>
    <title>GitHub Branch Protection Rules vs. Rulesets: The New Way to Enforce Standards</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-05-08-github-branch-protection-rules-vs-rulesets/"/>
    <updated>2026-05-08T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-05-08-github-branch-protection-rules-vs-rulesets/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Most teams set up branch protection rules once, years ago, and haven&#39;t touched them since. That&#39;s understandable — once it&#39;s configured, it&#39;s invisible infrastructure. What&#39;s less visible is the hole in it. Classic branch protection has a default behavior that&#39;s documented but easy to miss: &lt;strong&gt;repository admins bypass all rules&lt;/strong&gt;. Require pull request reviews? An admin can push directly to &lt;code&gt;main&lt;/code&gt;. Require status checks? An admin can merge without them. For most small and medium teams — where the admin is also a developer — the protection they think they have has a gap large enough to drive a production incident through.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub Rulesets&lt;/strong&gt; close that gap. They also add organization-level enforcement, tag protection, named bypass actors, and an evaluation mode that lets you audit what would be blocked before you enforce anything. This post maps what changed between the two systems, walks through a production-ready Ruleset configuration, and includes an audit workflow that checks Ruleset coverage across every repo in your org.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What Classic Branch Protection Actually Does — and Where It Breaks Down&lt;/h2&gt;
&lt;p&gt;Classic branch protection gives you the fundamentals most teams need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Require pull request reviews&lt;/strong&gt; before merging (with configurable reviewer count and stale review dismissal)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Require status checks&lt;/strong&gt; to pass before merging&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Require branches to be up to date&lt;/strong&gt; before merging&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Restrict who can push&lt;/strong&gt; to the branch&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Require signed commits&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Require linear history&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That list covers a lot. For a single repo with a small team, it&#39;s often enough. The limitations become visible as teams grow or when something goes wrong.&lt;/p&gt;
&lt;h3&gt;The Admin Bypass Problem&lt;/h3&gt;
&lt;p&gt;By default, repository admins are exempt from all classic branch protection rules. There is a checkbox — &amp;quot;Include administrators&amp;quot; — that removes the exemption, but it is not enabled by default, and in practice many teams never enable it. This means that on most repos, the people most likely to push directly to &lt;code&gt;main&lt;/code&gt; under pressure (the people with admin access) are the people for whom all those protections are silently inactive.&lt;/p&gt;
&lt;p&gt;This isn&#39;t a fringe edge case. It&#39;s the default behavior.&lt;/p&gt;
&lt;h3&gt;Everything Else the Classic System Can&#39;t Do&lt;/h3&gt;
&lt;p&gt;Beyond admin bypass, the classic model has structural limitations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No tag protection&lt;/strong&gt;: classic branch protection is branches-only. Tags have a separate, weaker protection mechanism that most teams don&#39;t configure at all. Your &lt;code&gt;v1.2.3&lt;/code&gt; release tags are likely unprotected.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No organization-level enforcement&lt;/strong&gt;: branch protection is configured per-repo. If your organization has 50 repositories, you need 50 separate configurations. There&#39;s no single source of truth.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No bypass actors&lt;/strong&gt;: you can&#39;t grant a specific team or GitHub App the ability to bypass rules without making them full admins on the repo. The access model is binary.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No evaluation mode&lt;/strong&gt;: you can&#39;t test what a new protection would block before you enable it. You enforce or you don&#39;t.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;What Rulesets Are and How They Differ&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;A &lt;strong&gt;Ruleset&lt;/strong&gt; is GitHub&#39;s next-generation enforcement layer — it can target branches and tags, applies at the repo or organization level, supports named bypass actors, and can be exported and version-controlled as JSON.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Rulesets were introduced for GitHub Enterprise and are now available on all plan tiers. They don&#39;t replace the classic system immediately — you can run both simultaneously — but they are strictly more capable in every dimension that matters for compliance and security.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Classic Branch Protection&lt;/th&gt;
&lt;th&gt;Rulesets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Applies to branches&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applies to tags&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organization-level enforcement&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bypass actors (non-admin)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin bypass (default)&lt;/td&gt;
&lt;td&gt;✅ (admins bypass by default)&lt;/td&gt;
&lt;td&gt;Configurable — admins can be included or excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple rulesets per repo&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exportable as JSON&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation mode (audit without enforcing)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Targets by branch name pattern&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full fnmatch pattern support&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;The Capabilities Worth Understanding Before You Migrate&lt;/h2&gt;
&lt;h3&gt;Bypass Actors&lt;/h3&gt;
&lt;p&gt;This is the most important capability Rulesets add. Instead of the binary admin/non-admin split, Rulesets let you define specific &lt;strong&gt;bypass actors&lt;/strong&gt; — entities that are permitted to bypass rules under defined conditions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A specific team&lt;/strong&gt; — your platform engineering team can push hotfixes directly to &lt;code&gt;main&lt;/code&gt; without a PR; no one else can&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A specific GitHub App&lt;/strong&gt; — your release automation app can create and delete version tags; human engineers cannot&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repository roles&lt;/strong&gt; — Maintainer role can bypass; Contributor role cannot&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;bypass_mode&lt;/code&gt; field is particularly useful. Setting &lt;code&gt;bypass_mode: &amp;quot;pull_request&amp;quot;&lt;/code&gt; means the bypass actor can still only merge via a pull request — they bypass the status check or review requirements, but not the PR itself. This lets you grant trusted actors flexibility without removing the audit trail that comes with PR history.&lt;/p&gt;
&lt;h3&gt;Evaluation Mode&lt;/h3&gt;
&lt;p&gt;Before enforcing a new Ruleset, set its &lt;code&gt;enforcement&lt;/code&gt; to &lt;code&gt;evaluate&lt;/code&gt;. In evaluation mode, GitHub runs all the checks and logs what would have been blocked — without actually blocking anything. This is indispensable for organizations rolling out standards across many repos: you see the blast radius before anyone&#39;s work is interrupted.&lt;/p&gt;
&lt;p&gt;Run a Ruleset in evaluate mode for one to two weeks. If nothing surprising surfaces in the audit log, switch to &lt;code&gt;active&lt;/code&gt;. If something does surface, you&#39;ve caught it before it becomes an incident.&lt;/p&gt;
&lt;h3&gt;Tag Protection&lt;/h3&gt;
&lt;p&gt;Classic branch protection has no equivalent for tags. Rulesets close this. A tag-targeting Ruleset prevents deletion, non-fast-forward updates, and unauthorized creation of version tags:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;name&amp;quot;: &amp;quot;Protect release tags&amp;quot;,
  &amp;quot;target&amp;quot;: &amp;quot;tag&amp;quot;,
  &amp;quot;enforcement&amp;quot;: &amp;quot;active&amp;quot;,
  &amp;quot;conditions&amp;quot;: {
    &amp;quot;ref_name&amp;quot;: {
      &amp;quot;include&amp;quot;: [&amp;quot;refs/tags/v*&amp;quot;],
      &amp;quot;exclude&amp;quot;: []
    }
  },
  &amp;quot;rules&amp;quot;: [
    { &amp;quot;type&amp;quot;: &amp;quot;deletion&amp;quot; },
    { &amp;quot;type&amp;quot;: &amp;quot;non_fast_forward&amp;quot; },
    { &amp;quot;type&amp;quot;: &amp;quot;creation&amp;quot; }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;creation&lt;/code&gt; rule blocks all creation of matching refs by default — only bypass actors can create &lt;code&gt;v*&lt;/code&gt; tags. If your release process creates tags through a GitHub App or Actions bot, add that actor as a bypass actor on this Ruleset. Human engineers — including admins — are blocked by default.&lt;/p&gt;
&lt;h3&gt;Organization-Level Rulesets&lt;/h3&gt;
&lt;p&gt;A single Ruleset defined at the organization level applies to all repos in that org, or to a filtered subset by repo name pattern. This is the answer to &amp;quot;how do we enforce our branching standards across all 200 repositories&amp;quot; — one Ruleset, not 200 individual configuration changes. Repos can layer additional repo-level Rulesets on top of the org baseline; the most restrictive rule wins when rules conflict.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;A Complete Ruleset for a Typical Project&lt;/h2&gt;
&lt;p&gt;The following is a production-ready Ruleset for protecting the &lt;code&gt;main&lt;/code&gt; branch of a typical open-source or team project. You can import it directly through the GitHub UI (Repository → Settings → Rules → Rulesets → Import) or apply it via the API.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;name&amp;quot;: &amp;quot;Protect main branch&amp;quot;,
  &amp;quot;target&amp;quot;: &amp;quot;branch&amp;quot;,
  &amp;quot;enforcement&amp;quot;: &amp;quot;active&amp;quot;,
  &amp;quot;conditions&amp;quot;: {
    &amp;quot;ref_name&amp;quot;: {
      &amp;quot;include&amp;quot;: [&amp;quot;refs/heads/main&amp;quot;],
      &amp;quot;exclude&amp;quot;: []
    }
  },
  &amp;quot;bypass_actors&amp;quot;: [
    {
      &amp;quot;actor_id&amp;quot;: 1,
      &amp;quot;actor_type&amp;quot;: &amp;quot;OrganizationAdmin&amp;quot;,
      &amp;quot;bypass_mode&amp;quot;: &amp;quot;pull_request&amp;quot;
    }
  ],
  &amp;quot;rules&amp;quot;: [
    {
      &amp;quot;type&amp;quot;: &amp;quot;deletion&amp;quot;
    },
    {
      &amp;quot;type&amp;quot;: &amp;quot;non_fast_forward&amp;quot;
    },
    {
      &amp;quot;type&amp;quot;: &amp;quot;pull_request&amp;quot;,
      &amp;quot;parameters&amp;quot;: {
        &amp;quot;required_approving_review_count&amp;quot;: 1,
        &amp;quot;dismiss_stale_reviews_on_push&amp;quot;: true,
        &amp;quot;require_code_owner_review&amp;quot;: false,
        &amp;quot;require_last_push_approval&amp;quot;: true,
        &amp;quot;allowed_merge_methods&amp;quot;: [&amp;quot;squash&amp;quot;, &amp;quot;merge&amp;quot;]
      }
    },
    {
      &amp;quot;type&amp;quot;: &amp;quot;required_status_checks&amp;quot;,
      &amp;quot;parameters&amp;quot;: {
        &amp;quot;strict_required_status_checks_policy&amp;quot;: true,
        &amp;quot;required_status_checks&amp;quot;: [
          {
            &amp;quot;context&amp;quot;: &amp;quot;build / compile&amp;quot;,
            &amp;quot;integration_id&amp;quot;: null
          },
          {
            &amp;quot;context&amp;quot;: &amp;quot;test / unit-tests&amp;quot;,
            &amp;quot;integration_id&amp;quot;: null
          }
        ]
      }
    },
    {
      &amp;quot;type&amp;quot;: &amp;quot;required_signatures&amp;quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few choices worth explaining:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;bypass_mode: &amp;quot;pull_request&amp;quot;&lt;/code&gt;&lt;/strong&gt; on the &lt;code&gt;OrganizationAdmin&lt;/code&gt; actor: org admins can still bypass review and status check requirements, but they can&#39;t push directly to &lt;code&gt;main&lt;/code&gt; — they still have to open a PR. The audit trail stays intact.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;require_last_push_approval: true&lt;/code&gt;&lt;/strong&gt;: the person who made the last push to a PR branch cannot be the one who approves the merge. This prevents a single developer from self-approving their own changes by pushing a trivial amendment to reset the review state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;strict_required_status_checks_policy: true&lt;/code&gt;&lt;/strong&gt;: the branch must be up to date with &lt;code&gt;main&lt;/code&gt; before merging. Disabling this allows a PR to merge even if its base has drifted in ways that would break the combined result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;allowed_merge_methods&lt;/code&gt;&lt;/strong&gt;: restricting to &lt;code&gt;squash&lt;/code&gt; and &lt;code&gt;merge&lt;/code&gt; (excluding rebase) is a project-specific choice — squash keeps &lt;code&gt;main&lt;/code&gt; history linear and readable; including &lt;code&gt;merge&lt;/code&gt; accommodates workflows that want to preserve PR structure. Adjust to match your conventions.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Replace &lt;code&gt;build / compile&lt;/code&gt; and &lt;code&gt;test / unit-tests&lt;/code&gt; with the actual check names from your Actions workflows. The names in &lt;code&gt;required_status_checks&lt;/code&gt; must match exactly — including the &lt;code&gt;&amp;lt;job-name&amp;gt; / &amp;lt;step-name&amp;gt;&lt;/code&gt; format that Actions generates.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Auditing Ruleset Coverage Across an Org&lt;/h2&gt;
&lt;p&gt;Rulesets are only useful if they&#39;re actually configured. As your organization grows, repos get created without anyone ensuring the baseline standards are applied. The following GitHub Actions workflow runs weekly and fails visibly if any repo in the org has no active Rulesets:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Audit Ruleset Coverage
on:
  schedule:
    - cron: &#39;0 9 * * 1&#39;  # Every Monday at 9am
  workflow_dispatch:

permissions:
  contents: read

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - name: Find repos without active Rulesets
        env:
          GH_TOKEN: ${{ secrets.ORG_READ_TOKEN }}
          ORG: ${{ vars.ORG_NAME }}
        run: |
          echo &amp;quot;Checking Ruleset coverage for org: $ORG&amp;quot;

          # Get all repos in the org
          repos=$(gh api /orgs/$ORG/repos --paginate &#92;
            --jq &#39;.[].name&#39;)

          uncovered=()

          while IFS= read -r repo; do
            ruleset_count=$(gh api /repos/$ORG/$repo/rulesets &#92;
              --jq &#39;[.[] | select(.enforcement == &amp;quot;active&amp;quot;)] | length&#39; &#92;
              2&amp;gt;/dev/null || echo &amp;quot;0&amp;quot;)

            if [ &amp;quot;$ruleset_count&amp;quot; -eq &amp;quot;0&amp;quot; ]; then
              uncovered+=(&amp;quot;$repo&amp;quot;)
            fi
          done &amp;lt;&amp;lt;&amp;lt; &amp;quot;$repos&amp;quot;

          if [ ${#uncovered[@]} -eq 0 ]; then
            echo &amp;quot;✅ All repos have active Rulesets configured.&amp;quot;
          else
            echo &amp;quot;⚠️  Repos missing active Rulesets:&amp;quot;
            printf &#39;  - %s&#92;n&#39; &amp;quot;${uncovered[@]}&amp;quot;
            exit 1
          fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things to know about running this:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ORG_READ_TOKEN&lt;/code&gt; needs &lt;code&gt;repo&lt;/code&gt; scope to read private repository metadata, or &lt;code&gt;read:org&lt;/code&gt; if you&#39;re working with org-level Rulesets. Store it as a repository secret on wherever this workflow lives — a dedicated &lt;code&gt;platform-engineering&lt;/code&gt; repo works well. &lt;code&gt;ORG_NAME&lt;/code&gt; is a repository variable (not a secret) set to your GitHub organization name.&lt;/p&gt;
&lt;p&gt;The workflow exits with code 1 when uncovered repos are found. That means it fails visibly in the Actions UI and can trigger notifications. You can extend it to open a GitHub Issue automatically or post to Slack, but the exit code alone is enough to make the gap impossible to ignore in a weekly check-in workflow.&lt;/p&gt;
&lt;p&gt;Note that this audit only detects repos with no active Rulesets at all — it doesn&#39;t validate that the Rulesets that exist are correctly configured. For more granular compliance checking, extend the inner loop to inspect specific rule types against your organization&#39;s baseline requirements.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Migration Path&lt;/h2&gt;
&lt;p&gt;This doesn&#39;t need to be a big-bang migration. Here&#39;s a sequence that keeps risk low.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Enable Rulesets in parallel.&lt;/strong&gt; Create a Ruleset that mirrors your existing branch protection rules and set &lt;code&gt;enforcement&lt;/code&gt; to &lt;code&gt;evaluate&lt;/code&gt;. Run it for two weeks. Check the Insights tab under Repository → Settings → Rules — it shows every rule evaluation and whether it would have been blocked. Confirm nothing unexpected surfaces.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Map your bypass actors.&lt;/strong&gt; Who on your team legitimately needs to bypass rules? Your release automation bot? A platform team doing emergency hotfixes? Write that list down and map each actor to a Ruleset bypass actor. Stop relying on admin status as a proxy for &amp;quot;trusted to bypass.&amp;quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Add tag protection immediately.&lt;/strong&gt; If you use version tags (&lt;code&gt;v1.2.3&lt;/code&gt;, &lt;code&gt;v2.0.0-rc.1&lt;/code&gt;), you almost certainly have no protection on them right now. Add a tag-targeting Ruleset today — this is the change with the best risk-to-effort ratio in this entire post.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Check your admin bypass exposure.&lt;/strong&gt; In your existing classic branch protection, is &amp;quot;Include administrators&amp;quot; checked? If not, every repo admin bypasses every rule. Fix this in the Ruleset (the &lt;code&gt;bypass_mode: &amp;quot;pull_request&amp;quot;&lt;/code&gt; pattern shown above), or add it to the classic rules as an immediate stopgap while you migrate.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. For orgs with many repos&lt;/strong&gt;: define one org-level Ruleset for baseline standards. Individual repos can add repo-level Rulesets on top for project-specific requirements.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Once confident, disable classic branch protection.&lt;/strong&gt; Running both simultaneously isn&#39;t dangerous — the stricter rule always wins — but it is confusing. When a developer asks &amp;quot;why can&#39;t I merge this?&amp;quot; and the answer requires knowing which system is blocking them, you&#39;ve created an unnecessary support burden. Once your Rulesets are active and validated, remove the classic rules.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;Migration Checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Check existing branch protection: is &amp;quot;Include administrators&amp;quot; enabled on every protected branch? If not, fix it first — this is your current exposure.&lt;/li&gt;
&lt;li&gt;[ ] Create a mirror Ruleset in &lt;code&gt;evaluate&lt;/code&gt; mode and run it for 1–2 weeks; review the Insights log for unexpected evaluations&lt;/li&gt;
&lt;li&gt;[ ] Map your bypass needs: list who legitimately needs to bypass rules and map each to a named bypass actor (team, app, or role)&lt;/li&gt;
&lt;li&gt;[ ] Add tag protection for release tags (&lt;code&gt;v*&lt;/code&gt;) — classic branch protection offers nothing here&lt;/li&gt;
&lt;li&gt;[ ] For multi-repo orgs: define an org-level baseline Ruleset that applies to all repositories&lt;/li&gt;
&lt;li&gt;[ ] Set the audit workflow to run on a weekly schedule&lt;/li&gt;
&lt;li&gt;[ ] Once Rulesets are active and validated: disable classic branch protection to eliminate confusion about which system is enforcing what&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;Classic branch protection did the job for years, but it was designed for a simpler model — one repo, one team, admin-or-not access control. Rulesets are designed for the actual complexity of modern engineering organizations: multiple repos, mixed access models, automated actors, and the need to audit compliance across all of it. The migration isn&#39;t urgent. But the admin bypass exposure — the protection that silently disappears for the people most likely to push directly to &lt;code&gt;main&lt;/code&gt; under pressure — is reason enough to start this week. That&#39;s not a theoretical gap. It&#39;s the default configuration.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Want to talk through Ruleset strategy for your organization, or get help designing a bypass actor model that matches your team&#39;s actual access needs? Reach out.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;mailto:steve.kaschimer@slalom.com&quot;&gt;steve.kaschimer@slalom.com&lt;/a&gt;&lt;/p&gt;
</content>
    <summary>GitHub Rulesets replace classic branch protection with organization-level enforcement, named bypass actors, and tag protection — here is what changed, what to migrate first, and an audit workflow to check coverage across your entire org.</summary>
    <category term="github"/>
    <category term="devsecops"/>
    <category term="platform-engineering"/>
  </entry>
</feed>
