How to Actually Migrate from GitHub to Codeberg Without Losing Your Mind

So you’ve finally decided to move some repos off GitHub. Maybe it’s the AI training concerns, maybe it’s the principle of using a platform run by a nonprofit, or maybe you just like the idea of Forgejo. Whatever the reason, you’ve landed on Codeberg — and now you’re staring at dozens of repositories wondering how to do this without mass-breaking everything.

I’ve been there. I migrated about 15 repos last month, and honestly? The actual migration is the easy part. It’s everything around the migration that trips you up.

The Real Problem: It’s Not the Git Part

Let’s be clear about something. Moving a git repository from one remote to another is trivial. You’ve probably done it a hundred times:

git remote set-url origin https://codeberg.org/yourname/yourrepo.git
git push --all
git push --tags

That’s it for the code. But your repo isn’t just code, is it? It’s issues, pull requests, CI pipelines, badges in your README, links other people have bookmarked, and contributor workflows that assume GitHub. That’s where the pain lives.

Step 1: Use Codeberg’s Built-In Migration Tool

Codeberg runs on Forgejo (a community fork of Gitea), and it has a surprisingly solid migration feature built right in. Go to the “+” menu in the top right, select “New Migration,” and paste your GitHub repo URL.

Here’s what it can pull over:

  • All branches and tags (obviously)
  • Issues and their comments
  • Labels and milestones
  • Pull requests (as issues, since the branches may not apply cleanly)
  • Releases with attached assets

The important thing is to check all the boxes during migration. I skipped the issues checkbox on my first repo thinking I’d do it manually later. I did not do it manually later. Don’t be me.

# If you prefer the API over the web UI, you can script it:
curl -X POST "https://codeberg.org/api/v1/repos/migrate" 
  -H "Authorization: token YOUR_CODEBERG_TOKEN" 
  -H "Content-Type: application/json" 
  -d '{
    "clone_addr": "https://github.com/yourname/yourrepo.git",
    "repo_name": "yourrepo",
    "repo_owner": "yourname",
    "service": "github",
    "auth_token": "YOUR_GITHUB_TOKEN",
    "mirror": false,
    "issues": true,
    "labels": true,
    "milestones": true,
    "releases": true,
    "pull_requests": true
  }'

The auth_token for GitHub is needed if you’re migrating private repos or want to pull over issues. A fine-grained personal access token with read-only permissions works fine.

Step 2: The Lazy Redirect Strategy

Here’s the thing most migration guides skip: you don’t have to go all-in on day one. The lazy approach is to keep your GitHub repo around as a mirror that redirects people to Codeberg.

Update your GitHub repo description to something like:

⚠️ This repo has moved to https://codeberg.org/yourname/yourrepo

Then update your README:

## This project has moved

This repository is now maintained at
[codeberg.org/yourname/yourrepo](https://codeberg.org/yourname/yourrepo).

This GitHub mirror may be out of date. Please file issues and
contributions on Codeberg.

Archive the GitHub repo (Settings → Danger Zone → Archive) so it becomes read-only. This way existing links don’t 404, stars and forks stay visible, and you’re not maintaining two active copies.

Step 3: Fix Your CI (This Is Where It Actually Hurts)

If you were using GitHub Actions, that’s the one thing that absolutely will not follow you. Codeberg supports Woodpecker CI, which is a community-maintained fork of Drone. The syntax is different but the concepts map pretty directly.

Here’s a GitHub Actions workflow vs the Woodpecker equivalent:

# .woodpecker.yml — equivalent of a basic CI build
steps:
  - name: test
    image: node:20-alpine
    commands:
      - npm ci
      - npm test

  - name: lint
    image: node:20-alpine
    commands:
      - npm ci
      - npm run lint

It’s simpler than GitHub Actions, which is both a blessing and a curse. No marketplace of prebuilt actions, but also no YAML spaghetti with uses: some-random-person/action@v3 and fingers crossed they haven’t been compromised.

You’ll need to enable CI for your repo at ci.codeberg.org. Sign in with your Codeberg account and activate the repo. Woodpecker picks up .woodpecker.yml from your repo root automatically.

Step 4: Batch Migrate Like a Reasonable Person

If you have more than a handful of repos, do NOT migrate them all at once. Seriously. I made a simple script that migrates repos one at a time with a delay, so I could sanity-check each one:

#!/bin/bash
# migrate-repos.sh — migrate a list of repos from GitHub to Codeberg

CODEBERG_TOKEN="your_codeberg_token"
GITHUB_TOKEN="your_github_token"
CODEBERG_USER="yourname"
GITHUB_USER="yourname"

# list your repos, one per line
REPOS=(
  "project-alpha"
  "dotfiles"
  "cool-library"
)

for repo in "${REPOS[@]}"; do
  echo "Migrating $repo..."
  curl -s -X POST "https://codeberg.org/api/v1/repos/migrate" 
    -H "Authorization: token $CODEBERG_TOKEN" 
    -H "Content-Type: application/json" 
    -d "{
      "clone_addr": "https://github.com/$GITHUB_USER/$repo.git",
      "repo_name": "$repo",
      "repo_owner": "$CODEBERG_USER",
      "service": "github",
      "auth_token": "$GITHUB_TOKEN",
      "mirror": false,
      "issues": true,
      "labels": true,
      "releases": true
    }"
  echo "Done. Check https://codeberg.org/$CODEBERG_USER/$repo"
  sleep 5  # be nice to the API
done

I migrated 3-4 repos per day, verified each one, and moved on. Took about a week total for 15 repos but nothing broke.

What You’ll Actually Miss

I want to be honest about the tradeoffs because pretending Codeberg is a perfect replacement helps nobody:

  • GitHub Actions ecosystem — Woodpecker works but you’ll rewrite CI configs and lose access to the massive actions marketplace
  • Discoverability — Nobody is browsing Codeberg to find new projects. If your project depends on drive-by contributors, this matters
  • GitHub Pages — Codeberg has Codeberg Pages, but the setup is different. You’ll need a separate branch or repo
  • Dependency graph / Dependabot — Doesn’t exist on Codeberg. You’ll want to set up Renovate Bot or similar yourself
  • Network effects — Your contributors need Codeberg accounts. Some won’t bother

On the flip side, Codeberg is fast, the Forgejo UI is clean and actively improving, and there’s something genuinely nice about using a platform that’s run by a nonprofit in the EU with no VC money involved.

Prevention: Set Yourself Up So the Next Migration Is Easier

The real lesson from any platform migration is to reduce lock-in going forward:

  • Keep CI config minimal — The less platform-specific magic you use, the easier the next move. Prefer running shell scripts from CI rather than relying on platform-specific action plugins
  • Use generic git features — Issues and PRs are the main lock-in. Consider using a CONTRIBUTING.md that works anywhere
  • Mirror from the start — You can push to multiple remotes trivially. Add this to your git config and you’re always one step ahead:
# Push to both GitHub and Codeberg simultaneously
git remote set-url --add --push origin https://codeberg.org/yourname/repo.git
git remote set-url --add --push origin https://github.com/yourname/repo.git

Now every git push goes to both. When you’re ready to drop GitHub, just remove that URL.

The Bottom Line

Migrating to Codeberg isn’t hard. It’s just tedious if you have a lot of repos with active issues and CI. The lazy strategy — migrate with the built-in tool, redirect GitHub repos, fix CI gradually — works well and doesn’t require a heroic weekend of DevOps.

Start with one or two less-critical repos. Get comfortable with the workflow. Then move the rest at your own pace. The repos aren’t going anywhere.

A Real Time Information Using Andorra’s Government APIs

Duty pharmacies map

