<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jesse Houwing</title>
    <description>The latest articles on DEV Community by Jesse Houwing (@jessehouwing).</description>
    <link>https://dev.to/jessehouwing</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F153902%2F2549a6a6-c745-4d10-b17b-3c8c7651681c.jpg</url>
      <title>DEV Community: Jesse Houwing</title>
      <link>https://dev.to/jessehouwing</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jessehouwing"/>
    <language>en</language>
    <item>
      <title>Auto-assign GitHub Copilot AI Credit budget to users</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Fri, 29 May 2026 13:26:23 +0000</pubDate>
      <link>https://dev.to/jessehouwing/auto-assign-github-copilot-ai-credit-budget-to-users-5fc1</link>
      <guid>https://dev.to/jessehouwing/auto-assign-github-copilot-ai-credit-budget-to-users-5fc1</guid>
      <description>&lt;p&gt;The deadline for GitHub Copilot Usage Based Billing is approaching rapidly. And today I've been busy setting up automation to assign each user their own bucket of Copilot AIC budget.&lt;/p&gt;

&lt;p&gt;On June 1st, all organizations should get access to the User Based Budgets and Universal User Budgets features in GitHub, allowing you to setup a a single &lt;em&gt;universal&lt;/em&gt; budget for all your users as well as individual overrides which can either be higher or lower.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up universal budget
&lt;/h2&gt;

&lt;p&gt;To setup a universal budget, go to the enterprise's &lt;strong&gt;Billing &amp;amp; Licensing&lt;/strong&gt; panel, select &lt;strong&gt;Budgets and Alerts&lt;/strong&gt; in the left menu an create a new budget of type "Bundled AI Credits budget":&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; the UI shows "premium request" until June 1st. Budgets should automatically transfer to "AI Credits".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwlgozkhexqqldagxpbiz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwlgozkhexqqldagxpbiz.png" alt="Auto-assign GitHub Copilot AI Credit budget to users" width="627" height="346"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Select the Bundled Premium Requests (before June 1st)/AI Credits budget (after June 1st)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then set a user level budget, but &lt;em&gt;do not select any specific user&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff84loo0kshf7lk5rqe10.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff84loo0kshf7lk5rqe10.png" alt="Auto-assign GitHub Copilot AI Credit budget to users" width="640" height="661"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Select the User budget scope, but do not select a user&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Finally select the default budget for all user without an individual budget override:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9dac750b3matfrd68eo2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9dac750b3matfrd68eo2.png" alt="Auto-assign GitHub Copilot AI Credit budget to users" width="626" height="497"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Set the Budget amount for all users without an individual budget&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting an individual user budget
&lt;/h2&gt;

&lt;p&gt;Setting an individual user budget follows the same flow, but in step 2 you need to select a specific user to which the budget applies:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F89vrensnlcvrp5uvsid0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F89vrensnlcvrp5uvsid0.png" alt="Auto-assign GitHub Copilot AI Credit budget to users" width="625" height="632"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;To set an individual user's budget, select a specific user in step 2&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Unfortunately you can't select a team or multiple users in this step, so each user needs their own individual budget. As you can imagine, this is a lot of work if you want to have specific control over different sets of users.&lt;/p&gt;
&lt;h2&gt;
  
  
  Automating the problem away
&lt;/h2&gt;

&lt;p&gt;Of course my employer is one of those places that wants control over specific groups of users. And because I'm a lazy administrator by nature, I'd like to be able to leverage our existing approval infrastructure as much as possible. Most approvals in our organization are handled by EntraID group memberships and there are existing approval workflows in Entra to allow people to request and approve access to specific groups.&lt;/p&gt;

&lt;p&gt;GitHub, unfortunately, doesn't allow linking a user budget to a group or a team, so there needs to be a mapping from group membership to budgets somewhere. And I ended up putting that in a GitHub Actions workflow of course.&lt;/p&gt;

&lt;p&gt;The workflows is quite simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It pulls all copilot users from the assigned seats API.&lt;/li&gt;
&lt;li&gt;It pulls all users from EntraID&lt;/li&gt;
&lt;li&gt;It pulls all group memberships for the copilot users from EntraId&lt;/li&gt;
&lt;li&gt;It pulls all user level budgets from GitHub&lt;/li&gt;
&lt;li&gt;It compares the membership to the budget setup and either creates, updates or deletes the budget in GitHub.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And it sets the universal budget based on a preconfigured value.&lt;/p&gt;

&lt;p&gt;All of this runs in a single GitHub Actions workflow an executes a single PowerShell script.&lt;/p&gt;

&lt;p&gt;I won't share the full script here in the blog post, but I will share a few of the basic building blocks so you can implement your own logic based on your own criteria:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Get existing Copilot budgets from the GitHub enterprise billing API.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Get-ExistingBudgets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$ProductSku&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nv"&gt;$allBudgets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@()&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;do&lt;/span&gt;&lt;span class="w"&gt; 
        &lt;/span&gt;&lt;span class="c"&gt;# Does not follow standard github paging, so need to manually page through the results&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/enterprises/&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="s2"&gt;/settings/billing/budgets?page=&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConvertFrom-Json&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$allBudgets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;budgets&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;while&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has_next_page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$allBudgets&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Create a new individual per-user budget.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;New-IndividualBudget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$GitHubLogin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$ProductSku&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;budget_amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$Amount&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;budget_scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$GitHubLogin&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;budget_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BundlePricing"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;budget_product_sku&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ProductSku&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;prevent_further_usage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$true&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;budget_alerting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;will_alert&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$false&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;alert_recipients&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@()&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="n"&gt;Write-Output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Creating individual budget of &lt;/span&gt;&lt;span class="se"&gt;`$&lt;/span&gt;&lt;span class="nv"&gt;$Amount&lt;/span&gt;&lt;span class="s2"&gt; for user &lt;/span&gt;&lt;span class="nv"&gt;$GitHubLogin&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConvertTo-Json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Depth&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--method&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/enterprises/&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="nx"&gt;/settings/billing/budgets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--input&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Update an existing budget amount.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Update-Budget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$BudgetId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$ProductSku&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;budget_amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$Amount&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;budget_product_sku&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ProductSku&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;prevent_further_usage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="n"&gt;Write-Output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Updating budget &lt;/span&gt;&lt;span class="nv"&gt;$BudgetId&lt;/span&gt;&lt;span class="s2"&gt; to &lt;/span&gt;&lt;span class="se"&gt;`$&lt;/span&gt;&lt;span class="nv"&gt;$Amount&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConvertTo-Json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Depth&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--method&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/enterprises/&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="nx"&gt;/settings/billing/budgets/&lt;/span&gt;&lt;span class="nv"&gt;$BudgetId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--input&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Delete a budget.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Remove-Budget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$BudgetId&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="n"&gt;Write-Output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deleting budget &lt;/span&gt;&lt;span class="nv"&gt;$BudgetId&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--method&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;DELETE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/enterprises/&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="nx"&gt;/settings/billing/budgets/&lt;/span&gt;&lt;span class="nv"&gt;$BudgetId&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Ensure the universal budget exists and has the correct amount.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Update-UniversalBudget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$ProductSku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$TargetAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$ExistingBudgets&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nv"&gt;$universalBudget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ExistingBudgets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Where-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;budget_scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-eq&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"multi_user_customer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;budget_product_sku&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-eq&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ProductSku&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$universalBudget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$universalBudget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;budget_amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-ne&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$TargetAmount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="n"&gt;Write-Output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Updating universal budget from &lt;/span&gt;&lt;span class="se"&gt;`$&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$universalBudget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;budget_amount&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; to &lt;/span&gt;&lt;span class="se"&gt;`$&lt;/span&gt;&lt;span class="nv"&gt;$TargetAmount&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="n"&gt;Update-Budget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Enterprise&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-BudgetId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$universalBudget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$TargetAmount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ProductSku&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ProductSku&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="n"&gt;Write-Output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Universal budget already set to &lt;/span&gt;&lt;span class="se"&gt;`$&lt;/span&gt;&lt;span class="nv"&gt;$TargetAmount&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;Write-Output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Creating universal budget of &lt;/span&gt;&lt;span class="se"&gt;`$&lt;/span&gt;&lt;span class="nv"&gt;$TargetAmount&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;budget_amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$TargetAmount&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;budget_scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"multi_user_customer"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;budget_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BundlePricing"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;budget_product_sku&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ProductSku&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;prevent_further_usage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$true&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;budget_alerting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nx"&gt;will_alert&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$false&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nx"&gt;alert_recipients&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@()&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConvertTo-Json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Depth&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--method&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/enterprises/&lt;/span&gt;&lt;span class="nv"&gt;$Enterprise&lt;/span&gt;&lt;span class="nx"&gt;/settings/billing/budgets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--input&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because of the change from per-user premium request budget to AI credits and the additionally included credits during the promotional period from June 1st to August 31st I've added a few helpers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Determine the correct budget_product_sku based on the current date.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Before June 1 2026: premium_requests&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# From June 1 2026 onwards: ai_credits&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Get-BudgetProductSku&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$AsOf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$cutoverDate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;System.DateTimeKind&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$AsOf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-lt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$cutoverDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"premium_requests"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ai_credits"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Determine the universal budget amount based on the current date.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Until May 31 2026: $0 (no included credits)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# June 1 - August 31 2026: $70&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# September 1 2026 onwards: $39&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Get-UniversalBudgetAmount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$AsOf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$june1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;System.DateTimeKind&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$sept1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;System.DateTimeKind&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$AsOf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-lt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$june1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="mf"&gt;0.00&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;elseif&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$AsOf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-lt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$sept1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="mf"&gt;70.00&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="mf"&gt;39.00&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your own logic to link a budget to a specific user, then call this from a simple workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Assign Copilot Budgets&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; 
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd&lt;/span&gt; &lt;span class="c1"&gt;# v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Azure CLI Login&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43&lt;/span&gt; &lt;span class="c1"&gt;#v3.0.0&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_CLIENT_ID }}&lt;/span&gt;
        &lt;span class="na"&gt;tenant-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_TENANT_ID }}&lt;/span&gt;
        &lt;span class="na"&gt;allow-no-subscriptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Assign Copilot Budgets&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
         &lt;span class="s"&gt;.\assign-copilot-budget.ps1&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GH_TOKEN }}&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pwsh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need to use a Classic PAT token with at least the following scopes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;manage_billing:enterprise
manage_billing:copilot
read:enterprise
read:user
user:email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>github</category>
      <category>ai</category>
      <category>githubcopilot</category>
    </item>
    <item>
      <title>Measuring the Value of AI</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Tue, 26 May 2026 13:16:04 +0000</pubDate>
      <link>https://dev.to/jessehouwing/measuring-the-value-of-ai-2f0k</link>
      <guid>https://dev.to/jessehouwing/measuring-the-value-of-ai-2f0k</guid>
      <description>&lt;h2&gt;
  
  
  We've deployed it everywhere. Now what?
&lt;/h2&gt;

&lt;p&gt;The honeymoon is over. After months of aggressive rollouts, enterprises across the globe have given their employees access to AI coding assistants, copilots, and chat interfaces. The credit card bills are arriving. And now the inevitable question lands on someone's desk: &lt;em&gt;What are we getting for this?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Organizations are scrambling to measure the return on their AI investment. They're building dashboards tracking token consumption, counting AI-assisted pull requests, and monitoring how many lines of code were generated versus written by hand. Some are going further — tracking tokens that led to PRs that led to production deployments, as if the pipeline from keystrokes to customer value were a simple conveyor belt.&lt;/p&gt;

&lt;p&gt;It isn't. And the uncomfortable truth is that this measurement problem isn't new at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  This isn't a new problem
&lt;/h2&gt;

&lt;p&gt;Long before AI assistants existed, businesses struggled to quantify the value of knowledge work. Agile coaches spent decades watching organizations try — and fail — to measure the output of Scrum teams. Lines of code, velocity points, features shipped: none of these correlated reliably with business outcomes.&lt;/p&gt;

&lt;p&gt;The problem is structural. As Barber (2008) demonstrated in research on value chain measurement, the further you move from tangible, physical processes toward intangible value creation, the harder measurement becomes. Software development, creative work, and strategic thinking exist almost entirely in that intangible space. Baruch Lev's seminal work &lt;em&gt;Intangibles: Management, Measurement, and Reporting&lt;/em&gt; (2000) — cited over 6,000 times — established that organizations systematically fail to account for intangible value creation, precisely because it resists the neat quantification that accounting systems demand.&lt;/p&gt;

&lt;p&gt;AI hasn't created a new measurement problem. It has amplified an existing one and made it impossible to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Traps We Keep Falling Into
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Goodhart's Law: When the Metric Becomes the Target
&lt;/h3&gt;

&lt;p&gt;"When a measure becomes a target, it ceases to be a good measure." Charles Goodhart formulated this principle in 1975 about monetary policy, but Fritz (2016) demonstrated its devastating applicability to software development. The moment lines of code became a productivity metric, developers produced more lines of code — without producing more value.&lt;/p&gt;

&lt;p&gt;We're walking into the same trap with AI. The moment "tokens consumed per PR" becomes a metric, teams will optimize for it. They'll either starve their AI of context to appear efficient, or feed it bloated prompts to appear productive, depending on which direction management rewards. Neither behavior correlates with value produced.&lt;/p&gt;

&lt;h3&gt;
  
  
  The McNamara Fallacy: Counting What's Countable
&lt;/h3&gt;

&lt;p&gt;During the Vietnam War, Secretary of Defense Robert McNamara insisted on measuring success through body counts and territory captured — metrics that were quantifiable but ultimately meaningless for determining who was winning. The McNamara Fallacy, as van Nieuwenhuizen describes it, is "prioritizing quantitative metrics while ignoring equally vital qualitative factors that resist easy measurement."&lt;/p&gt;

&lt;p&gt;Token usage is today's proxy. It tells you something is happening. It tells you resources are being consumed. It tells you absolutely nothing about whether the right things are being built, whether customers are happier, or whether your product is improving.&lt;/p&gt;

&lt;p&gt;The fallacy is seductive because the alternative — measuring qualitative outcomes — is genuinely hard. So organizations default to measuring what their billing systems already capture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-Serving Attribution Bias: We Believe Our Own Hype
&lt;/h3&gt;

&lt;p&gt;There's another problem lurking beneath the measurement challenge. Research by Shepperd, Malone, and others (2008) on self-serving attribution bias shows that people systematically over-attribute positive outcomes to their own efforts and under-attribute them to external factors. Libby and Rennekamp (2012) demonstrated this in forecasting contexts: after a success, people attribute it internally, leading to overconfident predictions about future performance.&lt;/p&gt;

&lt;p&gt;When a developer says "AI made me 10x more productive," they genuinely believe it. When a manager says "Our AI investment led to a 30% increase in shipping velocity," they genuinely believe it too. But these are uncontrolled observations colored by confirmation bias and the sunk-cost pressure of having championed the investment in the first place.&lt;/p&gt;

&lt;p&gt;Even when organizations attempt to estimate value &lt;em&gt;beforehand&lt;/em&gt;, those estimates are rarely tracked after the fact. The hypothesis that a feature would produce X value is tested at go/no-go time, then forgotten. Nobody goes back six months later to verify whether the value actually materialized. Estimates don't correlate with reality — they correlate with what was politically convenient to claim at funding time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shifted Filter: From Pre-Build to Post-Build
&lt;/h2&gt;

