<?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-07-31T00: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.webp&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.webp&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.webp&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.webp&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.webp&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-(--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>
  <entry>
    <title>Shift Right: Why Production Observability Is a Security Practice</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-05-15-shift-right-observability-as-a-security-practice/"/>
    <updated>2026-05-15T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-05-15-shift-right-observability-as-a-security-practice/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Shift left is right. Catch vulnerabilities in code review, in CI, in dependency scanning - before they ship. The DevSecOps community has invested heavily here and the tooling is genuinely good. But shift left has a blind spot: it only covers the attack surface you anticipated. Attackers don&#39;t respect your threat model. They operate in production, against live systems, exploiting behavior that passed every scan because it wasn&#39;t a vulnerability until it was combined with a specific runtime condition, a specific data pattern, or a specific sequence of requests. The answer isn&#39;t to abandon shift left - it&#39;s to complement it with &lt;strong&gt;shift right&lt;/strong&gt;: instrumentation in production that makes anomalous behavior visible before it becomes an incident.&lt;/p&gt;
&lt;p&gt;The reason this gets skipped is understandable. Shift-left tooling is largely automatic - you plug in Dependabot, add a CodeQL workflow, and the scans run without anyone thinking about them. Shift-right requires decisions: what to instrument, what thresholds to set, where to route alerts, how to avoid drowning your team in noise. Those decisions aren&#39;t hard, but they require intention. This post makes the argument for doing it, shows how to instrument a Node.js service with &lt;strong&gt;OpenTelemetry&lt;/strong&gt; for security-relevant signals, and routes those signals into GitHub Security alerts - where your team is already reviewing Dependabot and CodeQL findings - so the operational overhead stays close to zero.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Observability Data and Security Data Are the Same Data&lt;/h2&gt;
&lt;p&gt;Here is the core argument: everything your security team wants to see in a SIEM is already flowing through your observability stack. The difference is framing and threshold, not source.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A spike in 4xx errors might be a bug. It might be credential stuffing.&lt;/li&gt;
&lt;li&gt;Unusual outbound connections might be a misconfigured integration. They might be C2 beacon traffic.&lt;/li&gt;
&lt;li&gt;A process spawning a child process might be a legitimate job runner. It might be a shell injection exploit in progress.&lt;/li&gt;
&lt;li&gt;Authentication failures at 3am from a single IP might be a developer locked out. It might be a brute-force attempt.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these interpretations require different data. They require different context and different thresholds applied to the same stream. Most organizations run separate tooling for observability (Datadog, Grafana, OpenTelemetry collectors) and security (SIEM, GitHub Security alerts, Dependabot). They are looking at the same signals twice through different products, paying twice, and - critically - the integration between the two stacks is usually manual, asynchronous, and owned by nobody. The better model is to define security-relevant thresholds inside your observability instrumentation and route high-severity signals to security tooling directly.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Observability asks: &amp;quot;What is my system doing right now?&amp;quot; Security asks: &amp;quot;Is my system being abused right now?&amp;quot; At the production layer, these questions share an answer.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;The Three Signal Categories Worth Instrumenting for Security&lt;/h2&gt;
&lt;p&gt;Not everything in your telemetry is security-relevant. The goal is not to route all traces into a SIEM - it&#39;s to identify the narrow set of signals where an anomaly is evidence of abuse rather than just a bug. Three categories cover most of what matters for a typical web application.&lt;/p&gt;
&lt;h3&gt;Authentication and Authorization Signals&lt;/h3&gt;
&lt;p&gt;Failed logins are the most observable leading indicator of account-based attacks. What to track:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Failed authentication attempts&lt;/strong&gt; - rate, source IP, target account&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Successful auth after N prior failures&lt;/strong&gt; - credential stuffing success signal; this is the event that matters most&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Privilege escalation events&lt;/strong&gt; - role changes, permission grants, admin flag toggles&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Token anomalies&lt;/strong&gt; - same token presented from multiple distinct IPs, token used after recorded revocation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trick with auth signals is baselining. A handful of failed logins per hour is normal. Fifty from a single IP over five minutes is not. Establish a 30-day rolling baseline for failed auth rate per hour, alert when the current rate exceeds 3× baseline sustained for five or more minutes. This single threshold eliminates the noise from developers mistyping their passwords while catching the early ramp of a stuffing attack.&lt;/p&gt;
&lt;h3&gt;Outbound Connection Patterns&lt;/h3&gt;
&lt;p&gt;Server-side applications have a predictable set of outbound destinations. Your API calls GitHub, Slack, maybe a payment processor. That list changes infrequently and is known in advance. An unexpected outbound connection is anomalous by definition - it doesn&#39;t require a baseline, because the baseline is your allowlist.&lt;/p&gt;
&lt;p&gt;What to track:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Outbound connections to new or unknown hosts&lt;/strong&gt; - first-time destinations not on the allowlist&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DNS resolution of domains outside the allowlist&lt;/strong&gt; - catches connections that haven&#39;t been made yet&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data volume spikes on established outbound connections&lt;/strong&gt; - potential exfiltration even to a known destination&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This pattern is particularly high-value because it catches post-exploitation activity. An attacker who has achieved code execution in your application will eventually try to beacon home or exfiltrate data. The outbound connection is often the first observable evidence.&lt;/p&gt;
&lt;h3&gt;Process and Runtime Behavior&lt;/h3&gt;
&lt;p&gt;For Node.js specifically, &lt;strong&gt;&lt;code&gt;child_process.exec&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;child_process.spawn&lt;/code&gt;&lt;/strong&gt; with user-controlled input are a common injection vector. Logging every process spawn with its full argument list is cheap - the events are rare in a healthy application - and the signal-to-noise ratio when something anomalous appears is excellent.&lt;/p&gt;
&lt;p&gt;What to watch for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unexpected child process spawns&lt;/strong&gt;, especially shell interpreters: &lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;cmd.exe&lt;/code&gt;, &lt;code&gt;powershell.exe&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File system writes to locations outside expected application directories&lt;/strong&gt; - binaries dropping to &lt;code&gt;/tmp&lt;/code&gt;, writes to cron directories&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memory usage anomalies&lt;/strong&gt; - some exploit payloads and deserialization attacks cause distinctive heap growth patterns&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A Node.js web application should almost never spawn a shell. If one appears in your process spawn log, that is not a false positive you tune away - it is the beginning of an incident response.&lt;/p&gt;
&lt;p&gt;Instrument spawns by wrapping &lt;code&gt;child_process.spawn&lt;/code&gt; at a single callsite in your application:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// lib/safe-spawn.js
import { spawn as _spawn } from &#39;child_process&#39;;
import { trace } from &#39;@opentelemetry/api&#39;;

const SHELL_INTERPRETERS = new Set([&#39;sh&#39;, &#39;bash&#39;, &#39;zsh&#39;, &#39;cmd.exe&#39;, &#39;powershell.exe&#39;, &#39;pwsh&#39;]);

export function spawn(command, args = [], options = {}) {
  const fullCommand = [command, ...args].join(&#39; &#39;);
  const isShell = SHELL_INTERPRETERS.has(command);

  console.log(JSON.stringify({
    level: isShell ? &#39;error&#39; : &#39;info&#39;,
    event: &#39;process.spawn&#39;,
    command,
    args: args.join(&#39; &#39;),
    is_shell: isShell,
    timestamp: new Date().toISOString(),
  }));

  const span = trace.getActiveSpan();
  if (span) {
    span.setAttributes({
      &#39;process.spawn.command&#39;: command,
      &#39;process.spawn.args&#39;: args.join(&#39; &#39;),
      ...(isShell &amp;amp;&amp;amp; { &#39;security.anomaly&#39;: &#39;shell_spawn&#39;, &#39;security.severity&#39;: &#39;high&#39; }),
    });
  }

  return _spawn(command, args, options);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use &lt;code&gt;lib/safe-spawn.js&lt;/code&gt; everywhere in your application instead of importing &lt;code&gt;child_process&lt;/code&gt; directly - the wrapper is a &lt;code&gt;eslint&lt;/code&gt; rule away from being enforced (&lt;code&gt;no-restricted-imports&lt;/code&gt;).&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Instrumenting a Node.js App with OpenTelemetry&lt;/h2&gt;
&lt;p&gt;OpenTelemetry&#39;s auto-instrumentation handles the baseline - HTTP spans, framework-level timing, error tracking - without code changes. The security-relevant work is adding custom attributes to existing spans and emitting structured logs alongside them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Install the core packages:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @opentelemetry/sdk-node &#92;
  @opentelemetry/auto-instrumentations-node &#92;
  @opentelemetry/exporter-otlp-http
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Bootstrap the SDK&lt;/strong&gt; - load this before anything else using &lt;code&gt;--require ./instrumentation.js&lt;/code&gt;. Using &lt;code&gt;spanProcessors&lt;/code&gt; explicitly lets you compose the export pipeline with custom processors (the &lt;code&gt;OutboundAnomalyProcessor&lt;/code&gt; defined below):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// instrumentation.js
import { NodeSDK } from &#39;@opentelemetry/sdk-node&#39;;
import { getNodeAutoInstrumentations } from &#39;@opentelemetry/auto-instrumentations-node&#39;;
import { OTLPTraceExporter } from &#39;@opentelemetry/exporter-otlp-http&#39;;
import { BatchSpanProcessor } from &#39;@opentelemetry/sdk-trace-node&#39;;
import { OutboundAnomalyProcessor } from &#39;./processors/outbound-anomaly.js&#39;;

const exporter = new OTLPTraceExporter({
  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
});

const sdk = new NodeSDK({
  spanProcessors: [
    new BatchSpanProcessor(exporter),   // normal export pipeline
    new OutboundAnomalyProcessor(),     // security annotation layer
  ],
  instrumentations: [
    getNodeAutoInstrumentations({
      &#39;@opentelemetry/instrumentation-http&#39;: { enabled: true },
      &#39;@opentelemetry/instrumentation-express&#39;: { enabled: true },
    }),
  ],
});

sdk.start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Add security-relevant attributes in your auth middleware:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// middleware/auth.js
import { trace, SpanStatusCode } from &#39;@opentelemetry/api&#39;;

export function authMiddleware(req, res, next) {
  const span = trace.getActiveSpan();

  if (span) {
    span.setAttributes({
      &#39;auth.method&#39;: req.headers[&#39;authorization&#39;] ? &#39;bearer&#39; : &#39;none&#39;,
      &#39;auth.ip&#39;: req.ip,
      &#39;auth.user_agent&#39;: req.headers[&#39;user-agent&#39;] ?? &#39;unknown&#39;,
    });
  }

  // ... auth logic
  if (authFailed) {
    span?.setAttributes({ &#39;auth.failure_reason&#39;: &#39;invalid_credentials&#39; });
    span?.setStatus({ code: SpanStatusCode.ERROR, message: &#39;auth_failure&#39; });

    // Structured log alongside the span - queryable by your log aggregator
    console.log(JSON.stringify({
      level: &#39;warn&#39;,
      event: &#39;auth.failure&#39;,
      ip: req.ip,
      target: req.body?.username ?? &#39;unknown&#39;,
      timestamp: new Date().toISOString(),
    }));
  }

  next();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flag unknown outbound destinations&lt;/strong&gt; using a custom span processor. OpenTelemetry&#39;s HTTP auto-instrumentation captures outbound calls automatically; this processor adds a security annotation when the destination isn&#39;t recognized:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// processors/outbound-anomaly.js
const KNOWN_DESTINATIONS = new Set([
  &#39;api.github.com&#39;,
  &#39;hooks.slack.com&#39;,
  // ... your known outbound hosts
]);

export class OutboundAnomalyProcessor {
  onStart(span, _parentContext) {
    const host = span.attributes[&#39;net.peer.name&#39;];
    if (host &amp;amp;&amp;amp; !KNOWN_DESTINATIONS.has(host)) {
      span.setAttributes({
        &#39;security.anomaly&#39;: &#39;unknown_outbound_destination&#39;,
        &#39;security.severity&#39;: &#39;medium&#39;,
      });
      console.log(JSON.stringify({
        level: &#39;warn&#39;,
        event: &#39;outbound.unknown_destination&#39;,
        host,
        timestamp: new Date().toISOString(),
      }));
    }
  }
  onEnd() {}
  shutdown() { return Promise.resolve(); }
  forceFlush() { return Promise.resolve(); }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the processor in your SDK config by passing it to &lt;code&gt;spanProcessors&lt;/code&gt; alongside the exporter. The &lt;code&gt;onStart&lt;/code&gt; hook fires before the span is exported, so the attribute is visible in both your trace backend and your log stream.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Routing Security Signals to GitHub Security Alerts&lt;/h2&gt;
&lt;p&gt;Your team already reviews Dependabot and CodeQL findings in the GitHub Security tab. Routing runtime signals to the same interface means one fewer tool to check and no new alerting channel to maintain. GitHub&#39;s Code Scanning API accepts &lt;strong&gt;SARIF&lt;/strong&gt; (Static Analysis Results Interchange Format) - a JSON schema that was designed for static analysis but works equally well as a transport for runtime anomaly results.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A GitHub Actions workflow to ingest a batch of security signals:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Ingest Security Signals
on:
  workflow_dispatch:
    inputs:
      signals_file:
        description: &#39;Path to security signals JSON file&#39;
        required: true

permissions:
  security-events: write
  contents: read

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

      - name: Convert signals to SARIF
        id: convert
        run: |
          node scripts/signals-to-sarif.js &#92;
            --input &amp;quot;${{ inputs.signals_file }}&amp;quot; &#92;
            --output security-signals.sarif

      - name: Upload SARIF to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: security-signals.sarif
          category: runtime-security
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;category: runtime-security&lt;/code&gt; field keeps runtime signals visually separated from your static analysis results in the Security tab. &lt;strong&gt;The SARIF your conversion script emits&lt;/strong&gt; needs to be schema-valid - here is the minimal structure for an auth failure spike result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;version&amp;quot;: &amp;quot;2.1.0&amp;quot;,
  &amp;quot;runs&amp;quot;: [{
    &amp;quot;tool&amp;quot;: {
      &amp;quot;driver&amp;quot;: {
        &amp;quot;name&amp;quot;: &amp;quot;runtime-security&amp;quot;,
        &amp;quot;rules&amp;quot;: [{
          &amp;quot;id&amp;quot;: &amp;quot;auth-failure-spike&amp;quot;,
          &amp;quot;name&amp;quot;: &amp;quot;AuthenticationFailureSpike&amp;quot;,
          &amp;quot;shortDescription&amp;quot;: { &amp;quot;text&amp;quot;: &amp;quot;Elevated authentication failure rate&amp;quot; },
          &amp;quot;defaultConfiguration&amp;quot;: { &amp;quot;level&amp;quot;: &amp;quot;warning&amp;quot; }
        }]
      }
    },
    &amp;quot;results&amp;quot;: [{
      &amp;quot;ruleId&amp;quot;: &amp;quot;auth-failure-spike&amp;quot;,
      &amp;quot;message&amp;quot;: { &amp;quot;text&amp;quot;: &amp;quot;Auth failure rate 4.2x baseline for 8 minutes from 203.0.113.42&amp;quot; },
      &amp;quot;level&amp;quot;: &amp;quot;warning&amp;quot;,
      &amp;quot;locations&amp;quot;: [{
        &amp;quot;physicalLocation&amp;quot;: {
          &amp;quot;artifactLocation&amp;quot;: { &amp;quot;uri&amp;quot;: &amp;quot;src/middleware/auth.js&amp;quot; }
        }
      }]
    }]
  }]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;physicalLocation.artifactLocation.uri&lt;/code&gt; maps the alert to the relevant source file - the auth middleware, the HTTP client wrapper, wherever the instrumented code lives. GitHub uses this to anchor the alert in code review context.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The &lt;code&gt;signals-to-sarif.js&lt;/code&gt; conversion script&lt;/strong&gt; reads a newline-delimited JSON file of signal objects emitted by your application (each line: &lt;code&gt;{ ruleId, message, level, uri }&lt;/code&gt;) and produces the SARIF document:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// scripts/signals-to-sarif.js
import { readFileSync, writeFileSync } from &#39;fs&#39;;
import { parseArgs } from &#39;util&#39;;

const { values } = parseArgs({
  options: {
    input:  { type: &#39;string&#39; },
    output: { type: &#39;string&#39; },
  },
});

const signals = readFileSync(values.input, &#39;utf8&#39;)
  .trim().split(&#39;&#92;n&#39;).map(line =&amp;gt; JSON.parse(line));

const ruleIds = [...new Set(signals.map(s =&amp;gt; s.ruleId))];

const sarif = {
  version: &#39;2.1.0&#39;,
  runs: [{
    tool: {
      driver: {
        name: &#39;runtime-security&#39;,
        rules: ruleIds.map(id =&amp;gt; ({
          id,
          name: id.replace(/-([a-z])/g, (_, c) =&amp;gt; c.toUpperCase()),
          shortDescription: { text: id },
          defaultConfiguration: { level: &#39;warning&#39; },
        })),
      },
    },
    results: signals.map(s =&amp;gt; ({
      ruleId: s.ruleId,
      message: { text: s.message },
      level: s.level ?? &#39;warning&#39;,
      locations: [{ physicalLocation: { artifactLocation: { uri: s.uri } } }],
    })),
  }],
};

writeFileSync(values.output, JSON.stringify(sarif, null, 2));
console.log(`Wrote ${signals.length} signal(s) to ${values.output}`);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your runtime alerting code (rate-threshold logic, correlation checks) writes to the signals file; this script is the schema adapter. Keep them separate - the converter is stateless and easy to test; the threshold logic is where the real decisions live.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Defining Thresholds That Distinguish Abuse from Bugs&lt;/h2&gt;
&lt;p&gt;The hardest part of runtime security alerting is the signal-to-noise ratio. Alert on everything anomalous and the team stops reading alerts within a week. Alert on nothing and the whole exercise is a waste of instrumentation work. A practical framework has two layers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rate-based thresholds&lt;/strong&gt; apply to signals with a predictable normal distribution - auth failures, error rates, request volume:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Establish a 30-day rolling baseline per metric&lt;/li&gt;
&lt;li&gt;Alert at 3× baseline sustained for 5+ minutes - this filters transient spikes from deploys and traffic bursts&lt;/li&gt;
&lt;li&gt;Page at 10× baseline sustained for 2+ minutes - this is almost certainly not normal traffic under any reasonable interpretation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Novelty-based thresholds&lt;/strong&gt; apply to signals that should be near-zero in a healthy application - no baseline required:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;First occurrence of a new outbound destination: medium alert&lt;/li&gt;
&lt;li&gt;First occurrence of a process spawn with a shell interpreter: high alert immediately (this should not happen in a Node.js web server)&lt;/li&gt;
&lt;li&gt;First successful auth from an IP that had 10+ prior failures in the preceding hour: high alert&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Correlation is where the signal-to-noise math actually improves.&lt;/strong&gt; Single anomalous events are noisy - each one has plausible innocent explanations. Two correlated anomalies from the same source within a short window have very few:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Auth failure spike from IP X &lt;em&gt;and&lt;/em&gt; successful auth from IP X within 30 minutes → credential stuffing success, critical&lt;/li&gt;
&lt;li&gt;Error rate spike &lt;em&gt;and&lt;/em&gt; unknown outbound connection &lt;em&gt;and&lt;/em&gt; new process spawn within the same 5-minute window → potential active exploit, critical&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Correlation is the difference between alert fatigue and actionable detection. Build it into your threshold logic before you go live with alerting.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;Shift-Right Implementation Checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Install OpenTelemetry with &lt;code&gt;@opentelemetry/auto-instrumentations-node&lt;/code&gt; - baseline HTTP and framework spans are free&lt;/li&gt;
&lt;li&gt;[ ] Add security-relevant attributes to auth spans: IP, user agent, failure reason&lt;/li&gt;
&lt;li&gt;[ ] Log every auth failure as structured JSON (not free-text) - machines need to count these&lt;/li&gt;
&lt;li&gt;[ ] Build a known-destinations allowlist and use &lt;code&gt;OutboundAnomalyProcessor&lt;/code&gt; to flag first-time outbound connections&lt;/li&gt;
&lt;li&gt;[ ] Log every &lt;code&gt;child_process&lt;/code&gt; spawn with full argument list&lt;/li&gt;
&lt;li&gt;[ ] Define 30-day rolling rate baselines for auth failures and error rates&lt;/li&gt;
&lt;li&gt;[ ] Set alert thresholds at 3× baseline sustained for 5 minutes; page at 10×&lt;/li&gt;
&lt;li&gt;[ ] Add correlation rules: auth spike + auth success from same IP; error spike + unknown outbound + process spawn&lt;/li&gt;
&lt;li&gt;[ ] Route high-severity signals to GitHub Security via SARIF upload with &lt;code&gt;category: runtime-security&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] Review security signal alerts in the same workflow as Dependabot and CodeQL - same interface, same team, same weekly review cadence&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;Shift left is necessary. It is not sufficient. Every security posture has a boundary where it stops - and for shift-left tooling, that boundary is the production environment. Attackers don&#39;t respect that boundary. They operate where your SAST scanner doesn&#39;t run, where your dependency auditor has no visibility, where the only evidence of compromise is a pattern in your logs that nobody was watching for.&lt;/p&gt;
&lt;p&gt;Observability instrumentation doesn&#39;t require a dedicated security team or a SIEM contract. It requires structured logging, OpenTelemetry, a set of thresholds, and the discipline to review alerts in the same tool where you review everything else. The shift is small. The coverage gap it closes is not.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions about OpenTelemetry instrumentation strategy, threshold design, or wiring runtime signals into your existing GitHub security workflow? 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>Shift-left tooling stops at the deployment boundary - runtime observability closes the gap by turning the same logs and traces you already collect into a security detection layer.</summary>
    <category term="security"/>
    <category term="devsecops"/>
    <category term="platform-engineering"/>
  </entry>
  <entry>
    <title>Enforcing Code Quality with GitHub Actions Status Checks You Can Actually Trust</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-05-29-github-actions-status-checks-you-can-trust/"/>
    <updated>2026-05-29T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-05-29-github-actions-status-checks-you-can-trust/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Your required status checks are configured. CI is green. PRs merge. The team believes the gate is working.&lt;/p&gt;
&lt;p&gt;Then someone asks: what happens if a developer pushes directly to &lt;code&gt;main&lt;/code&gt;? What happens when the test suite takes 18 minutes and developers start merging before results are in - or, worse, start suppressing failures to keep velocity up? What happens when a job is renamed in the workflow file and silently stops matching the required check name, so every PR from that point forward merges without the check running at all?&lt;/p&gt;
&lt;p&gt;Status checks are only a quality gate if they are fast, reliable, correctly named, and actually enforced. Most pipelines satisfy one or two of those criteria. This post covers all four failure modes and a concrete fix for each.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Failure Mode 1: Flaky Tests That Erode Trust&lt;/h2&gt;
&lt;p&gt;When tests fail non-deterministically, developers learn to re-run rather than investigate. That&#39;s rational behavior - if the test failed without a code change, the fix is to run it again. But it&#39;s catastrophic for a quality gate. Once re-running is the default response to a red check, the check stops functioning as a signal. It becomes a speed bump: an obstacle to clear, not a question to answer.&lt;/p&gt;
&lt;p&gt;The erosion compounds. A developer re-runs a flaky test and it passes. The next time, a different developer re-runs a different flaky test. After enough repetitions, the implicit team norm becomes &amp;quot;if it&#39;s red, retry it.&amp;quot; Actual failures get retried and missed.&lt;/p&gt;
&lt;h3&gt;Identifying Flaky Tests&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;gh&lt;/code&gt; CLI makes it straightforward to spot patterns. Look for jobs that fail on one run and pass on a re-run without any code change between them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Re-run only the failed jobs from the last workflow run
gh run rerun --failed &amp;lt;run-id&amp;gt;

# View the history of runs on a workflow, filtering by conclusion
gh run list --workflow ci.yml --limit 50 &#92;
  --json conclusion,headBranch,headSha &#92;
  | jq &#39;.[] | select(.conclusion == &amp;quot;failure&amp;quot;)&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compare the failing run&#39;s SHA against subsequent passing runs. If the same SHA both fails and passes across runs, those tests are flaky by definition.&lt;/p&gt;
&lt;h3&gt;The Quarantine Pattern&lt;/h3&gt;
&lt;p&gt;The immediate mitigation is to quarantine flaky tests into a separate job that does not block merge:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test -- --testPathIgnorePatterns=&amp;quot;flaky&amp;quot;

  flaky-tests:
    runs-on: ubuntu-latest
    continue-on-error: true   # doesn&#39;t block merge
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test -- --testPathPattern=&amp;quot;flaky&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;continue-on-error: true&lt;/code&gt; means the &lt;code&gt;flaky-tests&lt;/code&gt; job can fail without failing the workflow. It runs, it reports, but it does not gate. The &lt;code&gt;tests&lt;/code&gt; job runs everything except the quarantined set and does gate.&lt;/p&gt;
&lt;p&gt;This is a mitigation, not a fix. The real fix is to treat flaky tests as bugs with a defined SLA - they cost more in lost trust than they save in coverage. A quarantined test that doesn&#39;t get scheduled for repair inside a sprint or two should be deleted. Zero coverage is more honest than false coverage.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Failure Mode 2: Slow Pipelines That Developers Route Around&lt;/h2&gt;
&lt;p&gt;18-minute CI is a social engineering problem as much as a technical one. When developers know the check takes that long, some will merge &amp;quot;probably fine&amp;quot; work before results are in. Others will pressure reviewers to approve before CI completes. The pipeline hasn&#39;t broken - it&#39;s been socially bypassed. The 10-minute rule: if your full CI pipeline exceeds 10 minutes, you will observe developers routing around it within three months. Not because they&#39;re careless, but because the feedback loop is too slow to respect.&lt;/p&gt;
&lt;h3&gt;Parallelizing Linters and Tests&lt;/h3&gt;
&lt;p&gt;The most common source of unnecessary serial time is running linters, type checks, and tests sequentially in one job. These have no dependency on each other. They should run in parallel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  lint:
    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 lint

  typecheck:
    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 typecheck

  test:
    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 test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These three jobs run simultaneously. The workflow completes in &lt;code&gt;max(lint, typecheck, test)&lt;/code&gt; time, not &lt;code&gt;sum&lt;/code&gt;. If each takes three minutes, the total is three minutes instead of nine. &lt;code&gt;cache: &#39;npm&#39;&lt;/code&gt; in &lt;code&gt;actions/setup-node&lt;/code&gt; handles dependency caching keyed on &lt;code&gt;package-lock.json&lt;/code&gt; - on a cache hit, &lt;code&gt;npm ci&lt;/code&gt; takes seconds.&lt;/p&gt;
&lt;h3&gt;Using &lt;code&gt;paths&lt;/code&gt; Filters to Skip Irrelevant Checks&lt;/h3&gt;
&lt;p&gt;A PR that only changes &lt;code&gt;README.md&lt;/code&gt; or &lt;code&gt;docs/&lt;/code&gt; does not need to run the full test suite. The &lt;code&gt;paths&lt;/code&gt; trigger filter skips the workflow entirely for those PRs - zero runner minutes consumed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;on:
  pull_request:
    paths:
      - &#39;src/**&#39;
      - &#39;package.json&#39;
      - &#39;package-lock.json&#39;
      - &#39;.github/workflows/**&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is an important caveat. If a required status check is configured and the workflow is skipped by a &lt;code&gt;paths&lt;/code&gt; filter, GitHub marks the check as &lt;strong&gt;Expected&lt;/strong&gt; rather than &lt;strong&gt;Success&lt;/strong&gt; - and the PR is blocked. The check never ran, so GitHub doesn&#39;t know if it would have passed.&lt;/p&gt;
&lt;p&gt;The fix is a &lt;code&gt;ci-gate&lt;/code&gt; job that always runs and evaluates whether the real checks passed or were legitimately skipped. This is covered in detail in Failure Mode 3 below - the &lt;code&gt;ci-gate&lt;/code&gt; pattern is the solution to both the bypass problem and the path-filter problem simultaneously.&lt;/p&gt;
&lt;h3&gt;The Path-Aware CI Pattern&lt;/h3&gt;
&lt;p&gt;The production-correct approach uses &lt;code&gt;dorny/paths-filter&lt;/code&gt; to detect which paths changed and make downstream jobs conditional:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: CI

on:
  pull_request:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      src: ${{ steps.filter.outputs.src }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            src:
              - &#39;src/**&#39;
              - &#39;package-lock.json&#39;

  test:
    needs: changes
    if: needs.changes.outputs.src == &#39;true&#39;
    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 &amp;amp;&amp;amp; npm test

  # Always runs - this is the job configured as the required status check
  ci-gate:
    needs: [changes, test]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Check all required jobs passed or were skipped
        run: |
          if [[ &amp;quot;${{ needs.test.result }}&amp;quot; == &amp;quot;failure&amp;quot; ]]; then
            echo &amp;quot;Tests failed&amp;quot;
            exit 1
          fi
          echo &amp;quot;CI gate passed&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ci-gate&lt;/code&gt; job runs on every PR without exception (&lt;code&gt;if: always()&lt;/code&gt;). It checks the result of the &lt;code&gt;test&lt;/code&gt; job: if &lt;code&gt;test&lt;/code&gt; failed, &lt;code&gt;ci-gate&lt;/code&gt; fails. If &lt;code&gt;test&lt;/code&gt; was skipped because the paths didn&#39;t match, &lt;code&gt;ci-gate&lt;/code&gt; passes - that&#39;s a legitimate skip. &lt;code&gt;ci-gate&lt;/code&gt; is the one job name registered as a required status check. It is the stable contract between the workflow and branch protection.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Failure Mode 3: Required Checks That Can Be Bypassed&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;A &lt;strong&gt;vacuous pass&lt;/strong&gt; occurs when a required status check is configured by name, but no check with that name runs on a given PR - GitHub treats an expected-but-absent check as satisfied and allows the merge to proceed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is the most insidious failure mode because it looks correct from the outside. Branch protection is enabled. Required checks are configured. PRs require checks to pass. But if the job was renamed - &lt;code&gt;unit-tests&lt;/code&gt; became &lt;code&gt;jest&lt;/code&gt; during a test runner migration, or a workflow file was restructured - the old required check name no longer matches any running check. Every PR from that point forward merges without running it.&lt;/p&gt;
&lt;p&gt;This is a naming contract problem. The required check name in branch protection is a string. The job name in the workflow is a string. Nothing enforces that they match.&lt;/p&gt;
&lt;h3&gt;Auditing the Mismatch&lt;/h3&gt;
&lt;p&gt;Check what&#39;s configured as required against what actually ran on recent PRs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# List configured required status checks for main
gh api /repos/{owner}/{repo}/branches/main/protection &#92;
  --jq &#39;.required_status_checks.contexts&#39;

# List checks that actually ran on the last merged PR
gh api /repos/{owner}/{repo}/commits/$(gh api /repos/{owner}/{repo}/pulls &#92;
  --jq &#39;map(select(.state==&amp;quot;closed&amp;quot;)) | first | .merge_commit_sha&#39;)/check-runs &#92;
  --jq &#39;[.check_runs[] | {name, status, conclusion}]&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compare the two lists. Any name in the first list that doesn&#39;t appear in the second list is a vacuous pass waiting to happen - or already happening.&lt;/p&gt;
&lt;h3&gt;Configuring Required Checks via the API&lt;/h3&gt;
&lt;p&gt;Clicking required checks in the UI works but isn&#39;t reproducible or auditable. Set them via the API instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Set required status checks via API
# PUT replaces the full protection config - include null for unused fields
gh api --method PUT /repos/{owner}/{repo}/branches/main/protection &#92;
  --input - &amp;lt;&amp;lt;&#39;EOF&#39;
{
  &amp;quot;required_status_checks&amp;quot;: {
    &amp;quot;strict&amp;quot;: true,
    &amp;quot;contexts&amp;quot;: [&amp;quot;ci-gate&amp;quot;]
  },
  &amp;quot;enforce_admins&amp;quot;: true,
  &amp;quot;required_pull_request_reviews&amp;quot;: null,
  &amp;quot;restrictions&amp;quot;: null
}
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;enforce_admins=true&lt;/code&gt; removes the admin bypass from classic branch protection - without it, repository admins skip all rules by default. (For a more complete treatment of admin bypass and why Rulesets are the better long-term answer, see the &lt;a href=&quot;https://steve-kaschimer.github.io/posts/2026-05-08-github-branch-protection-rules-vs-rulesets/&quot;&gt;May 8 post on Rulesets&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;Using Rulesets instead of classic branch protection, the required check is expressed as JSON that can be version-controlled and applied programmatically:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &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;ci-gate&amp;quot; }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One required check name - &lt;code&gt;ci-gate&lt;/code&gt; - that never changes. The workflow internals (job names, parallel vs. serial structure, which linter runs) can be refactored freely without ever touching branch protection configuration. The contract is between branch protection and &lt;code&gt;ci-gate&lt;/code&gt;. Everything else is internal.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Failure Mode 4: Coverage Enforcement With No Floor&lt;/h2&gt;
&lt;p&gt;Tests pass. Coverage is tracked. The badge on the README shows 73%. A PR removes a feature and its tests together - coverage drops to 65%. It merges. Nothing blocked it.&lt;/p&gt;
&lt;p&gt;Status checks can enforce a coverage floor, but most teams configure the check without one. Tracking coverage and enforcing a minimum are different things. The badge is decorative. The threshold is functional.&lt;/p&gt;
&lt;h3&gt;Enforcing a Floor in the Workflow&lt;/h3&gt;
&lt;p&gt;For Jest, threshold enforcement is built in - the &lt;code&gt;--coverageThreshold&lt;/code&gt; flag causes the process to exit non-zero if the threshold isn&#39;t met, which fails the step, which fails the job, which blocks the merge:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Run tests with coverage
  run: npm test -- --coverage --coverageThreshold=&#39;{&amp;quot;global&amp;quot;:{&amp;quot;lines&amp;quot;:80}}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For other frameworks, read the coverage report and fail explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Check coverage threshold
  run: |
    COVERAGE=$(cat coverage/coverage-summary.json | jq &#39;.total.lines.pct&#39;)
    echo &amp;quot;Line coverage: $COVERAGE%&amp;quot;
    if (( $(echo &amp;quot;$COVERAGE &amp;lt; 80&amp;quot; | bc -l) )); then
      echo &amp;quot;Coverage $COVERAGE% is below the 80% threshold&amp;quot;
      exit 1
    fi
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Threshold Philosophy&lt;/h3&gt;
&lt;p&gt;The floor should reflect your current coverage, not your aspirational coverage. Setting 80% when you&#39;re at 65% blocks every PR until someone dedicates a sprint to coverage work - which creates pressure to delete tests rather than write them, which is the opposite of the intent.&lt;/p&gt;
&lt;p&gt;Set the threshold at current coverage minus a small buffer. If you&#39;re at 73%, set 70%. This prevents regression without creating an immediate blocker. Then raise it quarterly, by five points at a time, as part of normal engineering hygiene. A threshold that moves deliberately upward is more useful than one set aspirationally high and immediately disabled under pressure.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Stable Required Check Pattern&lt;/h2&gt;
&lt;p&gt;These four failure modes share a common solution: a single &lt;code&gt;ci-gate&lt;/code&gt; job that acts as the stable contract between workflow internals and branch protection configuration.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PR opens
  → changes job:  detect which paths changed
  → lint job:     runs only if src/ changed (skipped otherwise)
  → test job:     runs only if src/ changed (skipped otherwise; coverage threshold enforced inline)
  → ci-gate job:  always runs, fails if lint or test failed
        ↑
        Required status check in Ruleset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The properties that make this pattern reliable:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;One stable check name&lt;/strong&gt; configured in branch protection or Rulesets: &lt;code&gt;ci-gate&lt;/code&gt;. This string never needs to change regardless of what happens inside the workflow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ci-gate&lt;/code&gt; always runs&lt;/strong&gt; - &lt;code&gt;if: always()&lt;/code&gt; ensures it has a result on every PR, including PRs where the real checks were skipped by path filters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ci-gate&lt;/code&gt; validates outcomes&lt;/strong&gt; - it passes if the real checks passed or were legitimately skipped; it fails if any real check failed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Workflow internals can change freely&lt;/strong&gt; - add a linter, rename a job, restructure parallelism. None of those changes affect the string registered in branch protection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Coverage threshold lives inside a job that &lt;code&gt;ci-gate&lt;/code&gt; depends on&lt;/strong&gt; - the &lt;code&gt;test&lt;/code&gt; job exits non-zero if coverage falls below the threshold, which propagates through &lt;code&gt;needs&lt;/code&gt; and surfaces as a &lt;code&gt;ci-gate&lt;/code&gt; failure.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The complete wiring for a typical project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: CI

on:
  pull_request:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      src: ${{ steps.filter.outputs.src }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            src:
              - &#39;src/**&#39;
              - &#39;package-lock.json&#39;

  lint:
    needs: changes
    if: needs.changes.outputs.src == &#39;true&#39;
    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 &amp;amp;&amp;amp; npm run lint

  test:
    needs: changes
    if: needs.changes.outputs.src == &#39;true&#39;
    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: &amp;gt;
          npm ci &amp;amp;&amp;amp; npm test -- --coverage
          --coverageThreshold=&#39;{&amp;quot;global&amp;quot;:{&amp;quot;lines&amp;quot;:80}}&#39;

  ci-gate:
    needs: [changes, lint, test]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Check all required jobs passed or were skipped
        run: |
          for result in &amp;quot;${{ needs.lint.result }}&amp;quot; &amp;quot;${{ needs.test.result }}&amp;quot;; do
            if [[ &amp;quot;$result&amp;quot; == &amp;quot;failure&amp;quot; || &amp;quot;$result&amp;quot; == &amp;quot;cancelled&amp;quot; ]]; then
              echo &amp;quot;A required job did not pass: $result&amp;quot;
              exit 1
            fi
          done
          echo &amp;quot;CI gate passed&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register &lt;code&gt;ci-gate&lt;/code&gt; as the required status check in your Ruleset. Everything else is an implementation detail.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;Status Check Audit Checklist&lt;/h2&gt;
&lt;p&gt;Run this against your current setup before the next sprint planning:&lt;/p&gt;
&lt;p&gt;[ ] List your required status check names (&lt;code&gt;gh api /repos/{owner}/{repo}/branches/main/protection --jq &#39;.required_status_checks.contexts&#39;&lt;/code&gt;) and verify they match actual job names running on recent PRs&lt;/p&gt;
&lt;p&gt;[ ] Check whether &amp;quot;Include administrators&amp;quot; is enforced on classic branch protection - or migrate to Rulesets (see May 8 post) where admin bypass is explicitly configurable&lt;/p&gt;
&lt;p&gt;[ ] Identify any test jobs that fail non-deterministically across the last 20 runs - quarantine them with &lt;code&gt;continue-on-error: true&lt;/code&gt; and file them as bugs with an SLA&lt;/p&gt;
&lt;p&gt;[ ] Measure CI wall-clock time on the last 10 PRs - is the median under 10 minutes? If not, identify which serial jobs can be parallelized&lt;/p&gt;
&lt;p&gt;[ ] If using &lt;code&gt;paths&lt;/code&gt; filters on the workflow trigger: verify you have a &lt;code&gt;ci-gate&lt;/code&gt; job with &lt;code&gt;if: always()&lt;/code&gt; that is the registered required check name&lt;/p&gt;
&lt;p&gt;[ ] Set a coverage threshold at current coverage minus 2-3% - then raise it 5 points per quarter&lt;/p&gt;
&lt;p&gt;[ ] Name the job registered as required status check something stable (&lt;code&gt;ci-gate&lt;/code&gt;) so workflow refactoring never silently disables enforcement&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Status checks are the enforcement layer between &amp;quot;code was reviewed&amp;quot; and &amp;quot;code meets quality standards.&amp;quot; When they&#39;re slow, developers route around them. When they&#39;re flaky, developers stop trusting them. When they&#39;re misconfigured, they enforce nothing at all.&lt;/p&gt;
&lt;p&gt;The fixes aren&#39;t complex - parallel jobs, a stable gate job, a coverage floor, path-aware skipping - but they require deliberate design. Default pipelines rarely have all four in place. A status check that developers respect and that actually enforces what it claims to enforce is one of the highest-leverage investments in code quality a team can make. It compounds: every PR, every merge, every release, automatically.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions about CI design, workflow architecture, or structuring required checks across an org? 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>Status checks only block bad code if they are fast, reliable, correctly named, and actually enforced - this post covers the four ways pipelines fail that test and the concrete workflow patterns that fix each one.</summary>
    <category term="github-actions"/>
    <category term="ci-cd"/>
    <category term="platform-engineering"/>
  </entry>
  <entry>
    <title>GitHub Copilot in CI: Automating Code Review at Scale</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-06-05-github-copilot-in-ci/"/>
    <updated>2026-06-05T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-06-05-github-copilot-in-ci/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Most teams use GitHub Copilot the same way they use autocomplete: a single developer, in an editor, getting suggestions on the code in front of them. That&#39;s a real productivity gain. It&#39;s also a fraction of what Copilot can do in a CI context, where it can review every PR, on every push, without a human reviewer being available - and where the quality of its configuration determines whether it becomes a genuine signal or just another source of noise to dismiss.&lt;/p&gt;
&lt;p&gt;This post covers how to wire up Copilot code review in a way that actually helps: what to configure, what to exclude, how to prevent it from becoming a gate that erodes trust, and how to read its output alongside human review rather than instead of it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Wiring &lt;code&gt;github/copilot-code-review&lt;/code&gt; in CI&lt;/h2&gt;
&lt;p&gt;The fastest way to operationalize Copilot review across every PR is the &lt;code&gt;github/copilot-code-review&lt;/code&gt; action. It runs on PR events, analyzes the diff, and posts review comments directly on changed lines.&lt;/p&gt;
&lt;p&gt;Start with a minimal workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: copilot-code-review

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]

jobs:
  review:
    if: github.event.pull_request.draft == false
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - uses: github/copilot-code-review@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three behavior details matter for rollout:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It never approves PRs.&lt;/strong&gt; Treat Copilot as a comment stream, not an approver. Required reviewer policies should still be human-only.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It reviews the diff, not your whole architecture.&lt;/strong&gt; It catches local issues in changed code; it does not replace design review.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It should be tuned before broad rollout.&lt;/strong&gt; Scope and instruction quality determine whether teams see signal or noise.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Enabling It: Org Policy vs. Repo Workflow&lt;/h2&gt;
&lt;p&gt;Copilot review still requires Copilot Business or Enterprise, but delivery happens in two layers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Organization or repository-level policy&lt;/strong&gt; is the prerequisite that enables Copilot code review.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repository workflow&lt;/strong&gt; controls when and how &lt;code&gt;github/copilot-code-review&lt;/code&gt; runs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Org admins configure this in &lt;strong&gt;Settings → Copilot → Policies → Code review&lt;/strong&gt; (or repository-level Copilot settings for per-repo control). If policy is disabled, the workflow will complete successfully in CI logs without posting any review comments on the PR.&lt;/p&gt;
&lt;p&gt;This split is useful: platform admins enable the feature once, while each repo keeps execution details versioned, reviewable, and constrained to its own risk profile.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Configuring What Copilot Reviews&lt;/h2&gt;
&lt;p&gt;Out of the box, Copilot reviews everything in every diff. That is the wrong default for most real codebases. Machine-generated files, test fixtures, migration scripts, and vendored code will generate comments that nobody wants to read. Configuring scope before you roll this out to a team matters.&lt;/p&gt;
&lt;h3&gt;Path Filters&lt;/h3&gt;
&lt;p&gt;The action supports &lt;code&gt;path_filters&lt;/code&gt;, so you can keep review focused on high-signal files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: github/copilot-code-review@v1
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    path_filters: |
      - &#39;src/**&#39;
      - &#39;.github/workflows/**&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This should be conservative by default: include only high-signal source and workflow paths first, then expand as needed. Revisit filters as your repo evolves so Copilot keeps looking where humans want help.&lt;/p&gt;
&lt;h3&gt;Custom Review Instructions&lt;/h3&gt;
&lt;p&gt;Custom instructions tell Copilot what to focus on, what to ignore, and what your team&#39;s standards are. They live in &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; alongside a &lt;code&gt;code-review&lt;/code&gt; section, or in a dedicated &lt;code&gt;.github/copilot-review-instructions.md&lt;/code&gt; file. The repository-level settings UI also accepts them directly under &lt;strong&gt;Settings → Copilot → Custom instructions&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A well-structured instruction file makes the difference between generic comments (&amp;quot;this could throw&amp;quot;) and comments that are specific to your codebase conventions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;# Code Review Instructions

## Focus Areas

Review all diffs for:
- Unhandled promise rejections and error paths that silently swallow exceptions
- Hardcoded credentials, secrets, or environment-specific values that belong in config
- Direct SQL string concatenation that should use parameterized queries
- Missing input validation on any function that accepts external data
- N+1 query patterns in ORM usage

## Ignore

Do not comment on:
- Code style or formatting (handled by our linter - see .eslintrc)
- Test coverage percentage or lack of test files in this PR
- TODO comments (we track these separately)
- Performance micro-optimizations unless there is a clear O(n²) or worse pattern

## Context

This is a Node.js API service. Database access uses Prisma. Authentication uses JWT
tokens validated by the `verifyToken` middleware. Any route handler that does not
call `verifyToken` should be flagged unless it is explicitly in the public routes list
at `src/routes/public.ts`.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The instruction file is committed to the repository, versioned, and reviewable in PRs like any other configuration. That is a feature - when the team decides to add a new focus area or update a convention, the change goes through a PR, gets discussed, and lands with a clear commit history.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Custom instructions have a length limit (currently around 8,000 characters). Prioritize security-relevant patterns and team-specific conventions over general best practices; Copilot already knows general best practices. The value of custom instructions is encoding the knowledge that is specific to &lt;em&gt;your&lt;/em&gt; codebase.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Preventing Copilot from &amp;quot;Approving Itself&amp;quot;&lt;/h2&gt;
&lt;p&gt;Copilot review comments are advisory, but teams often run separate automation that auto-approves bot PRs (for dependency updates or chore changes). Add an explicit Copilot denylist there so Copilot-authored changes cannot be auto-approved by bot logic:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  auto-approve-bot-prs:
    if: &amp;gt;
      github.event.pull_request.user.login != &#39;github-copilot[bot]&#39; &amp;amp;&amp;amp;
      github.event.pull_request.user.login != &#39;copilot-swe-agent[bot]&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;github-copilot[bot]&lt;/code&gt; is the review bot identity, and &lt;code&gt;copilot-swe-agent[bot]&lt;/code&gt; is a common bot identity for Copilot-authored PRs created by coding agents. Excluding both prevents bot-written changes from being auto-approved by bot-only logic.&lt;/p&gt;
&lt;p&gt;Pair that with branch protection requiring at least one human approval. The practical rule: Copilot can suggest and comment, but a human still owns merge intent.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Advisory vs. Hard Gate Decision&lt;/h2&gt;
&lt;p&gt;This is the most consequential configuration choice, and most teams get it wrong by defaulting to the wrong answer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The wrong answer&lt;/strong&gt;: make Copilot review a required gate that blocks merges when it posts &lt;code&gt;REQUEST_CHANGES&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it&#39;s wrong&lt;/strong&gt;: Copilot will flag real issues and non-issues with similar confidence. It will comment on patterns that are intentional in your codebase because it lacks the context to know they&#39;re intentional. Developers will encounter legitimate PRs blocked by a Copilot comment they disagree with and no clear path to dismiss it without either fixing something that doesn&#39;t need fixing or granting themselves an exception that undermines the gate&#39;s purpose.&lt;/p&gt;
&lt;p&gt;Once a required gate generates enough friction that developers start working around it - requesting each other&#39;s approvals before Copilot finishes, or dismissing reviews as a reflex - it is no longer a quality gate. It&#39;s a ceremony. The damage is not just to velocity; it&#39;s to the team&#39;s belief that automated tooling can be trusted, which makes the next time someone proposes a real quality gate harder.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The right answer&lt;/strong&gt;: run Copilot review as a non-blocking, advisory-only check. Copilot posts its comments. Developers see them. Human reviewers see them. The PR can merge with outstanding Copilot comments - that is fine. The comments are signal, not mandates.&lt;/p&gt;
&lt;p&gt;The way to enforce this configuration is to ensure that &lt;strong&gt;Copilot is never configured as a required reviewer&lt;/strong&gt; in branch protection rules or rulesets, and that your repository&#39;s review requirements (e.g., &amp;quot;require 1 approving review from code owners&amp;quot;) are satisfied by humans only.&lt;/p&gt;
&lt;p&gt;To make this explicit in documentation your team can refer to, add it to your CODEOWNERS file as a comment:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Copilot code review is enabled for all paths and runs automatically.
# Copilot review is ADVISORY ONLY - it does not count toward required reviewer approval.
# Do not dismiss Copilot comments without reading them, but do not feel obligated
# to address every comment before merging. Use judgment.

*       @your-org/eng-team
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;When Blocking Does Make Sense&lt;/h3&gt;
&lt;p&gt;There is a narrow category of Copilot comment that warrants a different policy: &lt;strong&gt;security findings&lt;/strong&gt;. If Copilot flags a potential credential exposure, SQL injection pattern, or authentication bypass, that comment deserves mandatory review before merge - but the mechanism should be a human, not an automated block.&lt;/p&gt;
&lt;p&gt;A pragmatic approach: configure a GitHub Actions workflow that is triggered when Copilot posts a review containing specific keywords. The workflow notifies a security channel (or opens an issue in a security repo) but does not block the PR. It creates a paper trail and a human gets eyes on it; the PR continues.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Copilot Security Alert Triage

on:
  pull_request_review:
    types: [submitted]

jobs:
  triage:
    if: &amp;gt;
      github.event.review.user.login == &#39;github-copilot[bot]&#39; &amp;amp;&amp;amp;
      github.event.review.state == &#39;changes_requested&#39;
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: read
    steps:
      - name: Check for security-related comments
        env:
          REVIEW_BODY: ${{ github.event.review.body }}
        run: |
          KEYWORDS=&amp;quot;credential|injection|hardcoded|secret|auth bypass|XSS|CSRF&amp;quot;
          if echo &amp;quot;$REVIEW_BODY&amp;quot; | grep -qiE &amp;quot;$KEYWORDS&amp;quot;; then
            echo &amp;quot;security_flag=true&amp;quot; &amp;gt;&amp;gt; $GITHUB_OUTPUT
          fi
        id: check

      - name: Create security triage issue
        if: steps.check.outputs.security_flag == &#39;true&#39;
        uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: `[Security Triage] Copilot flagged PR #${context.payload.pull_request.number}`,
              body: `Copilot posted a REQUEST_CHANGES review on PR #${context.payload.pull_request.number} containing security-related keywords.&#92;n&#92;nPR: ${context.payload.pull_request.html_url}&#92;nCopilot review: ${context.payload.review.html_url}&#92;n&#92;nA human should review this before or shortly after merge.`,
              labels: [&#39;security&#39;, &#39;triage&#39;]
            })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gets you the audit trail and the human escalation path without making Copilot a blocking gate. The PR author is not punished for a potential false positive; the security team is notified regardless.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Integrating with CODEOWNERS&lt;/h2&gt;
&lt;p&gt;CODEOWNERS and Copilot review are independent systems, but they interact in a way that&#39;s worth understanding. When a PR modifies files owned by &lt;code&gt;@your-org/backend-team&lt;/code&gt;, those team members are auto-requested as reviewers - that is the CODEOWNERS mechanism. Copilot review runs in addition to that, not as a replacement for it.&lt;/p&gt;
&lt;p&gt;The useful integration point is paths that have &lt;strong&gt;no CODEOWNERS entry&lt;/strong&gt;. Files without an owner get no automatic human reviewer. Those are exactly the paths where Copilot review provides the most value - it is the only reviewer that will automatically comment on them.&lt;/p&gt;
&lt;p&gt;Audit your CODEOWNERS file for gaps and make them explicit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Fully owned - humans review, Copilot supplements
src/api/**             @your-org/backend-team
src/web/**             @your-org/frontend-team

# Infrastructure - requires explicit DevOps review
.github/workflows/**   @your-org/devops
terraform/**           @your-org/devops

# No human owners - Copilot review is the primary automated check
# These should be promoted to owned paths when a team takes ownership
scripts/**
tools/**
docs/**
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comment is explicit that Copilot is providing the primary automated check for unowned paths. It is not invisible; it&#39;s a documented policy that the team can read and change. When someone takes ownership of &lt;code&gt;scripts/**&lt;/code&gt;, the CODEOWNERS entry gets added and the comment gets updated - and the change goes through a PR.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Working with the Output at Scale&lt;/h2&gt;
&lt;p&gt;At small team sizes, Copilot review comments are visible in the normal PR review flow. At larger scales - dozens of active PRs across multiple repositories - a few patterns help keep the signal-to-noise ratio manageable.&lt;/p&gt;
&lt;h3&gt;Resolving vs. Dismissing&lt;/h3&gt;
&lt;p&gt;GitHub distinguishes between &lt;strong&gt;resolving&lt;/strong&gt; a review comment (you addressed it) and &lt;strong&gt;dismissing&lt;/strong&gt; a review (you are overriding it). For Copilot comments specifically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Resolve&lt;/strong&gt; a comment when you fixed the issue Copilot flagged, even if you would have caught it anyway. This keeps the thread clean and signals to teammates that the concern was real.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dismiss or leave unresolved&lt;/strong&gt; a comment when it&#39;s a false positive or a pattern that&#39;s intentional in your codebase. Leave a short reply explaining why - not for Copilot&#39;s benefit, but for the next human who reads the PR history and wonders why an apparently valid concern was not addressed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The discipline of writing one sentence explaining a dismissal (&amp;quot;this is intentional - the retry logic is owned by the caller, not this function&amp;quot;) pays for itself quickly in onboarding new developers who read PR histories to understand codebase conventions.&lt;/p&gt;
&lt;h3&gt;Tracking False Positive Patterns&lt;/h3&gt;
&lt;p&gt;When Copilot repeatedly flags a pattern that is intentional in your codebase, that is signal to update your custom instructions. A lightweight workflow: create a &lt;code&gt;copilot-review-false-positives&lt;/code&gt; label in your repository. When a developer dismisses a Copilot comment as a false positive, they apply the label to the PR. At a monthly retro cadence, review the labeled PRs, identify recurring patterns, and add instructions to &lt;code&gt;.github/copilot-review-instructions.md&lt;/code&gt; to suppress them.&lt;/p&gt;
&lt;p&gt;This turns false positives from noise into signal about gaps in your instruction file - and the improvement compounds over time.&lt;/p&gt;
&lt;h3&gt;The Merge Pattern&lt;/h3&gt;
&lt;p&gt;The practical merge workflow for a team running Copilot review looks like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Developer opens a PR. Copilot review runs automatically within a few minutes.&lt;/li&gt;
&lt;li&gt;Developer reads Copilot comments before requesting human review. If something is obviously wrong, they fix it and push - no need to involve a human reviewer for issues Copilot caught in seconds.&lt;/li&gt;
&lt;li&gt;Human reviewer is requested. They see a PR where the easy catches are already addressed; their review time goes toward design, context, and judgment.&lt;/li&gt;
&lt;li&gt;PR merges with or without residual Copilot comments, per the author&#39;s judgment. The security triage workflow runs if any comments triggered the keyword check.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The optimization in step 2 is the real productivity gain: Copilot is not replacing human review, it is preprocessing the PR so that human review is higher-signal. A reviewer who arrives at a PR where null checks and hardcoded values are already handled can spend their limited attention on the decisions that actually require a human.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What Copilot Review Doesn&#39;t Replace&lt;/h2&gt;
&lt;p&gt;To use this tool well, it helps to be clear-eyed about where it stops.&lt;/p&gt;
&lt;p&gt;Copilot review does not catch &lt;strong&gt;architectural problems&lt;/strong&gt;. A pattern that is locally valid but globally wrong - a service that correctly handles its own logic but violates a system-wide invariant - requires context that a diff reviewer cannot have. Architecture decisions belong in ADRs, reviewed before code is written, not discovered by automated review of the implementation.&lt;/p&gt;
&lt;p&gt;Copilot review does not catch &lt;strong&gt;correctness for domain logic&lt;/strong&gt;. Whether a billing calculation is correct, whether a state machine covers the right transitions, whether a data transformation produces the right output for edge cases in your specific domain - these require a reviewer who understands the domain. Copilot does not.&lt;/p&gt;
&lt;p&gt;Copilot review does not replace &lt;strong&gt;pair programming or design review&lt;/strong&gt; for complex changes. A PR that involves a significant new feature, a refactor, or a change to a shared contract benefits from synchronous discussion before the code is written, not asynchronous comments after.&lt;/p&gt;
&lt;p&gt;The framing that works: Copilot review is good at catching the category of issues that a careful developer would catch by reading their own diff slowly. Most developers, under time pressure, do not read their own diffs that carefully before requesting review. Copilot does it for free, every time. That is a genuine and bounded value proposition - and it leaves the harder problems exactly where they should be.&lt;/p&gt;
</content>
    <summary>GitHub Copilot code review runs on every PR automatically and flags real issues - but only if you configure it correctly, constrain what it reviews, and treat its output as advisory signal rather than a hard gate.</summary>
    <category term="github-copilot"/>
    <category term="github-actions"/>
    <category term="ai"/>
    <category term="code-review"/>
    <category term="developer-productivity"/>
  </entry>
  <entry>
    <title>OpenID Connect in GitHub Actions: Replacing Long-Lived Secrets with Short-Lived Tokens</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-06-12-oidc-in-github-actions/"/>
    <updated>2026-06-12T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-06-12-oidc-in-github-actions/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Most GitHub Actions workflows that deploy to a cloud provider contain something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;env:
  AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These credentials are real, long-lived, and stored. They exist in GitHub&#39;s secret store, they are injected into the environment of every workflow run, and they work until someone rotates or revokes them - which, in practice, often means they work indefinitely. If a dependency in your build process is compromised, or if a workflow is vulnerable to command injection via an untrusted PR title or issue body, an attacker can exfiltrate these credentials and use them outside GitHub entirely.&lt;/p&gt;
&lt;p&gt;OIDC eliminates this attack surface entirely. With OIDC, your workflow authenticates to the cloud provider by presenting a short-lived token that GitHub mints for that specific workflow run. The cloud provider is configured to trust tokens from GitHub. No static credential is ever stored - not in GitHub secrets, not in environment variables, not anywhere. The token that grants cloud access expires in minutes and cannot be reused outside the context of that workflow run.&lt;/p&gt;
&lt;p&gt;This post covers how it works, how to configure it for Azure and AWS, and how to audit your existing workflows to find the credentials you can replace today.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;How OIDC Works in This Context&lt;/h2&gt;
&lt;p&gt;The mechanism is an OAuth 2.0 token exchange. GitHub acts as an OpenID Connect identity provider, issuing signed JWTs that describe the current workflow execution. The cloud provider - Azure, AWS, or GCP - is configured as a relying party that trusts those JWTs and will exchange them for its own short-lived access credentials.&lt;/p&gt;
&lt;p&gt;The flow for a single workflow run:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The workflow runner requests a JWT from GitHub&#39;s OIDC endpoint (&lt;code&gt;https://token.actions.githubusercontent.com&lt;/code&gt;). This requires the &lt;code&gt;id-token: write&lt;/code&gt; permission in the workflow&#39;s &lt;code&gt;permissions&lt;/code&gt; block.&lt;/li&gt;
&lt;li&gt;GitHub signs the JWT with its private key and includes claims that identify the workflow: the repository name, the ref (branch or tag), the triggering event, and - critically - the environment name if the job is associated with a GitHub Environment.&lt;/li&gt;
&lt;li&gt;The workflow presents this JWT to the cloud provider&#39;s STS (Security Token Service) endpoint.&lt;/li&gt;
&lt;li&gt;The cloud provider verifies the JWT signature against GitHub&#39;s published public keys, checks that the claims match the configured trust conditions, and issues a short-lived access token.&lt;/li&gt;
&lt;li&gt;The workflow uses the short-lived access token for the duration of the job. It expires - typically within an hour - and cannot be refreshed without a new workflow run.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The critical property: at no point does a human-created, stored credential enter the picture. The JWT is ephemeral. The access token is ephemeral. An attacker who exfiltrates either has minutes, not months, to use it - and even then, only within the scope of what that specific workflow was authorized to do.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The JWT issued by GitHub contains a &lt;code&gt;sub&lt;/code&gt; (subject) claim that identifies the execution context precisely. For a job running in the &lt;code&gt;production&lt;/code&gt; environment, the sub looks like: &lt;code&gt;repo:owner/repo-name:environment:production&lt;/code&gt;. For a branch push, it looks like: &lt;code&gt;repo:owner/repo-name:ref:refs/heads/main&lt;/code&gt;. The cloud provider&#39;s trust policy checks this claim, which means you can scope trust to a specific environment, branch, or ref - not just to the repository as a whole.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Setting Up OIDC with Azure&lt;/h2&gt;
&lt;p&gt;Azure&#39;s implementation of OIDC federation is called &lt;strong&gt;Workload Identity Federation&lt;/strong&gt;. You configure it on an Azure App Registration, which gives you a service principal you can grant RBAC roles.&lt;/p&gt;
&lt;h3&gt;Step 1: Create the App Registration&lt;/h3&gt;
&lt;p&gt;In the Azure portal, navigate to &lt;strong&gt;Azure Active Directory -&amp;gt; App registrations -&amp;gt; New registration&lt;/strong&gt;. Give it a descriptive name that includes the repo and environment, e.g. &lt;code&gt;github-actions-myorg-myrepo-production&lt;/code&gt;. You don&#39;t need a redirect URI.&lt;/p&gt;
&lt;p&gt;After creation, note the &lt;strong&gt;Application (client) ID&lt;/strong&gt; and &lt;strong&gt;Directory (tenant) ID&lt;/strong&gt; - you&#39;ll need both in the workflow.&lt;/p&gt;
&lt;h3&gt;Step 2: Add a Federated Identity Credential&lt;/h3&gt;
&lt;p&gt;On the App Registration, go to &lt;strong&gt;Certificates &amp;amp; secrets -&amp;gt; Federated credentials -&amp;gt; Add credential&lt;/strong&gt;. Select &lt;strong&gt;GitHub Actions deploying Azure resources&lt;/strong&gt; as the scenario.&lt;/p&gt;
&lt;p&gt;Fill in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Organization&lt;/strong&gt;: your GitHub org or username&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repository&lt;/strong&gt;: the repo name (without the org prefix)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Entity type&lt;/strong&gt;: select &lt;strong&gt;Environment&lt;/strong&gt; and enter &lt;code&gt;production&lt;/code&gt;, or &lt;strong&gt;Branch&lt;/strong&gt; and enter &lt;code&gt;main&lt;/code&gt;, depending on what you want to trust&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The entity type maps directly to what GitHub puts in the JWT&#39;s &lt;code&gt;sub&lt;/code&gt; claim. Using &lt;strong&gt;Environment&lt;/strong&gt; is strongly preferred over &lt;strong&gt;Branch&lt;/strong&gt; - it means the trust is scoped to jobs that explicitly run in a named GitHub Environment, which you can protect with required reviewer approval and deployment protection rules.&lt;/p&gt;
&lt;p&gt;You can add multiple federated credentials to the same App Registration - one for &lt;code&gt;production&lt;/code&gt;, one for &lt;code&gt;staging&lt;/code&gt;, each with appropriate trust scope.&lt;/p&gt;
&lt;h3&gt;Step 3: Assign RBAC Roles&lt;/h3&gt;
&lt;p&gt;The App Registration is now an identity that GitHub Actions can assume. Grant it the minimum RBAC roles it needs on the specific Azure resources the workflow accesses. For a typical deployment workflow:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Contributor&lt;/code&gt; on the target resource group (for deploying resources)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AcrPush&lt;/code&gt; on the container registry (if pushing images)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Do not grant &lt;code&gt;Owner&lt;/code&gt; or &lt;code&gt;Contributor&lt;/code&gt; at the subscription level unless the workflow genuinely needs it.&lt;/p&gt;
&lt;p&gt;Navigate to the resource group -&amp;gt; &lt;strong&gt;Access control (IAM) -&amp;gt; Add role assignment&lt;/strong&gt;, select the role, and search for your App Registration by name.&lt;/p&gt;
&lt;h3&gt;Step 4: Update the Workflow&lt;/h3&gt;
&lt;p&gt;The workflow needs three changes: a &lt;code&gt;permissions&lt;/code&gt; block granting &lt;code&gt;id-token: write&lt;/code&gt;, the three Azure identifiers as non-secret environment variables (they are not sensitive - they identify the identity, not a credential), and &lt;code&gt;azure/login@v2&lt;/code&gt; using the federated credential flow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Deploy to Azure

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write   # Required for OIDC token request

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # Must match the federated credential entity
    steps:
      - uses: actions/checkout@v4

      - name: Log in to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}        # Repository variable, not secret
          tenant-id: ${{ vars.AZURE_TENANT_ID }}        # Repository variable, not secret
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}  # Repository variable, not secret

      - name: Deploy
        run: az webapp deploy --resource-group my-rg --name my-app --src-path ./dist
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things worth noting. First, &lt;code&gt;vars.AZURE_CLIENT_ID&lt;/code&gt; uses repository &lt;strong&gt;variables&lt;/strong&gt; (not secrets) - these IDs are not credentials and don&#39;t need secret-level protection. Storing non-sensitive config in variables rather than secrets is the correct pattern. Second, &lt;code&gt;environment: production&lt;/code&gt; on the job is what generates the &lt;code&gt;environment:production&lt;/code&gt; subject claim in the JWT - it must match the entity you configured in the federated credential. If this is omitted or mismatched, Azure rejects the token. Third, &lt;code&gt;azure/login@v2&lt;/code&gt; handles the entire OIDC exchange internally - you don&#39;t write any token exchange code.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Setting Up OIDC with AWS&lt;/h2&gt;
&lt;p&gt;AWS&#39;s implementation uses IAM Roles and an OIDC Identity Provider. The setup is slightly more involved than Azure because you configure the trust relationship in an IAM role&#39;s trust policy.&lt;/p&gt;
&lt;h3&gt;Step 1: Create the OIDC Identity Provider&lt;/h3&gt;
&lt;p&gt;In the AWS IAM console, navigate to &lt;strong&gt;Identity providers -&amp;gt; Add provider&lt;/strong&gt;. Select &lt;strong&gt;OpenID Connect&lt;/strong&gt; and enter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Provider URL&lt;/strong&gt;: &lt;code&gt;https://token.actions.githubusercontent.com&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audience&lt;/strong&gt;: &lt;code&gt;sts.amazonaws.com&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Click &lt;strong&gt;Get thumbprint&lt;/strong&gt; to fetch GitHub&#39;s certificate thumbprint, then create the provider.&lt;/p&gt;
&lt;p&gt;This step is done once per AWS account, not once per repo. All GitHub Actions workflows in all repos can use the same OIDC provider.&lt;/p&gt;
&lt;h3&gt;Step 2: Create an IAM Role with a Trust Policy&lt;/h3&gt;
&lt;p&gt;Create an IAM role. The trust policy is what controls which GitHub workflows can assume this role. A trust policy scoped to a specific repo and environment:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;Version&amp;quot;: &amp;quot;2012-10-17&amp;quot;,
  &amp;quot;Statement&amp;quot;: [
    {
      &amp;quot;Effect&amp;quot;: &amp;quot;Allow&amp;quot;,
      &amp;quot;Principal&amp;quot;: {
        &amp;quot;Federated&amp;quot;: &amp;quot;arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com&amp;quot;
      },
      &amp;quot;Action&amp;quot;: &amp;quot;sts:AssumeRoleWithWebIdentity&amp;quot;,
      &amp;quot;Condition&amp;quot;: {
        &amp;quot;StringEquals&amp;quot;: {
          &amp;quot;token.actions.githubusercontent.com:aud&amp;quot;: &amp;quot;sts.amazonaws.com&amp;quot;,
          &amp;quot;token.actions.githubusercontent.com:sub&amp;quot;: &amp;quot;repo:myorg/myrepo:environment:production&amp;quot;
        }
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace &lt;code&gt;123456789012&lt;/code&gt; with your AWS account ID. The &lt;code&gt;sub&lt;/code&gt; condition is the critical constraint - it limits assumption of this role to jobs running in the &lt;code&gt;production&lt;/code&gt; environment of &lt;code&gt;myorg/myrepo&lt;/code&gt;. A workflow running in staging, or a workflow in a different repo, will get a JWT with a different &lt;code&gt;sub&lt;/code&gt; claim and will fail this condition.&lt;/p&gt;
&lt;p&gt;If you need to trust a branch instead of an environment, change the condition to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&amp;quot;token.actions.githubusercontent.com:sub&amp;quot;: &amp;quot;repo:myorg/myrepo:ref:refs/heads/main&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Attach permission policies to the role granting only what the workflow needs - &lt;code&gt;s3:PutObject&lt;/code&gt; for a deployment that uploads to S3, &lt;code&gt;ecr:GetAuthorizationToken&lt;/code&gt; and &lt;code&gt;ecr:BatchCheckLayerAvailability&lt;/code&gt; for pushing container images. Never attach &lt;code&gt;AdministratorAccess&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Note the role&#39;s ARN - you&#39;ll use it in the workflow.&lt;/p&gt;
&lt;h3&gt;Step 3: Update the Workflow&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Deploy to AWS

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write   # Required for OIDC token request

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-myrepo-production
          aws-region: us-east-1

      - name: Deploy
        run: aws s3 sync ./dist s3://my-bucket --delete
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;aws-actions/configure-aws-credentials@v4&lt;/code&gt; handles the OIDC exchange and populates the standard &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;, &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;, and &lt;code&gt;AWS_SESSION_TOKEN&lt;/code&gt; environment variables with short-lived values. Subsequent steps use the standard AWS SDK and CLI without any special handling - they pick up the credentials from the environment automatically.&lt;/p&gt;
&lt;p&gt;The role ARN in &lt;code&gt;role-to-assume&lt;/code&gt; is not sensitive - it&#39;s a resource identifier, not a credential. Store it as a repository variable (&lt;code&gt;vars.AWS_ROLE_ARN&lt;/code&gt;) rather than a hardcoded string, but it doesn&#39;t need secret-level protection.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Scoping Trust Correctly&lt;/h2&gt;
&lt;p&gt;The trust conditions you configure determine the blast radius if GitHub&#39;s OIDC infrastructure were ever compromised or if an attacker found a way to trigger your workflow from a fork. Getting this right is not optional.&lt;/p&gt;
&lt;h3&gt;Use Environments, Not Branches&lt;/h3&gt;
&lt;p&gt;Prefer environment-scoped subjects (&lt;code&gt;environment:production&lt;/code&gt;) over branch-scoped subjects (&lt;code&gt;ref:refs/heads/main&lt;/code&gt;) for any role or identity that has write access to production resources.&lt;/p&gt;
&lt;p&gt;The reason: you can protect a GitHub Environment with required reviewers and deployment protection rules. A job running in a protected environment cannot proceed until a designated human approves it. Branch-scoped trust has no equivalent human-in-the-loop mechanism - if the workflow is triggered on &lt;code&gt;main&lt;/code&gt;, it runs.&lt;/p&gt;
&lt;h3&gt;Avoid Wildcard Subjects&lt;/h3&gt;
&lt;p&gt;This trust policy is too broad:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&amp;quot;token.actions.githubusercontent.com:sub&amp;quot;: &amp;quot;repo:myorg/myrepo:*&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A wildcard subject means any job in any context in that repo can assume the role - PRs, scheduled runs, workflow_dispatch from any user with write access. If your deployment role should only be assumed by jobs in the &lt;code&gt;production&lt;/code&gt; environment, restrict it to exactly that. The specificity costs nothing.&lt;/p&gt;
&lt;h3&gt;One Role Per Deployment Target&lt;/h3&gt;
&lt;p&gt;A role that deploys to production and staging from the same trust policy is a staging-to-production privilege escalation waiting to happen. Create separate roles with separate trust conditions for each environment. The IAM overhead is minimal; the blast radius reduction is significant.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Auditing Existing Workflows&lt;/h2&gt;
&lt;p&gt;If your repository already has workflows using static credentials, here is a practical approach to finding and replacing them.&lt;/p&gt;
&lt;h3&gt;Finding Static Cloud Credentials&lt;/h3&gt;
&lt;p&gt;The patterns to search for vary by provider:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# AWS static credentials
grep -r &amp;quot;AWS_ACCESS_KEY_ID&#92;|AWS_SECRET_ACCESS_KEY&amp;quot; .github/workflows/

# Azure service principal JSON credential
grep -r &amp;quot;AZURE_CREDENTIALS&#92;|clientSecret&#92;|azure-credentials&amp;quot; .github/workflows/

# GCP service account key JSON
grep -r &amp;quot;GCP_SA_KEY&#92;|GOOGLE_CREDENTIALS&#92;|service_account_key&amp;quot; .github/workflows/

# Generic patterns
grep -r &amp;quot;secrets&#92;.&amp;quot; .github/workflows/ | grep -i &amp;quot;key&#92;|secret&#92;|credential&#92;|token&#92;|password&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For each match, determine:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Which cloud provider and what scope the credential has&lt;/li&gt;
&lt;li&gt;Whether the workflow runs on PRs (higher risk - PR workflows can be triggered by external contributors)&lt;/li&gt;
&lt;li&gt;When the credential was last rotated (check the cloud provider&#39;s IAM console for last-used date)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Migration Priority&lt;/h3&gt;
&lt;p&gt;Prioritize the migrations in this order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Workflows triggered by &lt;code&gt;pull_request&lt;/code&gt; from forks&lt;/strong&gt; - these are the highest risk because external contributors can trigger them. A forked PR workflow that exfiltrates a static credential is a realistic attack scenario.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Credentials with broad scope&lt;/strong&gt; - subscription-level Contributor in Azure, AdministratorAccess in AWS, Owner at the project level in GCP. These have the largest blast radius.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Credentials that have never been rotated&lt;/strong&gt; - static credentials that have been alive since a project&#39;s initial setup are the most likely to have been quietly exfiltrated without anyone knowing.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;After migrating each workflow, revoke the static credential immediately. Leaving it active &amp;quot;just in case&amp;quot; defeats the purpose and leaves a dormant attack surface.&lt;/p&gt;
&lt;h3&gt;The Rotation Trap&lt;/h3&gt;
&lt;p&gt;A common counterargument to the OIDC migration effort: &amp;quot;we rotate our credentials regularly.&amp;quot; Rotation reduces risk but does not eliminate it. A rotated credential is still a credential that exists, that can be exfiltrated, and that provides valid access until the next rotation cycle. The question OIDC asks is not &amp;quot;how fresh is the credential&amp;quot; but &amp;quot;why does a credential need to exist at all?&amp;quot; For workflows that authenticate to cloud providers, the answer is almost always: it doesn&#39;t.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What OIDC Doesn&#39;t Cover&lt;/h2&gt;
&lt;p&gt;OIDC is the right solution for cloud provider authentication from GitHub Actions. It is not a general-purpose secret replacement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Third-party service credentials&lt;/strong&gt; - API keys for Datadog, Snyk, Slack, npm, and similar services do not support OIDC federation. These still belong in GitHub secrets, rotated regularly, with minimum scope. OIDC reduces your secret surface area; it doesn&#39;t eliminate it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Intra-workflow secrets&lt;/strong&gt; - values generated during a workflow run (e.g., a database password for a test environment spun up during CI) should be generated ephemerally and passed between jobs using &lt;code&gt;outputs&lt;/code&gt;, not stored in secrets.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Self-hosted runner credentials&lt;/strong&gt; - if your self-hosted runners need to authenticate to resources at startup (not at workflow execution time), OIDC does not apply. Those credentials are infrastructure-level and belong in a secrets manager (Azure Key Vault, AWS Secrets Manager), not GitHub.&lt;/p&gt;
&lt;p&gt;The practical target: after a full OIDC migration, your GitHub secrets should contain only third-party API credentials, and your cloud provider access should require no secrets at all. Most repositories can reach that state within a sprint.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;OIDC Migration Checklist&lt;/h2&gt;
&lt;p&gt;Run this against each repository before and during the migration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Search for static cloud credentials: &lt;code&gt;grep -r &amp;quot;AWS_ACCESS_KEY_ID&#92;|AZURE_CREDENTIALS&#92;|GCP_SA_KEY&#92;|secrets&#92;..*[Kk]ey&#92;|secrets&#92;..*[Cc]redential&amp;quot; .github/workflows/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] For each credential found: identify the cloud provider, the scope (subscription vs. resource group vs. specific service), and whether the workflow triggers on &lt;code&gt;pull_request&lt;/code&gt; from forks&lt;/li&gt;
&lt;li&gt;[ ] Prioritize: fork-triggerable workflows first, then broad-scope credentials (subscription Contributor, AdministratorAccess), then credentials that have never been rotated&lt;/li&gt;
&lt;li&gt;[ ] Azure: create an App Registration with a Workload Identity federated credential; scope the entity to a &lt;strong&gt;GitHub Environment&lt;/strong&gt;, not a branch&lt;/li&gt;
&lt;li&gt;[ ] AWS: register the GitHub OIDC Identity Provider once per account (&lt;code&gt;https://token.actions.githubusercontent.com&lt;/code&gt;, audience &lt;code&gt;sts.amazonaws.com&lt;/code&gt;), then create an IAM role with a trust policy using &lt;code&gt;StringEquals&lt;/code&gt; on the &lt;code&gt;sub&lt;/code&gt; claim&lt;/li&gt;
&lt;li&gt;[ ] For each role or App Registration: attach only the minimum permissions the workflow actually needs - no &lt;code&gt;AdministratorAccess&lt;/code&gt;, no subscription-level &lt;code&gt;Contributor&lt;/code&gt; unless absolutely required&lt;/li&gt;
&lt;li&gt;[ ] Update each workflow: add &lt;code&gt;permissions: id-token: write&lt;/code&gt;, add &lt;code&gt;environment:&lt;/code&gt; on the job (matching the federated credential entity), replace the secret-based login step with the OIDC-enabled login action (&lt;code&gt;azure/login@v2&lt;/code&gt; or &lt;code&gt;aws-actions/configure-aws-credentials@v4&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Move non-sensitive identifiers (client IDs, tenant IDs, role ARNs, subscription IDs) from secrets to repository &lt;strong&gt;variables&lt;/strong&gt; (&lt;code&gt;vars.*&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Run the migrated workflow end-to-end and confirm successful cloud authentication before revoking the static credential&lt;/li&gt;
&lt;li&gt;[ ] Revoke the static credential immediately after successful validation - do not leave it active as a fallback&lt;/li&gt;
&lt;li&gt;[ ] After completing all migrations: audit GitHub secrets and confirm no cloud provider credentials remain; only third-party API keys should be left&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;OIDC is not a complicated migration. The implementation steps are mechanical and the trust model is sound. The credential that cannot be exfiltrated cannot be misused - and that is a meaningfully different security posture than the one most workflows start with.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions about OIDC setup, IAM trust policy design, or auditing workflows across an org? 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>Storing cloud credentials as GitHub secrets is unnecessary and risky - OIDC lets your workflows authenticate to Azure and AWS by exchanging a short-lived GitHub token for a short-lived cloud token, with no static secret anywhere in the chain.</summary>
    <category term="github-actions"/>
    <category term="security"/>
    <category term="devsecops"/>
    <category term="oidc"/>
    <category term="ci-cd"/>
  </entry>
  <entry>
    <title>Azure AI Foundry: A Developer&#39;s First Look at Agentic AI Workflows</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-06-19-azure-ai-foundry-first-look-agentic-ai-workflows/"/>
    <updated>2026-06-19T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-06-19-azure-ai-foundry-first-look-agentic-ai-workflows/</id>
    <content xml:lang="en" type="html">&lt;p&gt;When people describe Azure AI Foundry, they usually start with platform language. If you&#39;re a developer trying to build your first agent, that framing is not very useful.&lt;/p&gt;
&lt;p&gt;The practical way to think about Foundry is this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hub&lt;/strong&gt; is your governance and shared resource boundary.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Project&lt;/strong&gt; is your app workspace.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deployment&lt;/strong&gt; is the model endpoint your agent will call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connection&lt;/strong&gt; is how your project reaches external systems (search, storage, keys, APIs).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this post, we&#39;ll stand up a minimal project from scratch, deploy a model, create a basic agent with one tool, run it, and then map the model to OpenAI Assistants and LangChain so you can transfer what you already know.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What You&#39;re Building in 30 Minutes&lt;/h2&gt;
&lt;p&gt;By the end, you&#39;ll have:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;An Azure AI Foundry project.&lt;/li&gt;
&lt;li&gt;A deployed chat model (for example, &lt;code&gt;gpt-4o-mini&lt;/code&gt; where available).&lt;/li&gt;
&lt;li&gt;A simple agent configured with one callable tool.&lt;/li&gt;
&lt;li&gt;A test run where the agent decides to invoke that tool and returns an answer.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is intentionally the smallest useful workflow.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;You&#39;ll need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An Azure subscription with permission to create AI Foundry resources.&lt;/li&gt;
&lt;li&gt;Access to Azure AI Foundry in your tenant.&lt;/li&gt;
&lt;li&gt;A model quota/approval in your target region.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If model deployment fails, it&#39;s usually one of two things: wrong region or missing quota for that model family.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 1: Create a Hub and Project&lt;/h2&gt;
&lt;p&gt;In the Foundry UI:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a &lt;strong&gt;Hub&lt;/strong&gt; (or use an existing one if your team already has governance in place).&lt;/li&gt;
&lt;li&gt;Inside the hub, create a &lt;strong&gt;Project&lt;/strong&gt; for this app.&lt;/li&gt;
&lt;li&gt;Keep naming explicit (for example &lt;code&gt;hub-devsecops-lab&lt;/code&gt; and &lt;code&gt;proj-foundry-first-agent&lt;/code&gt;), because these names surface in audit and billing views later.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Think of this as the same separation you&#39;d use for cloud infrastructure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Hub = shared platform scope.&lt;/li&gt;
&lt;li&gt;Project = application scope.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 2: Deploy a Model&lt;/h2&gt;
&lt;p&gt;Inside the project:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;Models&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Choose a chat-capable model available in your region.&lt;/li&gt;
&lt;li&gt;Create a deployment with a clear name, e.g. &lt;code&gt;gpt-4o-mini-chat&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Your deployment name is what the agent targets at runtime. Don&#39;t hardcode model marketing names in app logic; use deployment names so you can swap model versions later without changing business code.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 3: Add a Connection&lt;/h2&gt;
&lt;p&gt;Open &lt;strong&gt;Connections&lt;/strong&gt; in the project and add at least one connection you&#39;ll use with tools (for example, Azure AI Search, Blob Storage, or Key Vault).&lt;/p&gt;
&lt;p&gt;For this first pass, even a placeholder connection is useful because it makes the project structure real: agents don&#39;t run in a vacuum, they run with context and systems access.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 4: Create a Basic Agent with One Tool&lt;/h2&gt;
&lt;p&gt;Create an agent in the project and bind it to your model deployment.&lt;/p&gt;
&lt;p&gt;Add one simple function tool so the agent can do something beyond plain prompting. A common first tool is a deterministic lookup (for example: &lt;code&gt;get_build_status(service_name)&lt;/code&gt;), because it&#39;s easy to verify when the model chose to call the tool.&lt;/p&gt;
&lt;p&gt;Pseudo-shape of a tool contract:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;name&amp;quot;: &amp;quot;get_build_status&amp;quot;,
  &amp;quot;description&amp;quot;: &amp;quot;Returns CI build status for a service.&amp;quot;,
  &amp;quot;parameters&amp;quot;: {
    &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
    &amp;quot;properties&amp;quot;: {
      &amp;quot;service_name&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot; }
    },
    &amp;quot;required&amp;quot;: [&amp;quot;service_name&amp;quot;]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then implement the handler in your app:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;async function getBuildStatus(serviceName: string): Promise&amp;lt;string&amp;gt; {
  const fakeStatus: Record&amp;lt;string, string&amp;gt; = {
    api: &amp;quot;passing&amp;quot;,
    web: &amp;quot;failing&amp;quot;,
  };
  return fakeStatus[serviceName] ?? &amp;quot;unknown&amp;quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Even with a mock tool, you can validate the full loop:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User asks a question.&lt;/li&gt;
&lt;li&gt;Agent chooses tool call.&lt;/li&gt;
&lt;li&gt;App executes tool.&lt;/li&gt;
&lt;li&gt;Tool result goes back to agent.&lt;/li&gt;
&lt;li&gt;Agent produces final answer.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 5: Run It End-to-End&lt;/h2&gt;
&lt;p&gt;Use the Foundry test interface (or your SDK client) and send:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;What&#39;s the current build status for the web service?&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Expected behavior:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Agent calls &lt;code&gt;get_build_status&lt;/code&gt; with &lt;code&gt;service_name = &amp;quot;web&amp;quot;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Tool returns &lt;code&gt;failing&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Agent responds in natural language using that tool output.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the agent never calls the tool, tighten your instructions and tool description. Most first-run failures are prompt clarity failures, not platform failures.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Four Concepts That Matter (Without the Marketing Layer)&lt;/h2&gt;
&lt;h3&gt;Hub&lt;/h3&gt;
&lt;p&gt;Governance container for teams, policies, and shared controls.&lt;/p&gt;
&lt;h3&gt;Project&lt;/h3&gt;
&lt;p&gt;Implementation workspace for one app or bounded initiative.&lt;/p&gt;
&lt;h3&gt;Deployment&lt;/h3&gt;
&lt;p&gt;A concrete, versioned model endpoint you can target and swap safely.&lt;/p&gt;
&lt;h3&gt;Connection&lt;/h3&gt;
&lt;p&gt;Managed wiring to external dependencies and data systems.&lt;/p&gt;
&lt;p&gt;If you understand those four nouns, Foundry becomes predictable.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Foundry vs. OpenAI Assistants vs. LangChain&lt;/h2&gt;
&lt;p&gt;Here&#39;s the practical mapping:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If you know...&lt;/th&gt;
&lt;th&gt;Rough equivalent in Foundry&lt;/th&gt;
&lt;th&gt;Key difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Assistants&lt;/td&gt;
&lt;td&gt;Agent + model deployment + tools&lt;/td&gt;
&lt;td&gt;Foundry adds stronger Azure-native project/resource boundaries and enterprise integration points.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LangChain&lt;/td&gt;
&lt;td&gt;Orchestrated agent flow with tool calls&lt;/td&gt;
&lt;td&gt;LangChain is framework-first and code-driven; Foundry is platform-first with managed project/deployment/runtime surfaces.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Another way to choose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use &lt;strong&gt;Foundry&lt;/strong&gt; when you want managed enterprise controls, Azure integration, and standardized deployment surfaces.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;OpenAI Assistants&lt;/strong&gt; when you want the fastest path in the OpenAI ecosystem with minimal platform overhead.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;LangChain&lt;/strong&gt; when you need maximum orchestration flexibility and are comfortable owning more runtime behavior in code.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;They&#39;re not mutually exclusive. Many teams prototype orchestration patterns in LangChain, then operationalize on managed platform primitives.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Common First-Week Mistakes&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Confusing model name with deployment name&lt;/strong&gt;&lt;br /&gt;
Code should target deployment names.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Skipping connections until later&lt;/strong&gt;&lt;br /&gt;
Agents without real data/tools look good in demos and fail in production.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Giving tools vague descriptions&lt;/strong&gt;&lt;br /&gt;
Tool selection quality is highly sensitive to clear parameter definitions.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Treating the first prompt as final architecture&lt;/strong&gt;&lt;br /&gt;
Start simple, inspect traces, tighten tool contracts, then expand.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;Where to Go Next&lt;/h2&gt;
&lt;p&gt;Once this baseline works, the next useful step is multi-agent coordination: one agent for retrieval, one for reasoning, one for action execution, with explicit handoff rules and traceability.&lt;/p&gt;
&lt;p&gt;But don&#39;t jump there on day one. A single agent with one tool and clean deployment boundaries teaches almost everything you need to avoid the early architecture traps.&lt;/p&gt;
&lt;p&gt;If you&#39;ve already used Assistants or LangChain, Foundry isn&#39;t a new mental model - it&#39;s a different operating model for the same core pattern: model + tools + context + orchestration.&lt;/p&gt;
</content>
    <summary>Azure AI Foundry is easiest to understand when you treat it as app infrastructure for agents: define a project, deploy a model, wire connections, add a tool, and run. This guide walks through that exact first workflow and maps it to OpenAI Assistants and LangChain concepts.</summary>
    <category term="azure-ai-foundry"/>
    <category term="ai-agents"/>
    <category term="agentic-development"/>
    <category term="azure"/>
    <category term="llm"/>
  </entry>
  <entry>
    <title>GitHub Projects Automation: Custom Fields, Workflows, and the GraphQL API</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-06-26-github-projects-automation-custom-fields-workflows-graphql-api/"/>
    <updated>2026-06-26T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-06-26-github-projects-automation-custom-fields-workflows-graphql-api/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Most teams use GitHub Projects the way they use a whiteboard: they move cards around manually, they check in on Fridays to see what slipped, and they treat the board as a reporting artifact rather than a live system. That&#39;s a reasonable starting point. It&#39;s not where you should stay.&lt;/p&gt;
&lt;p&gt;GitHub Projects v2 ships with a GraphQL API, a set of built-in automation workflows, and custom field types that compose into a lightweight engineering system. You can auto-assign new issues to the current sprint, reflect PR state back to issue status without human intervention, and schedule a weekly digest from a GitHub Actions workflow. None of this requires a third-party tool. None of it requires leaving GitHub.&lt;/p&gt;
&lt;p&gt;The thesis: if your team is already on GitHub, you have most of what Jira offers. You&#39;re just not using it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Custom Field Types&lt;/h2&gt;
&lt;p&gt;The three field types worth understanding are &lt;strong&gt;Iteration&lt;/strong&gt;, &lt;strong&gt;Single Select&lt;/strong&gt;, and &lt;strong&gt;Number&lt;/strong&gt;. Each has a specific purpose and a specific shape in the API.&lt;/p&gt;
&lt;h3&gt;Iteration&lt;/h3&gt;
&lt;p&gt;An iteration field models a sprint. You define a cadence - say, two-week periods starting on Mondays - and GitHub generates named iterations automatically. Each iteration has an &lt;code&gt;id&lt;/code&gt;, a &lt;code&gt;title&lt;/code&gt; (e.g., &lt;code&gt;&amp;quot;Iteration 3&amp;quot;&lt;/code&gt;), a &lt;code&gt;startDate&lt;/code&gt;, and a &lt;code&gt;duration&lt;/code&gt; in days.&lt;/p&gt;
&lt;p&gt;To add an iteration field to your project in the UI: open the project, click &lt;strong&gt;+&lt;/strong&gt; to add a field, choose &lt;strong&gt;Iteration&lt;/strong&gt;, and configure the cadence. You&#39;ll configure the start date and duration once; subsequent iterations are created automatically.&lt;/p&gt;
&lt;p&gt;The iteration field is the key to sprint assignment automation. When you have its field ID and the current iteration&#39;s option ID, you can set it programmatically on any project item - including items added in the last hour.&lt;/p&gt;
&lt;h3&gt;Single Select&lt;/h3&gt;
&lt;p&gt;A single-select field is a bounded set of named options with associated colors. This is where you put &lt;strong&gt;Status&lt;/strong&gt; (&lt;code&gt;Todo&lt;/code&gt;, &lt;code&gt;In Progress&lt;/code&gt;, &lt;code&gt;In Review&lt;/code&gt;, &lt;code&gt;Done&lt;/code&gt;) and &lt;strong&gt;Priority&lt;/strong&gt; (&lt;code&gt;P0&lt;/code&gt;, &lt;code&gt;P1&lt;/code&gt;, &lt;code&gt;P2&lt;/code&gt;). You define the options in the field configuration; the API refers to each option by its &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Single-select is what built-in workflows operate on. When you configure a workflow to &amp;quot;set status to In Progress when a PR is opened,&amp;quot; the workflow is writing to a single-select field.&lt;/p&gt;
&lt;h3&gt;Number&lt;/h3&gt;
&lt;p&gt;A number field stores a decimal value. Use it for story points, effort estimates, or cycle time. There&#39;s no special behavior from the API perspective - you set it the same way you set any other field - but it unlocks grouping by numeric range and aggregate views in the project&#39;s insight charts.&lt;/p&gt;
&lt;h3&gt;Adding Fields via the UI&lt;/h3&gt;
&lt;p&gt;You don&#39;t need the API to create fields - use the project UI for one-time configuration, and the API for automation:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open your project.&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;+&lt;/strong&gt; icon at the right of the column headers to add a field.&lt;/li&gt;
&lt;li&gt;Choose the field type and configure it (options for single-select, cadence for iteration).&lt;/li&gt;
&lt;li&gt;Note the field names exactly - you&#39;ll need them when querying the API.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;Built-In Workflows&lt;/h2&gt;
&lt;p&gt;GitHub Projects ships four built-in workflow triggers you should turn on for any active project:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Auto-add to project&lt;/strong&gt; - when an issue or PR is opened in a linked repository, it&#39;s automatically added to the project. Requires no code. Enable it under &lt;strong&gt;Workflows&lt;/strong&gt; in the project settings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auto-archive closed items&lt;/strong&gt; - when an issue or PR is closed and a configurable time passes (default: 7 days), the item is archived. Keeps the board from accumulating closed noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Set status when PR is merged&lt;/strong&gt; - when a linked PR is merged, the associated project item&#39;s status field is set to a value you configure (typically &lt;code&gt;Done&lt;/code&gt;). Requires a single-select field named &lt;code&gt;Status&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Set status when PR is opened&lt;/strong&gt; - sets a status value when a PR linked to an issue is opened. Use this to move an item to &lt;code&gt;In Progress&lt;/code&gt; automatically when someone begins the work.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;To configure: open your project, click &lt;strong&gt;...&lt;/strong&gt; → &lt;strong&gt;Workflows&lt;/strong&gt;, and enable the relevant triggers. Each workflow lets you choose which field to set and which option to use.&lt;/p&gt;
&lt;p&gt;These four cover the majority of status transitions in a normal sprint cycle. The only one you need the API for is sprint assignment, because the current iteration&#39;s option ID changes every sprint.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The GraphQL API&lt;/h2&gt;
&lt;p&gt;GitHub&#39;s Projects v2 API lives at &lt;code&gt;https://api.github.com/graphql&lt;/code&gt; and requires a personal access token or a fine-grained token with &lt;code&gt;project&lt;/code&gt; scope. For GitHub Actions, use a token stored in secrets.&lt;/p&gt;
&lt;h3&gt;Getting Your Project ID&lt;/h3&gt;
&lt;p&gt;Everything in the API starts with the project node ID. Fetch it by querying the organization or user:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;query {
  organization(login: &amp;quot;your-org&amp;quot;) {
    projectV2(number: 1) {
      id
      title
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or for a user-owned project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;query {
  user(login: &amp;quot;your-username&amp;quot;) {
    projectV2(number: 1) {
      id
      title
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; returned is a base64-encoded node ID like &lt;code&gt;PVT_kwDOA...&lt;/code&gt;. Store this as a repository variable - it doesn&#39;t change.&lt;/p&gt;
&lt;h3&gt;Fetching Field IDs and Option IDs&lt;/h3&gt;
&lt;p&gt;Field operations require the field&#39;s node ID and, for single-select and iteration fields, the option&#39;s node ID. Fetch them with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;query {
  node(id: &amp;quot;PVT_kwDOA...&amp;quot;) {
    ... on ProjectV2 {
      fields(first: 20) {
        nodes {
          ... on ProjectV2Field {
            id
            name
          }
          ... on ProjectV2SingleSelectField {
            id
            name
            options {
              id
              name
            }
          }
          ... on ProjectV2IterationField {
            id
            name
            configuration {
              iterations {
                id
                title
                startDate
                duration
              }
            }
          }
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run this once and record the IDs for each field and option you&#39;ll automate. The field IDs are stable. Iteration option IDs change each sprint - that&#39;s the piece you&#39;ll query dynamically.&lt;/p&gt;
&lt;h3&gt;Updating a Field Value&lt;/h3&gt;
&lt;p&gt;The mutation to update any project item field is &lt;code&gt;updateProjectV2ItemFieldValue&lt;/code&gt;. The shape varies slightly by field type.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Single-select field:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;mutation {
  updateProjectV2ItemFieldValue(
    input: {
      projectId: &amp;quot;PVT_kwDOA...&amp;quot;
      itemId: &amp;quot;PVTI_lADOA...&amp;quot;
      fieldId: &amp;quot;PVTSSF_lADOA...&amp;quot;
      value: {
        singleSelectOptionId: &amp;quot;abc123&amp;quot;
      }
    }
  ) {
    projectV2Item {
      id
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Iteration field:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;mutation {
  updateProjectV2ItemFieldValue(
    input: {
      projectId: &amp;quot;PVT_kwDOA...&amp;quot;
      itemId: &amp;quot;PVTI_lADOA...&amp;quot;
      fieldId: &amp;quot;PVTIF_lADOA...&amp;quot;
      value: {
        iterationId: &amp;quot;iteration_id_here&amp;quot;
      }
    }
  ) {
    projectV2Item {
      id
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Number field:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;mutation {
  updateProjectV2ItemFieldValue(
    input: {
      projectId: &amp;quot;PVT_kwDOA...&amp;quot;
      itemId: &amp;quot;PVTI_lADOA...&amp;quot;
      fieldId: &amp;quot;PVTF_lADOA...&amp;quot;
      value: {
        number: 3
      }
    }
  ) {
    projectV2Item {
      id
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;itemId&lt;/code&gt; is the project item&#39;s node ID (not the issue number). You get it by querying the project&#39;s items or by reading it from the &lt;code&gt;projects_v2_item&lt;/code&gt; event payload in a GitHub Actions trigger.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Practical GitHub Actions Workflows&lt;/h2&gt;
&lt;p&gt;Here are three workflows that together cover sprint assignment, status automation, and weekly reporting.&lt;/p&gt;
&lt;h3&gt;1. Auto-Assign to Current Iteration&lt;/h3&gt;
&lt;p&gt;This workflow fires whenever a new issue is added to the project. It queries the current iteration from the iteration field, then sets that iteration on the newly added item.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Assign to Current Sprint

on:
  projects_v2_item:
    types: [created]

jobs:
  assign-sprint:
    runs-on: ubuntu-latest
    steps:
      - name: Assign item to current iteration
        env:
          GH_TOKEN: ${{ secrets.PROJECT_TOKEN }}
          PROJECT_ID: ${{ vars.PROJECT_ID }}
          ITERATION_FIELD_ID: ${{ vars.ITERATION_FIELD_ID }}
        run: |
          ITEM_ID=&amp;quot;${{ github.event.projects_v2_item.node_id }}&amp;quot;

          # Get the current (first active) iteration ID
          ITERATION_ID=$(gh api graphql -f query=&#39;
            query($projectId: ID!) {
              node(id: $projectId) {
                ... on ProjectV2 {
                  fields(first: 20) {
                    nodes {
                      ... on ProjectV2IterationField {
                        id
                        configuration {
                          iterations {
                            id
                            startDate
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          &#39; -f projectId=&amp;quot;$PROJECT_ID&amp;quot; &#92;
            --jq &#39;[.data.node.fields.nodes[] | select(.configuration != null) | .configuration.iterations[0].id] | first&#39;)

          # Set the iteration field on the item
          gh api graphql -f query=&#39;
            mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $iterationId: String!) {
              updateProjectV2ItemFieldValue(input: {
                projectId: $projectId
                itemId: $itemId
                fieldId: $fieldId
                value: { iterationId: $iterationId }
              }) {
                projectV2Item { id }
              }
            }
          &#39; -f projectId=&amp;quot;$PROJECT_ID&amp;quot; &#92;
            -f itemId=&amp;quot;$ITEM_ID&amp;quot; &#92;
            -f fieldId=&amp;quot;$ITERATION_FIELD_ID&amp;quot; &#92;
            -f iterationId=&amp;quot;$ITERATION_ID&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;projects_v2_item&lt;/code&gt; event requires the &lt;code&gt;PROJECT_TOKEN&lt;/code&gt; to have &lt;code&gt;project&lt;/code&gt; scope. A fine-grained personal access token with &lt;code&gt;read and write&lt;/code&gt; access to the specific project works here.&lt;/p&gt;
&lt;h3&gt;2. Set &amp;quot;In Progress&amp;quot; When a Linked PR Is Opened&lt;/h3&gt;
&lt;p&gt;Built-in workflows handle this for the common case, but if you need custom logic - for example, only triggering on PRs from non-draft state, or updating a secondary field alongside status - a workflow gives you full control:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Set In Progress on PR Open

on:
  pull_request:
    types: [opened, ready_for_review]

jobs:
  update-project-status:
    runs-on: ubuntu-latest
    steps:
      - name: Find linked issue and update project status
        env:
          GH_TOKEN: ${{ secrets.PROJECT_TOKEN }}
          PROJECT_ID: ${{ vars.PROJECT_ID }}
          STATUS_FIELD_ID: ${{ vars.STATUS_FIELD_ID }}
          IN_PROGRESS_OPTION_ID: ${{ vars.IN_PROGRESS_OPTION_ID }}
        run: |
          PR_NUMBER=&amp;quot;${{ github.event.pull_request.number }}&amp;quot;
          REPO=&amp;quot;${{ github.repository }}&amp;quot;

          # Find the project item linked to this PR
          ITEM_ID=$(gh api graphql -f query=&#39;
            query($repo: String!, $owner: String!, $pr: Int!) {
              repository(owner: $owner, name: $repo) {
                pullRequest(number: $pr) {
                  projectItems(first: 5) {
                    nodes {
                      id
                      project {
                        id
                      }
                    }
                  }
                }
              }
            }
          &#39; -f owner=&amp;quot;${REPO%%/*}&amp;quot; &#92;
            -f repo=&amp;quot;${REPO##*/}&amp;quot; &#92;
            -F pr=&amp;quot;$PR_NUMBER&amp;quot; &#92;
            --jq &amp;quot;.data.repository.pullRequest.projectItems.nodes[] | select(.project.id == &#92;&amp;quot;$PROJECT_ID&#92;&amp;quot;) | .id&amp;quot;)

          if [ -z &amp;quot;$ITEM_ID&amp;quot; ]; then
            echo &amp;quot;No project item found for PR #$PR_NUMBER in project $PROJECT_ID&amp;quot;
            exit 0
          fi

          # Set status to In Progress
          gh api graphql -f query=&#39;
            mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
              updateProjectV2ItemFieldValue(input: {
                projectId: $projectId
                itemId: $itemId
                fieldId: $fieldId
                value: { singleSelectOptionId: $optionId }
              }) {
                projectV2Item { id }
              }
            }
          &#39; -f projectId=&amp;quot;$PROJECT_ID&amp;quot; &#92;
            -f itemId=&amp;quot;$ITEM_ID&amp;quot; &#92;
            -f fieldId=&amp;quot;$STATUS_FIELD_ID&amp;quot; &#92;
            -f optionId=&amp;quot;$IN_PROGRESS_OPTION_ID&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Store &lt;code&gt;STATUS_FIELD_ID&lt;/code&gt; and &lt;code&gt;IN_PROGRESS_OPTION_ID&lt;/code&gt; as repository variables - you get them from the field query above.&lt;/p&gt;
&lt;h3&gt;3. Weekly Digest via Scheduled Workflow&lt;/h3&gt;
&lt;p&gt;This workflow runs every Monday morning, queries the project for items by status, and posts a summary to a Slack webhook or writes it to a GitHub issue. The example below writes the digest as a new issue in the repository.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Weekly Project Digest

on:
  schedule:
    - cron: &#39;0 8 * * 1&#39;   # Every Monday at 08:00 UTC
  workflow_dispatch:

jobs:
  digest:
    runs-on: ubuntu-latest
    permissions:
      issues: write
    steps:
      - name: Generate weekly digest
        env:
          GH_TOKEN: ${{ secrets.PROJECT_TOKEN }}
          PROJECT_ID: ${{ vars.PROJECT_ID }}
        run: |
          TODAY=$(date +%Y-%m-%d)

          # Query all project items with their status and assignees
          DIGEST=$(gh api graphql -f query=&#39;
            query($projectId: ID!) {
              node(id: $projectId) {
                ... on ProjectV2 {
                  items(first: 100) {
                    nodes {
                      id
                      content {
                        ... on Issue {
                          title
                          number
                          url
                          assignees(first: 3) {
                            nodes { login }
                          }
                        }
                        ... on PullRequest {
                          title
                          number
                          url
                          assignees(first: 3) {
                            nodes { login }
                          }
                        }
                      }
                      fieldValues(first: 10) {
                        nodes {
                          ... on ProjectV2ItemFieldSingleSelectValue {
                            name
                            field {
                              ... on ProjectV2SingleSelectField {
                                name
                              }
                            }
                          }
                          ... on ProjectV2ItemFieldIterationValue {
                            title
                            field {
                              ... on ProjectV2IterationField {
                                name
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          &#39; -f projectId=&amp;quot;$PROJECT_ID&amp;quot;)

          # Build the digest body using jq
          BODY=$(echo &amp;quot;$DIGEST&amp;quot; | jq -r &#39;
            &amp;quot;## Weekly Project Digest - &amp;quot; + (now | strftime(&amp;quot;%Y-%m-%d&amp;quot;)) + &amp;quot;&#92;n&#92;n&amp;quot; +
            (
              .data.node.items.nodes
              | map(
                  . as $item |
                  {
                    title: (.content.title // &amp;quot;(no title)&amp;quot;),
                    number: (.content.number // &amp;quot;&amp;quot;),
                    url: (.content.url // &amp;quot;&amp;quot;),
                    status: (
                      .fieldValues.nodes[]
                      | select(.field.name == &amp;quot;Status&amp;quot;)
                      | .name
                    ) // &amp;quot;No Status&amp;quot;,
                    assignees: (
                      [.content.assignees.nodes[].login]
                      | if length &amp;gt; 0 then join(&amp;quot;, &amp;quot;) else &amp;quot;unassigned&amp;quot; end
                    )
                  }
                )
              | group_by(.status)
              | map(
                  &amp;quot;### &amp;quot; + (.[0].status) + &amp;quot;&#92;n&#92;n&amp;quot; +
                  (
                    map(&amp;quot;- [#&#92;(.number) &#92;(.title)](&#92;(.url)) - &#92;(.assignees)&amp;quot;)
                    | join(&amp;quot;&#92;n&amp;quot;)
                  )
                )
              | join(&amp;quot;&#92;n&#92;n&amp;quot;)
            )
          &#39;)

          # Post the digest as a new issue
          gh issue create &#92;
            --repo &amp;quot;${{ github.repository }}&amp;quot; &#92;
            --title &amp;quot;Weekly Digest - $TODAY&amp;quot; &#92;
            --label &amp;quot;digest&amp;quot; &#92;
            --body &amp;quot;$BODY&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;jq&lt;/code&gt; query groups items by their &lt;code&gt;Status&lt;/code&gt; field value and formats each group as a Markdown section. If you&#39;d rather post to Slack, replace the &lt;code&gt;gh issue create&lt;/code&gt; step with a &lt;code&gt;curl&lt;/code&gt; call to your Slack webhook URL.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Repository Variables to Set Up&lt;/h2&gt;
&lt;p&gt;Before running any of these workflows, configure the following as repository variables (&lt;code&gt;Settings → Secrets and variables → Variables&lt;/code&gt;):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;How to get it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PROJECT_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run the project query above; copy the &lt;code&gt;id&lt;/code&gt; field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ITERATION_FIELD_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run the fields query; copy the &lt;code&gt;id&lt;/code&gt; of your iteration field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;STATUS_FIELD_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run the fields query; copy the &lt;code&gt;id&lt;/code&gt; of your single-select Status field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IN_PROGRESS_OPTION_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;From the Status field&#39;s &lt;code&gt;options&lt;/code&gt; array; copy the &lt;code&gt;id&lt;/code&gt; for &lt;code&gt;In Progress&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Store the &lt;code&gt;PROJECT_TOKEN&lt;/code&gt; as a &lt;strong&gt;secret&lt;/strong&gt; (it&#39;s a credential). Store everything else as a variable - none of the IDs are sensitive.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;You Don&#39;t Need Jira&lt;/h2&gt;
&lt;p&gt;Every engineering team has an implicit project management tax: the time spent maintaining a tool that lives outside the actual work. Jira is powerful for the organizations that need its full feature set. For a team that lives in GitHub - where issues, PRs, code review, and CI/CD all coexist - the coordination overhead of a separate tool is real and rarely justified.&lt;/p&gt;
&lt;p&gt;GitHub Projects v2, with custom fields and the GraphQL API, covers the things that matter for most teams: sprint tracking, status visibility, and automation that keeps the board accurate without human ceremony. You configure it once, you wire up two or three workflows, and then it runs.&lt;/p&gt;
&lt;p&gt;The investment is an afternoon. The return is a project board that doesn&#39;t lie to you.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions, edge cases, or GraphQL shape oddities? 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 Projects v2 ships with a GraphQL API, custom field types, and built-in workflow triggers that most teams never configure. This post shows how to wire them together: auto-assign sprints, sync PR status to issue state, and generate a weekly digest - all without leaving GitHub.</summary>
    <category term="github"/>
    <category term="developer-productivity"/>
    <category term="project-management"/>
    <category term="github-actions"/>
  </entry>
  <entry>
    <title>Semantic Kernel and Azure AI Foundry: Building Your First AI Agent in .NET</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-07-03-semantic-kernel-azure-ai-foundry-first-agent-dotnet/"/>
    <updated>2026-07-03T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-07-03-semantic-kernel-azure-ai-foundry-first-agent-dotnet/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Python has LangChain. .NET has Semantic Kernel. They solve the same problem - how do you wire a language model to tools, memory, and business logic in a way that&#39;s testable and deployable - but for a different runtime and a different operational context.&lt;/p&gt;
&lt;p&gt;This post builds a working agent from scratch: a &lt;code&gt;Kernel&lt;/code&gt; instance backed by a Foundry-hosted model, a plugin with a real tool function the model can call, and a multi-step interaction where the agent decides when to invoke the tool and uses the result in its answer. At the end, a GitHub Actions workflow deploys the whole thing to Azure Container Apps with no stored secrets.&lt;/p&gt;
&lt;p&gt;If you&#39;ve read the &lt;a href=&quot;https://steve-kaschimer.github.io/posts/2026-06-19-azure-ai-foundry-first-look-agentic-ai-workflows/&quot;&gt;Foundry first-look post&lt;/a&gt;, you know the platform primitives. This post puts .NET code behind those primitives.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Model: What Semantic Kernel Actually Does&lt;/h2&gt;
&lt;p&gt;Semantic Kernel (SK) is an orchestration SDK. It doesn&#39;t host models. It doesn&#39;t manage infrastructure. What it does is define a composable execution model for agents:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Kernel&lt;/strong&gt; - the central object. Holds services (AI connectors, logging) and a plugin registry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plugin&lt;/strong&gt; - a class with annotated methods the model can call as tools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chat completion service&lt;/strong&gt; - the connector to the underlying model (Foundry, Azure OpenAI, OpenAI, etc.).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FunctionChoiceBehavior&lt;/strong&gt; - tells SK whether to let the model decide which tools to call, require a specific one, or call none.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Azure AI Foundry hosts the model endpoint the kernel connects to. SK handles the rest: tool discovery, function call dispatch, result injection, conversation state.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;You need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An Azure subscription with access to Azure AI Foundry.&lt;/li&gt;
&lt;li&gt;A Foundry hub and project created. If you haven&#39;t done this yet, follow Steps 1-2 from the &lt;a href=&quot;https://steve-kaschimer.github.io/posts/2026-06-19-azure-ai-foundry-first-look-agentic-ai-workflows/&quot;&gt;Foundry first-look post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A model deployment inside the project - for example, &lt;code&gt;gpt-4o-mini-chat&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your deployment&#39;s endpoint URL and API key (or a managed identity - more on that below).&lt;/li&gt;
&lt;li&gt;.NET 8 SDK.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Project Setup&lt;/h2&gt;
&lt;p&gt;Create a new console app and add the required packages:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet new console -n DevOpsAgent
cd DevOpsAgent
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Connectors.AzureOpenAI
dotnet add package Azure.Identity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Microsoft.SemanticKernel&lt;/code&gt; is the core SDK. &lt;code&gt;Microsoft.SemanticKernel.Connectors.AzureOpenAI&lt;/code&gt; provides the Azure OpenAI connector SK uses to talk to your Foundry-hosted model. &lt;code&gt;Azure.Identity&lt;/code&gt; is for &lt;code&gt;DefaultAzureCredential&lt;/code&gt; - the right way to authenticate in Azure-hosted deployments.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Wiring Up the Kernel&lt;/h2&gt;
&lt;p&gt;The kernel is built with a builder pattern. You add services - starting with the chat completion connector - and then call &lt;code&gt;Build()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using Azure.Identity;
using Microsoft.SemanticKernel;

// Development: use API key directly
var builder = Kernel.CreateBuilder();
builder.AddAzureOpenAIChatCompletion(
    deploymentName: &amp;quot;gpt-4o-mini-chat&amp;quot;,
    endpoint: &amp;quot;https://&amp;lt;your-project&amp;gt;.openai.azure.com/&amp;quot;,
    apiKey: Environment.GetEnvironmentVariable(&amp;quot;AZURE_OPENAI_API_KEY&amp;quot;)!
);

var kernel = builder.Build();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For production - where you want managed identity instead of a stored API key - swap the credential parameter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.AddAzureOpenAIChatCompletion(
    deploymentName: &amp;quot;gpt-4o-mini-chat&amp;quot;,
    endpoint: &amp;quot;https://&amp;lt;your-project&amp;gt;.openai.azure.com/&amp;quot;,
    credential: new DefaultAzureCredential()
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The endpoint format is the Azure OpenAI endpoint for your Foundry project. In the Foundry portal, find it under &lt;strong&gt;Deployments → your deployment → Target URI&lt;/strong&gt;, and strip the path - you want just &lt;code&gt;https://&amp;lt;resource-name&amp;gt;.openai.azure.com/&lt;/code&gt;. The deployment name is the name you assigned when you created the model deployment, not the model&#39;s marketing name.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Plugins and Tool Functions&lt;/h2&gt;
&lt;p&gt;A plugin is a C# class. Methods you want the model to be able to call get a &lt;code&gt;[KernelFunction]&lt;/code&gt; attribute. The &lt;code&gt;[Description]&lt;/code&gt; attribute on the method and its parameters is what the model sees - treat those descriptions the same way you&#39;d treat API documentation, because they directly affect whether the model calls the right tool with the right arguments.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using System.ComponentModel;
using Microsoft.SemanticKernel;

public class DevOpsPlugin
{
    [KernelFunction(&amp;quot;get_pipeline_status&amp;quot;)]
    [Description(&amp;quot;Returns the latest CI pipeline status for a given repository.&amp;quot;)]
    public string GetPipelineStatus(
        [Description(&amp;quot;The repository name, for example &#39;api-service&#39; or &#39;web-frontend&#39;&amp;quot;)]
        string repositoryName)
    {
        // In production: call your CI system&#39;s API here.
        // For this example, a deterministic stub makes the tool call easy to verify.
        var statuses = new Dictionary&amp;lt;string, string&amp;gt;(StringComparer.OrdinalIgnoreCase)
        {
            [&amp;quot;api-service&amp;quot;]    = &amp;quot;passing&amp;quot;,
            [&amp;quot;web-frontend&amp;quot;]   = &amp;quot;failing&amp;quot;,
            [&amp;quot;data-pipeline&amp;quot;]  = &amp;quot;pending&amp;quot;,
        };

        return statuses.TryGetValue(repositoryName, out var status)
            ? $&amp;quot;Pipeline for &#39;{repositoryName}&#39; is currently {status}.&amp;quot;
            : $&amp;quot;No pipeline found for repository &#39;{repositoryName}&#39;.&amp;quot;;
    }

    [KernelFunction(&amp;quot;list_repositories&amp;quot;)]
    [Description(&amp;quot;Returns a list of all tracked repositories.&amp;quot;)]
    public IEnumerable&amp;lt;string&amp;gt; ListRepositories() =&amp;gt;
        [&amp;quot;api-service&amp;quot;, &amp;quot;web-frontend&amp;quot;, &amp;quot;data-pipeline&amp;quot;];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the plugin with the kernel before building, or add it to an already-built kernel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Before Build():
builder.Plugins.AddFromType&amp;lt;DevOpsPlugin&amp;gt;(&amp;quot;DevOps&amp;quot;);
var kernel = builder.Build();

// Or after Build():
kernel.Plugins.AddFromType&amp;lt;DevOpsPlugin&amp;gt;(&amp;quot;DevOps&amp;quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SK inspects the class at registration time, reads the &lt;code&gt;[KernelFunction]&lt;/code&gt; and &lt;code&gt;[Description]&lt;/code&gt; attributes, and builds a tool schema the model can reason over. The model never sees your C# method names directly - it sees the function name you passed to &lt;code&gt;[KernelFunction]&lt;/code&gt; and the descriptions you wrote.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Running a Multi-Step Plan&lt;/h2&gt;
&lt;p&gt;With the kernel configured and the plugin registered, the agent loop looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;

var chatService = kernel.GetRequiredService&amp;lt;IChatCompletionService&amp;gt;();
var history = new ChatHistory();

history.AddSystemMessage(
    &amp;quot;You are a DevOps assistant. Use the available tools to answer questions &amp;quot; +
    &amp;quot;about CI pipeline status. Always check current status before reporting.&amp;quot;);

var executionSettings = new AzureOpenAIPromptExecutionSettings
{
    FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};

// Turn 1: ask about a specific repo
history.AddUserMessage(
    &amp;quot;What&#39;s the current pipeline status for api-service and web-frontend?&amp;quot;);

var response = await chatService.GetChatMessageContentAsync(
    history,
    executionSettings,
    kernel);

Console.WriteLine(response.Content);
history.AddAssistantMessage(response.Content!);

// Turn 2: follow-up that requires the model to call list_repositories first
history.AddUserMessage(&amp;quot;Which of our repos are currently failing?&amp;quot;);

response = await chatService.GetChatMessageContentAsync(
    history,
    executionSettings,
    kernel);

Console.WriteLine(response.Content);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FunctionChoiceBehavior.Auto()&lt;/code&gt; is the key setting. It tells SK to let the model decide when to call tools. In practice: the model receives the conversation history and the available tool schemas; when it decides a tool call is warranted, SK intercepts the tool call response, dispatches it to the registered plugin method, appends the result to the conversation, and sends the updated history back to the model automatically. Your application code doesn&#39;t need to handle the function call / function result round-trip - SK manages that loop.&lt;/p&gt;
&lt;p&gt;For the second turn, the model will typically call &lt;code&gt;list_repositories&lt;/code&gt; first (because it doesn&#39;t know the full list), then call &lt;code&gt;get_pipeline_status&lt;/code&gt; for each repo, then synthesize the answer. That&#39;s a multi-step plan executed without any orchestration code on your side.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;A Note on Memory&lt;/h2&gt;
&lt;p&gt;Semantic Kernel supports semantic memory via &lt;code&gt;ITextEmbeddingGenerationService&lt;/code&gt; and a vector store backend. The pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.AddAzureOpenAITextEmbeddingGeneration(
    deploymentName: &amp;quot;text-embedding-3-small&amp;quot;,
    endpoint: &amp;quot;https://&amp;lt;your-project&amp;gt;.openai.azure.com/&amp;quot;,
    credential: new DefaultAzureCredential()
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&#39;d then use &lt;code&gt;ISemanticTextMemory&lt;/code&gt; (or the newer kernel memory abstractions in SK 1.x) to store and retrieve documents, runbooks, or conversation history by semantic similarity, grounding agent answers against a knowledge base.&lt;/p&gt;
&lt;p&gt;Memory is worth a post of its own. The short version: for agents that need to retrieve internal documentation or past context before answering, add embedding generation and a vector store (Azure AI Search works well here via &lt;code&gt;Microsoft.SemanticKernel.Connectors.AzureAISearch&lt;/code&gt;). For tool-calling agents like the one in this post, memory is optional.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Deploying to Azure Container Apps&lt;/h2&gt;
&lt;p&gt;The agent above is a console app. To run it as a persistent service - listening on a queue, exposing an HTTP endpoint, or processing events - containerize it and deploy to Azure Container Apps. Here&#39;s a workflow that does the full build-push-deploy cycle with OIDC authentication and no stored cloud credentials.&lt;/p&gt;
&lt;p&gt;First, the Dockerfile (place this at the repo root, adjust paths to match your project layout):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY src/DevOpsAgent/DevOpsAgent.csproj DevOpsAgent/
RUN dotnet restore DevOpsAgent/DevOpsAgent.csproj
COPY src/DevOpsAgent/ DevOpsAgent/
RUN dotnet publish DevOpsAgent/DevOpsAgent.csproj -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/runtime:8.0
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT [&amp;quot;dotnet&amp;quot;, &amp;quot;DevOpsAgent.dll&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the GitHub Actions workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Deploy DevOps Agent

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}/devops-agent
  AZURE_RESOURCE_GROUP: rg-devops-agent
  CONTAINER_APP_NAME: devops-agent
  CONTAINER_APP_ENV: cae-devops-prod

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up .NET 8
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: &#39;8.0.x&#39;

      - name: Run tests
        run: dotnet test --no-build --verbosity normal

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push container image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

      - name: Azure login (OIDC - no stored credentials)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to Azure Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          resourceGroup: ${{ env.AZURE_RESOURCE_GROUP }}
          containerAppName: ${{ env.CONTAINER_APP_NAME }}
          containerAppEnvironment: ${{ env.CONTAINER_APP_ENV }}
          imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few things worth noting:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OIDC instead of service principal secrets.&lt;/strong&gt; The &lt;code&gt;azure/login@v2&lt;/code&gt; step exchanges a short-lived GitHub OIDC token for a short-lived Azure access token - no &lt;code&gt;AZURE_CREDENTIALS&lt;/code&gt; JSON, no credential rotation. You configure a federated identity credential on the Azure managed identity or app registration once; the workflow runs without stored secrets from then on. The OIDC post covers this setup in detail if you haven&#39;t configured it yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;GITHUB_TOKEN&lt;/code&gt; for GHCR.&lt;/strong&gt; The &lt;code&gt;packages: write&lt;/code&gt; permission and &lt;code&gt;secrets.GITHUB_TOKEN&lt;/code&gt; handle container registry authentication. No PAT needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Agent environment variables at deploy time.&lt;/strong&gt; The Container App needs &lt;code&gt;AZURE_OPENAI_API_KEY&lt;/code&gt; (for development) or a managed identity binding (for production). For managed identity, assign the &lt;code&gt;Cognitive Services OpenAI User&lt;/code&gt; role to the Container App&#39;s system-assigned identity on the Azure OpenAI resource, and the &lt;code&gt;DefaultAzureCredential()&lt;/code&gt; in your kernel setup will work without any key in the environment.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;Python developers reaching for LangChain get orchestration, tool calling, memory, and a deployment story. .NET developers get the same thing with Semantic Kernel and Azure AI Foundry - the kernel manages orchestration and tool dispatch, Foundry manages model hosting and deployment boundaries, and Container Apps runs the workload in a managed environment without cluster overhead.&lt;/p&gt;
&lt;p&gt;The pattern here - kernel + plugin + &lt;code&gt;FunctionChoiceBehavior.Auto&lt;/code&gt; + Foundry endpoint - is the smallest complete unit of a production-ready .NET agent. It&#39;s not a prototype. The same structure scales to multi-plugin agents, streaming responses, Redis or Azure AI Search-backed memory, and distributed agent networks with explicit handoff rules. You grow by adding plugins and services to the kernel, not by rearchitecting.&lt;/p&gt;
&lt;p&gt;For teams already deployed on Azure, this is the path of least resistance. For teams evaluating frameworks, it&#39;s the path that keeps infrastructure and orchestration inside the same ecosystem.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions or corrections? 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>Semantic Kernel is Microsoft&#39;s open-source SDK for building AI agents. Azure AI Foundry is where you deploy the model. Together they give .NET developers a production-ready agent path - the same orchestration-plus-hosting story Python teams get from LangChain, without leaving the Microsoft ecosystem. This post builds a working agent end-to-end.</summary>
    <category term="azure-ai-foundry"/>
    <category term="semantic-kernel"/>
    <category term="ai-agents"/>
    <category term="dotnet"/>
    <category term="agentic-development"/>
  </entry>
  <entry>
    <title>GitHub Actions Advanced Caching: Strategies That Actually Cut Build Times</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-07-10-github-actions-advanced-caching-strategies/"/>
    <updated>2026-07-10T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-07-10-github-actions-advanced-caching-strategies/</id>
    <content xml:lang="en" type="html">&lt;p&gt;Your CI takes four minutes to install dependencies. With a well-designed cache, it takes eighteen seconds. The gap between those two numbers is almost never about network speed or disk I/O - it&#39;s about whether your cache key is written correctly.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;actions/cache&lt;/code&gt; is deceptively simple to use and deceptively easy to use wrong. A key that&#39;s too broad invalidates on every run. A key that&#39;s too narrow never hits. Most teams land somewhere in the middle: a single key that hits inconsistently, a 30-40% hit rate they&#39;ve stopped questioning, and CI minutes burning at the rate of a full install on every other push.&lt;/p&gt;
&lt;p&gt;This post is the fix. Cache key anatomy, restore-keys fallback chains, branch scoping, matrix-aware keys, per-ecosystem examples, and the security model that keeps it all safe.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why Most Caches Miss&lt;/h2&gt;
&lt;p&gt;The anti-pattern looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: actions/cache@v4
  with:
    path: node_modules
    key: npm-cache
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This key never changes. The first run writes the cache. Every subsequent run hits it - until someone changes a dependency, at which point the cached &lt;code&gt;node_modules&lt;/code&gt; is stale and wrong. The solution most teams reach for is adding the lock file hash:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;key: npm-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Better - but &lt;code&gt;npm-a3f9c21d&lt;/code&gt; is the same key for an Ubuntu runner and a macOS runner, which means a cache written on one may be restored on the other. Compiled native modules, platform-specific binaries, and symlink layouts differ between operating systems. Cross-platform cache sharing silently corrupts builds.&lt;/p&gt;
&lt;p&gt;The other failure mode is keys that are too broad - omitting the lock file hash entirely - or too narrow - including something like &lt;code&gt;github.sha&lt;/code&gt; in the key. Committing the current SHA to the cache key guarantees a miss on every run except the one that wrote the cache. It turns &lt;code&gt;actions/cache&lt;/code&gt; into an expensive no-op.&lt;/p&gt;
&lt;p&gt;The actual hit rate problem is almost always one of these three things: missing the runner OS, missing the lock file hash, or including something that changes on every run. Fix those and hit rates climb above 80% without any other changes.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Cache Key Anatomy&lt;/h2&gt;
&lt;p&gt;A well-formed cache key has three components: runner OS, a stable identifier for the dependency set (usually a tool version), and a hash of the lock file. The canonical pattern for npm:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;key: ${{ runner.os }}-npm-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This key:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Changes when the OS changes.&lt;/strong&gt; &lt;code&gt;runner.os&lt;/code&gt; returns &lt;code&gt;Linux&lt;/code&gt;, &lt;code&gt;Windows&lt;/code&gt;, or &lt;code&gt;macOS&lt;/code&gt;. An Ubuntu job and a macOS job have separate caches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Changes when dependencies change.&lt;/strong&gt; &lt;code&gt;hashFiles(&#39;**/package-lock.json&#39;)&lt;/code&gt; hashes every &lt;code&gt;package-lock.json&lt;/code&gt; in the repo. When &lt;code&gt;npm install&lt;/code&gt; updates the lock file, the hash changes, the old cache key no longer matches, and the runner installs fresh.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does not change between runs on the same OS with the same dependencies.&lt;/strong&gt; A push to a feature branch with no dependency changes hits the same cache as the previous push to that branch.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;hashFiles()&lt;/code&gt; accepts glob patterns. &lt;code&gt;hashFiles(&#39;**/package-lock.json&#39;)&lt;/code&gt; covers monorepos with multiple lock files. For Gradle, &lt;code&gt;hashFiles(&#39;**/*.gradle*&#39;, &#39;**/gradle-wrapper.properties&#39;)&lt;/code&gt; captures both build scripts and the wrapper version. The function computes a SHA-256 over the sorted, concatenated contents of all matching files and returns the first 16 characters of the hex digest.&lt;/p&gt;
&lt;p&gt;If you&#39;re caching a tool that also depends on its own version - &lt;code&gt;actions/setup-node&lt;/code&gt;, &lt;code&gt;actions/setup-python&lt;/code&gt;, and similar - include the version in the key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two jobs running different Node versions install packages against different ABIs. Their caches should be separate.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Restore-Keys Fallback Chains&lt;/h2&gt;
&lt;p&gt;When no cache entry matches the primary key, &lt;code&gt;actions/cache&lt;/code&gt; searches for a usable partial match using the &lt;code&gt;restore-keys&lt;/code&gt; list, in order, treating each entry as a prefix. The first partial match it finds is restored - stale, but better than nothing for most dependency managers.&lt;/p&gt;
&lt;p&gt;Here is a three-level chain for npm:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
    restore-keys: |
      ${{ runner.os }}-npm-
      ${{ runner.os }}-
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read this as a priority list:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Exact key match&lt;/strong&gt; - the current lock file hash. Full hit, nothing to install.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;${{ runner.os }}-npm-&lt;/code&gt; prefix&lt;/strong&gt; - a cache from a previous lock file state. Restores, then &lt;code&gt;npm install&lt;/code&gt; adds or removes the delta packages. Much faster than a cold install.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;${{ runner.os }}-&lt;/code&gt; prefix&lt;/strong&gt; - any cache for this OS. Last resort, but any warmed npm cache beats downloading everything.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The reason stale caches are valuable: &lt;code&gt;npm install&lt;/code&gt; against a partially-warm &lt;code&gt;~/.npm&lt;/code&gt; global cache still skips the network fetch for every package that hasn&#39;t changed. Only the diff needs downloading. For a project with 800 packages where two change, the difference between a cold install and a stale cache hit is the difference between downloading all 800 and downloading 2.&lt;/p&gt;
&lt;p&gt;After the job completes, &lt;code&gt;actions/cache&lt;/code&gt; writes a new cache entry for the exact key if one didn&#39;t already exist. The stale cache does not overwrite the one it was restored from - it creates a new entry. The exact-key entry is the authoritative one; the partial-match entries are fallbacks.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Branch Scoping&lt;/h2&gt;
&lt;p&gt;GitHub scopes cache access by repository and branch. A job running on a feature branch can read caches written from &lt;code&gt;main&lt;/code&gt;, but a job on &lt;code&gt;main&lt;/code&gt; cannot read caches written from a feature branch. Fork PRs are even more restricted: they cannot read caches from the parent repo at all. This is a deliberate security boundary.&lt;/p&gt;
&lt;p&gt;The practical implication for cache key design: your default branch cache is the base that all feature branches fall back to. If you include &lt;code&gt;github.ref&lt;/code&gt; in the primary key, each branch maintains an isolated cache that starts cold. If you omit &lt;code&gt;github.ref&lt;/code&gt;, all branches share a single cache that overwrites each other on every push.&lt;/p&gt;
&lt;p&gt;The right balance is to include &lt;code&gt;github.ref&lt;/code&gt; in the primary key and exclude it from the &lt;code&gt;restore-keys&lt;/code&gt; chain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ github.ref_name }}-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
    restore-keys: |
      ${{ runner.os }}-npm-${{ github.ref_name }}-
      ${{ runner.os }}-npm-refs/heads/main-
      ${{ runner.os }}-npm-
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Branch-scoped primary key&lt;/strong&gt; - feature branches write their own caches and don&#39;t pollute the shared pool with every PR&#39;s lock file state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fallback to &lt;code&gt;main&lt;/code&gt;&#39;s cache&lt;/strong&gt; - a new branch&#39;s first run restores from &lt;code&gt;main&lt;/code&gt; rather than installing cold.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Last-resort fallback&lt;/strong&gt; - any npm cache for this OS, in case &lt;code&gt;main&lt;/code&gt; doesn&#39;t have a recent entry either.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;refs/heads/main-&lt;/code&gt; prefix in the fallback is explicit. &lt;code&gt;github.ref_name&lt;/code&gt; returns &lt;code&gt;main&lt;/code&gt; on the default branch, so the prefix matches that branch&#39;s cache entries.&lt;/p&gt;
&lt;p&gt;For short-lived feature branches, branch-scoped caches are written once and usually read once. GitHub&#39;s LRU eviction takes care of them within a few days of the branch going idle. You don&#39;t need to clean up manually.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Matrix-Aware Cache Keys&lt;/h2&gt;
&lt;p&gt;Matrix builds that share a single cache key cross-contaminate. A job testing against Node 18 and a job testing against Node 20 should not share a cache - native addons, some packages, and occasionally &lt;code&gt;.bin&lt;/code&gt; symlinks differ between Node versions.&lt;/p&gt;
&lt;p&gt;The fix is to include the matrix variable in the key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: [&#39;18.x&#39;, &#39;20.x&#39;]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}

      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
          restore-keys: |
            ${{ runner.os }}-node-${{ matrix.node-version }}-
            ${{ runner.os }}-node-

      - run: npm ci
      - run: npm test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;runner.os&lt;/code&gt; and &lt;code&gt;matrix.os&lt;/code&gt; serve the same purpose here - the OS identifier. Using &lt;code&gt;runner.os&lt;/code&gt; is slightly more reliable since it reflects the actual OS the runner is on, whereas &lt;code&gt;matrix.os&lt;/code&gt; includes the &lt;code&gt;latest&lt;/code&gt; suffix which can shift when GitHub updates the runner image. Either works; be consistent.&lt;/p&gt;
&lt;p&gt;For a 2×2 matrix (2 OS × 2 Node versions), this produces four independent cache namespaces. Each one maintains its own history and its own fallback chain. Hits stay within the correct namespace; misses fall back to any cache for that OS/version pair, not to a different version&#39;s stale cache.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Per-Ecosystem Examples&lt;/h2&gt;
&lt;h3&gt;npm&lt;/h3&gt;
&lt;p&gt;Cache &lt;code&gt;~/.npm&lt;/code&gt; (the global npm content-addressable cache), not &lt;code&gt;node_modules&lt;/code&gt;. &lt;code&gt;npm ci&lt;/code&gt; populates &lt;code&gt;node_modules&lt;/code&gt; from &lt;code&gt;~/.npm&lt;/code&gt; - if the global cache is warm, &lt;code&gt;npm ci&lt;/code&gt; takes seconds even when &lt;code&gt;node_modules&lt;/code&gt; is cold. Caching &lt;code&gt;node_modules&lt;/code&gt; directly is fragile: it ties the cache to the local directory structure, breaks cross-version fallbacks, and can cause subtle issues when native modules are involved.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
    restore-keys: |
      ${{ runner.os }}-npm-

- run: npm ci
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Gradle&lt;/h3&gt;
&lt;p&gt;Gradle&#39;s cache lives in &lt;code&gt;~/.gradle/caches&lt;/code&gt; and &lt;code&gt;~/.gradle/wrapper&lt;/code&gt;. Hash both build scripts and the wrapper properties file - either can change between builds:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles(&#39;**/*.gradle*&#39;, &#39;**/gradle-wrapper.properties&#39;) }}
    restore-keys: |
      ${{ runner.os }}-gradle-

- run: ./gradlew build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;**/*.gradle*&lt;/code&gt; pattern matches &lt;code&gt;build.gradle&lt;/code&gt;, &lt;code&gt;build.gradle.kts&lt;/code&gt;, &lt;code&gt;settings.gradle&lt;/code&gt;, and &lt;code&gt;settings.gradle.kts&lt;/code&gt;. If your build scripts are stable but your dependency versions in a &lt;code&gt;libs.versions.toml&lt;/code&gt; file change frequently, include that file in the hash as well.&lt;/p&gt;
&lt;h3&gt;pip&lt;/h3&gt;
&lt;p&gt;Cache &lt;code&gt;~/.cache/pip&lt;/code&gt;. Include the Python version in the key - wheels are version and platform-specific:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: actions/setup-python@v5
  with:
    python-version: &#39;3.12&#39;

- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles(&#39;**/requirements*.txt&#39;) }}
    restore-keys: |
      ${{ runner.os }}-pip-

- run: pip install -r requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;hashFiles(&#39;**/requirements*.txt&#39;)&lt;/code&gt; matches &lt;code&gt;requirements.txt&lt;/code&gt;, &lt;code&gt;requirements-dev.txt&lt;/code&gt;, &lt;code&gt;requirements-test.txt&lt;/code&gt;, and any other variant. If you use &lt;code&gt;pyproject.toml&lt;/code&gt; for dependencies, substitute &lt;code&gt;hashFiles(&#39;**/pyproject.toml&#39;)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If you use &lt;code&gt;actions/setup-python@v5&lt;/code&gt; with the &lt;code&gt;cache&lt;/code&gt; parameter, it handles this automatically. The built-in caching in setup actions is equivalent to the manual pattern above - use whichever you prefer, but don&#39;t do both.&lt;/p&gt;
&lt;h3&gt;Docker Layer Caching&lt;/h3&gt;
&lt;p&gt;Docker layer caching in GitHub Actions uses &lt;code&gt;docker/build-push-action&lt;/code&gt; with the &lt;code&gt;cache-from&lt;/code&gt; and &lt;code&gt;cache-to&lt;/code&gt; parameters and the &lt;code&gt;type=gha&lt;/code&gt; cache backend, which writes directly to the GitHub Actions cache API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: docker/setup-buildx-action@v3

- uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mode=max&lt;/code&gt; caches all layers, including intermediate build stages. &lt;code&gt;mode=min&lt;/code&gt; (the default) caches only the final stage. For multi-stage Dockerfiles where the build stage is expensive - compiling a large Go or Rust binary, for example - &lt;code&gt;mode=max&lt;/code&gt; is worth the additional cache storage.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;type=gha&lt;/code&gt; backend respects the same cache scoping rules as &lt;code&gt;actions/cache&lt;/code&gt;. A push to a feature branch can restore layers cached from &lt;code&gt;main&lt;/code&gt;, but writes from the feature branch don&#39;t pollute the &lt;code&gt;main&lt;/code&gt; cache. This means your CI&#39;s Docker builds get free warm-layer fallback on every branch without any additional configuration.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Cache Poisoning and GitHub&#39;s Isolation Model&lt;/h2&gt;
&lt;p&gt;Cache poisoning is the scenario where a malicious cache entry is written by an untrusted source and then read by a trusted workflow, causing it to execute or deploy compromised artifacts.&lt;/p&gt;
&lt;p&gt;GitHub&#39;s isolation model addresses this structurally:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Caches are scoped to a &lt;strong&gt;repository and branch&lt;/strong&gt;. A cache written by a workflow run in &lt;code&gt;repo-A&lt;/code&gt; is never readable by &lt;code&gt;repo-B&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fork PRs cannot read the parent repository&#39;s cache.&lt;/strong&gt; A &lt;code&gt;pull_request&lt;/code&gt; event from a fork runs in the fork&#39;s security context. The &lt;code&gt;ACTIONS_CACHE_URL&lt;/code&gt; token issued to that runner is scoped to the fork - it has no read access to the parent repo&#39;s cache entries. This blocks the attack where a malicious PR poisons the parent&#39;s cache and waits for a privileged workflow to restore it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache entries are immutable once written.&lt;/strong&gt; A key that already has an entry cannot be overwritten by another run. The first writer wins. This prevents a race where an attacker writes a poisoned entry by racing the legitimate workflow.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The practical implication: if your workflows run on &lt;code&gt;pull_request&lt;/code&gt; events from forks (common for open source projects), the isolation model already protects you. Fork PR workflows cannot read or write your repo&#39;s caches. You do not need to add logic to skip caching on fork PRs.&lt;/p&gt;
&lt;p&gt;The residual risk is internal: a workflow triggered from a branch in your own repo by a contributor with push access could write a malicious cache. This is a trust-level issue, not a caching issue - if a contributor has push access, they can already modify workflow files, which is a far larger attack surface than cache entries.&lt;/p&gt;
&lt;p&gt;One concrete hardening step: use &lt;code&gt;actions/cache/restore&lt;/code&gt; and &lt;code&gt;actions/cache/save&lt;/code&gt; as separate steps rather than the combined &lt;code&gt;actions/cache&lt;/code&gt;. This lets you restore before the main work and save after it, and it gives you explicit control over when and whether saving happens. In particular, you can skip the save step if the primary key already had a hit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- uses: actions/cache/restore@v4
  id: cache-restore
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
    restore-keys: |
      ${{ runner.os }}-npm-

- run: npm ci

- uses: actions/cache/save@v4
  if: steps.cache-restore.outputs.cache-hit != &#39;true&#39;
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;if&lt;/code&gt; condition prevents redundant saves when the primary key already matched. It&#39;s a minor optimization - &lt;code&gt;actions/cache&lt;/code&gt; already avoids overwriting an existing key - but the split step pattern is valuable for a different reason: the &lt;code&gt;save&lt;/code&gt; step runs even if the main job fails. The combined &lt;code&gt;actions/cache&lt;/code&gt; action&#39;s post-step also runs on failure, but having explicit control makes the behavior readable rather than implicit.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Cache Size Limits and Eviction&lt;/h2&gt;
&lt;p&gt;GitHub enforces a 10 GB total cache limit per repository. When the total size exceeds 10 GB, the oldest entries are evicted until the repo is back under the limit. There is no per-entry size limit - a single large cache entry is valid, it just consumes the shared budget.&lt;/p&gt;
&lt;p&gt;The practical limit this creates: don&#39;t cache build output. Build artifacts, compiled binaries, and test reports are not dependency caches - they change on every commit, they consume space fast, and restoring a stale build artifact is almost never useful. Cache only the inputs that don&#39;t change on every run: downloaded packages, resolved dependency trees, compiler toolchains.&lt;/p&gt;
&lt;p&gt;A few rules that keep cache storage under control:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cache the global package manager cache, not the local install directory.&lt;/strong&gt; For npm, &lt;code&gt;~/.npm&lt;/code&gt; not &lt;code&gt;node_modules&lt;/code&gt;. For pip, &lt;code&gt;~/.cache/pip&lt;/code&gt; not &lt;code&gt;venv/&lt;/code&gt;. The global cache stores compressed tarballs; the local install directory stores unpacked files which are larger and more volatile.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Include enough specificity in the key that stale entries expire quickly.&lt;/strong&gt; A key with a lock file hash creates a new entry every time dependencies change. If you change dependencies frequently, old entries accumulate. They&#39;ll be evicted by LRU, but if you&#39;re near the 10 GB limit, you can add an explicit cache version prefix to force a clean sweep:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;key: v2-${{ runner.os }}-npm-${{ hashFiles(&#39;**/package-lock.json&#39;) }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Increment &lt;code&gt;v2&lt;/code&gt; to &lt;code&gt;v3&lt;/code&gt; to immediately stop using any previously written &lt;code&gt;v2&lt;/code&gt; entries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;actions/cache/save&lt;/code&gt; with &lt;code&gt;if: always()&lt;/code&gt; for jobs that run long test suites.&lt;/strong&gt; By default, the save step only runs if the main job succeeded. For long-running jobs where you want to preserve the partially-warm state even on failure, use the split step pattern with &lt;code&gt;if: always()&lt;/code&gt; on the save step.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;The cache key is three things: runner OS, tool version or ecosystem identifier, and lock file hash. The &lt;code&gt;restore-keys&lt;/code&gt; chain is how you trade exactness for hit rate - a stale cache that installs a 50-package delta is faster than a cold install every time. Branch scoping keeps feature caches from polluting &lt;code&gt;main&lt;/code&gt;, and the fallback to &lt;code&gt;main&lt;/code&gt; means new branches don&#39;t start cold. Matrix builds need one cache namespace per combination - omit the matrix variable and you&#39;re sharing state across jobs that shouldn&#39;t share it.&lt;/p&gt;
&lt;p&gt;This is five minutes of work per workflow. The return is measured in CI minutes per week, compounded across every developer on every push. Most teams treat cache key design as an afterthought, which is why most teams have 30% hit rates when they could have 90%.&lt;/p&gt;
&lt;p&gt;Write the key correctly. Add the fallback chain. Scope it to the branch. That&#39;s the whole thing.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions or corrections? 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 teams use actions/cache with a single key and accept 20% hit rates as inevitable. They&#39;re not. Cache key strategy - how you compose the key, chain restore-keys fallbacks, scope by branch, and handle matrix builds - is the difference between a dependency install that takes 4 minutes and one that takes 18 seconds.</summary>
    <category term="github-actions"/>
    <category term="ci-cd"/>
    <category term="developer-productivity"/>
    <category term="performance"/>
  </entry>
  <entry>
    <title>Container Image Security in CI: Scanning with Trivy and GitHub Advanced Security</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-07-17-container-image-security-trivy-github-advanced-security/"/>
    <updated>2026-07-17T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-07-17-container-image-security-trivy-github-advanced-security/</id>
    <content xml:lang="en" type="html">&lt;p&gt;The scanner runs. Two hundred alerts appear. Nobody fixes anything.&lt;/p&gt;
&lt;p&gt;This is not a Trivy problem or a GitHub problem. It&#39;s a configuration problem. An unconfigured vulnerability scanner produces a list sorted by count, not by urgency, with no distinction between CVEs that have a fix available today and CVEs where the upstream hasn&#39;t shipped a patch in two years. Every alert looks equally important, which means none of them are. The team stops looking at the output. The scanner keeps running. The checkbox stays green.&lt;/p&gt;
&lt;p&gt;The workflow design is the security control, not the scanner. Trivy run without a severity threshold, without &lt;code&gt;ignore-unfixed&lt;/code&gt;, and without SARIF integration into a triage workflow is just noise generation. This post is about running Trivy in a way that produces decisions: block builds on what you can fix today, surface what you can&#39;t for explicit acceptance, and eliminate the middle ground where alert fatigue lives.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Signal-to-Noise Problem&lt;/h2&gt;
&lt;p&gt;A fresh &lt;code&gt;node:20-alpine&lt;/code&gt; base image typically has between 20 and 80 reported CVEs. Most are MEDIUM or LOW severity. Most are in OS-level packages that your application doesn&#39;t directly invoke. Most have no fix available because the Alpine maintainers haven&#39;t shipped an update yet. Running Trivy without configuration against this image and failing the build on any vulnerability means your CI fails the day you adopt it and never passes again until you&#39;ve suppressed every one of those alerts - at which point you&#39;ve also suppressed the ones that matter.&lt;/p&gt;
&lt;p&gt;The goal is a workflow where:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CRITICAL vulnerabilities with a fix available&lt;/strong&gt; block the build immediately&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HIGH vulnerabilities&lt;/strong&gt; are reported to the Security tab for triage, but don&#39;t block every push&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unfixed vulnerabilities&lt;/strong&gt; are tracked separately, accepted with explicit justification, and revisited when upstream patches ship&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MEDIUM and below&lt;/strong&gt; are invisible unless someone goes looking for them&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This isn&#39;t being permissive about security - it&#39;s being precise about what can be acted on. A build that fails because your base image has a MEDIUM CVE in a compression library that&#39;s been unpatched for eighteen months is not a secure build. It&#39;s a build that nobody bothered to un-break. The team learns to add the suppression rule and move on. The CRITICAL vulnerability that shipped last week because the developer suppressed everything to make CI green is what you&#39;re actually trying to prevent.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Running Trivy in GitHub Actions&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;aquasecurity/trivy-action&lt;/code&gt; wraps Trivy&#39;s scanning modes behind a clean interface. The two modes you&#39;ll use for container security are &lt;code&gt;image&lt;/code&gt; (scan a built image from the Docker daemon or a registry) and &lt;code&gt;config&lt;/code&gt; (scan a Dockerfile for misconfigurations).&lt;/p&gt;
&lt;h3&gt;Scanning a built image&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Scan container image
  uses: aquasecurity/trivy-action@0.28.0
  with:
    image-ref: &#39;ghcr.io/${{ github.repository }}:${{ github.sha }}&#39;
    scan-type: &#39;image&#39;
    format: &#39;table&#39;
    severity: &#39;CRITICAL,HIGH&#39;
    exit-code: &#39;1&#39;
    ignore-unfixed: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The critical inputs:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;severity&lt;/code&gt;&lt;/strong&gt; limits which findings are processed. &lt;code&gt;CRITICAL,HIGH&lt;/code&gt; means Trivy only reports and acts on those two severity levels - MEDIUM, LOW, and UNKNOWN are ignored entirely. This is your noise filter.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;exit-code: &#39;1&#39;&lt;/code&gt;&lt;/strong&gt; makes Trivy return a non-zero exit code when findings match the severity filter. Without this, Trivy scans and reports but never fails the step, which means it never blocks the build. Set this to &lt;code&gt;&#39;1&#39;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ignore-unfixed: true&lt;/code&gt;&lt;/strong&gt; excludes CVEs where no fixed version exists in the advisory database. This is the single most important configuration decision for base image scanning. If upstream hasn&#39;t released a patch, you cannot fix it by changing your code. Failing the build on an unfixable CVE generates a suppression rule, not a security improvement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;image-ref&lt;/code&gt;&lt;/strong&gt; points to the image to scan. This should be the image you just built in the same job, not a generic tag - scanning the exact artifact that will be pushed to the registry is the point.&lt;/p&gt;
&lt;h3&gt;Scanning a Dockerfile for misconfigurations&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Scan Dockerfile
  uses: aquasecurity/trivy-action@0.28.0
  with:
    scan-type: &#39;config&#39;
    scan-ref: &#39;.&#39;
    format: &#39;table&#39;
    exit-code: &#39;1&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The config scan checks your Dockerfile against Trivy&#39;s misconfiguration rules: running as root, using &lt;code&gt;ADD&lt;/code&gt; instead of &lt;code&gt;COPY&lt;/code&gt;, missing &lt;code&gt;USER&lt;/code&gt; directives, &lt;code&gt;HEALTHCHECK&lt;/code&gt; absence, and others. This is orthogonal to vulnerability scanning - it catches bad Dockerfile patterns independent of what CVEs are in the image. Run both.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;SARIF Output and the GitHub Security Tab&lt;/h2&gt;
&lt;p&gt;The table format is useful for humans reading workflow logs. The SARIF format is what connects Trivy to GitHub&#39;s Security tab - a persistent, triage-oriented interface that&#39;s separate from your CI runs and doesn&#39;t reset when the workflow finishes.&lt;/p&gt;
&lt;h3&gt;Generating SARIF output&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Scan container image (SARIF)
  uses: aquasecurity/trivy-action@0.28.0
  with:
    image-ref: &#39;ghcr.io/${{ github.repository }}:${{ github.sha }}&#39;
    scan-type: &#39;image&#39;
    format: &#39;sarif&#39;
    output: &#39;trivy-results.sarif&#39;
    severity: &#39;CRITICAL,HIGH&#39;
    ignore-unfixed: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note: no &lt;code&gt;exit-code&lt;/code&gt; here. The SARIF upload step is for visibility, not for build gating. The build gate is a separate step with &lt;code&gt;exit-code: &#39;1&#39;&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Uploading to the Security tab&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Upload SARIF results
  uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: &#39;trivy-results.sarif&#39;
    category: &#39;container-scanning&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;if: always()&lt;/code&gt; is important. Without it, if the Trivy scan step fails (because &lt;code&gt;exit-code: &#39;1&#39;&lt;/code&gt; triggered), the upload step is skipped - which means you get a failing build but no Security tab entry showing you what failed. &lt;code&gt;always()&lt;/code&gt; ensures the SARIF is uploaded regardless of whether the previous step succeeded.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;category: &#39;container-scanning&#39;&lt;/code&gt; namespaces the results in the Security tab, separating them from CodeQL static analysis alerts. If you run multiple Trivy scans in the same workflow (image scan + config scan), use distinct categories: &lt;code&gt;container-scanning&lt;/code&gt; and &lt;code&gt;container-config&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;What the Security tab shows&lt;/h3&gt;
&lt;p&gt;The Security tab (&lt;strong&gt;Security → Code scanning&lt;/strong&gt;) displays each finding as a persistent alert with its CVE ID, severity, affected package, fixed version (if available), and the workflow run that detected it. Alerts can be dismissed with a reason - &amp;quot;risk accepted&amp;quot;, &amp;quot;false positive&amp;quot;, &amp;quot;used in tests&amp;quot; - and a note. Dismissed alerts remain visible and auditable. A security reviewer can see what was dismissed, by whom, and why.&lt;/p&gt;
&lt;p&gt;This matters more than it seems. A suppression in &lt;code&gt;.trivyignore&lt;/code&gt; is a file in your repo that gets committed and approved in PRs - but it doesn&#39;t capture the reasoning. A dismissal in the Security tab has an author, a timestamp, and a reason attached. For compliance conversations, that&#39;s the difference between &amp;quot;we accepted this risk&amp;quot; and &amp;quot;we can prove we accepted this risk, here&#39;s who made the decision, here&#39;s when.&amp;quot;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;GitHub Advanced Security (GHAS) is required for private repositories.&lt;/strong&gt; The Security tab and code scanning features are available on public repos for free. Private repos need either a GHAS license or GitHub Enterprise Cloud. Public repos get all of this at no cost.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Severity Thresholds That Work&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;severity&lt;/code&gt; + &lt;code&gt;exit-code&lt;/code&gt; combination is how you express your security policy in code. Here&#39;s a starting configuration that most teams can adopt immediately and tighten over time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Step 1: Upload all CRITICAL and HIGH to Security tab (always runs)
- name: Scan image - SARIF upload
  uses: aquasecurity/trivy-action@0.28.0
  with:
    image-ref: &#39;ghcr.io/${{ github.repository }}:${{ github.sha }}&#39;
    scan-type: &#39;image&#39;
    format: &#39;sarif&#39;
    output: &#39;trivy-results.sarif&#39;
    severity: &#39;CRITICAL,HIGH&#39;
    ignore-unfixed: true

- name: Upload SARIF results
  uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: &#39;trivy-results.sarif&#39;
    category: &#39;container-scanning&#39;

# Step 2: Fail the build on CRITICAL only
- name: Scan image - build gate
  uses: aquasecurity/trivy-action@0.28.0
  with:
    image-ref: &#39;ghcr.io/${{ github.repository }}:${{ github.sha }}&#39;
    scan-type: &#39;image&#39;
    format: &#39;table&#39;
    severity: &#39;CRITICAL&#39;
    exit-code: &#39;1&#39;
    ignore-unfixed: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two separate steps: one that uploads HIGH and CRITICAL to the Security tab for triage, and one that fails the build on CRITICAL only. This gives you visibility into HIGH vulnerabilities without blocking every push over something that might be low-priority for your application&#39;s threat model.&lt;/p&gt;
&lt;h3&gt;Why not block on HIGH?&lt;/h3&gt;
&lt;p&gt;MEDIUM is obviously too aggressive - the noise volume is too high and the fix rate too low. CRITICAL is clearly the right threshold to start with. HIGH is where teams argue, and the argument usually resolves as follows: block on HIGH after your team has demonstrated it can triage and resolve CRITICAL findings consistently. If your Security tab currently has 40 unresolved CRITICAL findings, adding HIGH to the build gate adds noise without adding security. Clear the CRITICAL backlog first, then raise the bar.&lt;/p&gt;
&lt;p&gt;The threshold evolution looks like this over time:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Start:&lt;/strong&gt; fail on CRITICAL (fixable only), report HIGH to Security tab&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;After 2-3 sprints:&lt;/strong&gt; fail on CRITICAL and HIGH (fixable only), report MEDIUM to Security tab&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mature state:&lt;/strong&gt; fail on CRITICAL and HIGH (fixable only), suppress accepted risks in &lt;code&gt;.trivyignore&lt;/code&gt; with expiry dates, audit MEDIUM findings monthly&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Don&#39;t jump to step 3 on day one. Alert fatigue kills scanning programs faster than unconfigured ones.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;&lt;code&gt;ignore-unfixed&lt;/code&gt; and &lt;code&gt;.trivyignore&lt;/code&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;ignore-unfixed: true&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;When a CVE appears in the advisory database but no patched version of the affected package exists yet, it&#39;s unfixable at your level. You can&#39;t upgrade past it. The only options are: wait for upstream, switch base images, or accept the risk. Failing the build on an unfixable CVE forces you to suppress it immediately just to unblock development, which means you&#39;re creating suppressions that may never get revisited.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ignore-unfixed: true&lt;/code&gt; excludes these from the build gate. They still appear in the SARIF output if you want visibility. The key property: when a fix does become available, the CVE re-enters the fixable pool and your next scan will catch it.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;.trivyignore&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;For vulnerabilities where you&#39;ve made an explicit acceptance decision - &amp;quot;this library is only used in tests&amp;quot;, &amp;quot;the affected code path is unreachable in our use case&amp;quot;, &amp;quot;we&#39;re waiting for a base image update and this is documented in ADR-047&amp;quot; - &lt;code&gt;.trivyignore&lt;/code&gt; suppresses the finding.&lt;/p&gt;
&lt;p&gt;A basic &lt;code&gt;.trivyignore&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# CVE-2024-12345 - libssl in base image, no fix available upstream (as of 2026-07-17)
# Re-evaluate when node:20-alpine ships OpenSSL 3.1.6+
CVE-2024-12345

# CVE-2024-67890 - only affects the zlib compression CLI flag we don&#39;t use
CVE-2024-67890
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Trivy 0.45+ supports expiry dates, which is the right way to handle accepted-risk suppressions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# CVE-2024-12345 exp:2026-10-17
CVE-2024-12345
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the expiry date passes, Trivy treats the suppression as absent and the CVE returns to scan results. This forces periodic re-evaluation without requiring manual calendar reminders. Set expiry dates at 90 days for base-image CVEs with active upstream work, and at 180 days for anything else.&lt;/p&gt;
&lt;p&gt;Every entry in &lt;code&gt;.trivyignore&lt;/code&gt; should have a comment explaining why the suppression exists. A suppression without a comment is technical debt - nobody six months later knows whether it was a considered decision or something someone added to make CI green at 4pm on a Friday.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Base Image Pinning&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# Mutable - this resolved to a different image last week than it does today
FROM node:20-alpine

# Pinned - this resolves to exactly one image, forever
FROM node:20-alpine@sha256:a3f9c21d8e4b7f5d9c2a1b0e3d6f8c5e7a4b9d2f0e1c3a5b7d9f2e4c6a8b0d2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Docker image tags are mutable pointers. &lt;code&gt;node:20-alpine&lt;/code&gt; today resolves to one image; after the next Alpine security update it resolves to a different one with different packages and potentially different vulnerabilities. Pinning to a digest guarantees that your reproducible build is actually reproducible: the same digest always resolves to the same image, always has the same package set, and always produces the same scan results.&lt;/p&gt;
&lt;p&gt;The objection: &amp;quot;we want to pick up base image security updates automatically.&amp;quot; That&#39;s correct - and it&#39;s exactly what Dependabot handles.&lt;/p&gt;
&lt;h3&gt;Keeping pinned digests updated with Dependabot&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: &amp;quot;docker&amp;quot;
    directory: &amp;quot;/&amp;quot;
    schedule:
      interval: &amp;quot;weekly&amp;quot;
    commit-message:
      prefix: &amp;quot;docker&amp;quot;
    labels:
      - &amp;quot;dependencies&amp;quot;
      - &amp;quot;docker&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this configuration, Dependabot opens a PR whenever a new digest is available for your pinned base image. The PR goes through your normal review process - which means your security scan runs on the new image before the updated digest merges, not after it&#39;s already in production. You get automatic updates and reproducible builds simultaneously.&lt;/p&gt;
&lt;p&gt;If you have Dockerfiles in subdirectories, add a separate entry per directory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;  - package-ecosystem: &amp;quot;docker&amp;quot;
    directory: &amp;quot;/services/api&amp;quot;
    schedule:
      interval: &amp;quot;weekly&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Renovate is an alternative with more configuration options - it supports group PRs that update multiple digests in a single PR, which is useful when you have five Dockerfiles all using the same base image.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Multi-Stage Build Hardening&lt;/h2&gt;
&lt;p&gt;A standard Node.js Dockerfile often looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

EXPOSE 3000
CMD [&amp;quot;node&amp;quot;, &amp;quot;dist/server.js&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This ships the build toolchain, the &lt;code&gt;npm&lt;/code&gt; executable, the full Node.js standard library, and every package in &lt;code&gt;node_modules&lt;/code&gt; - including your &lt;code&gt;devDependencies&lt;/code&gt; - into the production image. The attack surface is everything in that image. If an attacker achieves code execution in the container, they have &lt;code&gt;npm&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;sh&lt;/code&gt;, and a full Linux userspace to work with.&lt;/p&gt;
&lt;p&gt;A hardened multi-stage version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# Build stage - has everything needed to compile
FROM node:20-alpine@sha256:a3f9c21d8e4b7f5d9c2a1b0e3d6f8c5e7a4b9d2f0e1c3a5b7d9f2e4c6a8b0d2 AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build &amp;amp;&amp;amp; npm prune --production

# Final stage - has only what&#39;s needed to run
FROM gcr.io/distroless/nodejs20-debian12

WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

USER nonroot

EXPOSE 3000
CMD [&amp;quot;dist/server.js&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The final stage is based on &lt;code&gt;gcr.io/distroless/nodejs20-debian12&lt;/code&gt; - Google&#39;s distroless Node.js image. Distroless images contain only the runtime and your application. No shell, no package manager, no curl, no libc tools. The attack surface is dramatically smaller: if code execution is achieved, the attacker has the Node.js runtime and your application&#39;s files - nothing else.&lt;/p&gt;
&lt;p&gt;What multi-stage removes from the final image:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The build toolchain (compilers, build utilities)&lt;/li&gt;
&lt;li&gt;npm and package management tools&lt;/li&gt;
&lt;li&gt;Shell (&lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;bash&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;devDependencies (pruned before the COPY step)&lt;/li&gt;
&lt;li&gt;Build-time secrets (if any were mounted as &lt;code&gt;--secret&lt;/code&gt; in the builder stage)&lt;/li&gt;
&lt;li&gt;Any intermediate build artifacts not explicitly copied&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;npm prune --production&lt;/code&gt; in the builder stage before the COPY removes devDependencies from &lt;code&gt;node_modules&lt;/code&gt;, so only production dependencies land in the final image.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;USER nonroot&lt;/code&gt; in the distroless image runs the process as uid 65532, not root. If there&#39;s a vulnerability that allows container escape to host, running as non-root significantly limits what the attacker can do on the host.&lt;/p&gt;
&lt;p&gt;Trivy scanning the distroless final image instead of the &lt;code&gt;node:20&lt;/code&gt; image typically reduces the CVE count by 60-80%. The scan still runs - distroless isn&#39;t vulnerability-free - but the surface area is smaller and the results are more actionable.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What to Do With Unfixable CVEs in Your Base Image&lt;/h2&gt;
&lt;p&gt;You&#39;ve run Trivy with &lt;code&gt;ignore-unfixed: false&lt;/code&gt; (temporarily, to assess the full picture) and found a CVE in your base image where upstream hasn&#39;t patched yet. Your options, in order of preference:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Wait for upstream.&lt;/strong&gt; If the CVE is in the base OS packages and upstream is actively working on it, the right answer is often to wait. Pin the current digest, monitor the base image&#39;s release notes, and have Dependabot open a PR when the updated digest arrives. Your SARIF upload keeps the finding visible in the Security tab while you wait.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Switch base images.&lt;/strong&gt; If a specific base image has a pattern of slow patching, consider alternatives. Chainguard&#39;s Node.js images (&lt;code&gt;cgr.dev/chainguard/node&lt;/code&gt;) are rebuilt daily from source with minimal packages and often have zero CVEs in the base OS. The tradeoff is ecosystem stability - Chainguard images are more aggressive about removing old package versions, which can require more active maintenance of your Dockerfile.&lt;/p&gt;
&lt;p&gt;Alpine-based images (&lt;code&gt;node:20-alpine&lt;/code&gt;) generally have lower CVE counts than Debian-based ones (&lt;code&gt;node:20&lt;/code&gt;) because Alpine uses musl libc and a smaller package set. If you&#39;re using a Debian base without a specific reason, switching to Alpine often resolves several unfixable CVEs immediately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Suppress with justification in &lt;code&gt;.trivyignore&lt;/code&gt;.&lt;/strong&gt; When the CVE is genuinely not applicable to your use case - the vulnerable code path isn&#39;t reachable from your application, the affected binary isn&#39;t present in your final stage, or the CVSS vector doesn&#39;t apply to your deployment - suppress it with a comment explaining the reasoning and an expiry date. This is the correct pattern for accepted risk, not a workaround.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Document the decision as an ADR.&lt;/strong&gt; For CVEs that will be suppressed for more than a sprint cycle, an Architecture Decision Record (&lt;a href=&quot;https://steve-kaschimer.github.io/posts/2026-05-01-architecture-decision-records/&quot;&gt;as covered in an earlier post&lt;/a&gt;) is the right place to document the threat model reasoning. An ADR that says &amp;quot;we accept CVE-2024-XXXX in the base image because X, and we will re-evaluate when Y&amp;quot; is auditable and revisable. A &lt;code&gt;.trivyignore&lt;/code&gt; comment is not, really.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Full Workflow Example&lt;/h2&gt;
&lt;p&gt;This is a complete job: build the image, scan it with Trivy (SARIF upload + build gate), and push to the registry only if the scan passes. Registry authentication uses OIDC - no stored credentials.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Build and Scan

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  id-token: write        # For OIDC registry authentication
  security-events: write # For SARIF upload to Security tab
  packages: write        # For GHCR push

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build image (do not push yet)
        uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          load: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      # Scan 1: Upload CRITICAL and HIGH to Security tab
      - name: Scan image - SARIF upload
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: &#39;ghcr.io/${{ github.repository }}:${{ github.sha }}&#39;
          scan-type: &#39;image&#39;
          format: &#39;sarif&#39;
          output: &#39;trivy-results.sarif&#39;
          severity: &#39;CRITICAL,HIGH&#39;
          ignore-unfixed: true

      - name: Upload Trivy SARIF to Security tab
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: &#39;trivy-results.sarif&#39;
          category: &#39;container-scanning&#39;

      # Scan 2: Block build on CRITICAL only
      - name: Scan image - build gate
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: &#39;ghcr.io/${{ github.repository }}:${{ github.sha }}&#39;
          scan-type: &#39;image&#39;
          format: &#39;table&#39;
          severity: &#39;CRITICAL&#39;
          exit-code: &#39;1&#39;
          ignore-unfixed: true

      # Scan 3: Dockerfile misconfiguration check
      - name: Scan Dockerfile for misconfigurations
        uses: aquasecurity/trivy-action@0.28.0
        with:
          scan-type: &#39;config&#39;
          scan-ref: &#39;.&#39;
          format: &#39;table&#39;
          exit-code: &#39;1&#39;

      # Push only if all scans pass
      - name: Push image to registry
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.ref == &#39;refs/heads/main&#39; }}
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few things worth noting:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build before push, scan before push.&lt;/strong&gt; The &lt;code&gt;build-push-action&lt;/code&gt; with &lt;code&gt;push: false&lt;/code&gt; and &lt;code&gt;load: true&lt;/code&gt; builds the image and loads it into the Docker daemon without pushing to the registry. The scans run against this local image. The final step pushes only after all scans pass. This ensures the image in the registry was scanned before it arrived - not after.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;security-events: write&lt;/code&gt; permission&lt;/strong&gt; is required for &lt;code&gt;upload-sarif&lt;/code&gt;. Without it, the upload step fails with a permissions error. Include it in the job-level permissions block.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;${{ secrets.GITHUB_TOKEN }}&lt;/code&gt;&lt;/strong&gt; for GHCR authentication is not a stored secret - &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is the auto-provisioned workflow token. Combined with &lt;code&gt;packages: write&lt;/code&gt; permission, it authenticates to the GitHub Container Registry without any manual credential setup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Push condition &lt;code&gt;${{ github.ref == &#39;refs/heads/main&#39; }}&lt;/code&gt;&lt;/strong&gt; pushes to the registry only on merges to &lt;code&gt;main&lt;/code&gt;. On PRs, the workflow builds and scans without pushing. This gives you scan feedback on PRs without polluting the registry with PR builds.&lt;/p&gt;
&lt;hr /&gt;
&lt;div class=&quot;callout-box&quot;&gt;
&lt;h2&gt;Container Scanning Checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Pin base image digests in all Dockerfiles - &lt;code&gt;FROM node:20-alpine@sha256:...&lt;/code&gt;, not &lt;code&gt;FROM node:20-alpine&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] Configure Dependabot for Docker (&lt;code&gt;package-ecosystem: &amp;quot;docker&amp;quot;&lt;/code&gt;) to auto-update pinned digests weekly&lt;/li&gt;
&lt;li&gt;[ ] Use multi-stage builds: builder stage with full toolchain, final stage with distroless or alpine runtime only&lt;/li&gt;
&lt;li&gt;[ ] Run &lt;code&gt;USER nonroot&lt;/code&gt; (distroless) or create a non-root user in the final stage&lt;/li&gt;
&lt;li&gt;[ ] Set &lt;code&gt;ignore-unfixed: true&lt;/code&gt; on the build gate scan - only block on what can be fixed&lt;/li&gt;
&lt;li&gt;[ ] Start with &lt;code&gt;severity: &#39;CRITICAL&#39;&lt;/code&gt; for the build gate; add HIGH after the team has cleared the CRITICAL backlog&lt;/li&gt;
&lt;li&gt;[ ] Upload &lt;code&gt;severity: &#39;CRITICAL,HIGH&#39;&lt;/code&gt; to the Security tab with &lt;code&gt;if: always()&lt;/code&gt; on the upload step&lt;/li&gt;
&lt;li&gt;[ ] Run a separate Dockerfile config scan (&lt;code&gt;scan-type: &#39;config&#39;&lt;/code&gt;) as its own build gate step&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;.trivyignore&lt;/code&gt; entries for accepted-risk CVEs with comment and expiry date (&lt;code&gt;exp:YYYY-MM-DD&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] For long-lived suppressions, document the decision in an ADR - not just a &lt;code&gt;.trivyignore&lt;/code&gt; comment&lt;/li&gt;
&lt;li&gt;[ ] Build before you push: scan the local image, push to registry only if scans pass&lt;/li&gt;
&lt;li&gt;[ ] For private repos: confirm GHAS is enabled for the Security tab to receive SARIF uploads&lt;/li&gt;
&lt;li&gt;[ ] Require &lt;code&gt;security-events: write&lt;/code&gt; permission in the workflow for &lt;code&gt;upload-sarif&lt;/code&gt; to work&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;The scanner is the easy part. Trivy is well-maintained, the &lt;code&gt;aquasecurity/trivy-action&lt;/code&gt; integration is straightforward, and the SARIF upload to GitHub&#39;s Security tab takes five lines of YAML. None of that is where teams struggle.&lt;/p&gt;
&lt;p&gt;Where teams struggle is the policy: what to block on, what to report but not block on, and what to explicitly accept. An unconfigured scanner produces undifferentiated noise. A scanner configured to block on CRITICAL-only with &lt;code&gt;ignore-unfixed: true&lt;/code&gt; produces decisions - specifically, the decision to fix something or accept it with a documented reason.&lt;/p&gt;
&lt;p&gt;The workflow design is the security control. The scanner surfaces findings; the severity threshold, the &lt;code&gt;ignore-unfixed&lt;/code&gt; flag, and the &lt;code&gt;.trivyignore&lt;/code&gt; file express your team&#39;s threat model in code. Treat them that way: review changes to &lt;code&gt;.trivyignore&lt;/code&gt; as carefully as changes to your authentication logic, and treat the first CRITICAL finding in the Security tab as a P1, not a thing to suppress before the end of the sprint.&lt;/p&gt;
&lt;p&gt;Pinned digests, multi-stage builds, and distroless final images reduce the finding count before the scanner runs. A smaller attack surface produces a more actionable scan report. Both things are true: reduce the surface and scan what remains.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions about Trivy configuration, GHAS setup, or container hardening? 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 teams run Trivy and get back 200 alerts they don&#39;t act on. The scanner isn&#39;t the problem - the workflow design is. Here&#39;s how to configure Trivy in GitHub Actions to produce signal: SARIF upload to the GitHub Security tab, severity thresholds that block on what you can fix, and a policy for unfixable CVEs in base images.</summary>
    <category term="container-security"/>
    <category term="github-advanced-security"/>
    <category term="devsecops"/>
    <category term="trivy"/>
    <category term="docker"/>
  </entry>
  <entry>
    <title>Multi-Agent Patterns with Azure AI Foundry: Orchestration, Handoff, and Shared State</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-07-24-multi-agent-patterns-azure-ai-foundry-orchestration-handoff-shared-state/"/>
    <updated>2026-07-24T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-07-24-multi-agent-patterns-azure-ai-foundry-orchestration-handoff-shared-state/</id>
    <content xml:lang="en" type="html">&lt;p&gt;The bottleneck in agentic systems isn&#39;t the model. It&#39;s the orchestration.&lt;/p&gt;
&lt;p&gt;A single agent eventually runs into three walls: the context window fills up before a complex task is done, specialization conflicts with generalization (a security reviewer that also writes code does both worse), and latency stacks up when one agent is responsible for every step of a pipeline. None of those are model problems. They&#39;re architecture problems.&lt;/p&gt;
&lt;p&gt;Azure AI Foundry provides the primitives to solve them - managed agent runtime, thread-based message passing, tool registration - without requiring you to build orchestration infrastructure from scratch. But Foundry doesn&#39;t tell you &lt;em&gt;which pattern to reach for&lt;/em&gt;. That&#39;s the design decision, and it determines whether your multi-agent system scales or collapses under real workloads.&lt;/p&gt;
&lt;p&gt;This post covers three patterns: sequential pipeline, parallel fan-out, and hierarchical orchestrator/sub-agent. Each one addresses a different constraint. They&#39;re not mutually exclusive. The closing section covers shared state, error handling, and observability across all three.&lt;/p&gt;
&lt;p&gt;If you haven&#39;t set up a Foundry project yet, start with the &lt;a href=&quot;https://steve-kaschimer.github.io/posts/2026-06-19-azure-ai-foundry-first-look-agentic-ai-workflows/&quot;&gt;first-look post&lt;/a&gt; before continuing here.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Setup&lt;/h2&gt;
&lt;p&gt;Install the required packages:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install azure-ai-projects azure-ai-agents azure-identity opentelemetry-sdk opentelemetry-api
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;azure-ai-projects&lt;/code&gt; package is your entry point to Foundry project resources. &lt;code&gt;azure-ai-agents&lt;/code&gt; provides the agent and thread runtime. All examples below use Python 3.11+.&lt;/p&gt;
&lt;p&gt;Initialize the project client once and reuse it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential

client = AIProjectClient(
    endpoint=os.environ[&amp;quot;AZURE_AI_PROJECT_ENDPOINT&amp;quot;],
    credential=DefaultAzureCredential(),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AZURE_AI_PROJECT_ENDPOINT&lt;/code&gt; is the endpoint URI for your Foundry project - find it in the portal under your project&#39;s &lt;strong&gt;Overview&lt;/strong&gt; tab.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Pattern 1: Sequential Pipeline&lt;/h2&gt;
&lt;p&gt;Each agent&#39;s output is the next agent&#39;s input. The pipeline runs to completion in order. No step starts until the previous step finishes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use case:&lt;/strong&gt; a content pipeline - research agent gathers sources, draft agent writes from them, review agent flags problems, publish agent formats the final output. Each stage depends strictly on the previous one&#39;s output. You can&#39;t draft before you have sources; you can&#39;t review a draft that doesn&#39;t exist.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; strict data dependencies between stages. The output of stage N is meaningfully different from the input to stage N - it&#39;s not just forwarded, it&#39;s transformed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where it breaks:&lt;/strong&gt; error propagation and latency. A failure in stage 2 kills stages 3 and 4. And because stages run serially, total latency is the sum of all stage latencies. Don&#39;t use this pattern for independent subtasks - that&#39;s what fan-out is for.&lt;/p&gt;
&lt;p&gt;Here&#39;s a minimal sequential pipeline with three agents - researcher, drafter, and reviewer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import asyncio
from azure.ai.agents.models import MessageRole

async def run_sequential_pipeline(topic: str) -&amp;gt; str:
    agents_client = client.agents

    # Create three specialized agents
    researcher = await agents_client.create_agent(
        model=&amp;quot;gpt-4o&amp;quot;,
        name=&amp;quot;researcher&amp;quot;,
        instructions=(
            &amp;quot;You are a technical researcher. Given a topic, return a structured &amp;quot;
            &amp;quot;set of key findings and source references. Be factual and concise.&amp;quot;
        ),
    )
    drafter = await agents_client.create_agent(
        model=&amp;quot;gpt-4o&amp;quot;,
        name=&amp;quot;drafter&amp;quot;,
        instructions=(
            &amp;quot;You are a technical writer. Given research findings, produce a clear, &amp;quot;
            &amp;quot;well-structured draft suitable for a developer audience.&amp;quot;
        ),
    )
    reviewer = await agents_client.create_agent(
        model=&amp;quot;gpt-4o&amp;quot;,
        name=&amp;quot;reviewer&amp;quot;,
        instructions=(
            &amp;quot;You are a technical editor. Given a draft, identify factual errors, &amp;quot;
            &amp;quot;unclear explanations, and structural problems. Return structured feedback.&amp;quot;
        ),
    )

    try:
        # Stage 1: Research
        thread = await agents_client.create_thread()
        await agents_client.create_message(
            thread_id=thread.id,
            role=MessageRole.USER,
            content=f&amp;quot;Research this topic and return structured findings: {topic}&amp;quot;,
        )
        run = await agents_client.create_and_process_run(
            thread_id=thread.id, agent_id=researcher.id
        )
        research_output = await _get_last_message(agents_client, thread.id)

        # Stage 2: Draft - pass research output as input to a new thread
        thread = await agents_client.create_thread()
        await agents_client.create_message(
            thread_id=thread.id,
            role=MessageRole.USER,
            content=f&amp;quot;Write a draft based on these research findings:&#92;n&#92;n{research_output}&amp;quot;,
        )
        run = await agents_client.create_and_process_run(
            thread_id=thread.id, agent_id=drafter.id
        )
        draft_output = await _get_last_message(agents_client, thread.id)

        # Stage 3: Review - pass draft as input
        thread = await agents_client.create_thread()
        await agents_client.create_message(
            thread_id=thread.id,
            role=MessageRole.USER,
            content=f&amp;quot;Review this draft and return structured feedback:&#92;n&#92;n{draft_output}&amp;quot;,
        )
        run = await agents_client.create_and_process_run(
            thread_id=thread.id, agent_id=reviewer.id
        )
        review_output = await _get_last_message(agents_client, thread.id)

        return review_output

    finally:
        # Clean up agents after use
        for agent in [researcher, drafter, reviewer]:
            await agents_client.delete_agent(agent.id)


async def _get_last_message(agents_client, thread_id: str) -&amp;gt; str:
    messages = await agents_client.list_messages(thread_id=thread_id)
    assistant_messages = [
        m for m in messages.data if m.role == MessageRole.ASSISTANT
    ]
    if not assistant_messages:
        raise RuntimeError(f&amp;quot;No assistant message in thread {thread_id}&amp;quot;)
    # Messages are returned newest-first
    content = assistant_messages[0].content
    return content[0].text.value if content else &amp;quot;&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each stage gets its own thread. The state handoff is explicit: the orchestrator extracts the final message from stage N and passes it as the user message to stage N+1. This is intentional - implicit state sharing between threads creates hard-to-trace bugs. Make the data flow visible in the code.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Pattern 2: Parallel Fan-Out&lt;/h2&gt;
&lt;p&gt;The orchestrator dispatches to N specialist agents simultaneously and collects results. Stages don&#39;t depend on each other - they run in parallel and converge at aggregation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use case:&lt;/strong&gt; analyzing a codebase across multiple dimensions simultaneously - security vulnerabilities, performance anti-patterns, code style violations. Each dimension is independent. Running them sequentially wastes time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; independent subtasks that don&#39;t need each other&#39;s output before they start. Total latency is bounded by the slowest agent, not the sum of all agents.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where it breaks:&lt;/strong&gt; partial failure. If one agent fails, do you fail the whole operation or return partial results? That&#39;s a policy decision. The example below returns partial results and includes failure information in the aggregated output.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import asyncio
from dataclasses import dataclass
from typing import Optional

@dataclass
class AgentResult:
    dimension: str
    output: Optional[str]
    error: Optional[str] = None

    @property
    def succeeded(self) -&amp;gt; bool:
        return self.error is None


async def analyze_dimension(
    agents_client,
    dimension: str,
    instructions: str,
    content: str,
) -&amp;gt; AgentResult:
    agent = await agents_client.create_agent(
        model=&amp;quot;gpt-4o&amp;quot;,
        name=f&amp;quot;analyst-{dimension}&amp;quot;,
        instructions=instructions,
    )
    try:
        thread = await agents_client.create_thread()
        await agents_client.create_message(
            thread_id=thread.id,
            role=MessageRole.USER,
            content=content,
        )
        await agents_client.create_and_process_run(
            thread_id=thread.id, agent_id=agent.id
        )
        output = await _get_last_message(agents_client, thread.id)
        return AgentResult(dimension=dimension, output=output)
    except Exception as exc:
        return AgentResult(dimension=dimension, output=None, error=str(exc))
    finally:
        await agents_client.delete_agent(agent.id)


async def run_parallel_fanout(codebase_summary: str) -&amp;gt; dict[str, AgentResult]:
    agents_client = client.agents

    dimensions = {
        &amp;quot;security&amp;quot;: (
            &amp;quot;You are a security reviewer. Analyze the provided code for vulnerabilities, &amp;quot;
            &amp;quot;hardcoded secrets, injection risks, and insecure dependencies. Return findings &amp;quot;
            &amp;quot;as a JSON array of {severity, finding, location} objects.&amp;quot;
        ),
        &amp;quot;performance&amp;quot;: (
            &amp;quot;You are a performance engineer. Identify N+1 queries, blocking I/O, &amp;quot;
            &amp;quot;unnecessary allocations, and missing cache opportunities. Return findings &amp;quot;
            &amp;quot;as a JSON array of {impact, finding, recommendation} objects.&amp;quot;
        ),
        &amp;quot;style&amp;quot;: (
            &amp;quot;You are a code reviewer focused on maintainability. Flag naming inconsistencies, &amp;quot;
            &amp;quot;missing error handling, and structural problems. Return findings &amp;quot;
            &amp;quot;as a JSON array of {severity, finding, suggestion} objects.&amp;quot;
        ),
    }

    tasks = [
        analyze_dimension(
            agents_client=agents_client,
            dimension=dim,
            instructions=instructions,
            content=f&amp;quot;Analyze this codebase summary:&#92;n&#92;n{codebase_summary}&amp;quot;,
        )
        for dim, instructions in dimensions.items()
    ]

    # All three agents run simultaneously
    results = await asyncio.gather(*tasks, return_exceptions=False)

    aggregated = {r.dimension: r for r in results}

    failed = [r.dimension for r in results if not r.succeeded]
    if failed:
        # Log partial failure but return what succeeded
        print(f&amp;quot;Warning: {len(failed)} analysis dimension(s) failed: {failed}&amp;quot;)

    return aggregated
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;asyncio.gather()&lt;/code&gt; dispatches all coroutines concurrently. If one agent call raises an unhandled exception, &lt;code&gt;gather&lt;/code&gt; propagates it by default. The &lt;code&gt;try/except&lt;/code&gt; inside &lt;code&gt;analyze_dimension&lt;/code&gt; converts agent-level failures into structured &lt;code&gt;AgentResult&lt;/code&gt; objects so the aggregation layer can reason about them without crashing.&lt;/p&gt;
&lt;p&gt;The caller decides what to do with partial results. That&#39;s the right place for that policy - not buried inside the agent call.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Pattern 3: Hierarchical Orchestrator/Sub-Agent&lt;/h2&gt;
&lt;p&gt;A planner agent decomposes an incoming task and routes subtasks to specialists via tool calls. The orchestrator never executes domain logic directly - it delegates. Specialists never decide what to work on - they only execute what&#39;s delegated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use case:&lt;/strong&gt; a customer support system. An orchestrator receives incoming requests and routes to a billing agent, a technical support agent, or an escalation agent based on the content. The orchestrator understands intent; the specialists own execution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; dynamic routing where the task structure isn&#39;t known in advance. The orchestrator figures out which specialist to call based on the input - something you can&#39;t hard-code in a sequential pipeline.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where it breaks:&lt;/strong&gt; prompt quality for the orchestrator. If the orchestrator&#39;s routing logic is vague, it will hallucinate routing decisions. Tool definitions must be precise - the orchestrator picks tools the same way models pick any function: based on the description.&lt;/p&gt;
&lt;p&gt;The orchestrator&#39;s tools are wrappers around sub-agent calls. Define them as standard function tools:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import json
from azure.ai.agents.models import FunctionTool, ToolSet

async def call_billing_agent(query: str) -&amp;gt; str:
    &amp;quot;&amp;quot;&amp;quot;Route a billing-related query to the billing specialist agent.&amp;quot;&amp;quot;&amp;quot;
    agents_client = client.agents
    agent = await agents_client.create_agent(
        model=&amp;quot;gpt-4o&amp;quot;,
        name=&amp;quot;billing-specialist&amp;quot;,
        instructions=(
            &amp;quot;You are a billing specialist. Answer questions about invoices, charges, &amp;quot;
            &amp;quot;subscription changes, and refunds accurately and concisely. If you cannot &amp;quot;
            &amp;quot;resolve the issue, return a JSON object: {&#92;&amp;quot;escalate&#92;&amp;quot;: true, &#92;&amp;quot;reason&#92;&amp;quot;: &#92;&amp;quot;...&#92;&amp;quot;}.&amp;quot;
        ),
    )
    try:
        thread = await agents_client.create_thread()
        await agents_client.create_message(
            thread_id=thread.id, role=MessageRole.USER, content=query
        )
        await agents_client.create_and_process_run(
            thread_id=thread.id, agent_id=agent.id
        )
        return await _get_last_message(agents_client, thread.id)
    finally:
        await agents_client.delete_agent(agent.id)


async def call_technical_agent(query: str) -&amp;gt; str:
    &amp;quot;&amp;quot;&amp;quot;Route a technical support query to the technical specialist agent.&amp;quot;&amp;quot;&amp;quot;
    agents_client = client.agents
    agent = await agents_client.create_agent(
        model=&amp;quot;gpt-4o&amp;quot;,
        name=&amp;quot;technical-specialist&amp;quot;,
        instructions=(
            &amp;quot;You are a technical support specialist. Diagnose and resolve technical issues &amp;quot;
            &amp;quot;with API integrations, SDK behavior, and platform configuration. Return a &amp;quot;
            &amp;quot;structured resolution or escalation recommendation.&amp;quot;
        ),
    )
    try:
        thread = await agents_client.create_thread()
        await agents_client.create_message(
            thread_id=thread.id, role=MessageRole.USER, content=query
        )
        await agents_client.create_and_process_run(
            thread_id=thread.id, agent_id=agent.id
        )
        return await _get_last_message(agents_client, thread.id)
    finally:
        await agents_client.delete_agent(agent.id)


# Tool definitions the orchestrator will see
billing_tool = FunctionTool(
    name=&amp;quot;route_to_billing&amp;quot;,
    description=(
        &amp;quot;Route a customer query to the billing specialist. Use this for questions about &amp;quot;
        &amp;quot;invoices, charges, subscription plans, payment failures, and refund requests.&amp;quot;
    ),
    parameters={
        &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
        &amp;quot;properties&amp;quot;: {
            &amp;quot;query&amp;quot;: {
                &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot;,
                &amp;quot;description&amp;quot;: &amp;quot;The customer&#39;s billing question, verbatim.&amp;quot;,
            }
        },
        &amp;quot;required&amp;quot;: [&amp;quot;query&amp;quot;],
    },
)

technical_tool = FunctionTool(
    name=&amp;quot;route_to_technical&amp;quot;,
    description=(
        &amp;quot;Route a customer query to the technical support specialist. Use this for questions &amp;quot;
        &amp;quot;about API errors, SDK configuration, integration issues, and platform behavior.&amp;quot;
    ),
    parameters={
        &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
        &amp;quot;properties&amp;quot;: {
            &amp;quot;query&amp;quot;: {
                &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot;,
                &amp;quot;description&amp;quot;: &amp;quot;The customer&#39;s technical question, verbatim.&amp;quot;,
            }
        },
        &amp;quot;required&amp;quot;: [&amp;quot;query&amp;quot;],
    },
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wire the orchestrator with the tool definitions and a function dispatch map:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;async def run_orchestrator(customer_query: str) -&amp;gt; str:
    agents_client = client.agents

    toolset = ToolSet()
    toolset.add(billing_tool)
    toolset.add(technical_tool)

    orchestrator = await agents_client.create_agent(
        model=&amp;quot;gpt-4o&amp;quot;,
        name=&amp;quot;support-orchestrator&amp;quot;,
        instructions=(
            &amp;quot;You are a customer support orchestrator. Analyze the customer query and route it &amp;quot;
            &amp;quot;to the appropriate specialist using the available tools. You do not answer &amp;quot;
            &amp;quot;domain questions directly - you always delegate. If the query spans multiple &amp;quot;
            &amp;quot;domains, make multiple tool calls. Synthesize the specialist responses into a &amp;quot;
            &amp;quot;single coherent reply for the customer.&amp;quot;
        ),
        tools=toolset.definitions,
    )

    # Map tool names to async handler functions
    tool_handlers = {
        &amp;quot;route_to_billing&amp;quot;: call_billing_agent,
        &amp;quot;route_to_technical&amp;quot;: call_technical_agent,
    }

    try:
        thread = await agents_client.create_thread()
        await agents_client.create_message(
            thread_id=thread.id, role=MessageRole.USER, content=customer_query
        )

        # Process the run, handling tool calls as they arrive
        run = await agents_client.create_run(
            thread_id=thread.id, agent_id=orchestrator.id
        )

        while run.status in (&amp;quot;queued&amp;quot;, &amp;quot;in_progress&amp;quot;, &amp;quot;requires_action&amp;quot;):
            run = await agents_client.get_run(thread_id=thread.id, run_id=run.id)

            if run.status == &amp;quot;requires_action&amp;quot;:
                tool_outputs = []
                for tool_call in run.required_action.submit_tool_outputs.tool_calls:
                    fn_name = tool_call.function.name
                    fn_args = json.loads(tool_call.function.arguments)
                    handler = tool_handlers.get(fn_name)
                    if handler:
                        result = await handler(**fn_args)
                    else:
                        result = json.dumps({&amp;quot;error&amp;quot;: f&amp;quot;Unknown tool: {fn_name}&amp;quot;})
                    tool_outputs.append(
                        {&amp;quot;tool_call_id&amp;quot;: tool_call.id, &amp;quot;output&amp;quot;: result}
                    )
                run = await agents_client.submit_tool_outputs_to_run(
                    thread_id=thread.id,
                    run_id=run.id,
                    tool_outputs=tool_outputs,
                )
            elif run.status not in (&amp;quot;queued&amp;quot;, &amp;quot;in_progress&amp;quot;):
                break

            await asyncio.sleep(0.5)

        return await _get_last_message(agents_client, thread.id)

    finally:
        await agents_client.delete_agent(orchestrator.id)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The orchestrator reasons about the query and decides which tool to call - or both, in sequence or parallel, if the query spans domains. Your application code handles the dispatch; the orchestrator handles the routing logic. Neither leaks into the other&#39;s domain.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Shared State and Memory&lt;/h2&gt;
&lt;p&gt;Agents don&#39;t share memory by default. Every thread is isolated. That isolation is a feature - it prevents state contamination across runs. But real pipelines need shared context. There are three ways to provide it, and they trade off differently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Passing state in messages&lt;/strong&gt; is the simplest approach and the one used in all the examples above. The orchestrator extracts output from agent N and passes it as input to agent N+1. The data flow is explicit and traceable. The limitation: large context increases token usage at every step, and there&#39;s no retrieval - if agent N+3 needs something from agent N, the orchestrator has to carry it the whole way.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Azure AI Search as a shared vector store&lt;/strong&gt; works well when agents need to retrieve knowledge from a shared corpus - documentation, previous run outputs, domain knowledge. Each agent gets an Azure AI Search connection and performs retrieval before generating its response. The agents don&#39;t share working memory; they share a retrieval layer.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from azure.ai.projects.models import AzureAISearchTool

search_tool = AzureAISearchTool(
    index_connection_id=os.environ[&amp;quot;AZURE_AI_SEARCH_CONNECTION_ID&amp;quot;],
    index_name=&amp;quot;shared-knowledge-base&amp;quot;,
)

agent = await agents_client.create_agent(
    model=&amp;quot;gpt-4o&amp;quot;,
    name=&amp;quot;retrieval-agent&amp;quot;,
    instructions=&amp;quot;Use the available search tool to retrieve relevant context before answering.&amp;quot;,
    tools=[search_tool.definitions],
    tool_resources=search_tool.resources,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Azure Cosmos DB or Blob Storage for structured shared state&lt;/strong&gt; is the right choice when agents need to write state that other agents will read - progress records, intermediate computation results, task queues. The orchestrator writes a structured record after each agent completes; the next agent reads it at startup. This decouples agents from each other in time, which matters for long-running pipelines where agents may run hours apart.&lt;/p&gt;
&lt;p&gt;The tradeoff is straightforward: pass state in messages when it&#39;s small and the pipeline is short; externalize it when it&#39;s large, needs retrieval, or spans a pipeline that runs over minutes or hours.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Error Handling When a Sub-Agent Fails&lt;/h2&gt;
&lt;p&gt;Sub-agents fail. Networks time out, rate limits hit, model responses come back malformed. The orchestrator needs to handle these failures gracefully rather than propagating exceptions upward blindly.&lt;/p&gt;
&lt;p&gt;The key design decision: errors from sub-agents should be structured, not plain text. The orchestrator is a model - it reasons over tool outputs. A structured error gives the model enough information to make a policy decision (retry, fallback, degrade gracefully). A plain text exception does not.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import asyncio
from typing import Any

async def call_agent_with_retry(
    agents_client,
    agent_id: str,
    query: str,
    max_retries: int = 3,
    base_delay: float = 1.0,
) -&amp;gt; dict[str, Any]:
    &amp;quot;&amp;quot;&amp;quot;
    Call an agent with exponential backoff retry.
    Returns a structured result the orchestrator can reason about.
    &amp;quot;&amp;quot;&amp;quot;
    last_error = None

    for attempt in range(max_retries):
        try:
            thread = await agents_client.create_thread()
            await agents_client.create_message(
                thread_id=thread.id, role=MessageRole.USER, content=query
            )
            run = await agents_client.create_and_process_run(
                thread_id=thread.id, agent_id=agent_id
            )
            output = await _get_last_message(agents_client, thread.id)
            return {
                &amp;quot;status&amp;quot;: &amp;quot;success&amp;quot;,
                &amp;quot;output&amp;quot;: output,
                &amp;quot;attempt&amp;quot;: attempt + 1,
            }

        except Exception as exc:
            last_error = exc
            if attempt &amp;lt; max_retries - 1:
                delay = base_delay * (2 ** attempt)
                await asyncio.sleep(delay)

    # All retries exhausted - return a structured failure
    return {
        &amp;quot;status&amp;quot;: &amp;quot;failed&amp;quot;,
        &amp;quot;error_type&amp;quot;: type(last_error).__name__,
        &amp;quot;error_message&amp;quot;: str(last_error),
        &amp;quot;attempts&amp;quot;: max_retries,
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The orchestrator receives a dict in both the success and failure cases. If &lt;code&gt;status == &amp;quot;failed&amp;quot;&lt;/code&gt;, the orchestrator can choose a fallback path - a simpler agent, a cached response, a graceful degradation message to the user - without that decision being forced on it by an unhandled exception.&lt;/p&gt;
&lt;p&gt;Return structured JSON errors, not plain text. A model reading &lt;code&gt;&amp;quot;The billing service is unavailable due to a transient error after 3 attempts.&amp;quot;&lt;/code&gt; has less to work with than a model reading &lt;code&gt;{&amp;quot;status&amp;quot;: &amp;quot;failed&amp;quot;, &amp;quot;error_type&amp;quot;: &amp;quot;TimeoutError&amp;quot;, &amp;quot;attempts&amp;quot;: 3}&lt;/code&gt;. The latter is something it can act on.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Observability: Tracing a Request Through the Chain&lt;/h2&gt;
&lt;p&gt;A request enters the orchestrator and fans out across multiple agents, threads, and model calls. Without tracing, debugging a failure means reading logs in isolation without understanding causality. With tracing, you can reconstruct the entire call graph from a single &lt;code&gt;trace_id&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Azure AI Foundry integrates with Azure Monitor / Application Insights. Enable it on the project client:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from azure.ai.projects.models import ApplicationInsightsConfiguration

# Enable built-in tracing to Application Insights
client.telemetry.enable(
    destination=client.telemetry.get_connection_string()
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For custom spans at each handoff point, use OpenTelemetry directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

# Configure the tracer once at startup
provider = TracerProvider()
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter())
)
trace.set_tracer_provider(provider)

tracer = trace.get_tracer(&amp;quot;multi-agent-pipeline&amp;quot;)


async def run_pipeline_with_tracing(topic: str) -&amp;gt; str:
    with tracer.start_as_current_span(&amp;quot;pipeline.run&amp;quot;) as root_span:
        root_span.set_attribute(&amp;quot;pipeline.topic&amp;quot;, topic)

        with tracer.start_as_current_span(&amp;quot;agent.research&amp;quot;) as span:
            span.set_attribute(&amp;quot;agent.model&amp;quot;, &amp;quot;gpt-4o&amp;quot;)
            span.set_attribute(&amp;quot;agent.name&amp;quot;, &amp;quot;researcher&amp;quot;)
            research_output = await run_research_stage(topic)
            span.set_attribute(&amp;quot;agent.output_tokens&amp;quot;, len(research_output.split()))

        with tracer.start_as_current_span(&amp;quot;agent.draft&amp;quot;) as span:
            span.set_attribute(&amp;quot;agent.model&amp;quot;, &amp;quot;gpt-4o&amp;quot;)
            span.set_attribute(&amp;quot;agent.name&amp;quot;, &amp;quot;drafter&amp;quot;)
            span.set_attribute(&amp;quot;agent.input_source&amp;quot;, &amp;quot;researcher.output&amp;quot;)
            draft_output = await run_draft_stage(research_output)

        with tracer.start_as_current_span(&amp;quot;agent.review&amp;quot;) as span:
            span.set_attribute(&amp;quot;agent.model&amp;quot;, &amp;quot;gpt-4o&amp;quot;)
            span.set_attribute(&amp;quot;agent.name&amp;quot;, &amp;quot;reviewer&amp;quot;)
            review_output = await run_review_stage(draft_output)

        return review_output
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What to record at each handoff: input token count, output content hash or length (not the full content - keep spans lightweight), the model and deployment name, the thread ID and run ID, and latency. The thread ID and run ID let you correlate a span in your trace backend with the full conversation in the Foundry portal.&lt;/p&gt;
&lt;p&gt;Correlate the Foundry &lt;code&gt;run_id&lt;/code&gt; with your trace spans by attaching it as an attribute. When a run fails, you can pivot from your trace view directly to the Foundry thread that contains the full model interaction.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Choosing the Right Pattern&lt;/h2&gt;
&lt;p&gt;The decision is mechanical once you understand the constraints:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sequential pipeline&lt;/strong&gt; when: stage N&#39;s output is the direct input to stage N+1, and partial results from stage N have no value if stage N+1 never runs. Use it when data transformation between stages is the point.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Parallel fan-out&lt;/strong&gt; when: subtasks are independent - they don&#39;t read each other&#39;s outputs before they start. Use it when the bottleneck is latency and the subtasks can run side-by-side.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hierarchical orchestrator/sub-agent&lt;/strong&gt; when: the routing decision is dynamic and can&#39;t be hard-coded. The orchestrator reasons about what to call and when. Use it when different requests need different agent combinations.&lt;/p&gt;
&lt;p&gt;Patterns compose. A hierarchical orchestrator can route to a sequential pipeline for one task class and a parallel fan-out for another. Start with the simplest pattern that fits the constraint. Introduce complexity when the constraint changes.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;The orchestration layer is where agentic applications win or lose. A single agent with GPT-4o and a good prompt is a prototype. A multi-agent system that correctly decomposes tasks, routes dynamically, shares state explicitly, handles partial failures gracefully, and produces traceable execution is production infrastructure.&lt;/p&gt;
&lt;p&gt;Azure AI Foundry gives you the building blocks: managed agent runtime, thread isolation, tool registration, built-in observability. The architecture is still yours to design. The three patterns here - sequential pipeline, parallel fan-out, hierarchical orchestrator - cover most of the routing problems you&#39;ll encounter. What they don&#39;t do is choose themselves.&lt;/p&gt;
&lt;p&gt;Pick the pattern that matches the constraint. Don&#39;t add orchestration complexity until you&#39;ve hit the wall that requires it.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions or corrections? 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>Single agents hit a ceiling fast - context window limits, generalization tradeoffs, and latency add up. The fix isn&#39;t a bigger model; it&#39;s the right orchestration pattern. This post covers the three core multi-agent patterns with concrete Azure AI Foundry implementations: sequential pipeline, parallel fan-out, and hierarchical orchestrator/sub-agent, plus shared state, error handling, and observability.</summary>
    <category term="azure-ai-foundry"/>
    <category term="ai-agents"/>
    <category term="agentic-development"/>
    <category term="multi-agent"/>
    <category term="azure"/>
  </entry>
  <entry>
    <title>IaC Security Scanning in CI: Catching Terraform and Bicep Misconfigurations Before They Deploy</title>
    <link href="https://steve-kaschimer.github.io/posts/2026-07-31-iac-security-scanning-terraform-bicep-ci/"/>
    <updated>2026-07-31T00:00:00Z</updated>
    <id>https://steve-kaschimer.github.io/posts/2026-07-31-iac-security-scanning-terraform-bicep-ci/</id>
    <content xml:lang="en" type="html">&lt;p&gt;An S3 bucket with public read access. A storage account without encryption at rest. A virtual machine with SSH open to &lt;code&gt;0.0.0.0/0&lt;/code&gt;. These are not exotic attack vectors - they are the default configuration in half the tutorials on the internet, and they ship to production every day because nobody ran a scanner before &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;IaC misconfigurations are infrastructure bugs. The same discipline that catches application bugs in CI - write a check, fail the build, fix before merge - applies here. The tooling exists. The GitHub Actions integration is straightforward. The reason most teams don&#39;t do this isn&#39;t complexity; it&#39;s that nobody set it up on the first sprint, and now the pipeline has been running without it for eighteen months.&lt;/p&gt;
&lt;p&gt;This post sets it up. Checkov and tfsec for Terraform, PSRule for Bicep, SARIF output to the GitHub Security tab, thresholds that block on exploitable misconfigurations without halting every PR for cosmetic issues, and inline suppressions that are visible in PR diffs so they actually get reviewed.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Cost Curve&lt;/h2&gt;
&lt;p&gt;The argument for catching misconfigurations in CI is simple arithmetic. A Checkov finding caught in a pull request takes five minutes to fix. The same misconfiguration caught by a cloud security posture management tool after deployment takes hours to investigate, validate, and remediate - with a window of exposure that starts at merge and ends when someone acts on the alert. Caught post-breach, the cost is measured differently.&lt;/p&gt;
&lt;p&gt;The CI integration is a one-time investment. Every PR that runs through it gets a scan for free.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Tool Landscape&lt;/h2&gt;
&lt;p&gt;Three tools cover most of the ground:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Checkov&lt;/strong&gt; is the most broadly adopted. It&#39;s a Python-based static analysis framework from Bridgecrew (now Prisma Cloud) that covers Terraform, Bicep, ARM, CloudFormation, Kubernetes manifests, Dockerfiles, and more. It has over 1,000 built-in rules, produces SARIF natively, and has both a CLI and an official GitHub Action. It&#39;s the right default for mixed-provider or multi-format repositories.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;tfsec&lt;/strong&gt; is a Go-based scanner focused exclusively on Terraform. It&#39;s fast - faster than Checkov on large Terraform directories - and its rule set has different coverage than Checkov&#39;s. The two scanners are complements: running both in parallel catches things either one might miss. tfsec was acquired by Aqua Security and is actively maintained as &lt;code&gt;aquasecurity/tfsec&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PSRule for Azure&lt;/strong&gt; is a PowerShell-based framework from Microsoft, purpose-built for Bicep and ARM templates. It covers naming conventions, SKU restrictions, security baselines, and Azure Well-Architected Framework alignment. If your infrastructure is Azure-first and you&#39;re using Bicep, PSRule is the right tool for that layer - its rules are closer to Azure-native than the generic cloud rules in Checkov.&lt;/p&gt;
&lt;p&gt;The short version: use Checkov as the baseline for everything, add tfsec for Terraform for faster feedback and additional coverage, and add PSRule for Bicep if you&#39;re on Azure.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Checkov for Terraform&lt;/h2&gt;
&lt;p&gt;Checkov&#39;s GitHub Action is &lt;code&gt;bridgecrewio/checkov-action&lt;/code&gt;. It scans a directory, applies your rule configuration, and - critically - produces SARIF output that uploads directly to the GitHub Security tab.&lt;/p&gt;
&lt;p&gt;A minimal step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Checkov scan
  uses: bridgecrewio/checkov-action@v12
  with:
    directory: terraform/
    output_format: sarif
    output_file_path: checkov-results.sarif
    soft_fail: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;soft_fail: true&lt;/code&gt; means the step exits 0 even when findings exist - the build failure comes later, after the SARIF is uploaded. This is the right pattern: you want findings in the Security tab regardless of whether they block the build.&lt;/p&gt;
&lt;p&gt;Key flags to know when using the CLI directly (&lt;code&gt;pip install checkov&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Hard fail on HIGH and CRITICAL, soft fail on everything else
checkov -d terraform/ &#92;
  --output sarif &#92;
  --output-file-path checkov-results.sarif &#92;
  --soft-fail-on LOW,MEDIUM &#92;
  --compact

# Run only specific checks
checkov -d terraform/ --check CKV_AWS_18,CKV_AWS_21

# Skip specific checks
checkov -d terraform/ --skip-check CKV_AWS_18
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--soft-fail-on LOW,MEDIUM&lt;/code&gt; flag is the threshold design: LOW and MEDIUM findings don&#39;t block the job; HIGH and CRITICAL do. This keeps the scanner from becoming noise-only by day two.&lt;/p&gt;
&lt;p&gt;To upload results to the GitHub Security tab, you need the &lt;code&gt;security-events: write&lt;/code&gt; permission and the &lt;code&gt;github/codeql-action/upload-sarif&lt;/code&gt; action:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;permissions:
  security-events: write
  contents: read

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

      - name: Checkov scan
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: terraform/
          output_format: sarif
          output_file_path: checkov-results.sarif
          soft_fail: true

      - name: Upload SARIF to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: checkov-results.sarif
          category: checkov
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;if: always()&lt;/code&gt; on the upload step is not optional. When Checkov finds HIGH or CRITICAL issues and exits non-zero, subsequent steps don&#39;t run by default. &lt;code&gt;if: always()&lt;/code&gt; ensures the SARIF upload happens even when the scan step fails - which is exactly when you most need the results in the Security tab.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;tfsec for Terraform&lt;/h2&gt;
&lt;p&gt;tfsec is faster than Checkov on pure Terraform - it compiles to a single Go binary and doesn&#39;t have the Python startup overhead. For a large Terraform monorepo, the difference is meaningful. Its rule set also differs from Checkov&#39;s; running both in parallel catches more than either alone.&lt;/p&gt;
&lt;p&gt;The official action is &lt;code&gt;aquasecurity/tfsec-action&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: tfsec scan
  uses: aquasecurity/tfsec-action@v1.0.0
  with:
    working_directory: terraform/
    format: sarif
    sarif_file: tfsec-results.sarif
    soft_fail: true

- name: Upload tfsec SARIF
  uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: tfsec-results.sarif
    category: tfsec
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CLI equivalent:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tfsec terraform/ &#92;
  --format sarif &#92;
  --out tfsec-results.sarif &#92;
  --minimum-severity HIGH
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--minimum-severity HIGH&lt;/code&gt; means tfsec exits non-zero only when it finds HIGH or CRITICAL issues. LOW and MEDIUM are still written to the SARIF file and appear in the Security tab - they just don&#39;t break the build. This mirrors the Checkov threshold design and makes both scanners consistent in their blocking behavior.&lt;/p&gt;
&lt;p&gt;To run Checkov and tfsec in parallel (the right default for Terraform repositories), use separate jobs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  checkov:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: bridgecrewio/checkov-action@v12
        with:
          directory: terraform/
          output_format: sarif
          output_file_path: checkov-results.sarif
          soft_fail: true
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: checkov-results.sarif
          category: checkov

  tfsec:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aquasecurity/tfsec-action@v1.0.0
        with:
          working_directory: terraform/
          format: sarif
          sarif_file: tfsec-results.sarif
          soft_fail: true
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: tfsec-results.sarif
          category: tfsec
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two parallel jobs, two SARIF uploads, two categories in the Security tab. The &lt;code&gt;category&lt;/code&gt; parameter keeps the findings separated - Checkov findings and tfsec findings don&#39;t merge into a single undifferentiated list.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;PSRule for Bicep&lt;/h2&gt;
&lt;p&gt;PSRule for Azure is the right scanner for Bicep templates. It&#39;s maintained by Microsoft, its rules map directly to Azure Policy and Well-Architected Framework requirements, and it understands Bicep&#39;s type system better than general-purpose scanners do.&lt;/p&gt;
&lt;p&gt;The GitHub Action is &lt;code&gt;microsoft/ps-rule&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: PSRule for Azure
  uses: microsoft/ps-rule@v2
  with:
    modules: PSRule.Rules.Azure
    inputPath: bicep/
    outputFormat: Sarif
    outputPath: psrule-results.sarif
    outcome: Fail

- name: Upload PSRule SARIF
  uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: psrule-results.sarif
    category: psrule
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;outcome: Fail&lt;/code&gt; means the step exits non-zero only for failed rules, not for warnings or informational findings. Adjust to &lt;code&gt;outcome: Problem&lt;/code&gt; if you want warnings to block as well.&lt;/p&gt;
&lt;p&gt;What PSRule.Rules.Azure covers that Checkov doesn&#39;t: Azure naming convention enforcement, allowed SKUs for compute and storage resources, diagnostic settings requirements, Azure Policy compliance, and resource-level security baselines that are Azure-specific (Key Vault soft delete, Azure Monitor integration, Defender for Cloud plans). For Azure-native teams, this layer is worth adding even when Checkov is already running.&lt;/p&gt;
&lt;p&gt;PSRule also supports a &lt;code&gt;ps-rule.yaml&lt;/code&gt; baseline configuration file at the repo root for module-wide settings:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# ps-rule.yaml
requires:
  PSRule.Rules.Azure: &#39;&amp;gt;=1.38.0&#39;

configuration:
  AZURE_BICEP_CHECK_IMPORTS: true
  AZURE_BICEP_FILE_EXPANSION: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AZURE_BICEP_FILE_EXPANSION: true&lt;/code&gt; tells PSRule to expand Bicep templates before analysis rather than scanning the raw HCL-like syntax - this produces more accurate results for templates that use modules.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;SARIF and the GitHub Security tab&lt;/h2&gt;
&lt;p&gt;SARIF (Static Analysis Results Interchange Format) is the common language between security scanners and GitHub&#39;s Security tab. Every scanner in this post produces it. The upload action is always &lt;code&gt;github/codeql-action/upload-sarif&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What the Security tab gives you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A persistent list of open findings, tied to the file and line that introduced them&lt;/li&gt;
&lt;li&gt;Grouping by rule, severity, and category - so you can see &amp;quot;all PUBLIC_ACL findings&amp;quot; without grepping through JSON&lt;/li&gt;
&lt;li&gt;Automatic dismissal when the finding is no longer present (the scanner doesn&#39;t find it in the next run)&lt;/li&gt;
&lt;li&gt;A history of when findings were introduced, dismissed, or reopened&lt;/li&gt;
&lt;li&gt;Ability to manually dismiss findings with a reason (false positive, won&#39;t fix, risk accepted)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The SARIF upload deduplicates: if Checkov and tfsec both report the same misconfiguration, they appear as separate findings from separate tools, not as a single merged finding. This is useful - it confirms the misconfiguration is real when two independent scanners agree.&lt;/p&gt;
&lt;p&gt;One important caveat: the Security tab with SARIF code scanning requires &lt;strong&gt;GitHub Advanced Security&lt;/strong&gt; for private repositories. Public repositories get it for free. For private repos on GitHub Enterprise Cloud or GitHub Enterprise Server, GHAS must be enabled on the repository. If you&#39;re on a plan without GHAS and running private repos, the SARIF upload step will fail silently or produce a 403 - run it conditionally or accept that findings will only appear in workflow logs.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Break-on-Severity Thresholds&lt;/h2&gt;
&lt;p&gt;The default behavior of every scanner in this post is to fail on any finding. That&#39;s the wrong default for day-to-day CI. A Terraform module that&#39;s been in production for two years will have dozens of LOW and MEDIUM findings. Failing the build on all of them means every PR that touches infrastructure blocks until someone works through the entire backlog - which means developers start suppressing everything to get PRs to merge, which defeats the purpose.&lt;/p&gt;
&lt;p&gt;The right threshold design:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HIGH and CRITICAL block the build.&lt;/strong&gt; These are the misconfigurations with clear, exploitable attack paths: open security groups, unencrypted data stores, publicly accessible resources, missing authentication. They should never reach production without a deliberate decision.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MEDIUM goes to the Security tab, doesn&#39;t block.&lt;/strong&gt; Real issues worth tracking and fixing in the next sprint, but not enough to halt a PR that&#39;s touching an unrelated module.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LOW is visible on request.&lt;/strong&gt; Write it to SARIF so it appears in the Security tab if someone goes looking, but don&#39;t let it block anything.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Checkov threshold configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# In checkov-action
soft_fail_on: LOW,MEDIUM
# or via .checkov.yaml
soft-fail-on:
  - LOW
  - MEDIUM
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;tfsec threshold configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tfsec terraform/ --minimum-severity HIGH
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PSRule doesn&#39;t have a native severity threshold in the same sense - its &lt;code&gt;outcome&lt;/code&gt; parameter controls which result types cause a non-zero exit. For severity-based control, use a PSRule suppression group (covered in the next section) or a &lt;code&gt;.ps-rule/Baseline.Rule.yaml&lt;/code&gt; to exclude specific rule severities.&lt;/p&gt;
&lt;p&gt;The key principle: the threshold is a policy decision, not a tool decision. Write it down in your team&#39;s contributing guide, not just in the workflow YAML. When someone asks &amp;quot;why is this HIGH finding blocking my PR?&amp;quot;, the answer should be in a document, not lost in a commit comment on the workflow file.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Handling False Positives&lt;/h2&gt;
&lt;p&gt;False positives are inevitable. A scanner sees a public S3 bucket and flags it. The bucket is your static website&#39;s CDN origin - public by design. The right response is a suppression with a reason, not a suppression without one.&lt;/p&gt;
&lt;p&gt;The key principle: &lt;strong&gt;every suppression must include a reason, and the reason must be visible in the PR diff&lt;/strong&gt;. A suppression comment without a reason is indistinguishable from &amp;quot;I didn&#39;t want to fix this.&amp;quot; A suppression that&#39;s buried in a config file and not visible in the code is easy to miss in review.&lt;/p&gt;
&lt;h3&gt;Checkov Inline Suppression&lt;/h3&gt;
&lt;p&gt;Add a comment to the Terraform resource:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_s3_bucket&amp;quot; &amp;quot;website&amp;quot; {
  bucket = &amp;quot;my-public-website-assets&amp;quot;
  #checkov:skip=CKV_AWS_20:Public read is intentional - this is the website CDN origin
  #checkov:skip=CKV2_AWS_65:ACL configuration managed separately via aws_s3_bucket_acl
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The format is &lt;code&gt;#checkov:skip=&amp;lt;CHECK_ID&amp;gt;:&amp;lt;reason&amp;gt;&lt;/code&gt;. The reason is not optional in the sense that Checkov will accept the suppression without it - it&#39;s optional in the sense that your code review should reject it without one.&lt;/p&gt;
&lt;h3&gt;tfsec Inline Suppression&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_security_group_rule&amp;quot; &amp;quot;allow_ssh&amp;quot; {
  type        = &amp;quot;ingress&amp;quot;
  from_port   = 22
  to_port     = 22
  protocol    = &amp;quot;tcp&amp;quot;
  cidr_blocks = [&amp;quot;0.0.0.0/0&amp;quot;] #tfsec:ignore:AVD-AWS-0107:Bastion host - SSH locked down at network ACL level
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The format is &lt;code&gt;#tfsec:ignore:&amp;lt;AVD-ID&amp;gt;:&amp;lt;reason&amp;gt;&lt;/code&gt;. tfsec uses Aqua Vulnerability Database identifiers (AVD-*) in newer versions; older rules use the &lt;code&gt;aws-*&lt;/code&gt; prefix. Both forms work.&lt;/p&gt;
&lt;h3&gt;PSRule Suppression&lt;/h3&gt;
&lt;p&gt;PSRule suppressions live in &lt;code&gt;.ps-rule/suppress.Rule.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .ps-rule/suppress.Rule.yaml
---
apiVersion: github.com/microsoft/PSRule/v1
kind: SuppressionGroup
metadata:
  name: SuppressedStorageRules
spec:
  rule:
    - name: Azure.Storage.BlobPublicAccess
  if:
    name: &#39;.&#39;
    equals: storage/website-assets.bicep
  reason: &amp;gt;
    Public blob access is intentional. This storage account hosts static website assets
    served via Azure CDN. Access is read-only and content is not sensitive.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This suppression is file-scoped - it only suppresses the rule for &lt;code&gt;storage/website-assets.bicep&lt;/code&gt;, not for all storage accounts in the repository. File-scoped suppressions are preferable to rule-wide suppressions because they fail safely: if a new storage account is added to a different file, it still gets scanned.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Full Workflow Example&lt;/h2&gt;
&lt;p&gt;Here is a complete &lt;code&gt;pull_request&lt;/code&gt; workflow that runs all three scanners, uploads SARIF for everything, blocks on HIGH/CRITICAL, and only runs IaC jobs when IaC files actually changed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: IaC Security Scan

on:
  pull_request:
    paths:
      - &#39;terraform/**&#39;
      - &#39;bicep/**&#39;
      - &#39;**.tf&#39;
      - &#39;**.bicep&#39;

permissions:
  contents: read
  security-events: write

jobs:
  checkov:
    name: Checkov (Terraform)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: terraform/
          output_format: sarif
          output_file_path: checkov-results.sarif
          soft_fail_on: LOW,MEDIUM

      - name: Upload Checkov SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: checkov-results.sarif
          category: checkov

  tfsec:
    name: tfsec (Terraform)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tfsec
        uses: aquasecurity/tfsec-action@v1.0.0
        with:
          working_directory: terraform/
          format: sarif
          sarif_file: tfsec-results.sarif
          minimum_severity: HIGH
          soft_fail: true

      - name: Upload tfsec SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: tfsec-results.sarif
          category: tfsec

  psrule:
    name: PSRule (Bicep)
    runs-on: ubuntu-latest
    if: &amp;gt;
      contains(github.event.pull_request.changed_files, &#39;.bicep&#39;) ||
      github.event_name == &#39;push&#39;
    steps:
      - uses: actions/checkout@v4

      - name: Run PSRule for Azure
        uses: microsoft/ps-rule@v2
        with:
          modules: PSRule.Rules.Azure
          inputPath: bicep/
          outputFormat: Sarif
          outputPath: psrule-results.sarif
          outcome: Fail

      - name: Upload PSRule SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: psrule-results.sarif
          category: psrule
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few notes on this workflow:&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;paths&lt;/code&gt; filter on the trigger means the workflow only runs when IaC files change. A PR that touches only application code doesn&#39;t pay the scan cost. This is important for monorepos where infrastructure and application code coexist - you don&#39;t want every frontend PR waiting for a Terraform scan.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;permissions&lt;/code&gt; block is at the workflow level and applies to all jobs. &lt;code&gt;security-events: write&lt;/code&gt; is required for SARIF upload. &lt;code&gt;contents: read&lt;/code&gt; is the minimum for checkout. If your workflow also needs to comment on PRs or create check annotations, add &lt;code&gt;pull-requests: write&lt;/code&gt; and &lt;code&gt;checks: write&lt;/code&gt; as needed - but only those.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;checkov&lt;/code&gt; and &lt;code&gt;tfsec&lt;/code&gt; jobs run in parallel. Both upload to the Security tab with different &lt;code&gt;category&lt;/code&gt; values. The build fails if either job fails (both are required jobs with no &lt;code&gt;continue-on-error&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;psrule&lt;/code&gt; job has a conditional that tries to detect whether &lt;code&gt;.bicep&lt;/code&gt; files changed. Note that &lt;code&gt;github.event.pull_request.changed_files&lt;/code&gt; is not a reliable API for this - a more robust approach uses &lt;code&gt;dorny/paths-filter&lt;/code&gt; or a dedicated detection step. For a repository where Bicep files are in a known directory, the &lt;code&gt;paths&lt;/code&gt; filter on the trigger is sufficient and the per-job conditional is redundant; I&#39;ve included it to show the pattern.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;IaC is code. The same process that prevents application bugs from reaching production - write a test, run it in CI, block the merge if it fails - applies to infrastructure misconfigurations. An S3 bucket that&#39;s accidentally public is a bug. A storage account without encryption is a bug. They show up in the codebase before they show up in the cloud; that&#39;s when they&#39;re cheapest to fix.&lt;/p&gt;
&lt;p&gt;The CI integration shown here is a one-time setup. After that, every PR that touches Terraform or Bicep gets scanned automatically. Findings appear in the Security tab with file and line context. Suppressions are inline, with reasons, visible in PR diffs. HIGH and CRITICAL issues block merges. Everything else is tracked for remediation without stopping work.&lt;/p&gt;
&lt;p&gt;The alternative is catching the same issues post-deployment, in a CSPM alert, after the resource has been sitting exposed for however long it took someone to notice. That cost comparison isn&#39;t close.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Questions or corrections? 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>Misconfigured infrastructure is one of the most common causes of cloud security incidents. Catching it in CI costs nothing compared to fixing it post-deployment. Here&#39;s how to integrate Checkov and tfsec for Terraform, and PSRule for Bicep, into a GitHub Actions workflow that blocks on high-severity findings and surfaces everything else in the GitHub Security tab.</summary>
    <category term="infrastructure-as-code"/>
    <category term="security"/>
    <category term="devsecops"/>
    <category term="terraform"/>
    <category term="github-actions"/>
  </entry>
</feed>