For those who don’t know it, Andorra is a tiny country of 80k people located in the Pyrenees. I’ve been living here for 3 years and built a guide for people moving here.

The downside of living here? Snowstorms and traffic jams. Andorra only has two road entrances, so when there’s heavy snow or a holiday weekend, it’s chaos. You need to know which roads are clear, whether snowplows are out, and if there’s an alternative route.

Turns out the Andorran government has quite good public APIs. No keys or auth, so I used them to make my life easier.

Architecture

Built this with Next.js on Vercel. Each API route caches for 3 minutes so I don’t overload the government APIs:

let cache = null;
const CACHE_TTL = 3 * 60 * 1000;

export async function GET() {
  if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
    return NextResponse.json(cache);
  }

  const data = await fetch(`${MOBILITAT_BASE}/cameras`).then(r => r.json());
  cache = { ...data, timestamp: Date.now() };
  return NextResponse.json(cache);
}

For OpenStreetMap data (supermarkets, banks, pharmacies…) the API has aggressive rate limiting, so I scrape 3 categories per run via GitHub Actions and store in Upstash Redis, so I don’t need to pay for a server.

Duty pharmacies map

A small issue is that Pharmacy shifts change at 9:30, not midnight, so if someone checks at 8AM, yesterday’s pharmacy is still displayed for now.

The endpoints

These are the endpoints I used:

Mobilitat (that, is, traffic)

app.mobilitat.ad/api/v1/ gives you:

  • GET /cameras: Dozens oflive traffic cameras (animated GIFs!)
  • GET /incidents: Active road incidents
  • GET /llevaneus: Snowplows with real-time GPS + speed
  • GET /nevadesactive: Snow alert active? true/false

The snowplow endpoint returns the exact position, heading and speed of every active snowplow:

{
  "latitude": 42.5436866,
  "longitude": 1.733995,
  "heading": 22,
  "speed": 19
}

So now I can check if the snowplows are out before leaving home in the morning, since I live at 2000 meters and some days the road is impassable.

The camera endpoints return GIFs and are quite heavy.

Traffic cameras

Fuel Prices

All 60 gas stations with official prices. Gas here is cheap (1.53 EUR/L) because Andorra’s VAT is only 4.5%:

{
  "name": "BP Whatever",
  "parish": "Canillo",
  "fuels": [{ "fuel_name": "Gas 95", "price": 1.535 }]
}

Ski Weather

Free, no API key. You need to pass the resort’s elevation to get accurate mountain weather:

GET https://api.open-meteo.com/v1/forecast
  ?latitude=42.57&longitude=1.66&elevation=1800
  &current=temperature_2m,snow_depth,wind_speed_10m

The tools

These are the tools I built:

Thanks for reading!

How I trained a computer vision model on the AWS Free Tier

App detecting 4 Easter eggs in an astronaut scene, with bounding boxes and confidence scores from 52% to 66.8%

Last updated: March 26, 2026

Developers hide Easter eggs in code. Filmmakers hide them in scenes. I hid them in AI-generated images and asked Amazon Rekognition to find them.

If you’ve ever wondered whether AWS Free Tier gives you enough credits to build something cool, this post is for you. I built an Easter egg detector powered by Amazon Rekognition, tested its standard object detection API on 37 images, and trained a custom model, all with my AWS Free Tier credits.

(If you haven’t set up a Free Tier account, check out this great guide!)

The idea

I wanted to build a simple and fun app where I can upload a photo and have the app tell me if there’s an Easter egg in it. It sounds kind of silly, but the technical problem it maps to — locating a small, specific object in a complex scene — is a foundational one for the field of computer vision.

AWS has a lot of services for different AI/ML use cases, including Amazon Rekognition for computer vision. I learned that organizations like C-SPAN and the NFL use it to understand what’s in their images and video. And Amazon Rekognition is available on the AWS Free Tier, which makes experimenting with it easier.

The service gives you different ways to figure out what’s in images. The DetectLabels API works out of the box and recognizes thousands of common objects and concepts, everything from aircraft to zucchini to birthday parties. Custom Labels let you train your own computer vision model when the built-in labels fall short. With this project, I wanted to find out where the first one stops being useful and the other is needed.

What I built

The app I built to explore that question is a Streamlit app with two modes. Standard mode sends your image to the DetectLabels API and checks if it returns “Egg” or “Easter Egg” in the labels. Custom Labels mode uses a custom model I trained on my own images. Both draw bounding boxes around any eggs they find.

App detecting 4 Easter eggs in an astronaut scene, with bounding boxes and confidence scores from 52% to 66.8%

The application code is about 200 lines of Python, 100 lines of which is UI-related, and you can check it out at this GitHub repo. You upload an image, the app calls Rekognition using the AWS SDK for Python (Boto3), checks the response, and draws some boxes around any eggs that it finds.

Here’s an example of code that handles regular DetectLabels requests.

response = rekognition.detect_labels(
    Image={"Bytes": image_bytes},
    MaxLabels=30,
    MinConfidence=30.0,
)

Generating test images

I needed images with scenes where Easter eggs would be unexpected. I used an image generator to create 19 images: a messy desk with an egg tucked next to the monitor, a toy box with a painted Easter egg between two teddy bears, a picnic spread with a couple eggs in between the sandwiches.

AI-generated picnic scene with colorful Easter eggs hidden among sandwiches, chips, and lemonade on a blanket

Then I generated 18 images with no eggs at all, but similar to the other scenes. That gave me a relatively balanced dataset to start with.

Test image: sunny picnic scene with sandwiches, chips, and a straw hat but no hidden Easter eggs

Round 1: DetectLabels

I wrote a batch test script (also in the GitHub repo) that ran every image I had through DetectLabels and checked whether Rekognition found an egg.

The results at a 30% confidence threshold:

Metric Score
Accuracy 75.70%
Precision 100%
Recall 52.60%

100% precision means it never hallucinated an egg where there wasn’t one in the no-egg images. But 52.6% recall means it missed about half the eggs in the egg images. Imagine you’re doing an Easter egg hunt, and there are 19 eggs hidden in the yard. A recall of 52.6% means you only found 10 of them.

At a stricter 70% confidence threshold, recall dropped to 36.8%, so you’d only find about 7 out of 19.

I added logging to see what objects Rekognition detected for the missed eggs. The logged objects were pretty accurate, which told me that Rekognition identified the gist of the images well but couldn’t “see” the Easter eggs.

I realized the out-of-the-box API was great for detecting Easter eggs in more typical scenes, like this one.

Colorful painted Easter eggs nestled in green grass on a sunny day

Script output:

  ✅ 1774389521556_img.png eggs: Egg (99.9%), Easter Egg (99.1%)

DetectLabels had a harder time finding Easter eggs in less obvious scenarios or with more competing objects, like this one.

A painted Easter egg hidden among teddy bears, crayons, and building blocks in a toy box

Script output:

❌ MISS 1774389716587_img.png eggs: none
↳ saw instead: Toy (98.7%), Teddy Bear (93.5%), Lego Set (69.8%), 
   Plush (53.7%), Doll (48.1%), Play Area (48.0%), Handicraft (47.7%), 
   Hedgehog (46.9%)

Round 2: Custom Labels

Custom Labels exist for situations where you need to detect specific objects in specific scenes that go beyond the capabilities of DetectLabels, like my Easter egg images. With Custom Labels, you can train a computer vision model on your own images. The model can learn to spot what you care about, even when the object is small, partially hidden, or surrounded by lots of visual noise. The benefit here is that rather than training a computer vision model from scratch on tens of thousands of images, you can build on existing capabilities by just uploading a relatively small set of training images. Then, Rekognition handles the model training and evaluation process for you.