&lt;p&gt;Here's what AI has fundamentally changed about value creation in organizations: the capacity constraint used to be a natural quality filter.&lt;/p&gt;

&lt;p&gt;In the pre-AI world, building anything required convincing a team or a product owner to spend their limited time on it. Plenty of good ideas died in this filter, but the most important ones usually survived. Scarcity imposed discipline.&lt;/p&gt;

&lt;p&gt;Now that filter is evaporating. With AI-assisted development, the cost of building something has dropped dramatically. When capacity is infinite (or feels that way), everything gets built. Business stakeholders who used to have to fight for engineering time can now point agents at problems themselves.&lt;/p&gt;

&lt;p&gt;The result? The filter moves from pre-build to post-build. Instead of asking "Should we build this?" teams must now ask "Now that we built this, should we ship it?" And crucially: &lt;em&gt;most organizations have never developed the muscle for this.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A/B testing, eye tracking, funnel optimization — these post-build validation techniques exist in marketing departments and growth teams. But they're almost entirely absent from internal business software development. And you can tell just by using most enterprise software.&lt;/p&gt;

&lt;p&gt;Imagine a product like SAP with infinite capacity to add even more schemas, tables, and configuration options. The complexity crisis of enterprise software isn't a resource problem — it's a judgment problem. AI removes the resource constraint without addressing the judgment gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Polishing Problem
&lt;/h2&gt;

&lt;p&gt;There's a related dysfunction: AI makes it trivially easy to generate an MVP-quality implementation of anything, but finishing things — truly finishing them — still requires human judgment, taste, and iteration.&lt;/p&gt;

&lt;p&gt;I recently rewrote a piece of software I'd been maintaining for ten years. The AI-assisted rewrite took roughly a week. Making myself &lt;em&gt;proud&lt;/em&gt; of the result took three more months of tweaks and refinements. The initial rewrite was the easy part. The value was in the polish.&lt;/p&gt;

&lt;p&gt;Organizations optimizing for velocity — for things shipped, story points earned, PRs merged, tokens consumed, features delivered — are implicitly de-prioritizing this polish. Every half-baked, AI-generated feature that ships without refinement represents a small quality-of-life tax on every user who has to interact with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Non-Technical Blind Spot
&lt;/h2&gt;

&lt;p&gt;If measuring AI value for developers is hard, measuring it for non-technical departments can sometimes be even harder. At least with engineers, we have many crude proxies, like commits and deployments. With HR, finance, and operations teams paying per-seat for AI assistants, organizations typically have &lt;em&gt;no visibility whatsoever&lt;/em&gt; into usage or impact or the ability to directly correlate AI usage to measurable productivity improvements.&lt;/p&gt;

&lt;p&gt;Yet sometimes the value story for non-technical work is actually simpler. The use of AI can enable teams to quickly automate tasks they occasionally do manually. Of which the results can multiply if the "occasionally" turns into "frequently" suddenly. I recently supported a team who applies content updates on a website once in a while, and was face with a request to update 75 pages all at once. &lt;em&gt;From word templates&lt;/em&gt;. Automating that away saves 2 weeks of manual labor, and also removes the pain of smaller batches in the future.&lt;/p&gt;

&lt;p&gt;It's the ambient, non-targeted AI features that defy measurement. The auto-complete suggestions, the email drafts, the meeting summaries. Each one might save 30 seconds. Multiply by thousands of employees and the theoretical value is enormous. But theoretical value isn't the same as &lt;em&gt;realized&lt;/em&gt; value, and nobody is checking whether those 30-second savings actually compound into better outcomes rather than simply expanding the time available for other low-value work.&lt;/p&gt;

&lt;p&gt;Plus, sometimes the devil is in the details, not the summarized conversation of a 2 hour teams conversation. The quirked eyebrow of a participant as something is said. The person dropping from the call after a comment was made...&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Measure Better
&lt;/h2&gt;

&lt;p&gt;If pure output metrics are misleading and self-reported value is biased, what alternatives exist? Here are approaches grounded in measurement science:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Measure Outcomes, Not Outputs
&lt;/h3&gt;

&lt;p&gt;Douglas Hubbard's framework from &lt;em&gt;How to Measure Anything&lt;/em&gt; (2014) argues that most "immeasurable" things can be measured — but only if you define what you actually mean by value. Stop counting tokens, PRs, and features shipped. Instead, identify the business outcomes you care about (customer retention, time-to-resolution, revenue per feature, support ticket volume) and measure &lt;em&gt;those&lt;/em&gt; over time. The AI investment is a confounding variable, not the thing you're measuring.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Use Controlled Comparisons
&lt;/h3&gt;

&lt;p&gt;Rather than measuring before/after (which conflates AI impact with everything else that changed), run controlled experiments. Give some teams AI access for specific tasks and others not. Rotate. Compare outcomes, not effort. This is operationally inconvenient, which is exactly why almost nobody does it — and why everyone's "measurement" is actually just observational storytelling.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Track Value Hypothesis Closure Rates
&lt;/h3&gt;

&lt;p&gt;For every feature or project that gets built, require a falsifiable value hypothesis &lt;em&gt;before&lt;/em&gt; build starts. Then measure whether that hypothesis proved true &lt;em&gt;after&lt;/em&gt; deployment. Track the ratio of hypotheses confirmed versus falsified. If AI is helping you ship more things, but the hypothesis hit rate is falling, you're producing more waste faster. That's negative ROI regardless of what the token dashboard says.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Measure What Didn't Get Built
&lt;/h3&gt;

&lt;p&gt;One of the most important potential values of AI is &lt;em&gt;compression&lt;/em&gt; — not building more things, but building the same things with fewer resources, freeing capacity for quality, exploration, or rest. If your team is shipping the same amount as before but with less overtime, less burnout, and fewer shortcuts, that's value. It just doesn't show up in velocity charts.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Adopt the Post-Build Filter
&lt;/h3&gt;

&lt;p&gt;If AI is shifting your constraint from capacity to judgment, invest in judgment infrastructure. Feature flags, gradual rollouts, user research, usage analytics. The organizations that will get the most value from AI aren't the ones building the most — they're the ones with the tightest feedback loop between &lt;em&gt;shipping&lt;/em&gt; and &lt;em&gt;learning whether they should have shipped.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Accept Irreducible Uncertainty
&lt;/h3&gt;

&lt;p&gt;Some value is genuinely unmeasurable in the moment. How much value was produced by allowing Microsoft Word to italicize text? Probably immense — if it never had basic formatting, we'd all be using something else. But no ROI calculation could have predicted that in 1985. Some infrastructure investments only reveal their value in retrospect, through the things they enabled that nobody foresaw. Not everything needs a dashboard. Some things need trust and taste.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Answer
&lt;/h2&gt;

&lt;p&gt;Companies want a clean number. "Our AI investment produced €X in value." That number doesn't exist and it won't exist, for the same reason that organizations never successfully quantified the value of email, spreadsheets, or the internet itself. These are enabling technologies whose value is diffused across every process they touch, modified by how skillfully they're used, and contingent on decisions that haven't been made yet.&lt;/p&gt;

&lt;p&gt;What you &lt;em&gt;can&lt;/em&gt; measure is cost. What you &lt;em&gt;can&lt;/em&gt; observe is whether your customers are happier, your employees are less burned out, your products are more refined, and your judgment about &lt;em&gt;what to build&lt;/em&gt; is getting sharper.&lt;/p&gt;

&lt;p&gt;The hardest pill to swallow: maybe that feature your AI-powered team just shipped in record time should never have been built in the first place. And no token dashboard in the world will tell you that.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;References:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.emerald.com/ijpdlm/article/38/9/685/389929?ref=jessehouwing.net" rel="noopener noreferrer"&gt;&lt;em&gt;Barber, E. (2008). "How to measure the 'value' in value chains." International Journal of Physical Distribution &amp;amp; Logistics Management, 38(9), 685.&lt;/em&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.sciencedirect.com/science/article/pii/B9780128042069000131?ref=jessehouwing.net" rel="noopener noreferrer"&gt;&lt;em&gt;Fritz, T. (2016). "Measuring individual productivity." Perspectives on Data Science for Software Engineering. Elsevier.&lt;/em&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://books.google.com/books?id=IyPwAgAAQBAJ&amp;amp;ref=jessehouwing.net" rel="noopener noreferrer"&gt;&lt;em&gt;Hubbard, D.W. (2014). How to Measure Anything: Finding the Value of Intangibles in Business. Wiley.&lt;/em&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://books.google.com/books?id=Xgx4EQAAQBAJ&amp;amp;ref=jessehouwing.net" rel="noopener noreferrer"&gt;&lt;em&gt;Lev, B. (2000). Intangibles: Management, Measurement, and Reporting. Brookings Institution Press.&lt;/em&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://onlinelibrary.wiley.com/doi/abs/10.1111/j.1475-679X.2011.00430.x?ref=jessehouwing.net" rel="noopener noreferrer"&gt;&lt;em&gt;Libby, R. &amp;amp; Rennekamp, K. (2012). "Self-serving attribution bias, overconfidence, and the issuance of management forecasts." Journal of Accounting Research.&lt;/em&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://compass.onlinelibrary.wiley.com/doi/abs/10.1111/j.1751-9004.2008.00078.x?ref=jessehouwing.net" rel="noopener noreferrer"&gt;&lt;em&gt;Shepperd, J., Malone, W., &amp;amp; Sweeny, K. (2008). "Exploring causes of the self-serving bias." Social and Personality Psychology Compass, 2(2), 895-908.&lt;/em&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.academia.edu/download/123891267/The_McNamara_Fallacy.pdf?ref=jessehouwing.net" rel="noopener noreferrer"&gt;&lt;em&gt;van Nieuwenhuizen, P. "The McNamara Fallacy: Relearning Old Lessons."&lt;/em&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>githubcopilot</category>
    </item>
    <item>
      <title>The why for Usage Based Billing for GitHub Copilot</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Mon, 11 May 2026 12:58:31 +0000</pubDate>
      <link>https://dev.to/jessehouwing/the-why-for-usage-based-billing-for-github-copilot-4eb3</link>
      <guid>https://dev.to/jessehouwing/the-why-for-usage-based-billing-for-github-copilot-4eb3</guid>
      <description>&lt;p&gt;Last month GitHub announced Usage Based Billing for GitHub Copilot. This completely changes the way its costs are calculated. A lot has already been written about the impact and a lot is still unknown.&lt;/p&gt;

&lt;p&gt;In this post I want to explore the reasons why this switch was inevitable and how it enables new scenarios to make Copilot perform better than it did before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick recap of copilots current pricing model
&lt;/h2&gt;

&lt;p&gt;GitHub Copilot is currently available in 5 different plans:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub Copilot Free - severely rate limited free version with no option to buy additional requests.&lt;/li&gt;
&lt;li&gt;GitHub Copilot Pro - Individual license with limited Premium Request Units.&lt;/li&gt;
&lt;li&gt;GitHub Copilot Pro+ - Individual license with more extensive Premium Request Units.&lt;/li&gt;
&lt;li&gt;GitHub Copilot for Business - License for organizations with centralized policies and limited Premium Request Units.&lt;/li&gt;
&lt;li&gt;GitHub Copilot Enterprise - License for organizations with centralized policies and more extensive Premium Request Units.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these licenses has a fixed monthly price. GitHub Copilot includes unlimited suggestions and a limited set of free models.&lt;/p&gt;

&lt;p&gt;And about a year ago, GitHub introduced the concept of Premium Request Units to pay for more powerful models and and PRUs are the used to charge for specific features like the Cloud Coding Agent and Copilot Code Review.&lt;/p&gt;

&lt;p&gt;When the Cloud Coding Agent was introduced, it could use more than 1 PRU per request. This made the cost of the agent unpredictable and GitHub soon changed the price of the Cloud Coding Agent to a single PRU per request.&lt;/p&gt;

&lt;p&gt;When a user has exhausted their included PRU budget, additional PRUs are charged. Additional PRUs have a fixed cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Usages of PRUs
&lt;/h2&gt;

&lt;p&gt;The primary use of PRUs are to charge the costs of requests to the users of GitHub Copilot. Each model is assigned a multiplier of 0, 1, up to 50 (highest so far) PRUs.&lt;/p&gt;

&lt;p&gt;The model multiplier wasn't just used to charge the cost, it's an open secret that it was also used to divert users to other models to load balance them. Most people are more hesitant to use expensive models carelessly. The introduction of the "Auto" mode rests on this principle. Users get a 10% discount on PRUs if they let GitHub select their model. While most users seem to assume this will route them to the "best model for the task", this generally sends the request to the "model with the best availability".&lt;/p&gt;

&lt;p&gt;Premium Request Units were also reduced for promotional purposes. When Claude Opus 4.7 was introduced, it's multiplier was temporarily set to 3x, later increased to 15x. This promotional use isn't just to get us "hooked" on the more powerful model features, it's also to allow GitHub to get crucial statistics on how these new models perform and to help them iron out bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How GitHub pays for model usage
&lt;/h2&gt;

&lt;p&gt;The model providers, Anthropic, Google, OpenAI and X, don't charge the cost of their models in Premium Request Units. They charge the cost in terms of Tokens. Input Tokens, Output tokens and Cached tokens at the highest level.&lt;/p&gt;

&lt;p&gt;Given the amount GitHub uses, they have probably got a really good deal.&lt;/p&gt;

&lt;p&gt;But GitHub needs to balance the cost for the Tokens it spends against what it can charge its users in Premium Request Units.&lt;/p&gt;

&lt;p&gt;GitHub also balances the costs between Tokens and PRUs by limiting the maximum token window for models. For example, if you're using Claude Opus 4.6 through GitHub Copilot, the maximum token window is 200k tokens. If you use the same model directly through Anthropic you have a maximum of 1m tokens.&lt;/p&gt;

&lt;p&gt;As Agent Mode and Cloud Coding Agent got more powerful, more tools appeared, the models are using more and more tokens for every request, reducing the available token window for your requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other ways GitHub control costs
&lt;/h2&gt;

&lt;p&gt;GitHub has a few other options to control the maximum number of tokens you can use for each Premium Request Unit they charge you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent Mode -&lt;/strong&gt; Agent Mode spends tokens. GitHub can control the token spend by forcing the agent to come to an early conclusion or to postpone work to a future session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud Coding Agent&lt;/strong&gt; - The cloud coding agent spends tokens and action minutes. GitHub can control the token spend by forcing the agent to come to an early conclusion or to postpone work to a future session. The workflows running the Cloud Coding Agent runs on is limited to 2 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code Review&lt;/strong&gt; - Similar to the Cloud Coding Agent, when using GitHub's Code Review feature, GitHub can control the token spend by limiting the total time and the total of tokens the feature can spend.&lt;/p&gt;

&lt;p&gt;When using these features, GitHub can also control which model is used to perform these tasks.&lt;/p&gt;

&lt;p&gt;Some features, like tool optimization in Visual Studio Code also limit the total number of tokens used for each session by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recent creative uses
&lt;/h2&gt;

&lt;p&gt;New features, like subagent and fleets have multiplied the number of tokens you can use in a single Agent request. Visual Studio Code extensions have allowed users to steer the agent without completing the Agent request.&lt;/p&gt;

&lt;p&gt;These creative usages, some people might call them abuse, have allowed some people to use significantly more tokens costs than the PRU is worth. In some cases thousands time more.&lt;/p&gt;

&lt;p&gt;And instead of figuring out how to charge its users in Premium Request Units in this new model, GitHub will charge us for the actual tokens instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what changes
&lt;/h2&gt;