I did this all directly in the Amazon Rekognition console, where I created a new project and uploaded my dataset of images. Rekognition automatically split my data 80/20 for training and testing.

Amazon Rekognition Custom Labels console showing the dataset page with 37 uploaded training images

I created my custom label of “Easter egg” and drew bounding boxes around the Easter eggs in my dataset images, then hit Train.

Custom Labels console labeling view with bounding boxes drawn around Easter eggs on a cluttered desk scene

Training took around 30 minutes. When it finished, I got a model ARN and performance metrics. Here’s how the custom model did on the test dataset compared to the standard DetectLabels API:

Metric DetectLabels (30% threshold) Custom Labels
Precision 100% 77.80%
Recall 52.60% 75%
F1 score (not calculated) 0.764

Recall jumped from 52.6% to 75%. This meant that the custom model was finding eggs that DetectLabels missed entirely. Precision dropped from 100% to 77.8% (more false positives), meaning it occasionally saw an egg where there wasn’t one, but I think that’s a reasonable trade-off for catching 22% more of the eggs.

The F1 score is a metric that shows how well the model balances precision and recall, indicating its overall accuracy. A score of 0.764 from a dataset of 37 images is a good starting point.

Going back to the Easter egg hunt analogy, DetectLabels was the careful egg hunter who only picked up real eggs but walked past half of them. The custom label found most of the eggs but also grabbed a few eggish-looking rocks along the way in the test dataset.

Considering the fact that I only had 37 total images in the dataset, these are promising results, but there’s definitely room to improve. I’d expect the model’s performance to climb if I added more training data. But this experiment was for learning and my own curiosity, and that F1 score worked for me. I plugged my custom model’s ARN into my Streamlit app, and uploaded a few images. The custom model did a pretty good job of detecting Easter eggs on new images that weren’t part of the training dataset.

Egg or Not app in Custom Labels mode detecting one Easter egg in a grocery store aisle at 75% confidence

It also was pretty good about not registering false positives, even with temptingly bright round objects in the image.

Egg or Not app correctly reporting no eggs found in a grocery store produce aisle image

Cost

Everything in this project fits comfortably within the AWS Free Tier credits for new accounts. Here are some more details on the pricing for training and inferencing with a custom model in the us-east-1 region:

DetectLabels (Group 2 API)

  • 74 images × $0.001/image = $0.074
    • I had 37 images, but I ran the batch analysis script twice (first checking for egg presence, then again to log the labels that were found for false negatives).

Custom Labels training:

  • 0.52 hours × $1/hour = $0.52
    • The docs note that Amazon Rekognition may run multiple compute resources in parallel, so actual billed hours can be higher than elapsed time for model training.

Custom Labels inferencing:

  • 1.41 hours x $4/hour = $5.71

If you build this in your own account, be sure to STOP the model when you’re done inferencing to avoid accumulating further charges. In total for this project, I was billed for 85.7 inference minutes and 31 training minutes, which amounted to $6.24. This amount was deducted from my Free Tier credits. (The charges for other services in the following cost breakdown image are related to other Free Tier projects.)

AWS Cost Explorer showing March 2026 breakdown: Rekognition $6.24, EC2 $0.27, VPC $0.13, S3 $0.01

What I learned, and what I’d do differently

The jump from DetectLabels to Custom Labels was the most interesting part of this project for me. With Custom Labels, you get to label your own data, train a model, evaluate its performance, and iterate. That loop (data → train → evaluate → improve) is the same loop behind every ML system. Custom Labels just makes it accessible without needing to have ML experience or having to spin up your own dedicated hardware.

I also came away with a better sense of when to use what. You could send an image to one of the newer multimodal foundation models and ask, “Are there any Easter eggs in this picture?” It would probably give you a very good answer. But for a tightly-scoped detection task like this one, I think a purpose-built service is a better fit. A single API call returns predictable JSON with labels, confidence scores, and bounding box coordinates. No system prompt to write, no natural language to parse. And the whole project cost me $6.24 in Free Tier credits.

A couple things I’d change if I were starting over:

  • Create more images for the dataset. The docs say that most custom models need a few hundred images or less, but I only had 37. I’d aim for 50+ per class (Easter egg and no Easter egg). I’d also set up a script to generate text for image prompts and call an image generating API to quickly create a bunch of training and test images.
  • Test with real photos, not just AI-generated ones. Generated images have a certain look to them. Real phone photos with unexpected lighting and odd angles would be a tougher test.

Try it yourself

The full code and installation directions are on GitHub here.

If you want to skip the training and just play with DetectLabels, the standard mode works immediately. Upload a photo of your breakfast and see what happens. 🍳

If you want more ideas for Free Tier projects, let me know in the comments!

Building a Media Monitoring Tool: Mentions Across News Sites

Media monitoring, tracking brand mentions, competitor coverage, and industry news, is a service companies pay thousands for monthly. Here’s how to build your own with Python and web scraping.

What We’ll Build

A tool that:

  • Monitors multiple news sources for keyword mentions
  • Extracts article metadata (title, date, author, snippet)
  • Deduplicates across sources
  • Sends alerts for new mentions

Setup

pip install requests beautifulsoup4 pandas feedparser newspaper3k

RSS Feed Monitor

The fastest approach starts with RSS feeds, as many news sites still publish them:

import feedparser
from datetime import datetime

class RSSMonitor:
    def __init__(self, keywords):
        self.keywords = [k.lower() for k in keywords]
        self.seen_urls = set()

    def check_feed(self, feed_url, source_name):
        """Check an RSS feed for keyword mentions."""
        feed = feedparser.parse(feed_url)
        mentions = []

        for entry in feed.entries:
            title = entry.get("title", "")
            summary = entry.get("summary", "")
            link = entry.get("link", "")

            if link in self.seen_urls:
                continue

            text = f"{title} {summary}".lower()
            matched = [k for k in self.keywords if k in text]

            if matched:
                self.seen_urls.add(link)
                mentions.append({
                    "source": source_name,
                    "title": title,
                    "url": link,
                    "summary": summary[:300],
                    "keywords_matched": matched,
                    "published": entry.get("published", ""),
                    "found_at": datetime.now().isoformat()
                })

        return mentions

Web Scraping for Sites Without RSS

import requests
from bs4 import BeautifulSoup

SCRAPER_API_KEY = "YOUR_KEY"

class WebMonitor:
    def __init__(self, keywords):
        self.keywords = [k.lower() for k in keywords]
        self.seen_urls = set()

    def scrape_news_site(self, base_url, source_name, article_selector, title_selector):
        """Scrape a news site for articles matching keywords."""
        api_url = (
            f"http://api.scraperapi.com?api_key={SCRAPER_API_KEY}"
            f"&url={base_url}"
        )
        response = requests.get(api_url, timeout=30)
        soup = BeautifulSoup(response.text, "html.parser")

        mentions = []
        for article in soup.select(article_selector):
            title_el = article.select_one(title_selector)
            link_el = article.select_one("a[href]")

            if not title_el or not link_el:
                continue

            title = title_el.text.strip()
            url = link_el["href"]
            if not url.startswith("http"):
                url = base_url.rstrip("/") + url

            if url in self.seen_urls:
                continue

            matched = [k for k in self.keywords if k in title.lower()]

            if matched:
                self.seen_urls.add(url)
                mentions.append({
                    "source": source_name,
                    "title": title,
                    "url": url,
                    "keywords_matched": matched,
                    "found_at": datetime.now().isoformat()
                })

        return mentions

Full Article Extraction

from newspaper import Article