&lt;p&gt;On June 1st 2026 GitHub will change the way we all pay for our AI usage.&lt;/p&gt;

&lt;p&gt;Instead of the abstract thing, called Premium Request Units, acting as a proxy for the real cost of AI usage, we're going to be charged in tokens, the same unit in which GitHub is charged.&lt;/p&gt;

&lt;p&gt;Unfortunately, the different model providers, each charge different prices for different kinds of tokens. Your usage of each model is translated from the price per token into AI Credits, the credits are deducted from the budget included in your account.&lt;/p&gt;

&lt;p&gt;GitHub explains &lt;a href="https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/?ref=jessehouwing.net" rel="noopener noreferrer"&gt;how the new pricing model works&lt;/a&gt; on the GitHub Blog.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benefits
&lt;/h2&gt;

&lt;p&gt;The introduction of tokens isn't meant to simply increase the cost of AI, but it is also expected to more equally spread the load across the available models as users will be more likely to try to find the most cost effective model to use for the work they're trying to accomplish, instead of using most powerful model available to them for all their tasks. This should result in better model availability and higher performance.&lt;/p&gt;

&lt;p&gt;A key benefit of GitHub Copilot is that you get access to the models of Anthropic, Google and OpenAI through a single subscription. And now that we are charged for the actual usage of tokens, GitHub can start raising the tokens available for each session, making the models more comparable to subscribing directly through each model provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Additional changes for Business accounts
&lt;/h2&gt;

&lt;p&gt;In addition to the new pricing, GitHub is introducing a number of welcome changes for business accounts (Copilot for Business and Copilot Enterprise).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pooled usage&lt;/strong&gt; - Under the current model, when a user has spent all of its budget, additional requests are automatically charged. Under the new model, the budget of all users is pooled together. So the unused budget from less frequent users can be consumed by more frequent users in the same organization. Only when the full pooled budget is used up, will additional usage be charged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Individual additional budgets&lt;/strong&gt; - Under the current model, additional charges can be controlled through budgets, these are pooled together for all users in the same cost center. In the new model it's possible to set a per-user budget as well as a universal budget for all users in addition to the already existing budget control options.&lt;/p&gt;

&lt;p&gt;In addition to these changes, existing policy options to control costs are still available:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise, Organization&lt;/strong&gt; and &lt;strong&gt;Cost Center Budgets&lt;/strong&gt; - Existing budget options to limit the total spend at the Enterprise, Organization and Cost Center can still be configured to limit the total spend on AI costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limit model availability&lt;/strong&gt; - Enterprise and Organization level policies allow admins to control the available models. This can remove less effective and more expensive model options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limit feature availability&lt;/strong&gt; - Enterprise and Organization level policies allow admins to control the available features. This can remove more expensive features like Code Review and Cloud Coding Agent for specific users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't hamstring your users, educate them
&lt;/h2&gt;

&lt;p&gt;But, ultimately, limiting the cost isn't the path forward. Optimizing the value delivered through AI is. Use the available limits to bridge the time it takes to educate your users.&lt;/p&gt;

</description>
      <category>github</category>
      <category>githubcopilot</category>
    </item>
    <item>
      <title>Automatic versioning for GitHub Actions authors</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Sat, 07 Feb 2026 10:34:01 +0000</pubDate>
      <link>https://dev.to/jessehouwing/automatic-versioning-for-github-actions-authors-2e19</link>
      <guid>https://dev.to/jessehouwing/automatic-versioning-for-github-actions-authors-2e19</guid>
      <description>&lt;p&gt;As a maintainer of 3 GitHub Actions, I've always struggled a bit with the versioning of them. GitHub's documentation provides guidance, but no easy way to verify you did everything right. Based on a quick scan of the actions on the marketplace I'm not alone, even some of GitHub's own actions have some inconsistencies when it comes to it.&lt;/p&gt;

&lt;p&gt;This is why I cobbled together v1 of my Actions Semver Checker action a couple of years ago and it has served me well. But as I threw the action together in a short amount of time way back when, it contained a few bugs, was very hard to maintain and I always dreaded making changes to it, since I had fixed my own repos, so it was hard to test whether I broke anything or accidentally introduced unwanted side effects.&lt;/p&gt;

&lt;p&gt;This is why I had Copilot Agent use the existing v1 implementation as a blueprint to build a new version. But with a lot of improvements. I gave Copilot the order to refactor the action with the following constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make sure the old implementation and the new one can be called side-by-side so it's easy to compare the differences.&lt;/li&gt;
&lt;li&gt;Extract the validation rules into separate scripts so they're easier to maintain and test.&lt;/li&gt;
&lt;li&gt;Provide manual remediation options wherever possible.&lt;/li&gt;
&lt;li&gt;Implement an autofix functionality which tries to execute the manual remediation steps.&lt;/li&gt;
&lt;li&gt;Add unit tests for everything.&lt;/li&gt;
&lt;li&gt;Add integration tests for everything.&lt;/li&gt;
&lt;li&gt;Try to remain backwards compatible with v1 as much as possible when it comes to the inputs.&lt;/li&gt;
&lt;li&gt;I gave it links to all the relevant GitHub docs and blogs for:

&lt;ul&gt;
&lt;li&gt;GitHub CLI release commands&lt;/li&gt;
&lt;li&gt;Releases REST API&lt;/li&gt;
&lt;li&gt;Tags REST API&lt;/li&gt;
&lt;li&gt;GitHub's GraphQL API&lt;/li&gt;
&lt;li&gt;Blogs discussing immutable releases&lt;/li&gt;
&lt;li&gt;GitHub Actions versioning &lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;I used Claude Opus to generate a plan for this, since it's a pretty big effort. I went over the plan and made a few tweaks and then had the Copilot Coding Agent spend a good 45 minutes to do the work. And this resulted in my first vibe coded GitHub Action. And the result? It did mostly what it needed to do. It surfaced a couple of gaps in the initial plan and the request to keep both old and new code side-by-side caused some interesting interweaving between the 2 implementations.&lt;/p&gt;

&lt;p&gt;But now that I had a test suite of 90+ tests, I felt much better changing the actual behavior. And after many round trips with the Copilot Coding Agent as well as a few hands-on sessions inside Visual Studio Code with Copilot, I am proud to release &lt;a href="https://github.com/jessehouwing/actions-semver-checker/" rel="noopener noreferrer"&gt;v2 of the GitHub Actions Semver Checker Action&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it do exactly?
&lt;/h2&gt;

&lt;p&gt;Good question, the action is meant to be installed into the repository which hosts a GitHub action, like &lt;code&gt;actions/checkout&lt;/code&gt; or &lt;code&gt;jessehouwing/actions-semver-checker&lt;/code&gt;. It analyzes all branches, tags and releases and validates them based on a number of rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There should be a vX tag or branch (configurable) which points to the latest vX.y.z tag.&lt;/li&gt;
&lt;li&gt;There should be a vX.Y tag or branch (configurable) which points to the latest vX.Y.z tag.&lt;/li&gt;
&lt;li&gt;There should be a vx.y.z tag&lt;/li&gt;
&lt;li&gt;There should be &lt;em&gt;no&lt;/em&gt; vx.y.z branch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;new -&lt;/strong&gt; There should be a release for every vx.y.z tag

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;new -&lt;/strong&gt; That release should be immutable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;new -&lt;/strong&gt; The release for the highest vx.y.z should be set to "latest"&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;new -&lt;/strong&gt; There should be &lt;em&gt;no&lt;/em&gt; release for vX and vX.Y tags&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;new -&lt;/strong&gt; The action should be published to the marketplace

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;new -&lt;/strong&gt; The action.yml should have the correct metadata elements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;new -&lt;/strong&gt; The latest version of the action should be published to the marketplace&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;new -&lt;/strong&gt; Immutable releases should be turned on&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;And for almost all of these validations, I managed to build an autofix option which automatically corrects any issues found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's new
&lt;/h2&gt;

&lt;p&gt;As you can see in the list above, v2 adds a lot of new validations and it also adds the autofix feature to automatically resolve most of the issues found. If the action is not able to fix the issue, manual remediation steps are logged to the workflows summary pages.&lt;/p&gt;

&lt;p&gt;Autofixes available:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;delete/update/create branches&lt;/li&gt;
&lt;li&gt;delete/update/create tags&lt;/li&gt;
&lt;li&gt;convert branch to tag&lt;/li&gt;
&lt;li&gt;convert tag to branch&lt;/li&gt;
&lt;li&gt;delete/update/create/publish releases&lt;/li&gt;
&lt;li&gt;automatically set the latest version&lt;/li&gt;
&lt;li&gt;republish releases to make them immutable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are a few other improvements worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fully configurable which suites of rules you want to run&lt;/li&gt;
&lt;li&gt;You no longer need to check out the whole repo to run the action&lt;/li&gt;
&lt;li&gt;Retry logic and handling of rate limits&lt;/li&gt;
&lt;li&gt;Available as module on the PowerShell Gallery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is one unfortunate change to v1 I could not work around&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You should pass the &lt;code&gt;${{ secrets.GUTHUB_TOKEN }}&lt;/code&gt; to the &lt;code&gt;token:&lt;/code&gt; input of the action&lt;/li&gt;
&lt;li&gt;For some validations the token must have &lt;code&gt;contents: write&lt;/code&gt; permissions (to read draft releases)&lt;/li&gt;
&lt;li&gt;For autofix functionality the token must have &lt;code&gt;contents: write&lt;/code&gt; permissions&lt;/li&gt;
&lt;li&gt;To fix certain issues, the token needs permissions which cannot be granted to GitHub Actions, such as &lt;code&gt;workflows: write&lt;/code&gt;. Use a GitHub App or Fine-grained Personal Access Token instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Recommended usage
&lt;/h2&gt;

&lt;p&gt;I recommend you install the action into your action repository with its default settings, this will analyze your repo and will detect all rule violations.&lt;/p&gt;