def extract_full_article(url):
    """Extract full article text and metadata."""
    try:
        article = Article(url)
        article.download()
        article.parse()

        return {
            "title": article.title,
            "authors": article.authors,
            "publish_date": str(article.publish_date) if article.publish_date else "",
            "text": article.text,
            "top_image": article.top_image,
            "word_count": len(article.text.split())
        }
    except Exception as e:
        return {"error": str(e)}

The Monitoring Pipeline

import time
import json

NEWS_FEEDS = [
    ("TechCrunch", "https://techcrunch.com/feed/"),
    ("The Verge", "https://www.theverge.com/rss/index.xml"),
    ("Ars Technica", "https://feeds.arstechnica.com/arstechnica/index"),
]

NEWS_SITES = [
    ("Reuters Tech", "https://www.reuters.com/technology/", "article", "h3"),
]

def run_monitoring(keywords, interval_minutes=30):
    rss = RSSMonitor(keywords)
    web = WebMonitor(keywords)
    all_mentions = []

    while True:
        print(f"n[{datetime.now()}] Checking sources...")

        for name, url in NEWS_FEEDS:
            mentions = rss.check_feed(url, name)
            all_mentions.extend(mentions)
            for m in mentions:
                print(f"  NEW: [{m['source']}] {m['title']}")

        for name, url, art_sel, title_sel in NEWS_SITES:
            try:
                mentions = web.scrape_news_site(url, name, art_sel, title_sel)
                all_mentions.extend(mentions)
                for m in mentions:
                    print(f"  NEW: [{m['source']}] {m['title']}")
            except Exception as e:
                print(f"  Error with {name}: {e}")
            time.sleep(3)

        with open("mentions.json", "w") as f:
            json.dump(all_mentions, f, indent=2)

        print(f"Total mentions: {len(all_mentions)}")
        time.sleep(interval_minutes * 60)

# Run it
run_monitoring(["openai", "anthropic", "google ai", "llm"])

Scaling Considerations

For production media monitoring, ScraperAPI handles proxy rotation across news sites reliably. ThorData residential proxies help with geo-restricted content. Track your scraper health metrics with ScrapeOps.

Conclusion

A custom media monitoring tool gives you real-time awareness without expensive SaaS subscriptions. Start with RSS feeds for speed, add web scraping for coverage, and build alerts from there.

AI Weekly Digest: Mar 20-27, 2026 — TurboQuant Shakes Memory Markets, MCP Hits 97M, Manus Founders Barred from Leaving China

This Week in AI (March 20–27, 2026)

A week of infrastructure-shaking research, geopolitical drama, and record-breaking funding. Here are the stories that matter.

🔬 Research & Breakthroughs

Google TurboQuant — The Compression Breakthrough That Crashed Memory Stocks

Google unveiled TurboQuant (to be presented at ICLR 2026), a compression algorithm that reduces LLM memory requirements by 6x and achieves up to 8x faster attention by quantizing key-value caches to just 3 bits — with zero accuracy loss.

The announcement immediately impacted markets: Micron, Samsung, and Western Digital stock prices dropped as investors reassessed demand forecasts for AI memory chips. If models need 6x less memory, do we really need all those HBM fabs?

Think of it as the “Pied Piper moment” for AI infrastructure — suddenly, the bottleneck shifted.

“Societies of Thought” — Published in Science

A fascinating paper published in Science revealed that when reinforcement learning rewards models solely for reasoning accuracy, they spontaneously develop multi-perspective, conversational reasoning behaviors.

The implication: robust reasoning doesn’t need to be explicitly programmed — it emerges as a social process through optimization pressure alone. Models essentially learn to debate with themselves.

🛠 Infrastructure & Protocols

MCP Crosses 97 Million Installs

The Model Context Protocol (MCP) hit 97 million monthly SDK downloads (Python + TypeScript combined), now adopted by every major AI provider: Anthropic, OpenAI, Google, Microsoft, Amazon, xAI, Mistral, and Cohere.

What started as Anthropic’s experimental protocol has become the de facto standard for AI tool integration. The transition from “interesting experiment” to “foundational infrastructure” happened faster than anyone predicted.

💰 Industry & Funding

Harvey AI Raises $200M at $11B Valuation

Legal AI startup Harvey raised $200 million co-led by GIC and Sequoia, reaching an $11 billion valuation — nearly quadrupling from $3B just one year ago. Over 100,000 lawyers across 1,300 organizations now use the platform, with ARR at $190 million.

The legal industry, once considered slow to adopt technology, is now one of AI’s fastest-growing verticals.

AI Startups Now Consume 41% of All Venture Capital

AI startups accounted for 41% of the $128 billion in venture dollars raised on Carta in the past year — a record-high annual share. March 2026 alone has already produced more $100M+ AI funding rounds than any comparable period in venture history.

The question is no longer “should we invest in AI?” but “is there anything left to invest in that isn’t AI?”

🔧 Chips & Hardware

Huawei Ascend 950PR — China’s Answer to NVIDIA

Huawei launched the Ascend 950PR chip with the Atlas 350 accelerator card, claiming 2.8x the compute power of NVIDIA’s H20. The chip features:

  • Huawei-designed HBM (in-house, not Samsung/SK Hynix)
  • 128GB memory
  • 1.6 TB/s bandwidth

This is significant: Huawei is building a completely self-sufficient AI chip stack, from design to memory, independent of US-allied supply chains.

Samsung HBM4E Unveiled at GTC 2026

Samsung unveiled its seventh-generation HBM4E memory at NVIDIA GTC:

  • 4.0 TB/s bandwidth at 16 Gbps per pin
  • HBM4 already shipping since February
  • Pledging to triple HBM production capacity

The HBM arms race between Samsung and SK Hynix continues to intensify, with SK Hynix holding roughly two-thirds of NVIDIA’s 2026 HBM4 allocation.

🌏 China & Geopolitics

DeepSeek Pivots to Agentic AI

DeepSeek V4 remains unreleased despite months of anticipation, but the company is clearly shifting strategy. A cluster of new job openings for agentic AI specialists signals a pivot toward autonomous task-execution technology.

The question: is V4 delayed because they’re rethinking the entire approach, or because Huawei Ascend optimization is harder than expected?

Manus AI Founders Barred from Leaving China

In one of the most dramatic stories of the week, Chinese authorities barred Manus AI co-founders Xiao Hong and Ji Yichao from leaving the country amid a review of Meta’s $2 billion acquisition.

Regulators are examining whether core AI agent IP was transferred to Singapore without required government approvals. This case could set a precedent for how China handles AI company acquisitions by US tech giants.

ByteDance Leads China’s AI Race

ByteDance’s dominance in China’s AI landscape is staggering:

  • Doubao chatbot: 227 million monthly active users
  • 2026 AI budget: 160 billion yuan ($23 billion)
  • In talks with Samsung to manufacture 100,000 custom AI chips

For context, ByteDance’s AI budget alone exceeds the GDP of many small countries.

📜 Policy & Regulation

White House National AI Policy Framework

The White House released a comprehensive National Policy Framework for Artificial Intelligence with seven pillars:

  1. Child protection
  2. Community safety
  3. Intellectual property
  4. Free speech
  5. Innovation
  6. Workforce development
  7. Federal preemption of state laws

Key takeaways:

  • No new federal AI regulatory body — existing sector-specific regulators will handle AI
  • Limits developer liability for third-party misuse
  • Preempts state-level AI laws — significant given that 78 AI bills are active across 27 US states

This is the clearest signal yet of the current administration’s light-touch regulatory philosophy for AI.

📊 Week at a Glance

Category Headline Impact
Research Google TurboQuant 6x memory reduction, memory stocks drop
Research Societies of Thought Reasoning emerges as social process
Infrastructure MCP hits 97M installs Protocol becomes industry standard
Funding Harvey AI $200M raise Legal AI valued at $11B
Funding AI = 41% of all VC Record concentration of capital
Hardware Huawei Ascend 950PR 2.8x H20, fully self-sufficient stack
Hardware Samsung HBM4E 4.0 TB/s, tripling production
China DeepSeek → Agentic AI V4 delayed, strategy shift
China Manus founders detained Meta $2B deal under review
China ByteDance dominance $23B AI budget, 227M MAU
Policy White House AI Framework Light-touch, no new regulator

What caught your eye this week? Drop a comment below.

🤖 This digest is compiled using AI-assisted research and human editorial judgment. Published weekly every Friday.

From 326K Chars to 127K: Real Benchmark Results

Abstract percentages are easy to doubt. Here are concrete numbers from real projects.

Benchmark: Next.js E-Commerce Project

Command Before After Saved
npm install 326,421 127,104 61%
npm run build 48,291 12,847 73%
npm test 89,204 4,521 95%
npm run lint 24,891 3,204 87%
TypeScript error (40 files) 12,847 412 97%
Total session 501,654 148,088 70%

One development session. 353,566 characters of noise removed. That’s roughly 88,000 tokens saved.

Benchmark: Python Django API

Command Before After Saved
pip install -r requirements.txt 45,891 2,104 95%
Django traceback (3 errors) 8,421 891 89%
python manage.py test 34,204 8,921 74%
python manage.py migrate 12,104 3,412 72%
Total session 100,620 15,328 85%

Benchmark: Rust Microservice

Command Before After Saved
cargo build 18,291 7,842 57%
Panic backtrace (tokio) 4,521 891 80%
cargo test 12,847 3,204 75%
cargo clippy 8,204 2,891 65%
Total session 43,863 14,828 66%

The Takeaway

Python/Django sessions save the most (85%) because pip and Django produce verbose output. Next.js/npm sessions save 70% because of deprecation warnings and test runner output. Rust sessions save 66% — Rust tools are already relatively clean.

Across all three: hundreds of thousands of characters saved per session. That’s context your AI can use for your actual code.

cargo install contextzip
eval "$(contextzip init)"

GitHub: github.com/contextzip/contextzip

Part of the ContextZip Daily series. Follow for daily tips on optimizing your AI coding workflow.

Install: npx contextzip | GitHub: jee599/contextzip

Real-time React: Syncing State Across Browser Tabs

Real-time React: Syncing State Across Browser Tabs

Your user logs out in one tab. In another tab, they are still browsing authenticated content. They change the theme to dark mode, but the other three tabs stay light. They add an item to their cart, switch tabs, and the cart count shows zero. These are not edge cases — they are everyday realities of multi-tab browsing, and most React applications handle them poorly or not at all.

Browsers do not share React state between tabs by default. Each tab runs its own JavaScript context with its own component tree, its own state, and its own memory. Yet users expect a seamless experience. When something changes in one tab, they expect every tab to reflect that change immediately.

In this article, we will explore the browser APIs that make cross-tab communication possible, look at the manual approach and its pitfalls, and then see how hooks from ReactUse reduce all of that complexity to a few lines of code.

Two Browser APIs for Cross-Tab Communication

Before reaching for any library, it helps to understand what the browser gives us natively.

BroadcastChannel API

The BroadcastChannel API lets you send messages between browsing contexts — tabs, windows, iframes — that share the same origin. You create a channel by name, and any context that opens a channel with the same name can send and receive messages.

// Tab A
const channel = new BroadcastChannel("my-app");
channel.postMessage({ type: "LOGOUT" });

// Tab B
const channel = new BroadcastChannel("my-app");
channel.onmessage = (event) => {
  if (event.data.type === "LOGOUT") {
    // redirect to login
  }
};

BroadcastChannel is fast, supports structured cloning (so you can send objects, arrays, and even ArrayBuffer), and does not touch persistent storage. It is purely in-memory messaging between contexts. The downside is that messages are fire-and-forget — if a tab is not open when the message is sent, it never receives it.

Storage Events

When one tab writes to localStorage, every other tab on the same origin receives a storage event. This gives you cross-tab reactivity for free — but only for string-serializable data, and only through localStorage (not sessionStorage, which is scoped to a single tab).

// Tab A writes
localStorage.setItem("theme", "dark");

// Tab B listens
window.addEventListener("storage", (event) => {
  if (event.key === "theme") {
    console.log("Theme changed to:", event.newValue); // "dark"
  }
});

Storage events have a major advantage: the data persists. If a user opens a new tab after the change was made, the new tab reads the current value from localStorage on mount. You get both reactivity and persistence.

The Manual Approach — And Why It Gets Messy

Let us try to build cross-tab theme syncing from scratch. We need to:

  1. Read the initial value from localStorage.
  2. Parse it (everything in localStorage is a string).
  3. Set up a storage event listener to detect changes from other tabs.
  4. Serialize and write back when the local tab changes the value.
  5. Clean up the listener on unmount.
import { useState, useEffect, useCallback } from "react";

function useCrossTabTheme() {
  const [theme, setThemeState] = useState<"light" | "dark">(() => {
    try {
      const stored = localStorage.getItem("app-theme");
      return stored === "dark" ? "dark" : "light";
    } catch {
      return "light";
    }
  });

  // Listen for changes from other tabs
  useEffect(() => {
    const handler = (event: StorageEvent) => {
      if (event.key === "app-theme" && event.newValue) {
        setThemeState(event.newValue as "light" | "dark");
      }
    };
    window.addEventListener("storage", handler);
    return () => window.removeEventListener("storage", handler);
  }, []);

  // Write to localStorage when local state changes
  const setTheme = useCallback((value: "light" | "dark") => {
    setThemeState(value);
    try {
      localStorage.setItem("app-theme", value);
    } catch {
      // storage full or unavailable
    }
  }, []);

  return [theme, setTheme] as const;
}

That is about 30 lines for a single string value. Now imagine doing this for auth tokens, user preferences, cart state, and notification counts. Each one needs its own serialization logic, error handling, and cleanup. And we have not even touched BroadcastChannel yet — if we want to send structured messages (not just key-value strings), we need a second communication layer with its own setup and teardown.

This is where well-designed hooks eliminate boilerplate without hiding the underlying concepts.

useBroadcastChannel: Type-Safe Messaging Between Tabs

The useBroadcastChannel hook from ReactUse wraps the BroadcastChannel API in a clean, declarative interface. It handles channel creation, message listening, cleanup on unmount, and even SSR safety — all in a single call.

import { useBroadcastChannel } from "@reactuses/core";

function NotificationSync() {
  const { data, post, error } = useBroadcastChannel<{
    type: string;
    payload?: unknown;
  }>("my-app-notifications");

  // Send a message to all other tabs
  const broadcastLogout = () => {
    post({ type: "LOGOUT" });
  };

  // React to messages from other tabs
  useEffect(() => {
    if (data?.type === "LOGOUT") {
      // Clear local auth state and redirect
      authStore.clear();
      window.location.href = "/login";
    }
  }, [data]);

  return <button onClick={broadcastLogout}>Log out everywhere</button>;
}

The generic type parameter gives you full TypeScript safety for the message shape. No manual serialization — BroadcastChannel uses structured cloning natively. No cleanup code — the hook closes the channel when the component unmounts. And the error value lets you handle the rare case where BroadcastChannel is not supported.

useLocalStorage: Automatic Cross-Tab Sync

For state that should persist and sync across tabs, useLocalStorage is the right tool. It works like useState, but the value is backed by localStorage and automatically stays in sync across all tabs via storage events.