&lt;p&gt;Change the settings of the action to match your desired behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;floating-versions-use&lt;/code&gt; can be set to &lt;code&gt;tags&lt;/code&gt; or &lt;code&gt;branches&lt;/code&gt; depending on how you want to manage the &lt;code&gt;vx&lt;/code&gt; and &lt;code&gt;vx.y&lt;/code&gt; versions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check-minor-version&lt;/code&gt; can be set to &lt;code&gt;none&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt; to validate the existence of &lt;code&gt;vx.y&lt;/code&gt; versions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check-releases&lt;/code&gt; can be set to &lt;code&gt;none&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt; to validate the existence of releases for &lt;code&gt;vx.y.z&lt;/code&gt; tags.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check-release-immutability&lt;/code&gt; can be set to &lt;code&gt;none&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt; to validate whether releases are immutable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ignore-preview-releases&lt;/code&gt; can be set to &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt; to exclude preview releases when checking floating versions (&lt;code&gt;vx&lt;/code&gt; an &lt;code&gt;vx.y&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ignore-versions&lt;/code&gt; can exclude versions from validation completely. This can be used to ignore really old versions or versions that were accidentally made immutable and can no longer be fixed.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: Check SemVer

on:
  push:
    branches: ['main']
    tags:
      - '*'
  workflow_dispatch:

permissions: {}

jobs:
  check-semver:
    permissions:
      contents: write

    concurrency:
      group: '${{ github.workflow }}'
      cancel-in-progress: true

    runs-on: ubuntu-latest

    steps:
      - uses: jessehouwing/actions-semver-checker@v2
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          check-release-immutability: none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ be extra careful with the &lt;code&gt;check-release-immutability&lt;/code&gt; setting prior to turning on autofix. Most automatic fixes can be easily undone using the git commandline, but once a release is made immutable, there is no way back.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once your existing tags have been cleaned up and you understand the implications, turn on release immutability checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    steps:
      - uses: jessehouwing/actions-semver-checker@v2
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          check-release-immutability: error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the action in read-only mode for a while, manually performing the suggested remediation steps.&lt;/p&gt;

&lt;p&gt;When you're confident the action does what you want it to do, consider turning on &lt;code&gt;autofix: true&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    steps:
      - uses: jessehouwing/actions-semver-checker@v2
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          autofix: true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With autofix turned on, the only thing you need to do to release a new version and update all tags and releases, is to push a new tag to to create a new release with the correct name: &lt;code&gt;vx.y.z&lt;/code&gt;. This will trigger autofix to do the rest on your behalf.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>github</category>
    </item>
    <item>
      <title>Improved Dependency Submission for GitHub Actions</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Sat, 27 Dec 2025 18:47:09 +0000</pubDate>
      <link>https://dev.to/jessehouwing/improved-dependency-submission-for-github-actions-1b29</link>
      <guid>https://dev.to/jessehouwing/improved-dependency-submission-for-github-actions-1b29</guid>
      <description>&lt;p&gt;If you've read some of my earlier blogs about GitHub Actions Security, you may have followed my advice to pin your actions and configure Dependabot. Recently someone brought to my attention that you then Security Advisories for GitHub actions do not show up in the Dependency Graph and Dependabot won't create security updates.&lt;/p&gt;

&lt;p&gt;I decided to solve that problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;When a security vulnerability is found and a security advisory is created, it will have the action and its affected versions as metadata. &lt;strong&gt;Versions&lt;/strong&gt; , not &lt;strong&gt;SHAs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When Automatic Dependency Submission submits the versions of actions used by a repository it records:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@v1.2.3&lt;/code&gt; as &lt;code&gt;1.2.3&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@v1&lt;/code&gt; as &lt;code&gt;1.*.*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;8e8c483db84b4bee98b60c0593521ed34d9990e8&lt;/code&gt; as &lt;code&gt;8e8c483db84b4bee98b60c0593521ed34d9990e8&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Security Advisory Database can't match &lt;code&gt;8e8c483db84b4bee98b60c0593521ed34d9990e8&lt;/code&gt; against the vulnerable version range and won't show the vulnerability in the dependency graph.&lt;/p&gt;

&lt;p&gt;Another issue exists when actions are forked to an organization (a common technique to create a private actions marketplace). The dependency is recorded using the organization name that holds the fork, not the upstream.&lt;/p&gt;

&lt;p&gt;The Security Advisory Database can't match &lt;code&gt;myorg/actions_checkout&lt;/code&gt; against the vulnerable action &lt;code&gt;actions/checkout&lt;/code&gt; and won't show the vulnerably in the dependency graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Impacted features
&lt;/h2&gt;

&lt;p&gt;Without recording the exact version of each action used, a number of features won't work as you might expect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dependency Graph won't show which actions you use are vulnerable.&lt;/li&gt;
&lt;li&gt;Dependabot Security Updates won't be created for workflows using vulnerable actions.&lt;/li&gt;
&lt;li&gt;Dependency Review Action won't block pull requests introducing vulnerable actions into your workflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Solution
&lt;/h2&gt;

&lt;p&gt;To solve the issue I created a new manual submission action which mimics the automatic submission, but adds a few extra features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It resolves the SHA to the highest, most specific version and records that as an additional dependency.&lt;/li&gt;
&lt;li&gt;It resolves a wildcard version to the current specific version and records that as an additional dependency.&lt;/li&gt;
&lt;li&gt;It can be configured to look up the upstream of a forked action and records that as an additional dependency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With these additional dependencies recorded, the Dependency Graph now contains all the metadata to determine whether the GitHub actions you depend on are vulnerable and all features should now work as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to
&lt;/h2&gt;

&lt;p&gt;Changing how Actions' dependencies are reported, you simply add my &lt;code&gt;jessehouwing/actions-dependency-submission&lt;/code&gt; action to your workflows (and optionally add &lt;code&gt;actions/dependency-review-action&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: Submit and validate dependencies of GitHub Actions

on:
  push:
    branches:
      - main
  pull_request:
  schedule:
    - cron: '33 4 * * *'

permissions: {}

jobs:
  submit-actions-dependencies:
    runs-on: ubuntu-latest
    permissions:
      # submitting dependencies requires 'contents: write'
      contents: write

    steps:
      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
      - uses: jessehouwing/actions-dependency-submission@e848a29fd84b874cce3e45ceb00619bc72dbeca3 # 1.0.2
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
      - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # 4.8.2
        if: github.event_name == 'pull_request'
        with:
          retry-on-snapshot-warnings: true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can test whether the action is working correctly by adding a vulnerable action to your repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: Submit Dependencies
on:
  workflow_dispatch:

jobs:
  submit-dependencies:
    if: false
    runs-on: ubuntu-latest
    permissions: {}
    steps:
      - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This should show as a &lt;strong&gt;high&lt;/strong&gt; vulnerability in the Dependency graph of the repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq1h3mw72qvf0wzlbrf7n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq1h3mw72qvf0wzlbrf7n.png" alt="Improved Dependency Submission for GitHub Actions" width="800" height="275"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Dependency Graph showing &lt;code&gt;actions/download-artifact@v4.1.2&lt;/code&gt; as vulnerable.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And you'll also see the original report using the SHA:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyqx4ncq8tvechctofm9f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyqx4ncq8tvechctofm9f.png" alt="Improved Dependency Submission for GitHub Actions" width="800" height="72"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Dependency Graph showing actions/download-artifact referenced by SHA.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You can &lt;a href="https://github.com/jessehouwing/actions-dependency-submission" rel="noopener noreferrer"&gt;find the action and additional documentation and examples here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>github</category>
      <category>supplychainsecurity</category>
      <category>security</category>
    </item>
    <item>
      <title>GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Wed, 24 Sep 2025 08:48:31 +0000</pubDate>
      <link>https://dev.to/jessehouwing/github-billing-let-organization-and-repository-admins-set-the-cost-center-in-github-1ad2</link>
      <guid>https://dev.to/jessehouwing/github-billing-let-organization-and-repository-admins-set-the-cost-center-in-github-1ad2</guid>
      <description>&lt;p&gt;We rely on GitHub Cost Centers to split the GitHub bill across the different entities that make up our company. What resource is tied to which cost center is managed in the GitHub Enterprise Settings and for now this can only be done be a user with Enterprise Owner or Billing Administrator permissions.&lt;/p&gt;

&lt;p&gt;I'd been searching for a way to allow Organization Owners and Repository Administrators to set the Cost Center on a repository themselves without having to involve one of these highly privileged accounts. We found a solution using GitHub Actions and Repository Custom Properties.&lt;/p&gt;

&lt;p&gt;Since my last blog post on Cost Centers, a few small improvements were released by GitHub:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;There is now an API to associate a Repository with a Cost Center.&lt;/li&gt;
&lt;li&gt;It's now possible to associate a Repository to a different Cost Center than the Organization that owns it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These two improvements allow us to solve this problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the Custom Property
&lt;/h2&gt;

&lt;p&gt;GitHub has a feature called Custom Properties for repositories which allows you to link arbitrary data to a repo. You can define these properties at the Enterprise, Organization and Repository level.&lt;/p&gt;

&lt;p&gt;Since our cost centers are defined at the Enterprise level, I chose to create this custom property at the same level. To add a new custom property navigate to your enterprise, select the &lt;strong&gt;Policies&lt;/strong&gt; tab and then drill down into &lt;strong&gt;Repository&lt;/strong&gt; and then &lt;strong&gt;Custom Properties&lt;/strong&gt;. Choose &lt;strong&gt;New Property&lt;/strong&gt; to create your new property:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fipkkak8h8owq5ukb6pq6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fipkkak8h8owq5ukb6pq6.png" alt="GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Navigate to the Custom Properties section and click new property&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Give the property a clear &lt;strong&gt;Name&lt;/strong&gt; and &lt;strong&gt;Description&lt;/strong&gt;. Since our list of cost centers is clearly defined, I've chosen the &lt;strong&gt;Type&lt;/strong&gt;  &lt;strong&gt;single-select&lt;/strong&gt; to allow our users to pick a cost center from a predefined list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8k5yho61ng6opij6dg4t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8k5yho61ng6opij6dg4t.png" alt="GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Give the property a unique name and set the type to single select&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Next, add the list of cost centers you want people to be able to select from as options. I've also included an option &lt;strong&gt;inherit&lt;/strong&gt; which I'll explain later:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkwor8jp2orvquernj95.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkwor8jp2orvquernj95.png" alt="GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Add the required cost-center options.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And finally make the field required and set the &lt;strong&gt;default value&lt;/strong&gt; to &lt;strong&gt;inherit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2i0xyxs9lvv8vars9lu5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2i0xyxs9lvv8vars9lu5.png" alt="GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Set the permission to allow users with properties permission to edit this field, make it required and set the default value.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now when you create a new repository or navigate to the settings of an existing repository, you can set the cost-center custom field:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn1277a5oqgektmnw0ope.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn1277a5oqgektmnw0ope.png" alt="GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Users are required to select a cost center when creating a new repository.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And existing repositories will now show the custom property in the repository settings:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe32htzq7z8h3h7xny3g3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe32htzq7z8h3h7xny3g3.png" alt="GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The reason for Inherit
&lt;/h2&gt;

&lt;p&gt;In the above example we added a special value &lt;strong&gt;inherit&lt;/strong&gt; , when this value is set, we &lt;em&gt;don't actually set any cost center on the repository&lt;/em&gt; and this causes all costs generated by this repository to automatically flow to the cost center associated to the Organization. If the Organization isn't associated to a cost center, the costs will flow to the Enterprise.&lt;/p&gt;
&lt;h2&gt;
  
  
  Making it all work using GitHub Actions
&lt;/h2&gt;

&lt;p&gt;Creating the Custom Property itself doesn't do anything by itself. In order to actually associate the Repository to the Cost Center, we still need to update the configuration in the GitHub Enterprise Billing &amp;amp; Licensing section:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx88z91ra5ytbgim7zg6f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx88z91ra5ytbgim7zg6f.png" alt="GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Billing Administrators can associate Organizations, Repositories and Users to Cost Centers&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Without additional automation each repository needs to be manually added to a cost center by editing the Cost Center and adding the Repository. This is a tedious manual process.&lt;/p&gt;

&lt;p&gt;Instead, to update the Cost Center configuration I've created a GitHub Actions workflow that runs once a day and compares the Cost Center configuration against the values of the Custom Properties and performs updates to the Cost Center configuration if needed.&lt;/p&gt;

&lt;p&gt;The basic workflow is quite simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query the current Cost Center configuration&lt;/li&gt;
&lt;li&gt;Query all Organizations in our GitHub Enterprise &lt;/li&gt;
&lt;li&gt;Query all Repositories in each GitHub Organization&lt;/li&gt;
&lt;li&gt;Query the Custom Properties for each Repository&lt;/li&gt;
&lt;li&gt;Update the Cost Center configuration in case it doesn't match the Custom Property&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow relies on the GitHub CLI t0 query the data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$costCenters = (invoke-gh -fromJson -- api /enterprises/$enterprise/settings/billing/cost-centers).costCenters

$orgs = get-allorganizations $enterprise | % { $_.login }

update-repocostcenters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;update-repocostcenters&lt;/code&gt; function checks which updates are needed:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function update-repocostcenters {
    Write-Output "Updating Costcenters for repositories in $enterprise." 

    foreach ($org in $orgs) {

        Write-Output "Processing organization $org"
        $repos = invoke-gh -slurp api /orgs/$org/repos --paginate --jq '.[] | { name: .name, full_name: .full_name }' | ConvertFrom-Json

        foreach ($repo in $repos) { 

            $customProperties = invoke-gh api /repos/$($repo.full_name)/properties/values --jq '.' | ConvertFrom-Json
            $repoCostCenterProperty = $customProperties | Where-Object { $_.property_name -eq "cost-center" } | Select-Object -ExpandProperty value -ErrorAction Ignore
            $repo | Add-Member -NotePropertyName cost_center -NotePropertyValue $repoCostCenterProperty -Force

            $currentCostCenter = $costCenters | ? { $_.resources | ? { $_.type -eq "Repo" -and $_.name -eq $repo.full_name } }

            if ( $repo.cost_center -eq "inherit" ) {
                $repo.cost_center = $null
            }

            $targetCostCenter = $null

            if ($repo.cost_center) {
                $targetCostCenter = $costCenters | ? { $_.name -eq $repo.cost_center }
                if (-not $targetCostCenter) {
                    Write-Warning "Costcenter for repository $($repo.full_name) not found."
                }
            }

            if ($null -eq $currentCostCenter) {
                if ($null -ne $targetCostCenter) {
                    Write-Output "Updating costcenter for repository $($repo.full_name) from unassigned to $($targetCostCenter.name)."
                    Update-CostCenterResources -Handles $repo.full_name -ResourceType "Repo" -Action "Add" -CostCenter $targetCostCenter -Enterprise $enterprise
                }
                else {
                    Write-Verbose "Repository $($repo.full_name) does not have a cost center assigned."
                }
            }
            else {
                if ($null -eq $targetCostCenter) {
                    Write-Verbose "Repository $($repo.full_name) does not have a cost center assigned."
                    Write-Output "Updating costcenter for repository $($repo.full_name) from $($currentCostCenter.name) to unassigned."
                    Update-CostCenterResources -Handles $repo.full_name -ResourceType "Repo" -Action "Delete" -CostCenter $currentCostCenter -Enterprise $enterprise
                }
                elseif ($currentCostCenter.id -ne $targetCostCenter.id) {
                    Write-Output "Updating costcenter for repository $($repo.full_name) from $($currentCostCenter.name) to $($targetCostCenter.name)."
                    Update-CostCenterResources -Handles $repo.full_name -ResourceType "Repo" -Action "Delete" -CostCenter $currentCostCenter -Enterprise $enterprise
                    Update-CostCenterResources -Handles $repo.full_name -ResourceType "Repo" -Action "Add" -CostCenter $targetCostCenter -Enterprise $enterprise
                }
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Unfortunately, you can't simply update the associated Cost Center in a single call, when changing the current value, an add plus delete is required.&lt;/p&gt;

&lt;p&gt;I shared the &lt;code&gt;Update-CostCenterResources&lt;/code&gt; function in a previous blogpost. GitHub added the ability to set Organizations and Repositories recently, so here is the updated version:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function Update-CostCenterResources {
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Handles,

        [ValidateSet('User', 'Repo', 'Org')]
        [string]$ResourceType = "User",

        [Parameter(Mandatory = $true)]
        [ValidateSet('Add', 'Delete')]
        [string]$Action,

        [Parameter(Mandatory = $true)]
        $CostCenter,

        [Parameter(Mandatory = $true)]
        [string]$Enterprise
    )

    switch ($Action) {
        'Add' {
            $method = 'POST'
            $Handles = $Handles | Where-Object { 
                $handle = $_
                return (($costCenter.resources | ? { $_.type -eq $ResourceType } | ? { $_.name -eq $handle }).Count -eq 0)
            }
        }
        'Delete' {
            $method = 'DELETE'
            $Handles = $Handles | Where-Object { 
                $handle = $_
                return (($costCenter.resources | ? { $_.type -eq $ResourceType } | ? { $_.name -eq $handle }).Count -gt 0)
            }
        }
    }

    # Call fails when processing too many users at once. Thus batching the calls...
    $count = 0
    do {
        $batch = $Handles | Select-Object -Skip $count -First 30
        $count += $batch.Count

        if ($batch.Count -gt 0) {
            switch ($ResourceType) {
                'User' { 
                    $body = @{
                        users = [string[]]$batch
                    }
                }
                'Org' { 
                    $body = @{
                        organizations = [string[]]$batch
                    }
                }
                'Repo' { 
                    $body = @{
                        repositories = [string[]]$batch
                    }
                }
            }


            $_ = ($body | ConvertTo-Json) | gh api --method $method /enterprises/$Enterprise/settings/billing/cost-centers/$($CostCenter.id)/resource --input -
        }
    } while ($batch.Count -gt 0)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;All of this runs from a simple GitHub workflow which has access to a Personal Access token with the required permissions to access the Cost Center information as well as sufficient permissions to query all Repositories in all Organizations in the enterprise.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: Set Costcenters

on:
  schedule:
    - cron: "5 4 * * *"

jobs:
  update:
    permissions: 
      contents: read

    runs-on: ubuntu-latest

    steps:      
    - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
      with:
        fetch-depth: 0

    - name: Assign Costcenters
      run: |
         .\assign-costcenters.ps1
      env:
        GH_TOKEN: ${{ secrets.GH_TOKEN }}
      shell: pwsh 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;You can find the whole script here:&lt;/p&gt;


&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



</description>
      <category>github</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>GitHub Actions learnings from the recent nx hack</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Tue, 02 Sep 2025 21:05:46 +0000</pubDate>
      <link>https://dev.to/jessehouwing/github-actions-learnings-from-the-recent-nx-hack-57l1</link>
      <guid>https://dev.to/jessehouwing/github-actions-learnings-from-the-recent-nx-hack-57l1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1562813733-b31f71025d54%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDN8fGhhY2tlcnxlbnwwfHx8fDE3NTY3OTQ1ODN8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1562813733-b31f71025d54%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDN8fGhhY2tlcnxlbnwwfHx8fDE3NTY3OTQ1ODN8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="GitHub Actions learnings from the recent nx hack" width="2000" height="1336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You may have seen recent reporting around the compromise of the &lt;code&gt;nx&lt;/code&gt; project. A malicious version of their package was published to npmjs which subsequently published GitHub tokens, crypto wallets and other sensitive materials of 1000s of users.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nrwl/nx/security/advisories/GHSA-cxm3-wv7p-598c?ref=jessehouwing.net" rel="noopener noreferrer"&gt;The maintainers of the project have spelled out exactly how the project was compromised&lt;/a&gt;. Yet, it doesn't tell exactly what they did to prevent the same issue from happening in the future. Or how &lt;em&gt;you&lt;/em&gt; can protect yourself from these attack vectors.&lt;/p&gt;

&lt;p&gt;I'll walk through the attack chain and will explain what options are available to you to prevent these things from happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding the vulnerable workflow
&lt;/h2&gt;

&lt;p&gt;Let's look at the main cause of this hack. &lt;a href="https://github.com/nrwl/nx/pull/32458/files?ref=jessehouwing.net" rel="noopener noreferrer"&gt;The introduction of a new GitHub Actions Workflow that had the &lt;code&gt;pull_request_target&lt;/code&gt; trigger enabled&lt;/a&gt;. This trigger is dangerous, since it signals to GitHub that the workflow is safe and has access to secrets. By default only to secrets referenced in the workflow itself, but that &lt;em&gt;always&lt;/em&gt; includes the built-in GitHub Actions token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on:
  pull_request_target:
    types: [opened, edited, synchronize, reopened]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second stage of the hack was possible due to script injection in that same workflow. This allowed the injection of arbitrary code in the pipeline through the title of the pull request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      - name: Create PR message file
        run: |
          mkdir -p /tmp
          cat &amp;gt; /tmp/pr-message.txt &amp;lt;&amp;lt; 'EOF'
          ${{ github.event.pull_request.title }}

          ${{ github.event.pull_request.body }}
          EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pull request's title and body are written to the script prior to execution, so including &lt;code&gt;EOF&lt;/code&gt; in any of them cause any subsequent text to be interpreted as arbitrary bash commands. And adding &lt;code&gt;$(...)&lt;/code&gt; would also lead to code execution prior to writing the contents to &lt;code&gt;pr-message.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Since the workflow can now execute arbitrary code, and the GitHub Hosted Runner runs with sudo by default, &lt;a href="https://karimrahal.com/2023/01/05/github-actions-leaking-secrets/?ref=jessehouwing.net" rel="noopener noreferrer"&gt;it would also be possible to write a memory dump of the runner process to gain access to the Runner's GitHub Token&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The payload was probably hidden in the body of the pull request and could have been similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Break the multi-line content redirection by supplying the defined EOF token.
EOF
# Dump the listener process so we can get access to its secrets

sudo apt-get install -y gdb &amp;amp;&amp;amp; \
sudo gcore -o k.dump "$(ps ax | grep 'Runner.Listener' | head -n 1 | awk '{ print $1 }')"

# Then, we grep the memory dump for the format of secret values—per the job data we decrypted in the network analysis:

grep -Eao '"[^"]+":\{"value":"[^"]*","issecret":true\}' k.dump*

# Optionally use CURL to send these secrets to a url you control. 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;This is a well-known way to exfiltrate secrets from the GitHub Hosted Runner and I speculate it was used to gain access to the GITHUB_TOKEN secret.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Since this token has write-all permissions on older repositories by default, it is possible to either write to an unprotected branch on the repo, or to run other workflows. &lt;a href="https://github.com/nrwl/nx/blob/400e3003d72acd75522468d58fdc160e76e13a68/.github/workflows/publish.yml?ref=jessehouwing.net#L8-L13" rel="noopener noreferrer"&gt;The &lt;code&gt;publish.yml&lt;/code&gt; workflow had the ability to be triggered manually&lt;/a&gt; in the &lt;code&gt;master&lt;/code&gt; branch. This means that it can be triggered against any branch in the project repo and will use the contents of the workflow in that specific branch. Because the npm token is stored as a repository secret, it will be available to all workflows in the repository, regardless of the branch it's defined in.&lt;/p&gt;

&lt;p&gt;In this case I could have used the GitHub Token to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a new branch in the project repository&lt;/li&gt;
&lt;li&gt;Overwrite the publish.yml in the new branch to send the npm token to a webserver I control&lt;/li&gt;
&lt;li&gt;Trigger the publish workflow against my branch&lt;/li&gt;
&lt;li&gt;Delete the branch&lt;/li&gt;
&lt;li&gt;Optionally delete the workflow run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without overwriting the &lt;code&gt;publish.yml&lt;/code&gt;, changes in any of the build scripts executed in the publish job would have had access to the token, since it was added as a job level environment variable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  publish:
    if: ${{ github.repository_owner == 'nrwl' }}
    env:
      GH_TOKEN: ${{ github.token }}
      NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the disclosure the project mentions that they have branch rulesets in place to protect their master branch. Without branch protections this would have been even simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding the vulnerable (human) process
&lt;/h2&gt;

&lt;p&gt;This attack couldn't have happened if the workflow above wouldn't have been added, or if it had used the standard &lt;code&gt;pull_request&lt;/code&gt; trigger instead. If you read the documentation for &lt;code&gt;pull_request_target&lt;/code&gt;, it calls out that its dangerous.&lt;/p&gt;

&lt;p&gt;It looks like this workflow was generated by AI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🤖 Generated with Claude Code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that none of the humans in the loop were aware of the dangers. Nor was there any tooling in place to scan the workflows and to alert them of the potential problems.&lt;/p&gt;

&lt;p&gt;Once the attacker figured out the attack chain, it would have been possible to execute the attack in seconds, with only minimal observable traces. And because all actions would be performed by the &lt;code&gt;github-actions[bot]&lt;/code&gt; user, none of the changes would stand out and any commits would even be considered verified if done correctly.&lt;/p&gt;

&lt;p&gt;It's trivially easy to setup a repo in which to test this attack without actually having to interact with the nx repo.&lt;/p&gt;

&lt;p&gt;Because the NPM token was stored as a repository secret, access to it would not require an explicit approval.&lt;/p&gt;

&lt;p&gt;Not related to this attack, but worrying nonetheless. The actions referenced by the nx project are pinned to their major version tag. This is against best practices for GitHub Actions in public repos. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      - uses: actions/checkout@v4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Best practices require each action to be pinned to their commit sha. In this case as far as I can tell none of the referenced actions have a known vulnerability, but &lt;a href="https://www.cve.news/cve-2025-30066/?ref=jessehouwing.net" rel="noopener noreferrer"&gt;this leaves the project open to attacks similar to the tj-actions/changed-files hack from a few months back&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;p&gt;Let's break down the remediations that can be put in place:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Change the default permissions for GitHub Actions to read-only.&lt;/li&gt;
&lt;li&gt;Set workflow permissions at the job level&lt;/li&gt;
&lt;li&gt;Enable branch rulesets to require pull requests for all important branches.&lt;/li&gt;
&lt;li&gt;Require Approval for running fork pull request workflows from contributors&lt;/li&gt;
&lt;li&gt;Do not rely on &lt;code&gt;pull_request_target&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Do not use &lt;code&gt;workflow_dispatch&lt;/code&gt; or &lt;code&gt;repository _dispatch&lt;/code&gt; triggers in workflows that have access to critical secrets.&lt;/li&gt;
&lt;li&gt;Do not mix pull-request validation and continuous integration workflows&lt;/li&gt;
&lt;li&gt;Store critical secrets in environment secrets, instead of repository secrets.&lt;/li&gt;
&lt;li&gt;Pass secrets only to the steps that need them&lt;/li&gt;
&lt;li&gt;Pass data to script steps using the environment&lt;/li&gt;
&lt;li&gt;Pin all actions to their sha.&lt;/li&gt;
&lt;li&gt;Enable GitHub Advanced Security Code Scanning for GitHub Actions and add Branch Ruleset.&lt;/li&gt;
&lt;li&gt;Enable Dependabot for GitHub Actions to automatically update action references to the latest version.&lt;/li&gt;
&lt;li&gt;Consider 3rd party security hardening tools like StepSecurity's Harden Runner.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each of these remediations is discussed in more detail below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change the default permissions for GitHub Actions to read-only
&lt;/h3&gt;

&lt;p&gt;When GitHub Actions was first released, the default permissions were set to write-all. When GitHub changed the default permission to read-all later, they did not retroactively reset the permission for existing accounts and organizations.&lt;/p&gt;

&lt;p&gt;GitHub Enterprise Owners and GitHub Organization Owners can enforce a default permission of read-all through the Settings:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fakytquk6pl89qniracem.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fakytquk6pl89qniracem.png" alt="GitHub Actions learnings from the recent nx hack" width="800" height="275"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Set the default workflow permissions to: Read repository contents and package permissions.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This will break all workflows that do not explicitly declare their required permissions in case they need more than read-all.&lt;/p&gt;

&lt;p&gt;While you're at it, you should also turn off &lt;strong&gt;Allow GitHub Actions to create and approve pull requests&lt;/strong&gt;. Allowing this setting offers attackers a way to bypass branch rulesets under certain conditions.&lt;/p&gt;
&lt;h3&gt;
  
  
  Set workflow permissions at the job level
&lt;/h3&gt;

&lt;p&gt;A workflow can declare its required permissions at the workflow or at the job level. For best security, it's recommended to deny all permissions at the workflow level and explicitly declare permissions for each job specifically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Opt out of any default permissions
permissions: {}

jobs:
  build:
    # explicitly request permissions
    permissions:
      contents: read

  deploy:
    permissions:
      contents: read
      packages: write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This also removes write permissions in case the default permissions are set to read and write.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enable branch rulesets to require pull requests for all important branches.
&lt;/h3&gt;

&lt;p&gt;The nx project reports that they had enabled Branch Rulesets to require a pull request for any changes to enter the master branch and other important branches in their repository.&lt;/p&gt;

&lt;p&gt;This prevented attackers from simply pushing a new workflow directly to the main branch to exfiltrate the npm token and other secrets.&lt;/p&gt;

&lt;p&gt;Protecting branches in your repository is a straight forward configuration change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F86tb5oqj12rz1tudyrzr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F86tb5oqj12rz1tudyrzr.png" alt="GitHub Actions learnings from the recent nx hack" width="731" height="510"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Require a pull request before merging&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is also a prerequisite for other protections, like requiring Code Scanning Results before merging.&lt;/p&gt;

&lt;p&gt;There are quite a few policy settings you can enable through Branch Rulesets the &lt;a href="https://medium.com/boostsecurity/slsa-dip-source-of-the-problem-a1dac46a976?ref=jessehouwing.net" rel="noopener noreferrer"&gt;following research explains why each of these settings is important and how an attacker might abuse a ruleset with a slightly less strict configuration&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Require approval for running fork pull request workflows from contributors
&lt;/h3&gt;

&lt;p&gt;This is a controversial one. As it adds quite a bit of overhead to project teams receiving many 3rd party contributions. But it might have been enough for a maintainer to detect the script injection attack that was used to exfiltrate the GitHub Actions token.&lt;/p&gt;

&lt;p&gt;GitHub Enterprise Owner, Organization Owners and Repository Admins can set a policy to require approval before running any GitHub Actions workflow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiy7lm6xyuoc73qa72vkw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiy7lm6xyuoc73qa72vkw.png" alt="GitHub Actions learnings from the recent nx hack" width="800" height="240"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Require approval for all external contributors&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;While it might feel tempting to set this policy to any of the lighter settings, there are already known cases where a person appears helpful by fixing a few typos. They'll be considered trusted after that.&lt;/p&gt;

&lt;p&gt;Setting this policy will require a maintainer to review the Pull Request and trigger the workflows each time an external contributor pushes new commits.&lt;/p&gt;
&lt;h3&gt;
  
  
  Do not rely on &lt;code&gt;pull_request_target&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;pull_request_target&lt;/code&gt; is inherently dangerous. It opens you up to all kinds of additional attack paths and very few people are completely aware of the implications. &lt;strong&gt;It is very easy to make a mistake when you enable this trigger on a workflow and open your repository up for attacks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My recommendation is to not rely on the &lt;code&gt;pull_request_target&lt;/code&gt; trigger.&lt;/strong&gt; For most scenarios the &lt;code&gt;pull_request&lt;/code&gt; trigger is sufficient.&lt;/p&gt;

&lt;p&gt;In the enterprises I control we've added a &lt;a href="https://docs.github.com/en/code-security/secret-scanning/using-advanced-secret-scanning-and-push-protection-features/custom-patterns/managing-custom-patterns?ref=jessehouwing.net" rel="noopener noreferrer"&gt;Custom Pattern for Secret Scanning to block all commits that contain &lt;code&gt;pull_request_target&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwwe7f3zuirx4i3spmsbd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwwe7f3zuirx4i3spmsbd.png" alt="GitHub Actions learnings from the recent nx hack" width="800" height="416"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Define a custom pattern to block pushes&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And we enabled this pattern for Push protection:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbz3apoa5193vr0ul4nr3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbz3apoa5193vr0ul4nr3.png" alt="GitHub Actions learnings from the recent nx hack" width="800" height="274"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Enable custom pattern for push protection&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;While there are cases where &lt;code&gt;pull_request_target&lt;/code&gt; can be useful, it's generally not worth the security risks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows?ref=jessehouwing.net#pull_request_target" rel="noopener noreferrer"&gt;From the docs&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Warning&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
For workflows that are triggered by the &lt;code&gt;pull_request_target&lt;/code&gt; event, the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is granted read/write repository permission unless the &lt;code&gt;permissions&lt;/code&gt; key is specified and the workflow can access secrets, even when it is triggered from a fork. Although the workflow runs in the context of the base of the pull request, you should make sure that you do not check out, build, or run untrusted code from the pull request with this event. Additionally, any caches share the same scope as the base branch. To help prevent cache poisoning, you should not save the cache if there is a possibility that the cache contents were altered. For more information, see &lt;a href="https://securitylab.github.com/research/github-actions-preventing-pwn-requests?ref=jessehouwing.net" rel="noopener noreferrer"&gt;Keeping your GitHub Actions and workflows secure: Preventing pwn requests&lt;/a&gt; on the GitHub Security Lab website.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Do not use &lt;code&gt;workflow_dispatch&lt;/code&gt; or &lt;code&gt;repository_dispatch&lt;/code&gt; triggers in workflows that have access to critical secrets.
&lt;/h3&gt;

&lt;p&gt;Since &lt;code&gt;workflow_dispatch&lt;/code&gt; and &lt;code&gt;repository_dispatch&lt;/code&gt; allow an attacker to control the branch against the workflow will run, it can be used to exfiltrate repository secrets if the attacker has write access to any other branch on the repository.&lt;/p&gt;

&lt;p&gt;The only way to protect secrets from this attack path, is to store them as an environment secret.&lt;/p&gt;
&lt;h3&gt;
  
  
  Do not mix pull-request validation and continuous integration workflows
&lt;/h3&gt;

&lt;p&gt;Many developers will try to reduce duplication by combining multiple workflows into 1 and defining multiple triggers or conditions. These conditions then control what code is executed depending on the event that triggered the workflow.&lt;/p&gt;

&lt;p&gt;A really common example is to have a single pull request validation workflow that also doubles as the first set of steps of the full CI build.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;publish.yml&lt;/code&gt; in the &lt;code&gt;nx&lt;/code&gt; project has 3 such purposes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Release the packages for the nx project whenever a new tag is created&lt;/li&gt;
&lt;li&gt;A canary release based on a schedule&lt;/li&gt;
&lt;li&gt;Creation of a test-build for pull requests
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: publish

on:
  # Automated schedule - canary releases from master
  schedule:
    - cron: "0 3 * * 2-6" # Tuesdays - Saturdays, at 3am UTC
  # Manual trigger - PR releases or dry-runs (based on workflow inputs)
  workflow_dispatch:
    inputs:
      pr:
        description: "PR Number - If set, a real release will be created for the branch associated with the given PR number. If blank, a dry-run of the currently selected branch will be performed."
        required: false
        type: number
  release:
    types: [published]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;nx&lt;/code&gt; project does this by restricting the build and publish jobs to only run when the triggering repository is owned by &lt;code&gt;nrwl&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    build:
      if: ${{ github.repository_owner == 'nrwl' }}

    publish:
      if: ${{ github.repository_owner == 'nrwl' }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which would prevent the publish job from running against pull requests from a forked repository.&lt;/p&gt;

&lt;p&gt;However, since the leaked token had write-all permission, it would have been easy to publish a new branch which would satisfy the &lt;code&gt;repository_owner&lt;/code&gt; condition and thus trigger the publication jobs.&lt;/p&gt;

&lt;p&gt;More restrictions could have been added, like checking against the actor to see whether it's a maintainer or preventing workflows triggered by GitHub Actions, or restricting jobs to specific branches. But when the attacker has write permissions to the repository, none of these will likely save you.&lt;/p&gt;

&lt;p&gt;A better approach would be to split the workflow into 2 separate workflows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;publish.yaml&lt;/code&gt; - which publishes the production packages&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pr-validation.yaml&lt;/code&gt; - which builds a PR validation package&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These two workflows can use a 3rd &lt;a href="https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows?ref=jessehouwing.net" rel="noopener noreferrer"&gt;callable workflow&lt;/a&gt;, or one or more &lt;a href="https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action?ref=jessehouwing.net" rel="noopener noreferrer"&gt;Composite actions&lt;/a&gt; to reduce code duplication while also reducing chances of an attacker calling a workflow they're not meant to call.&lt;/p&gt;

&lt;p&gt;Another advantage of callable workflows and composite actions is that they can be stored in a different repository, limiting an attacker's ability to leverage a leaked Actions Token to change the workflow code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Store critical secrets in environment secrets, instead of repository secrets.
&lt;/h3&gt;

&lt;p&gt;Secrets defined at the repository level are accessible to all workflows. There is no way to limit access to repository level secrets.&lt;/p&gt;

&lt;p&gt;In order to protect a critical secret, such as the NPM token used to publish your package, it should be defined as an Environment Secret.&lt;/p&gt;

&lt;p&gt;In the Environment's policy you can then set a branch policy to limit which branches have access to the secret.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft9y721u5yuqahvewvwfp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft9y721u5yuqahvewvwfp.png" alt="GitHub Actions learnings from the recent nx hack" width="704" height="460"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Set specific branches to limit access to critical secrets.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Optionally add &lt;strong&gt;Required Reviewers&lt;/strong&gt; to prevent access to the secrets without manual approval:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy013g55h7hdodrqhcrjx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy013g55h7hdodrqhcrjx.png" alt="GitHub Actions learnings from the recent nx hack" width="698" height="447"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Add Required Reviewers to prevent access to the secrets without manual approval&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then update the workflow to reference the environment when defining the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jobs:
  deploy:
    environment: public
    steps:
      uses: actions/deploy@sha
      with:
        npm_token: ${{ secrets.NPM_TOKEN }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/nrwl/nx/commit/f9293243032b516d8c15fbbf2e4750b46da2d1a5?ref=jessehouwing.net" rel="noopener noreferrer"&gt;It looks like the &lt;code&gt;nx&lt;/code&gt; project has already implemented this in a recent change&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pass secrets only to the steps that need them
&lt;/h3&gt;

&lt;p&gt;In the nx repository &lt;a href="https://github.com/nrwl/nx/blob/400e3003d72acd75522468d58fdc160e76e13a68/.github/workflows/publish.yml?ref=jessehouwing.net#L433-L436" rel="noopener noreferrer"&gt;the &lt;code&gt;NPM_AUTH_TOKEN&lt;/code&gt; is defined at the job level&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  publish:
    env:
      # DO NOT USE THIS!
      GH_TOKEN: ${{ github.token }}
      NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
    steps:
      - ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes the secrets available to all steps in the workflow. If the attacker can take control of any of the code executed in these steps, they would be able to exfiltrate the secret.&lt;/p&gt;

&lt;p&gt;In this case the job also calls &lt;code&gt;pnpm build:wasm&lt;/code&gt; which adds a number of additional places to hide exfiltration code.&lt;/p&gt;

&lt;p&gt;It's better to explicitly pass the secrets only to the steps that need them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  publish:
    env:
      # NO SECRETS SET HERE
    steps:
      - ...
      - ...
      - name: Publish
        env:
          VERSION: ${{ needs.resolve-required-data.outputs.version }}
          DRY_RUN: ${{ needs.resolve-required-data.outputs.dry_run_flag }}
          PUBLISH_BRANCH: ${{ needs.resolve-required-data.outputs.publish_branch }}
          NX_VERBOSE_LOGGING: true
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          echo ""
          # Create and check out the publish branch
          git checkout -b $PUBLISH_BRANCH
          echo ""
          echo "Version set to: $VERSION"
          echo "DRY_RUN set to: $DRY_RUN"
          echo ""
          pnpm nx-release --local=false $VERSION $DRY_RUN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pass data to script steps using the environment
&lt;/h3&gt;

&lt;p&gt;The Script Injection attack was possible because variables were in-lined in the body of a script step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      - name: Create PR message file
        run: |
          mkdir -p /tmp
          cat &amp;gt; /tmp/pr-message.txt &amp;lt;&amp;lt; 'EOF'
          ${{ github.event.pull_request.title }} &amp;lt;- unsafe variable reference

          ${{ github.event.pull_request.body }} &amp;lt;- unsafe variable reference
          EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To pass data safely to scripts, you should instead pass them through the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      - name: Create PR message file
        run: |
          mkdir -p /tmp
          echo $PR_TITLE &amp;gt;&amp;gt; /tmp/pr-message.txt
          echo "" &amp;gt;&amp;gt; /tmp/pr-message.txt
          echo $PR_BODY &amp;gt;&amp;gt; /tmp/pr-message.txt
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
          PR_BODY: ${{ github.event.pull_request.body }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Script Injection can be detected by GitHub Advanced Security Code Scanning for Actions as well as &lt;a href="https://github.com/rhysd/actionlint?ref=jessehouwing.net" rel="noopener noreferrer"&gt;ActionLint&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pin all actions to their sha.
&lt;/h3&gt;

&lt;p&gt;The attack on nx doesn't seem to have relied on this attack vector, but the recent attack on tj-actions/changed-files has shown again that it's dangerous to rely on only the tag to reference actions of 3rd parties.&lt;/p&gt;

&lt;p&gt;GitHub recommends that you always reference actions you do not control using their explicit full commit sha.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      - uses: actions/checkout@v4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Should have used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.blog/changelog/2025-08-15-github-actions-policy-now-supports-blocking-and-sha-pinning-actions/?ref=jessehouwing.net" rel="noopener noreferrer"&gt;There is now a policy which requires all actions to be pinned to their commit sha&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm3lkrcjdsjshsi0woqvu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm3lkrcjdsjshsi0woqvu.png" alt="GitHub Actions learnings from the recent nx hack" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Require actions to be pinned to a full-length commit SHA&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because this is a new policy, GitHub Administrators must explicitly opt-in to this policy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are several tools available to perform the pinning on your behalf:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/amenocal/gh-pin-actions?ref=jessehouwing.net" rel="noopener noreferrer"&gt;gh-pin-actions extension for the GitHub CLI.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.renovatebot.com/modules/manager/github-actions/?ref=jessehouwing.net#digest-pinning-and-updating" rel="noopener noreferrer"&gt;RenovateBot - Digest Pinning and Updating&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once, pinned, if enabled, Dependabot will automatically submit a pull request with the correct new commit sha. Unfortunately, it doesn't automatically change the tag &lt;code&gt;v4&lt;/code&gt; to its sha.&lt;/p&gt;
&lt;h3&gt;
  
  
  Enable GitHub Advanced Security Code Scanning for GitHub Actions and add Branch Ruleset.
&lt;/h3&gt;

&lt;p&gt;The script injection vulnerability at the root of this attack would have been detected by GitHub Advanced Security Code Scanning for GitHub Actions.&lt;/p&gt;

&lt;p&gt;GitHub Advanced Security is free for all public repos, so there is not really a good reason not to enable it.&lt;/p&gt;

&lt;p&gt;Because support for GitHub Actions was added recently, GitHub Repository Owners must explicitly enable GitHub Actions in the Code Scanning configuration:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgjp1vht2gd7se1dd5goe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgjp1vht2gd7se1dd5goe.png" alt="GitHub Actions learnings from the recent nx hack" width="713" height="195"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Enable GitHub Actions for CodeQL Code Scanning&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Or add &lt;code&gt;actions&lt;/code&gt; to the list of supported languages in their workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jobs:
  analyze:
    name: Analyze (${{ matrix.language }})
    runs-on: ${{ (matrix.language == 'swift' &amp;amp;&amp;amp; 'macos-latest') || 'ubuntu-latest' }}
    permissions:
      security-events: write
      packages: read
      actions: read
      contents: read

    strategy:
      fail-fast: false
      matrix:
        include:
        # Make sure Actions is added to the list of languages:
        - language: actions
          build-mode: none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will detect a number of GitHub Actions vulnerabilities, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Script Injection&lt;/li&gt;
&lt;li&gt;Unsafe checkout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In order to prevent workflows with these vulnerabilities to enter the repository, add a branch ruleset that requires the Code QL workflow to succeed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flkzba2nxk54oovksdtsn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flkzba2nxk54oovksdtsn.png" alt="GitHub Actions learnings from the recent nx hack" width="675" height="337"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Enable Require status checks to pass and add the CodeQL check explicitly&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And require Code Scanning results:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv8k48tkj852zk444f3dj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv8k48tkj852zk444f3dj.png" alt="GitHub Actions learnings from the recent nx hack" width="680" height="217"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Enable Require code scanning results and add CodeQL explicitly.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An alternative for GitHub Advanced Security Code Scanning for Actions that would also have detected the Script Injection vulnerability is &lt;a href="https://github.com/rhysd/actionlint/?ref=jessehouwing.net" rel="noopener noreferrer"&gt;ActionLint&lt;/a&gt;. &lt;a href="https://github.com/marketplace/actions/rhysd-actionlint?ref=jessehouwing.net" rel="noopener noreferrer"&gt;Actionlint can also be added as a workflow&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Enable Dependabot for GitHub Actions to automatically update action references to the latest version.
&lt;/h3&gt;

&lt;p&gt;To automatically keep your referenced GitHub Actions up to date, especially when you are pinning to the full commit sha, you can enable Dependabot to automatically create a pull request whenever an action is updated.&lt;/p&gt;

&lt;p&gt;To enable Dependabot for GitHub Actions, explicitly add &lt;code&gt;github-actions&lt;/code&gt; to your &lt;code&gt;.github/dependabot.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/" # Location of package manifests
    schedule:
      interval: "weekly"

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To reduce maintainer burden, &lt;a href="https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions?ref=jessehouwing.net" rel="noopener noreferrer"&gt;GitHub Actions can be used to automate updates completely&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consider 3rd party security hardening tools like StepSecurity's Harden Runner.
&lt;/h3&gt;

&lt;p&gt;All of the above remediations are built-in features of the GitHub platform.&lt;/p&gt;

&lt;p&gt;There are 3rd party solutions available to further secure GitHub Actions. Of those StepSecurity's Harden Runner is probably the best-known option. You can add harden-runner to any workflow by adding a single step to your workflow jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;steps:
  - name: Harden Runner
    uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
    with:
      egress-policy: audit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Community version of Harden runner offers the following protections to all of workflows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD-Aware Event Correlation:&lt;/strong&gt;  Each outbound network connection, file operation, and process execution is mapped to the exact step, job, and workflow where it occurs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated Baseline Creation:&lt;/strong&gt;  Harden-Runner builds a baseline for each job based on past outbound network connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anomaly Detection:&lt;/strong&gt;  Once the baseline is created, any future outbound calls not in the baseline trigger a detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block Network Egress Traffic with Domain Allowlist:&lt;/strong&gt;  Optionally use the automatically created baseline to control outbound network traffic by specifying allowed domains, preventing unauthorized data exfiltration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect Modification of Source Code:&lt;/strong&gt;  Monitor and alert on unauthorized changes to your source code during the CI/CD pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;GitHub offers many features to protect your project against attacks that try to abuse GitHub Actions. Unfortunately, many of these features require explicit opt-in or configuration.&lt;/p&gt;

&lt;p&gt;This should not be an excuse to leave your open-source projects open to attack.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;em&gt;Dear GitHub,&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;We love the unlimited free environment and all the tools you provide to keep our open-source projects free and healthy. And while we love your commitment to not break our workflows, it's not helpful to make many of the protections you offer opt-in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Please, please, please start enabling core security features automatically, at least on popular open-source projects as part of your roll-out strategy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The new GitHub Advanced Security Code Scanning for GitHub Actions would have alerted the maintainers of the &lt;code&gt;nx&lt;/code&gt; project to the Script Injection vulnerability. But it isn't activated by default, nor are maintainers warned they must opt-in to receive this protection.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The long ago changed default read-only permissions for GitHub Actions would have prevented the triggering of the publish workflow, but this setting is kept to the organization default, and maintainers aren't warned they should change this setting.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Requiring workflows triggered by external contributors to always be approved by a maintainer would have at least triggered a human intervention as part of the attack chain. But this new default has also not been rolled out to existing organizations, nor are maintainers warned they should really change this setting.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pinning Actions to their full commit sha has been a recommendation for years now. Yet GitHub has not provided any tooling to make this process easier, and while the policy has finally arrived, again it has rolled out with relatively low fanfare and maintainers are not warned they should really change this setting. Nor does Dependabot or GitHub Advanced Security Code Scanning for Actions submit a pull request to pin these actions.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This hasn't been the first attack to leverage the &lt;code&gt;pull_request_target&lt;/code&gt; trigger, I'd wish workflows with this trigger would not automatically enable and would require explicit enablement by an owner through a danger-zone action which clearly spells out the risks.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;So again, please, start enabling these core security features automatically and make such roll-out part of the introduction of new features such as these.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>github</category>
      <category>security</category>
    </item>
    <item>
      <title>Authenticate Connect-MgGraph using OIDC in GitHub Actions</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Tue, 10 Jun 2025 15:08:58 +0000</pubDate>
      <link>https://dev.to/jessehouwing/authenticate-connect-mggraph-using-oidc-in-github-actions-5bkb</link>
      <guid>https://dev.to/jessehouwing/authenticate-connect-mggraph-using-oidc-in-github-actions-5bkb</guid>
      <description>&lt;p&gt;I'm running a number of maintenance scripts against our Azure EntraId to manage GitHub related things. Removing dormant users, asking users to setup their notification email correctly etc.&lt;/p&gt;

&lt;p&gt;For a long time, I ran these scripts with an interactive session, before moving them over to GitHub Actions. Recently I made the move to convert my scripts to a workflow and changed the authentication mechanism to OIDC-connect, in order to remove the need to manage secrets and tokens.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect?ref=jessehouwing.net" rel="noopener noreferrer"&gt;documentation is quite clear on how to set things up&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Unfortunately, while it explains how to setup an Azure PowerShell session using &lt;code&gt;enable-AzPSSession: true&lt;/code&gt;, it doesn't explain how to authenticate using &lt;code&gt;connect-mggraph&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After some experimentation (and of course some help from GitHub Copilot), I figured out I could query the access token from &lt;code&gt;az&lt;/code&gt;, then pass that straight to &lt;code&gt;connect-mggraph&lt;/code&gt;. So now my workflow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Azure CLI Login&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure/login@v2&lt;/span&gt;
    &lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_CLIENT_ID }}&lt;/span&gt;
      &lt;span class="na"&gt;tenant-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_TENANT_ID }}&lt;/span&gt;
      &lt;span class="na"&gt;allow-no-subscriptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Assign Costcenters&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
       &lt;span class="s"&gt;$accessToken = az account get-access-token --resource https://graph.microsoft.com `&lt;/span&gt;
         &lt;span class="s"&gt;--query accessToken --output tsv&lt;/span&gt;
       &lt;span class="s"&gt;write-host "::add-mask::$accessToken"&lt;/span&gt;
       &lt;span class="s"&gt;$accessToken = $accessToken | ConvertTo-SecureString -AsPlainText -Force&lt;/span&gt;
       &lt;span class="s"&gt;Connect-MgGraph -AccessToken $accessToken -NoWelcome&lt;/span&gt;

       &lt;span class="s"&gt;# rest of the script here ... &lt;/span&gt;
    &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pwsh&lt;/span&gt; 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And my scripts can happily query the Microsoft Graph.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>github</category>
      <category>azure</category>
      <category>security</category>
    </item>
    <item>
      <title>Picking the right GitHub Copilot model</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Fri, 25 Apr 2025 09:24:19 +0000</pubDate>
      <link>https://dev.to/jessehouwing/picking-the-right-github-copilot-model-52k6</link>
      <guid>https://dev.to/jessehouwing/picking-the-right-github-copilot-model-52k6</guid>
      <description>&lt;p&gt;GitHub recently released documentation on all the large language models available in GitHub Copilot. The list is ever expanding, especially when you have previews turned on for your account. But it's still hard for many people to pick the right model for their task.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.github.com/en/copilot/using-github-copilot/ai-models/choosing-the-right-ai-model-for-your-task" rel="noopener noreferrer"&gt;You can find the official guidance in the GitHub Documentation.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wanted to make model selection even easier, so I asked GitHub Copilot to read the doc for me and suggest the right model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#fetch https://docs.github.com/en/copilot/using-github-copilot/ai-models/choosing-the-right-ai-model-for-your-task

What model would I use to plan a large refactoring involving multiple files for which I do not yet have a working example?

Select the best 2 and explain the pros and cons between them
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it actually came back with a pretty good suggestion:&lt;/p&gt;

&lt;p&gt;To plan a large refactoring involving multiple files without a working example, the two best models to consider are  &lt;strong&gt;GPT-4.5&lt;/strong&gt;  and  &lt;strong&gt;GPT-4o&lt;/strong&gt;. Here's a comparison of their pros and cons:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;GPT-4.5&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Pros:&lt;/strong&gt;
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deep Reasoning and Contextual Understanding:&lt;/strong&gt;  GPT-4.5 excels at handling complex logic and multi-step reasoning, making it ideal for large-scale refactoring tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-File Context:&lt;/strong&gt;  It can retain and process context across multiple files, which is crucial for understanding dependencies and relationships in a large codebase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliable and Consistent Results:&lt;/strong&gt;  Produces high-quality, structured suggestions for improving maintainability and modularity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nuanced Problem-Solving:&lt;/strong&gt;  Handles ambiguous or incomplete requirements effectively, which is helpful when no working example exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Cons:&lt;/strong&gt;
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Higher Latency:&lt;/strong&gt;  Slightly slower response times compared to smaller models like GPT-4o.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher Cost:&lt;/strong&gt;  More resource-intensive, which may not be ideal for cost-sensitive scenarios.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;GPT-4o&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Pros:&lt;/strong&gt;
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Faster Response Times:&lt;/strong&gt;  Provides quicker iterations, which is beneficial for rapid back-and-forth during refactoring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Balanced Performance:&lt;/strong&gt;  Offers a good mix of reasoning and speed, making it suitable for lightweight tasks or smaller refactoring steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost-Effective:&lt;/strong&gt;  Less expensive than GPT-4.5, making it a better choice for budget-conscious projects.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Cons:&lt;/strong&gt;
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Limited Depth for Complex Tasks:&lt;/strong&gt;  While capable, it may struggle with deeply nuanced or highly interconnected refactoring tasks compared to GPT-4.5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduced Cross-File Context:&lt;/strong&gt;  May not handle dependencies across multiple files as effectively as GPT-4.5.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;Recommendation:&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Use  &lt;strong&gt;GPT-4.5&lt;/strong&gt;  if the refactoring involves complex logic, dependencies across multiple files, or requires high-quality, structured suggestions.&lt;/li&gt;
&lt;li&gt;Use  &lt;strong&gt;GPT-4o&lt;/strong&gt;  if you need faster iterations, are working on smaller or less complex refactoring tasks, or are constrained by cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both models are strong candidates, but the choice depends on the complexity of the refactoring and your priorities (speed vs. depth).&lt;/p&gt;

</description>
      <category>githubcopilot</category>
      <category>github</category>
      <category>visualstudiocode</category>
    </item>
    <item>
      <title>Say goodbye to your Personal Access Tokens</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Tue, 04 Mar 2025 12:30:04 +0000</pubDate>
      <link>https://dev.to/jessehouwing/say-goodbye-to-your-personal-access-tokens-4b20</link>
      <guid>https://dev.to/jessehouwing/say-goodbye-to-your-personal-access-tokens-4b20</guid>
      <description>&lt;p&gt;We got rid of all Azure DevOps PAT usage and so should you. &lt;/p&gt;

&lt;p&gt;📢 Big shout out to Jesse! &lt;/p&gt;

&lt;p&gt;Without his blog post and direct help, I was probably still renewing expired PATs manually. But let's start at the beginning.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Michael, author of some of my favorite Azure DevOps Extensions, reached out on the &lt;a href="https://www.azuredevops.club/?ref=jessehouwing.net" rel="noopener noreferrer"&gt;Azure DevOps Club slack channel&lt;/a&gt; for help. He wanted to automatically rotate Personal Access Tokens to integrate with Azure DevOps, which lead to a quest to completely eradicate Personal Access Tokens altogether. After sharing my work to make the Azure DevOps Extension Tasks work without Personal Access Tokens, Michael went to work. I've asked him to share his results in this guest blog, since I feel they're useful for anyone doing automation against Azure DevOps. As you can see he agreed!  &lt;/p&gt;

&lt;p&gt;Jesse Houwing&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  PATs, PATs and more PATs - our 5 scenarios we used PATs for
&lt;/h2&gt;

&lt;p&gt;Building &lt;a href="https://www.bravonotes.com/?ref=jessehouwing.net" rel="noopener noreferrer"&gt;Bravo Notes&lt;/a&gt; here at Agile Extensions we are extensively using the Azure DevOps REST APIs in many ways.&lt;/p&gt;

&lt;p&gt;And I'm not talking about the API calls the Bravo Notes extension makes at runtime e.g. to load work items, publish wiki pages etc.&lt;/p&gt;

&lt;p&gt;We use the Azure DevOps REST APIs in these &lt;strong&gt;5 scenarios&lt;/strong&gt; as well:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;publish multiple versions of the Bravo Notes extension for development, staging and production via the marketplace APIs (in Azure Pipelines)&lt;/li&gt;
&lt;li&gt;retrieve marketplace event data for installs/uninstalls and more on a schedule (in Azure Functions app)&lt;/li&gt;
&lt;li&gt;run integration tests for many Bravo Notes components that need Azure DevOps APIs (in Azure Pipelines)&lt;/li&gt;
&lt;li&gt;run e2e tests of the main Bravo Notes app outside of Azure DevOps (in Azure Pipelines)&lt;/li&gt;
&lt;li&gt;run integration and e2e tests locally&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the past we used personal access tokens (PATs) for all of those scenarios.&lt;/p&gt;

&lt;p&gt;For years our process was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;watch for notifications that a PAT was about to expire&lt;/li&gt;
&lt;li&gt;be annoyed that this manual regular chore was on your plate (AGAIN!)&lt;/li&gt;
&lt;li&gt;regenerate PAT in Azure DevOps&lt;/li&gt;
&lt;li&gt;update pipeline variables and secrets with the new PAT&lt;/li&gt;
&lt;li&gt;feel bad and wonder why there wasn't a more elegant solution&lt;/li&gt;
&lt;li&gt;be done and push responsibility for improving this to future Michael&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  PAT REST APIs to the rescue?
&lt;/h2&gt;

&lt;p&gt;A while I ago a new set of APIs got introduced to manage PATs. That sounded intriguing and a few weeks back I reached out to the community to ask for guidance on how to automatically renew and manage PATs and do away with the manual process entirely.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🤔 Me: Is there a good resource online on how to transition from manually rotating PATs to rotating PATs automatically (a pipeline using PAT REST API, KeyVault)?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Thankfully Jesse pointed me to a better (the right) way:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Don't use PATs&lt;/strong&gt; but use an Azure Service principle with workload Federation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When you are reading this on his blog you probably already know that Jesse is one of those people who uses the internet for what it was build for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;He shares what he has learned (often the hard way) to make the lives of all of us easier.&lt;/strong&gt; In this case in form of &lt;a href="https://dev.to/jessehouwing/publish-azure-devops-extensions-using-azure-workload-identity-2979"&gt;this blog post on how to publish marketplace extensions without using PATs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I was convinced already that using PATs was less than ideal.&lt;/p&gt;

&lt;p&gt;But at this point I was still hesitant about whether this approach could help us do away with PATs entirely. After all we had 5 different scenarios.&lt;/p&gt;

&lt;p&gt;There was only one way to find out so we started by tackling the scenario that Jesse wrote about in his blog post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 1: Publish marketplace extensions without a PAT
&lt;/h2&gt;

&lt;p&gt;The blog post didn't disappoint as it contained all the pieces to help us &lt;strong&gt;publish marketplace extensions without a PAT&lt;/strong&gt; - whohoo 🥳!&lt;/p&gt;

&lt;p&gt;Below are the steps we needed to take. Please refer to Jesse's blog post for all the datails. Our mileage varied a bit as the ARM service connection now uses an "App registration" in Azure instead of a "Service Principal".&lt;/p&gt;

&lt;p&gt;The process is the still the same in general, though.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the ARM service connection (the UX changed a bit since Jesse's blog post)

&lt;ul&gt;
&lt;li&gt;Choose "Azure Resource Manager" Connection&lt;/li&gt;
&lt;li&gt;Identity Type: "App registration (automatic)"&lt;/li&gt;
&lt;li&gt;Credential: "Workload identity federation"&lt;/li&gt;
&lt;li&gt;the rest is about the same&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Add the App registration/service principal as a user in Azure DevOps&lt;/li&gt;
&lt;li&gt;Extract the Azure DevOps Identity Id from the Profile API via a pipeline task&lt;/li&gt;
&lt;li&gt;Use that Id to add the App registration as a member to the marketplace publisher&lt;/li&gt;
&lt;li&gt;Update our pipeline to use the ARM service connection&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note that we could use the newest version of the marketplace extension tasks (v5) that allows you to publish using the ARM service connection directly.&lt;/p&gt;

&lt;p&gt;So there is no need to fetch an access token and overwrite the marketplace service connection credentials anymore. After changing our pipelines we were able to delete our old "Visual Studio Marketplace" service connection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- task: PublishAzureDevOpsExtension@5
  displayName: 'Publish Extension'
  inputs:
    connectTo: 'AzureRM'
    connectedServiceNameAzureRM: 'marketplace-service-connection'
    fileType: vsix
    vsixFile: '$(Pipeline.Workspace)/vsix/production.vsix'
    updateTasksVersion: false

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that was it. The pipeline worked as before and we could revoke the PAT for publishing extensions to the marketplace.&lt;/p&gt;

&lt;p&gt;Of course we didn't want to stop here and tackle the next scenario and remove one more PAT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 2: Call Azure DevOps APIs from a Azure Function app
&lt;/h2&gt;

&lt;p&gt;We maintain an Azure Function app to query data about install and uninstall events and pull statistics from the marketplace. Authentication works using - what else could it be - a PAT.&lt;/p&gt;

&lt;p&gt;In Azure Functions of course we don't have the magic of service connections that we can utilize in Azure Pipelines.&lt;/p&gt;

&lt;p&gt;After initially failing to find the code to use for our NodeJS based function app, searching for the magic GUID &lt;code&gt;499b84ac-1321-427f-aa17-267ca6975798&lt;/code&gt; that represents Azure DevOps led me right to what we needed to acquire an access token inside the Function app.&lt;/p&gt;

&lt;p&gt;After finding the right way to acquire the access token, we got this solved in 2 simple steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the Azure Function app as a user in Azure DevOps&lt;/li&gt;
&lt;li&gt;Use the code below to acquire an access token and authorize Azure DevOps REST API calls
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const { ManagedIdentityCredential } = require("@azure/identity");

async function getMarketplaceAccessToken() {
    const credential = new ManagedIdentityCredential({
        // `AZURE_CLIENT_ID` points to the App registration created via the ARM service connection
        clientId: process.env.AZURE_CLIENT_ID,
    });
    const tokenResponse = await credential.getToken(
        "499b84ac-1321-427f-aa17-267ca6975798/.default",
        {
            tenantId: process.env.AZURE_TENANT_ID,
        }
    );

    return tokenResponse.token;
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was no stopping now - but still 3 PATs to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 3: Run intergration tests in Azure Pipelines
&lt;/h2&gt;

&lt;p&gt;Next up was our integration testing pipeline tasks that run integration tests using NodeJS and &lt;code&gt;jest&lt;/code&gt;. Until now a PAT was used from a secret pipeline variable. Another one that we didn't want to renew manually every few months.&lt;/p&gt;

&lt;p&gt;As we already acquired an access token in a pipeline via an ARM service connection in scenario 1, this dind't seem to difficult to pull off.&lt;/p&gt;

&lt;p&gt;Following the least priviledge principle we didn't want to use the same service principal from scenario 1. This would have given it access to the marketplace AND Azure DevOps resources that were needed for our integration tests.&lt;/p&gt;

&lt;p&gt;Luckily you can create as many ARM service connections as you want and so we did. To use the newly created App registration/service principal, the pipeline task from Jesse's blog post to retrieve an access token that we dind't need for scenario 1 now came in handy again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- task: AzureCLI@2
  displayName: 'Accquire token for integration testing'
  inputs:
    azureSubscription: 'azure-devops-integration-testing-connection'
    scriptType: 'pscore'
    scriptLocation: 'inlineScript'
    useGlobalConfig: true
    inlineScript: |
      $accessToken = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query "accessToken" --output tsv
      write-host "##vso[task.setsecret]$accessToken"
      write-host "##vso[task.setvariable variable=SECRET_INGETRATION_TESTING_TOKEN;issecret=true]$accessToken"

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we could use the the token for subsequent integration test tasks. Yay only 2 PATs to go!&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 4: Run e2e tests in Azure Pipelines (against external Azure DevOps organization)
&lt;/h3&gt;

&lt;p&gt;While this scenario seemed to be exactly the same es scenario 3 at first, we had an issue here that stopped us in our tracks at first.&lt;/p&gt;

&lt;p&gt;The e2e tests called APIs in a separate Azure DevOps org that only existed for testing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[OrgA Pipeline] ---- REST API call ----&amp;gt; [OrgB Work item API]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The solution was too simple to be true.&lt;/p&gt;

&lt;p&gt;We were just able to add the App registration/service principal as a user in OrgB and give it access to the resources needed for the e2e tests.&lt;/p&gt;

&lt;p&gt;Another PAT down!&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 5: Running integration and e2e tests locally
&lt;/h3&gt;

&lt;p&gt;This might be my least favorite solution so far, but it was simple and did the trick for now.&lt;/p&gt;

&lt;p&gt;We use &lt;code&gt;jest&lt;/code&gt; to run our tests and in our npm scripts we just stuck in a Azure Developer CLI call to log in prior to starting the test script.&lt;/p&gt;

&lt;p&gt;In our code to set up API authentication we now run some special code to acquire the access token, again with the azure identity SDK.&lt;/p&gt;

&lt;p&gt;So&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Modify testing script to log in - &lt;code&gt;azd auth login &amp;amp;&amp;amp; npx jest --config jest.integration.config.js"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Retrieve the access token from the Azure Developer Cli:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let token = process.env.SECRET_INGETRATION_TESTING_TOKEN;
if (!token &amp;amp;&amp;amp; !process.env.CI) {
    const credential = new AzureDeveloperCliCredential({
        tenantId: process.env.AZURE_TENANT_ID,
    });
    const tokenResult = await credential.getToken(
        '499b84ac-1321-427f-aa17-267ca6975798/.default',
        {
            tenantId: process.env.AZURE_TENANT_ID,
        },
    );
    token = tokenResult.token;
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Would love to hear about a better solution to securly get an access token for running scripts locally in a development environment.)&lt;/p&gt;

&lt;p&gt;All 5 scenarios done! The champaign 🍾 was almost open when I revoked all PATs that were not needed anymore when I discovered that I had forgotten one last PAT.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus scenario: the last token - the renovate pipline
&lt;/h3&gt;

&lt;p&gt;We use renovate to update dependencies and automatically create pull requests in yet another pipeline. (You can thank Jesse for his work online here as well).&lt;/p&gt;

&lt;p&gt;Immideately I had a bad feeling and I thought that renovate would only accept PATs as it only accepts &lt;code&gt;RENOVATE_TOKEN&lt;/code&gt; as a means to authenticate. The &lt;a href="https://docs.renovatebot.com/modules/platform/azure/?ref=jessehouwing.net" rel="noopener noreferrer"&gt;docs&lt;/a&gt; make it look that way for sure.&lt;/p&gt;

&lt;p&gt;Looking at the code (yay open source) I could see that renovate automatically detects whether the token is a PAT or a OAuth token and chooses the Authorization header (&lt;code&gt;Basic&lt;/code&gt; or &lt;code&gt;Bearer&lt;/code&gt;) accordingly.&lt;/p&gt;

&lt;p&gt;So we could just use the same AzureCLI pipeline task as in scenarios 3 to acquire and 4 and be done!&lt;/p&gt;

&lt;p&gt;P.S.: Talking to Jesse about that he mentioned that Azure DevOps now accepts OAuth tokens when using the &lt;code&gt;Basic&lt;/code&gt; authentication scheme as well which is great in my opinion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Just reading all the terms involved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ARM service connection&lt;/li&gt;
&lt;li&gt;Open Id Connect&lt;/li&gt;
&lt;li&gt;Workload Identity federation&lt;/li&gt;
&lt;li&gt;service principal&lt;/li&gt;
&lt;li&gt;App registration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;made the whole process seem daunting to me.&lt;/p&gt;

&lt;p&gt;But in the end we got rid of all our PATs within a couple of hours.&lt;/p&gt;

&lt;p&gt;Personal access tokens are great to quickly test an API call, authenticate a throw-away script where you don't have other means of authentication ready.&lt;/p&gt;

&lt;p&gt;However for the long term you really don't want to have to create PATs and renew them until the end of time.&lt;/p&gt;

&lt;p&gt;Thanks again to Jesse and Joost Voskuil for their help.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In case you are facing your own challenge with Azure DevOps or GitHub, don't hesitate to join the &lt;a href="https://www.azuredevops.club/?ref=jessehouwing.net" rel="noopener noreferrer"&gt;Azure DevOps Club&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>azuredevops</category>
      <category>azure</category>
      <category>security</category>
    </item>
    <item>
      <title>GitHub's new Billing - Assigning Cost Centers in Bulk</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Thu, 20 Feb 2025 14:10:17 +0000</pubDate>
      <link>https://dev.to/jessehouwing/githubs-new-billing-assigning-cost-centers-in-bulk-2nel</link>
      <guid>https://dev.to/jessehouwing/githubs-new-billing-assigning-cost-centers-in-bulk-2nel</guid>
      <description>&lt;p&gt;You can assign resources to cost centers, which will in turn charge any costs associated with that resource to that cost center.&lt;/p&gt;

&lt;p&gt;Examples of ressource categories are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repositories - All costs associated with a specific repository. Action Minutes, Codespaces, LFS Storage etc.&lt;/li&gt;
&lt;li&gt;Users - All costs associated with a specific user. Enterprise Seat, Copilot, Advanced Security.&lt;/li&gt;
&lt;li&gt;Organizations - All costs associated with a specific organization. Costs generated by any unassigned repositories in the organization. GitHub Packages storage and network traffic. &lt;/li&gt;
&lt;li&gt;Enterprise - All costs associated with the whole enterprise. Costs associated with users who are not explicitly assigned to a cost center.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By linking organizations, users and repositories to a cost center, you can cause their specific costs to be redirected from the Enterprise to the specific Cost Center.&lt;/p&gt;

&lt;p&gt;Linking Organizations and Repositories can be done through the Cost Center UI in the GitHub Enterprise Admin portal:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fezcl1smzn3tl8pos0dwr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fezcl1smzn3tl8pos0dwr.png" alt="GitHub's new Billing - Assigning Cost Centers in Bulk" width="800" height="504"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;In the Enterprise Admin portal, you can assign Organizations and Repositories to Cost Centers. Members can only be assigned through the API.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you're lucky there are only a few Organizations that need to be assigned, but in our case, Members were a whole other story. We already have 500+ members and external collaborators in our enterprise and these needed to be assigned to specific cost centers. As you can see, this can't be done in the UI, but requires API calls.&lt;/p&gt;

&lt;p&gt;It turns out the API is a bit tricky, there are certain things about the API that aren't (yet) nicely documented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can only assign users to a cost center in batches of 50 (confirmed with GitHub Support)&lt;/li&gt;
&lt;li&gt;You can only assign a user if it isn't already assigned to another cost center. Changing cost centers requires 2 operations.&lt;/li&gt;
&lt;li&gt;The API is eventually consistent. Changes made may take a couple of seconds to show up in subsequent API calls.&lt;/li&gt;
&lt;li&gt;While you could assign cost centers on a per-user basis, this is a great way to burn through your API Rate Limits quickly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To work around these issues, I've written a small wrapper around the API in PowerShell which uses the existing resource assignments stored in the Cost Centers to reduce the number of required calls to the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function Update-CostCenterResources {
    param(
        [Parameter(Mandatory=$true)]
        [string[]]$Handles,

        [Parameter(Mandatory=$true)]
        [ValidateSet('Add','Delete')]
        [string]$Action,

        [Parameter(Mandatory=$true)]
        $CostCenter,

        [Parameter(Mandatory=$true)]
        [string]$Enterprise
    )

    switch ($Action)
    {
        'Add' {
            $method = 'POST'
            $Handles = $Handles | Where-Object { 
                $handle = $_
                return (($costCenter.resources | ?{ $_.type -eq "User" } | ?{$_.name -eq $handle }).Count -eq 0)
            }
        }
        'Delete' {
            $method = 'DELETE'
            $Handles = $Handles | Where-Object { 
                $handle = $_
                return (($costCenter.resources | ?{ $_.type -eq "User" } | ?{$_.name -eq $handle }).Count -gt 0)
            }
        }
    }

    # Call fails when processing too many users at once. Thus batching the calls...
    $count = 0
    do {
        $batch = $Handles | Select-Object -Skip $count -First 50
        $count += $batch.Count

        if ($batch.Count -gt 0) {
            $body = @{
                users = [string[]]$batch
            }

            $_ = ($body | ConvertTo-Json) | gh api --method $method /enterprises/$Enterprise/settings/billing/cost-centers/$($CostCenter.id)/resource --input -
        }
    } while ($batch.Count -gt 0)
}

$enterprise = "xebia"
$costCenters = (invoke-gh -fromJson -- api /enterprises/$enterprise/settings/billing/cost-centers).costCenters

$costCenterNL = $costCenters | ?{ $_.name -eq "Netherlands" }

$handles = @("jessehouwing", "jessehouwing-demo")

# First remove the users from their currently assigned cost centers (if any)
$costCenters | 
  ?{ $_.id -ne $costCenterNL.id } | 
  ?{ $_ | ?{ $.resources | ?{ $_.type -eq "User" -and $_.name -in $handles } } | 
  %{ 
    Update-CostCenterResources -handles $handles -action "Delete" -CostCenter $_ -Enterprise $enterprise
  }

# Then assign the users to their new cost center
Update-CostCenterResources -handles $handles -action "Add" -CostCenter $costCenterNL -Enterprise $enterprise
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can find the current cost center for a user in the Cost Center's Resources array, or from the GitHub Enterprise assigned-seats REST API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$handle = "jessehouwing"
$enterprise = "xebia"

# retrieve from Cost Center API
$costCenters = (gh api /enterprises/$enterprise/settings/billing/cost-centers | ConvertFrom-Json).costCenters
$currentCostCenter = $costCenters | ?{ $_.resources | ?{ $_.type -eq "User" -and $_.name -eq $handle } }

# retrieve from Assigned Seats API:
$enterpriseUsers = gh api https://api.github.com/enterprises/$enterprise/consumed-licenses --jq '.users[]' --paginate | ConvertFrom-Json
$currentCostCenter = ($enterpriseUsers | ?{ $_.github_com_login -eq $handle }).github_com_cost_center
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By combining the above logic with some more proprietary magic, we were able to quickly assign all our members to Cost Centers based on their Azure EntraID metadata.&lt;/p&gt;

&lt;p&gt;As you can see in the chart below, when we assigned out cost centers on January 9th, all future costs were no longer associated to the enterprise (in green), but to the respective cost centers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8btwiqcgcmai90dtjrnb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8btwiqcgcmai90dtjrnb.png" alt="GitHub's new Billing - Assigning Cost Centers in Bulk" width="800" height="297"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Costs distributed between Enterprise and a number of Cost Centers as of January 9th.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: It is currently not possible to retroactively assign a resource to a cost center. Otherwise, we'd have assigned all costs as of the 1st of the month.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>github</category>
    </item>
    <item>
      <title>Use inline completions for more answers with GitHub Copilot Free</title>
      <dc:creator>Jesse Houwing</dc:creator>
      <pubDate>Mon, 10 Feb 2025 16:03:31 +0000</pubDate>
      <link>https://dev.to/jessehouwing/use-inline-completions-for-more-answers-with-github-copilot-free-5626</link>
      <guid>https://dev.to/jessehouwing/use-inline-completions-for-more-answers-with-github-copilot-free-5626</guid>
      <description>&lt;p&gt;2 months ago, GitHub gave everybody their taste of GitHub Copilot with the limited free tier. You get 2000 completions and 50 chats.&lt;/p&gt;

&lt;p&gt;This is awesome! But 50 chats aren't that many.&lt;/p&gt;

&lt;p&gt;Did you know you can use your completions for simple chat answers as well? And this is a nice tip for NeoVim users as well who don't have access to Chat in their editor. Inline Suggestions are significantly faster than Chat to boot.&lt;/p&gt;

&lt;p&gt;You can use inline completions to get tips, answer questions and suggest improvements to your code as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Q: Explain the key usage scenarios for the Singleton pattern
// A: The singleton pattern |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In most cases typing &lt;code&gt;// A:&lt;/code&gt; will do the trick, but sometimes you'll need to give Copilot a starting sentence to provide you with an answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Q: Explain the key usage scenarios for the Singleton pattern. A detailed description
// A: The Singleton pattern is used when we want to ensure that a class has only one instance and provide a global point of access to that instance. Some key usage scenarios for the Singleton pattern include:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you type &lt;code&gt;// -&lt;/code&gt; on the next line or open the completions panel (&lt;code&gt;ctrl-enter&lt;/code&gt;), you'll get more details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// 1. Global Access: It provides a single point of access to the instance of the class, allowing other objects to easily access and use its functionality.
// 2. Resource Management: It can be used to manage shared resources, such as database connections, file systems, or configuration settings, ensuring that there is only one instance to avoid conflicts or resource wastage.
// 3. Caching: It can be used to implement a cache that stores frequently used data or objects in memory, reducing the need to recreate or fetch them from external sources.
// 4. Logging: It can be used to create a single logger instance that logs messages from different parts of the application, centralizing the logging functionality.
// 5. Configuration Settings: It can be used to manage application-wide configuration settings, ensuring that changes are reflected consistently across the application.
// 6. State Management: It can be used to maintain a shared state or context across different parts of the application, allowing objects to share and update state information.
// 7. Thread Safety: It can be used to provide a thread-safe way to access and modify shared resources or data, ensuring that concurrent access does
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Suggest test cases
&lt;/h2&gt;

&lt;p&gt;You can also ask questions about the contents of the current file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Suggest additional test cases to add to this test fixture, signatures only:
// |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returns the following suggestions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// public void GetById_WhenFlightDoesNotExist_ReturnsNull()
// public void Add_WhenFlightIsNull_DoesNotAddFlightToRepository()
// public void Add_WhenFlightHasNullFlightNumber_ThrowsException()
// public void Add_WhenFlightHasEmptyFlightNumber_ThrowsException()
// public void Add_WhenFlightHasNullOrigin_ThrowsException()
// public void Add_WhenFlightHasEmptyOrigin_ThrowsException()
// public void Add_WhenFlightHasNullDestination_ThrowsException()
// public void Add_WhenFlightHasEmptyDestination_ThrowsException()
// public void Add_WhenFlightHasDepartureTimeInThePast_ThrowsException()
// public void Add_WhenFlightHasArrivalTimeBeforeDepartureTime_ThrowsException()
// public void Add_WhenFlightHasFuelRangeLessThanZero_ThrowsException()
// public void Add_WhenFlightHasFuelTankLeakTrue_ThrowsException()
// public void Add_WhenFlightHasNullFlightLogSignature_ThrowsException()
// public void Add_WhenFlightHasEmptyFlightLogSignature_ThrowsException()
// public void Add_WhenFlightHasNullAerobaticSequenceSignature_ThrowsException()
// public void Add_WhenFlightHasEmptyAerobaticSequenceSignature_ThrowsException()
// public void Add_WhenFlightHasDuplicateId_ThrowsException()
// public void Add_WhenFlightHasDuplicateFlightNumber_ThrowsException()
// public void Add_WhenFlightHasDuplicateOriginAndDestination_ThrowsException()
// public void Add_WhenFlightHasDuplicateFlightLogSignature_ThrowsException()
// public void Add_WhenFlightHasDuplicateAerobaticSequenceSignature_ThrowsException()
// public void Add_WhenFlightHasDuplicateFlightNumberAndOriginAndDestination_ThrowsException()
// public void Add_WhenFlightHasDuplicateFlightNumberAndFlightLogSignature_ThrowsException()
// public void Add_WhenFlightHasDuplicateFlightNumberAndAerobaticSequenceSignature_ThrowsException()
// public void Add_WhenFlightHasDuplicateFlightNumberAndOriginAndDestinationAndFlightLogSignature_ThrowsException()
// public void Add_WhenFlightHasDuplicateFlightNumberAndOriginAndDestinationAndAerobaticSequenceSignature_ThrowsException()
// public void Add_WhenFlightHasDuplicateFlightNumberAndFlightLogSignatureAndAerobaticSequenceSignature_ThrowsException()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Suggest improvements
&lt;/h2&gt;

&lt;p&gt;You can ask for improvements you can make to the current code file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// To improve the security of this REST API I can:
// |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And GitHub Copilot will reply with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// add an authorization filter to this controller
// and require authentication for all actions
// using the [Authorize] attribute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can tweak the replies by adding &lt;code&gt;// and&lt;/code&gt; or &lt;code&gt;//something else I could do is&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;If you run out of Chat messages, you can continue the conversation using inline suggestions. If you run out of those too, you seem to be getting enough value from GitHub Copilot to warrant a subscription ;).&lt;/p&gt;

&lt;p&gt;This also adds a bit of Chat support to NeoVim, Qt Explorer, XCode and other tools that only support inline suggestions for now.&lt;/p&gt;

</description>
      <category>githubcopilot</category>
      <category>github</category>
      <category>visualstudiocode</category>
    </item>
  </channel>
</rss>