import { useLocalStorage } from "@reactuses/core";

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">(
    "app-theme",
    "light"
  );

  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      Current: {theme}
    </button>
  );
}

When setTheme is called in one tab, every other tab running this hook with the same key ("app-theme") updates automatically. The hook handles JSON serialization, initial value fallback, SSR guards, and storage event subscription internally. You write one line of hook usage; the hook writes thirty lines of browser API code for you.

Contrast this with useSessionStorage, which provides the same API but scopes the value to the current tab. Session storage does not fire cross-tab events and does not persist after the tab closes. Choose useLocalStorage when you want cross-tab sync; choose useSessionStorage when you want tab-isolated persistence.

Practical Patterns

Pattern 1: Syncing Auth State (Logout Everywhere)

One of the most critical cross-tab scenarios is authentication. When a user logs out in one tab, every other tab must react immediately — otherwise they might continue making authenticated requests that fail silently or expose stale data.

import { useBroadcastChannel, useLocalStorage } from "@reactuses/core";

function useAuth() {
  const [token, setToken] = useLocalStorage<string | null>("auth-token", null);
  const { data, post } = useBroadcastChannel<{ type: "LOGOUT" | "LOGIN" }>(
    "auth-channel"
  );

  // Handle messages from other tabs
  useEffect(() => {
    if (data?.type === "LOGOUT") {
      setToken(null);
      window.location.href = "/login";
    }
  }, [data, setToken]);

  const login = (newToken: string) => {
    setToken(newToken);
    post({ type: "LOGIN" });
  };

  const logout = () => {
    setToken(null);
    post({ type: "LOGOUT" });
    window.location.href = "/login";
  };

  return { token, login, logout, isAuthenticated: token !== null };
}

This uses both hooks together: useLocalStorage persists the token and syncs it across tabs, while useBroadcastChannel sends an immediate imperative signal that triggers the redirect. The token sync via localStorage ensures any tab opened after the logout reads null. The broadcast ensures tabs open during the logout react instantly.

Pattern 2: Syncing Theme Across Tabs

import { useLocalStorage } from "@reactuses/core";
import { useEffect } from "react";

function useThemeSync() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">(
    "app-theme",
    "light"
  );

  useEffect(() => {
    document.documentElement.setAttribute("data-theme", theme ?? "light");
  }, [theme]);

  return { theme: theme ?? "light", setTheme };
}

Because useLocalStorage already handles cross-tab sync, the useEffect fires in every tab whenever the theme changes — keeping the DOM attribute in sync everywhere.

Pattern 3: Cart State in E-Commerce

Shopping cart data is a classic candidate for cross-tab sync. Users often browse products in multiple tabs and expect the cart to be consistent.

import { useLocalStorage } from "@reactuses/core";

interface CartItem {
  id: string;
  name: string;
  quantity: number;
  price: number;
}

function useCart() {
  const [items, setItems] = useLocalStorage<CartItem[]>("cart-items", []);

  const addItem = (item: Omit<CartItem, "quantity">) => {
    setItems((prev) => {
      const current = prev ?? [];
      const existing = current.find((i) => i.id === item.id);
      if (existing) {
        return current.map((i) =>
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        );
      }
      return [...current, { ...item, quantity: 1 }];
    });
  };

  const removeItem = (id: string) => {
    setItems((prev) => (prev ?? []).filter((i) => i.id !== id));
  };

  const totalItems = (items ?? []).reduce((sum, i) => sum + i.quantity, 0);

  return { items: items ?? [], addItem, removeItem, totalItems };
}

Add an item in Tab A, and the cart badge updates in Tab B instantly. No WebSocket, no polling, no server round-trip.

Pattern 4: Leader Election

Sometimes you want only one tab to perform a task — polling an API, maintaining a WebSocket connection, or running a background sync. The useBroadcastChannel hook provides the messaging layer for a simple leader election protocol.

import { useBroadcastChannel } from "@reactuses/core";
import { useState, useEffect, useRef } from "react";

function useLeaderElection(channelName: string) {
  const [isLeader, setIsLeader] = useState(false);
  const idRef = useRef(Math.random().toString(36).slice(2));
  const { data, post } = useBroadcastChannel<{
    type: "CLAIM" | "HEARTBEAT" | "RELEASE";
    id: string;
  }>(channelName);

  useEffect(() => {
    // On mount, try to claim leadership
    post({ type: "CLAIM", id: idRef.current });
    const timer = setTimeout(() => setIsLeader(true), 200);

    return () => {
      clearTimeout(timer);
      post({ type: "RELEASE", id: idRef.current });
    };
  }, [post]);

  useEffect(() => {
    if (data?.type === "CLAIM" && data.id !== idRef.current) {
      // Another tab is claiming — compare IDs to break ties
      if (data.id > idRef.current) {
        setIsLeader(false);
      }
    }
  }, [data]);

  return isLeader;
}

Only the leader tab runs expensive operations. When it closes, it broadcasts a RELEASE message and another tab claims leadership.

Optimizing Background Tabs

Cross-tab sync is only part of the picture. When a tab is in the background, you often want to pause expensive work — polling APIs, running animations, or processing data. Two hooks from ReactUse make this straightforward.

useDocumentVisibility

useDocumentVisibility returns the current visibility state of the document — "visible" or "hidden". Use it to pause work when the tab is not visible.

import { useDocumentVisibility } from "@reactuses/core";
import { useEffect, useState } from "react";

function usePolling(url: string, intervalMs: number) {
  const visibility = useDocumentVisibility();
  const [data, setData] = useState(null);

  useEffect(() => {
    if (visibility === "hidden") return; // stop polling in background

    const fetchData = async () => {
      const res = await fetch(url);
      setData(await res.json());
    };

    fetchData();
    const id = setInterval(fetchData, intervalMs);
    return () => clearInterval(id);
  }, [url, intervalMs, visibility]);

  return data;
}

When the user switches away from the tab, the interval is cleared. When they switch back, a fresh interval starts. No wasted network requests while the tab is hidden.

useWindowFocus

useWindowFocus tracks whether the browser window itself has focus. This is subtler than visibility — a tab can be visible but unfocused (for example, when the user is interacting with DevTools or another window overlapping the browser).

import { useWindowFocus } from "@reactuses/core";

function FocusIndicator() {
  const focused = useWindowFocus();

  return (
    <div>
      {focused
        ? "You are viewing this tab"
        : "Welcome back when you return!"}
    </div>
  );
}

Combine useDocumentVisibility and useWindowFocus for fine-grained control: pause non-critical work when the tab is hidden, and throttle less-critical work when the tab is visible but unfocused.

Combining Hooks: A Cross-Tab Notification System

Let us put it all together. Here is a notification system that broadcasts alerts across tabs, persists unread counts in localStorage, and pauses updates when the tab is hidden.

import {
  useBroadcastChannel,
  useLocalStorage,
  useDocumentVisibility,
  useOnline,
} from "@reactuses/core";
import { useEffect, useCallback } from "react";

interface Notification {
  id: string;
  title: string;
  body: string;
  timestamp: number;
}

function useNotificationSync() {
  const [notifications, setNotifications] = useLocalStorage<Notification[]>(
    "app-notifications",
    []
  );
  const [unreadCount, setUnreadCount] = useLocalStorage<number>(
    "unread-count",
    0
  );
  const { data, post } = useBroadcastChannel<{
    type: "NEW_NOTIFICATION" | "MARK_READ" | "CLEAR_ALL";
    notification?: Notification;
  }>("notification-channel");
  const visibility = useDocumentVisibility();
  const isOnline = useOnline();

  // Handle messages from other tabs
  useEffect(() => {
    if (!data) return;

    switch (data.type) {
      case "NEW_NOTIFICATION":
        if (data.notification) {
          setNotifications((prev) => [data.notification!, ...(prev ?? [])]);
          setUnreadCount((prev) => (prev ?? 0) + 1);
        }
        break;
      case "MARK_READ":
        setUnreadCount(0);
        break;
      case "CLEAR_ALL":
        setNotifications([]);
        setUnreadCount(0);
        break;
    }
  }, [data, setNotifications, setUnreadCount]);

  const addNotification = useCallback(
    (title: string, body: string) => {
      const notification: Notification = {
        id: crypto.randomUUID(),
        title,
        body,
        timestamp: Date.now(),
      };
      setNotifications((prev) => [notification, ...(prev ?? [])]);
      setUnreadCount((prev) => (prev ?? 0) + 1);
      post({ type: "NEW_NOTIFICATION", notification });
    },
    [post, setNotifications, setUnreadCount]
  );

  const markAllRead = useCallback(() => {
    setUnreadCount(0);
    post({ type: "MARK_READ" });
  }, [post, setUnreadCount]);

  // Auto-mark as read when tab becomes visible
  useEffect(() => {
    if (visibility === "visible" && (unreadCount ?? 0) > 0) {
      markAllRead();
    }
  }, [visibility, unreadCount, markAllRead]);

  return {
    notifications: notifications ?? [],
    unreadCount: unreadCount ?? 0,
    addNotification,
    markAllRead,
    isOnline,
  };
}

This hook uses four ReactUse hooks working together:

  • useBroadcastChannel sends real-time signals between tabs when notifications arrive or are read.
  • useLocalStorage persists the notification list and unread count so new tabs pick up the current state.
  • useDocumentVisibility automatically marks notifications as read when the user returns to a background tab.
  • useOnline (via useOnline) exposes the network status so the UI can indicate when the app is offline and notifications may be delayed.

Each hook handles one concern. Composed together, they form a complete system with persistence, real-time sync, visibility awareness, and network status — in under 70 lines.

When to Use Which Approach

Scenario Recommended Hook Why
Persisted state that syncs across tabs useLocalStorage Data survives refresh; storage events provide sync
Tab-scoped state that does not sync useSessionStorage Isolated per tab; no cross-tab events
Real-time imperative messages useBroadcastChannel Fast, supports structured data, no persistence overhead
Both persistence and instant messaging useLocalStorage + useBroadcastChannel Best of both: persist for new tabs, broadcast for open tabs
Pausing background work useDocumentVisibility / useWindowFocus Reduce unnecessary computation and network requests

Installation

npm install @reactuses/core

Or with your preferred package manager:

pnpm add @reactuses/core
yarn add @reactuses/core

Related Hooks

ReactUse provides 100+ hooks for React. Explore them all →

Originally published on ReactUse Blog

How I Built an AI Changelog Generator That Writes Release Notes for Three Different Audience

The Problem Nobody Wants to Own

Every team I’ve been on has the same ritual. Sprint ends. Someone asks: “Can you write up what shipped?” The room goes quiet.

Developers don’t want to do it because they already wrote PR descriptions. Product managers don’t want to do it because they don’t understand half the technical changes. And customers? They get either nothing or a wall of jargon.

The real pain isn’t writing a changelog. It’s writing three:

  1. Technical changelog for developers — breaking changes, migration notes, API diffs
  2. Product changelog for PMs and internal stakeholders — features, UX changes, bugs fixed
  3. Customer-facing release notes — polished, non-technical, ready for a blog or newsletter

Same PRs. Three completely different documents. Nobody has time for that.

Existing Tools Fall Short

I looked at what’s out there:

  • Release Drafter — Template-based, requires strict PR labeling. One format only.
  • GitHub’s native release notes — Auto-generated from PR titles. Bare minimum.
  • semantic-release — Requires conventional commit discipline. Developer-facing only.
  • Changelogfy — Manual editor. Defeats the purpose.

None of them generate multi-audience content. None of them understand the context of changes beyond titles and labels.

Building ShipLogs

I built ShipLogs — a GitHub App that watches merged PRs and generates changelogs for developers, product teams, and customers automatically.

Architecture

GitHub (webhook) → Next.js API → Claude AI → Supabase → Dashboard

Tech stack:

  • Next.js 16 (App Router) on Vercel
  • Supabase (PostgreSQL) for data storage
  • Claude API (Sonnet) for generation
  • Stripe for billing
  • TypeScript in strict mode throughout

How the GitHub App Works

When you install the ShipLogs GitHub App on your org or repos, it registers a webhook for pull request events. When a PR is merged, the webhook fires and we capture:

  • PR title and description
  • Diff summary (files changed, additions, deletions)
  • Labels
  • Linked issues
  • Author

This data goes into Supabase. When you trigger changelog generation (or it runs automatically), we batch the merged PRs and send them to Claude.

The AI Prompting Approach

The interesting part is how we get three distinct outputs from the same input. Each audience mode has a different system prompt that tells Claude:

  • Developer mode: “Focus on technical impact. Highlight breaking changes, new APIs, deprecations, and migration steps. Use precise technical language.”
  • Product mode: “Translate technical changes into feature and UX language. Group by user impact. Skip internal refactoring unless it affects performance.”
  • Customer mode: “Write as if this is going on a public blog. Lead with benefits, not implementation. No jargon. No PR numbers.”

We use Claude’s structured output (Zod schemas) so the response is always parseable — title, summary, categorized entries, and metadata.

Prompt caching cuts costs significantly. The system prompt is cached with cache_control: { type: "ephemeral" }, so repeated calls within a window only pay for the PR-specific input. Cost per changelog: roughly $0.06.

Multi-Audience Output Example

From the same set of PRs:

Developer mode:

Breaking: AuthMiddleware now requires explicit session configuration. Migration: update middleware.ts to pass sessionConfig option.

Added: Rate limiting on /api/webhooks/* endpoints (100 req/min). Uses sliding window with Redis backend.

Product mode:

Improved: Login flow is now faster and more reliable with updated session handling.

Infrastructure: API endpoints are now protected against traffic spikes, improving stability during high-usage periods.

Customer mode:

Faster login — We’ve improved the sign-in experience so you can get to your dashboard quicker.

Better reliability — Behind-the-scenes improvements to keep ShipLogs running smoothly, even during peak hours.

Same changes. Three lenses. That’s the value.

Daily PR Summaries via Slack

For teams on the Team plan, ShipLogs sends a daily digest of merged PRs to a Slack channel. Your team starts every morning knowing what shipped yesterday — without anyone writing a summary.

Export & GitHub Releases

One-click export to Markdown or clipboard. You can also draft a GitHub Release directly from the dashboard — it creates the release with the correct tag, title, and body.

Lessons Learned

1. Free tier needs guardrails from day one.
We cap free at 1 repo, 3 changelogs/month, 1/day. Without caps, 10K free users could cost ~$6K/mo in AI calls. With caps, it’s ~$1.8K/mo — sustainable as a loss leader.

2. Structured output > raw text.
Using Zod schemas with Claude’s structured output means we never have to parse freeform text. The response is always typed and predictable.

3. Multi-installation support matters early.
Users often belong to multiple GitHub orgs. Supporting multiple GitHub App installations per user from the start saved us a painful migration later.

4. Prompt caching is essential for cost control.
System prompts are large and repeated. Caching them cut per-request costs by ~60%.

Try It

ShipLogs is live at shiplogs.xyz.

  • Free: 1 repo, 3 changelogs/month — no credit card
  • Pro ($39/mo): 5 repos, all audience modes, GitHub Releases
  • Team ($69/mo): 10 repos, daily summaries, Slack, custom templates

The free tier is enough to try it on a real project. Install the GitHub App, merge a few PRs, and generate your first multi-audience changelog.

I’d love to hear how your team handles changelogs today. Does the multi-audience approach resonate, or is single-format enough? Let me know in the comments.

Goodbye Ingress, Hello Gateway API: Migrating the Right Way

Hi, dear community of readers, continuing with this blog posts to learn called Learning Kubernetes, this time is the time to learn about how to use the Gateway API. Now all the teams are looking to migrate, so my goal with this post is to help you with a quick start guide to understand and use the Gateway API with the implementation created by NGINX. I know that a lot of people used the NGINX Ingress Controller in their clusters, so makes sense to continue in that way but using the Gateway API. So let’s summarize my goals:

  • Start using Gateway API and deprecate your Ingress Controller
  • Setup a functional Gateway API with NGINX for your services
    So let’s start with this content, I know you want to now how to use the Gateway API.

What you will learn

In this blog post you will learn how to:

  • Install the Gateway API & NGINX Gateway Fabric
  • Create and use Gateways and HTTP Routes
  • Configure Cert-Manager for TLS for your Gateways

Requirements

  • A Kubernetes Cluster
  • Access to the cluster using kubectl

Install the Gateway API & NGINX Gateway Fabric

To install the Gateway API & NGINX Gateway Fabric follow these steps:
1. Install Gateway API & NGINX Gateway Fabric CRDs:

kubectl kustomize "https://github.com/nginx/nginx-gateway-fabric/config/crd/gateway-api/standard?ref=v2.4.2" | kubectl apply -f -

2. Install the NGINX Gateway Fabric using Helm

helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric --create-namespace -n nginx-gateway

Create and use Gateways and HTTP Routes

Now let’s create a simple deployment wiht a Gateway and HTTP Route by following the next steps:
1. Install the following application waiting for the Public api created by the Gateway :

kubectl apply -f - <<EOF
apiVersion: v1
kind: Namespace
metadata:
  creationTimestamp: null
  name: mynamespace
spec: {}
status: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: myapp
  name: myapp
  namespace: mynamespace
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: myapp
    spec:
      containers:
      - image: nginx
        name: nginx
        resources: {}
status: {}
---
apiVersion: v1
kind: Service
metadata:
  creationTimestamp: null
  labels:
    app: myapp
  name: myapp
  namespace: mynamespace
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 80
  selector:
    app: myapp
  type: ClusterIP
status:
  loadBalancer: {}
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: mygateway
  namespace: mynamespace
spec:
  gatewayClassName: nginx
  listeners:
  - name: http
    port: 80
    protocol: HTTP
EOF

2. Get the IP assigned to the Gateway using the following command:

export GW=$(kubectl get svc mygateway-nginx -n mynamespace -o jsonpath='{.status.loadBalancer.ingress[0].ip}')

Note: You have to wait for some minutes in order that the cloud provider assign an external IP to the Gateway created.
3. Create the route to expose the application:

kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: approute
  namespace: mynamespace
spec:
  parentRefs:
  - name: mygateway
  hostnames:
  - "$GW.nip.io"
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: myapp
      port: 80
EOF

4. Test the Application by using curl as follows:

curl http://$GW.nip.io

And that’s it now it works, now let’s move on using TLS.

Configure Cert-Manager for TLS for your Gateways

Now let’s configure the Cert-Manage to generate a certificate for your app by following these steps:
0. (Optional) Delete the previous gateway and route as follows:

kubectl delete gateway mygateway -n mynamespace
kubectl delete httproute approute -n mynamespace

1. Add the cert-manager helm chart

helm repo add jetstack https://charts.jetstack.io
helm repo update

2. Add the cert-manager helm chart
Install the cert-manager, and enable the GatewayAPI feature gate:

helm install 
  cert-manager jetstack/cert-manager 
  --namespace cert-manager 
  --create-namespace 
  --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" 
  --set config.kind="ControllerConfiguration" 
  --set config.enableGatewayAPI=true 
  --set crds.enabled=true

3. Create a ClusterIssuer to use to generate your certicate:

export EMAIL="[email protected]"
kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    email: $EMAIL
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: issuer-account-key
    solvers:
    - http01:
        gatewayHTTPRoute:
          parentRefs:
          - name: gateway
            namespace: default
            kind: Gateway
EOF

4. Change your current Gateway as follows:

kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: mygateway
  namespace: mynamespace
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  gatewayClassName: nginx
  listeners:
  - name: http
    port: 80
    protocol: HTTP
  - name: https
    hostname: "$GW.nip.io"
    port: 443
    protocol: HTTPS
    tls:
      mode: Terminate
      certificateRefs:
      - kind: Secret
        name: tls-secret
EOF

Note: Use the previous GW value the first time, then check for the service called mygateway-nginx using the following command:

kubectl get svc -n mynamespace

Then update the value GW with the following command:

export GW=$(kubectl get svc mygateway-nginx -n mynamespace -o jsonpath='{.status.loadBalancer.ingress[0].ip}')

And apply the YAML again to point to the correct URL.
5. Now modify your HTTP Route as follows:

kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: approute
  namespace: mynamespace
spec:
  parentRefs:
  - name: mygateway
    sectionName: https
  hostnames:
  - "$GW.nip.io"
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: myapp
      port: 80
EOF

6. Now test your application by running the following command:

curl https://$GW.nip.io

And that’s it, now you have learned how to use the Gateway API with the NGINX Fabric Gateway implementation using also TLS with Cert-Manager. Take a look to the Links in the references for more theory and examples, of course if you need something more complicated :).

References

Conclusion about the Gateway API with NGINX

Well, a lot of companies are getting into chaos because of deprecated their previous Ingress Controller, but it seems that its not as complicated for simple configuration like the one I showed in the post. I hope this can help as an initial configuration that you can expand for your needs. Well this Gateway API is the future for Kubernetes to have something more stable to manage. I am curious to test another implementations. But it seems that works, maybe the documentation is not as well designed but you can find the necessary parts that you need in the links before.

So what implementation of Gateway API are you going to use, write a comment below :).

That a lot for me this time, see you the next one. I hope so hehehe.

Follow me

These are my social networks:

https://www.linkedin.com/in/sergioarmgpl
https://sergiops.xyz
https://x.com/sergioarmgpl
https://www.instagram.com/sergioarmgpl/

One Decorator to Audit Every AI Agent Call

Your AI agent makes hundreds of API calls a day. Can you prove what it did last Tuesday at 3pm?

asqav signs every function call with quantum-safe cryptography. One decorator.

Setup

pip install asqav

Sign any function

import asqav

asqav.init(api_key="sk_...")

@asqav.sign
def call_model(prompt: str):
    return openai.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": prompt}]
    )

result = call_model("Summarize this document")
# Function runs normally. A signed audit record is created automatically.

The decorator detects async functions too. No changes needed.

@asqav.sign
async def async_call(prompt: str):
    return await client.chat.completions.create(...)

Custom action types

Default action type is function:call. Override it:

@asqav.sign(action_type="deploy:production")
def deploy(version: str):
    ...

Group signs with sessions

with asqav.session() as s:
    s.sign("step:fetch", {"source": "api"})
    s.sign("step:process", {"records": 150})
    s.sign("step:store", {"table": "results"})
# All three signs grouped under one session ID

Every sign gets an ML-DSA-65 signature and an RFC 3161 timestamp. Quantum-safe, independently verifiable, tamper-proof.

SDK docs | GitHub