Skip to content

List releases via Git instead of S3#33

Merged
samestep merged 7 commits into
mainfrom
releases-git
May 25, 2026
Merged

List releases via Git instead of S3#33
samestep merged 7 commits into
mainfrom
releases-git

Conversation

@samestep
Copy link
Copy Markdown
Owner

@samestep samestep commented May 25, 2026

Expand to see an /export of the Claude Code conversation that generated the first commit of this PR.
╭─── Claude Code v2.1.143 ─────────────────────────────────────────────────────╮
│                                             │ Tips for getting started       │
│              Welcome back Sam!              │ Run /init to create a CLAUDE.… │
│                                             │ ────────────────────────────── │
│                   ▐▛███▜▌                   │ What's new                     │
│                  ▝▜█████▛▘                  │ Pinned background sessions (`… │
│                    ▘▘ ▝▝                    │ Renamed `/simplify` to `/code… │
│                                             │ Improved auto-updater: retrie… │
│   Opus 4.7 · Claude Team · sunshine-group   │ /release-notes for more        │
│            ~/github/samestep/npc            │                                │
╰──────────────────────────────────────────────────────────────────────────────╯

❯ Commit 604dbcf4d55d97355b8d4f560ab2262d549e3160 fixed an error `npc` would    
  throw shortly after a Nixpkgs release branch-off, when some but not all of    
  the three beta channels have been created. With the new fix, as soon as any   
  of those three beta channels exist, that is recognized as the latest release. 
                                                                                
  What I'm concerned about is the situation where the branch-off has happened,  
  but none of the three beta channels have been created yet. So for instance,   
  for this current 26.05 release, you could imagine the following happening:    
                                                                                
  - The branch-off happens, so now `master` is 26.11pre.                        
  - At least one of the three unstable branches updates past the branch-off, so 
   the prerelease version is now detected to be 26.11.                          
  - None of the three beta branches have been created yet, so the most recent   
  release is still detected as 25.11.                                           
  - So, for a short period of time until `nixos-26.05-small` is created, `npc`  
  "forgets" about the existence of 26.05 entirely.                              
  - This would cause about six months of the unstable channels to appear        
  missing.                                                                      
  - Then the error would go away as soon as one of the three beta branches gets 
   created.                                                                     
                                                                                
  Is this a real bug, or did I miss anything? If the former, is there a good    
  way to fix this?                                                              

⏺ Bash(git show 604dbcf --stat)
  ⎿  commit 604dbcf4d55d97355b8d4f560ab2262d549e3160
     Author: Sam Estep <sam@samestep.com>
     Date:   Sun May 24 23:59:25 2026 -0400
     … +6 lines (ctrl+o to expand)

⏺ Bash(git show 604dbcf)
  ⎿  commit 604dbcf4d55d97355b8d4f560ab2262d549e3160
     Author: Sam Estep <sam@samestep.com>
     Date:   Sun May 24 23:59:25 2026 -0400
     … +64 lines (ctrl+o to expand)

  Searched for 1 pattern, read 1 file (ctrl+o to expand)

⏺ Yes, this is a real bug. Walking through your scenario against src/main.rs:

  - prerelease() at line 563 now takes release.max(Some(...)) across the three
  unstable channels, so as soon as any one of them advances past branch-off, the
   returned prerelease jumps to 26.11.
  - releases() at line 603 inserts only that prerelease plus what it scrapes out
   of S3. The S3 scrape relies on the existence of nixos/26.05/,
  nixos/26.05-small/, or nixpkgs/26.05-darwin/ — none of which exist until the
  corresponding beta channel publishes its first build.
  - So cache.releases becomes [..., 25.05, 25.11, 26.11] with no 26.05 entry.
  Branch::iter at line 257 only emits
  NixpkgsStableDarwin(r)/NixosStableSmall(r)/NixosStable(r) for releases in that
   list, so the entire 26.05 stable channel set is silently dropped.
  - And because NixpkgsUnstable/NixosUnstableSmall/NixosUnstable are tagged with
   the (now 26.11) prerelease, you also lose the ability to see the ~6 months of
   unstable commits that were still labeled 26.05pre.

  A few options for fixing it, roughly in order of robustness:

  1. Use the unstable channels' disagreement directly. Instead of collapsing the
   three channels with max, collect the set of distinct prerelease versions. The
   maximum is the active prerelease; anything below it is, by definition,
  already branched off and is a stable release. Insert all of them into
  releases. This catches the common case where one of the three channels has
  rolled over but the others haven't, but has a (small) race window if all three
   roll over before any beta channel exists.
  2. Walk the Git clone for release-XX.YY branches. Those refs are created at
  branch-off, before the S3 beta directories. Since npc fetch already does a
  mirror clone and git fetch, you can git for-each-ref refs/heads/release-* (or
  refs/remotes/origin/release-* depending on the mirror layout) and derive the
  release list from there. This closes the race entirely.
  3. Interpolate missing 6-month slots. Given known NixOS cadence
  (May/November), fill in any 6-month gaps between the latest S3-confirmed
  release and the prerelease. Simplest, but bakes in an assumption about release
   cadence.

  I'd lean toward (1)+(2): collect all distinct prereleases from the unstable
  channels and union with release-XX.YY refs from the Git clone. That removes
  the race window without committing to a release-cadence heuristic.

  Want me to draft the fix?

✻ Brewed for 1m 46s

❯ The first and third options don't seem like real fixes. The second one seems  
  plausible though. Could you implement that one?                               

  Searched for 1 pattern, read 1 file (ctrl+o to expand)

⏺ Let me also see how missing S3 data is handled when iterating revisions.

  Read 1 file (ctrl+o to expand)

⏺ Let me also check the master.dat lines and the test infrastructure.

  Searched for 1 pattern, read 1 file (ctrl+o to expand)

⏺ I'll implement the fix using git ls-remote from inside releases() to query the
   release-* refs directly. That preserves the existing git fetch ordering
  (post-S3, so all commit hashes from S3 land in the local clone) without
  requiring a separate pre-fetch, and union the result with the S3-derived
  releases.

⏺ Update(src/main.rs)
  ⎿  Added 22 lines, removed 1 line
      601      }
      602  
      603      async fn releases(&self) -> anyhow::Result<Vec<Release>> {
      604 +        let oldest = Release { year: 16, month: 9 };          
      605          let mut releases = BTreeSet::new();
      606          for (start, re) in [
      607              (
     ...
      634                      if let Some(caps) = re.captures(&prefix) {
      635                          let release: Release = caps[1].parse()
           .unwrap();
      636                          // Omit earlier releases: the bucket h
           as no `git-revision` objects for them.
      636 -                        let oldest = Release { year: 16, month
          -: 9 };                                                        
      637                          if release >= oldest {
      638                              releases.insert(release);
      639                          }
     ...
      645                  };
      646              }
      647          }
      648 +        // Also include releases derived from Nixpkgs `release
          +-*` branches. These get created at                            
      649 +        // branch-off, before the corresponding S3 prefixes st
          +art being populated, so without this                          
      650 +        // there's a window during which the new release is un
          +known even though branch-off has                              
      651 +        // already happened.                                  
      652 +        let output = self                                     
      653 +            .cache                                            
      654 +            .git()                                            
      655 +            .args(["ls-remote", "--heads", "origin", "release-
          +*"])                                                          
      656 +            .output()?;                                       
      657 +        if !output.status.success() {                         
      658 +            bail!("failed to list Nixpkgs release branches"); 
      659 +        }                                                     
      660 +        let re = Regex::new(r"refs/heads/release-(\d\d\.\d\d)$
          +").unwrap();                                                  
      661 +        for line in String::from_utf8(output.stdout)?.lines() 
          +{                                                             
      662 +            if let Some(caps) = re.captures(line) {           
      663 +                let release: Release = caps[1].parse().unwrap(
          +);                                                            
      664 +                if release >= oldest {                        
      665 +                    releases.insert(release);                 
      666 +                }                                             
      667 +            }                                                 
      668 +        }                                                     
      669          releases.insert(prerelease().await?);
      670          Ok(releases.into_iter().collect())
      671      }

⏺ Let me verify it builds.

⏺ Bash(cargo build 2>&1 | tail -30)
  ⎿     Compiling npc v0.0.0 (/Users/admin/github/samestep/npc)
         Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.15
     s

⏺ Builds clean. Let me also run clippy and the test suite for completeness.

⏺ Bash(cargo clippy --all-targets -- -D warnings 2>&1 | tail -20 && echo
      "---tests---" && cargo test 2>&1 | tail -20)
  ⎿     Compiling rustls v0.21.12
         Checking rustls-webpki v0.101.7
         Checking sct v0.7.1
     … +21 lines (ctrl+o to expand)

⏺ Clean. The change adds git ls-remote --heads origin release-* inside
  Remote::releases() and unions any matching release-XX.YY branches into the
  release set (subject to the same >= 16.09 filter as the S3 scrape). Since this
   hits the remote directly via the existing origin clone URL, it doesn't depend
   on the local refs being fresh, and the existing post-S3 git fetch ordering is
   preserved.

  Effect on the scenario you described: after branch-off creates release-26.05
  on GitHub — even before any of the three beta channels have published their
  first build — npc fetch will see the branch, add 26.05 to the releases list,
  and continue to treat the unstable channels as 26.11pre once they advance past
   branch-off. The 6-month gap no longer disappears.

✻ Cooked for 5m 59s

❯ Did you test it?                                                              
                                                                                
  Also, why union this into the test set? Why not just replace the previous     
  code entirely and compute the releases (and prerelease?) using this Git-based 
   method?                                                                      

⏺ No, I only ran cargo build, cargo clippy, and cargo test — and that test suite
   is empty. I haven't run npc fetch end-to-end against the real S3 bucket and
  Nixpkgs git, and I haven't simulated the post-branch-off state. So "builds and
   lints clean" is all I can claim. Sorry for overstating it.

  On the unioning question — you're right, it's the wrong shape. The S3
  nix-releases scrape and the release-* ref scrape are answering the same
  question, and the git refs are the more authoritative source (the branches
  exist before any S3 prefix does, and every release ≥ 16.09 has one). The S3
  scrape is just a less-complete copy of the same information, so keeping it as
  a union is dead weight rather than belt-and-suspenders.

  Prerelease is a separate concept though, so it's worth thinking about whether
  it can be folded into the same git-based source. A couple of options:

  1. Read .version from origin/master. That's authoritative for the in-progress
  prerelease. Requires master to be locally fresh at the time releases() runs,
  which means doing a git fetch (or at least fetching that one ref) before
  releases() rather than after. We'd either accept a single pre-S3 fetch (small
  race: master commits referenced by S3 mid-run might miss the local clone), or
  do two fetches (cheap when no-op).
  2. Keep prerelease() as-is (it still queries the nix-channels bucket, a
  separate concern from the nix-releases scrape we're replacing). It's already
  robust now that it uses .max(...), and it's the only thing that needs S3.

  I'd lean toward (1) plus dropping the post-S3 fetch entirely, since the
  existing git_revisions fallback already handles S3-referenced commits that
  aren't local yet (lines 672–678) — the only thing the post-S3 fetch buys us is
   a fresher master.dat, and that's a "next fetch will catch it" issue rather
  than correctness. But I want to flag the tradeoff before going further.

  Want me to do the full replacement (drop the S3 nix-releases scrape, switch
  prerelease to .version on master, restructure the fetch ordering)? Or replace
  just the stable-release part and leave prerelease() alone?

✻ Churned for 1m 25s

❯ Option 1 seems good. One question I have is, since we do a blobless clone, is 
   there a convenient way to read the contents of `lib/.version`? And the other 
   thought I have is about fetching (before vs twice): do we _actually_ need to 
   fetch after we get stuff from S3? In `Remote::git_revisions`, we already     
  have two code paths to expand the short Git SHA into the full Git SHA, one    
  using `git rev-parse` and the other using the `git-revision` objects in S3.   
  But it seems to me that we could tweak this: if `git rev-parse` fails and we  
  get the full SHA from the S3 `git-revision` object, we can then ask our local 
   Git clone whether it has that full SHA. If it still doesn't have it, that    
  means it's too new; it was just added after we did our `git fetch`, so we     
  discard it to maintain consistency. This will also have another benefit:      
  currently with the S3-before-Git strategy, we can end up with a case where    
  `npc checkout nixpkgs-unstable` fails even when the flake is on               
  `nixpkgs-unstable`, because the `nixpkgs-unstable` branch in the Git clone    
  may be newer than the set of commits we fetched from S3. If we instead fetch  
  from Git first and then only keep commits from S3 if they're in our Git       
  clone, we'll have consistency both ways.                                      
⏺ Both your points are solid. Let me verify the lib/.version location and look
  at the existing --no-lazy-fetch setup before answering.

⏺ Bash(curl -sI
      https://raw.githubusercontent.com/NixOS/nixpkgs/master/lib/.version | head
       -1 && curl -sI
      https://raw.githubusercontent.com/NixOS/nixpkgs/master/.versi…)
  ⎿  HTTP/2 200
     HTTP/2 200

⏺ Bash(curl -s
      https://raw.githubusercontent.com/NixOS/nixpkgs/master/lib/.version &&
      echo "---" && curl -s
      https://raw.githubusercontent.com/NixOS/nixpkgs/master/.ver…)
  ⎿  26.11---
     lib/.version

⏺ Both questions have good answers:

  1. lib/.version with --filter=tree:0: yes — the file content is 26.11\n (the
  root .version is just a symlink to lib/.version). Reading it via git show 
  master:lib/.version works, but it needs lazy fetch enabled, and Cache::git()
  currently passes --no-lazy-fetch. The cheap fix is a sibling helper that omits
   that flag — we're already inside the fetch subcommand, which is the one place
   the project allows network access from Git.
  2. Fetch-once-and-discard: your reasoning is right. With git-fetch-first plus
  filtering S3 commits against the local clone, the cache and the Git mirror
  reflect a single point-in-time snapshot, which fixes the npc checkout 
  nixpkgs-unstable case as a bonus. The fallback in git_revisions already
  handles the (rare) case where a short SHA can't be resolved locally; adding
  the existence check after the S3 lookup is a small extension.

  Implementing now.

  Read 1 file (ctrl+o to expand)

⏺ Update(src/main.rs)
  ⎿  Added 8 lines
      437          cmd
      438      }
      439  
      440 +    /// Like `git`, but with lazy fetch enabled. Only safe to 
          +call from the `fetch` subcommand,                             
      441 +    /// which is the one place we allow Git to touch the netwo
          +rk.                                                           
      442 +    fn git_lazy(&self) -> Command {                           
      443 +        let mut cmd = git();                                  
      444 +        cmd.arg("-C").arg(self.path(CacheKey::Git));          
      445 +        cmd                                                   
      446 +    }                                                         
      447 +                                                              
      448      fn sha(&self, rev: &str) -> anyhow::Result<Sha> {
      449          let output = self
      450              .git()

⏺ Now replace releases() to derive everything from Git, and drop the
  prerelease() function and its imports.

⏺ Update(src/main.rs)
  ⎿  Removed 34 lines
      539      }
      540  }
      541  
      542 -/// Query the nix-channels bucket to determine the next releas
          -e.                                                            
      543 -async fn prerelease() -> anyhow::Result<Release> {            
      544 -    let config = aws_config::defaults(BehaviorVersion::latest(
          -))                                                            
      545 -        .no_credentials()                                     
      546 -        .region("us-east-1")                                  
      547 -        .load()                                               
      548 -        .await;                                               
      549 -    let s3 = s3::Client::new(&config);                        
      550 -    let unstable = [                                          
      551 -        Branch::NixpkgsUnstable,                              
      552 -        Branch::NixosUnstableSmall,                           
      553 -        Branch::NixosUnstable,                                
      554 -    ];                                                        
      555 -    let outputs = try_join_all(unstable.map(|channel| {       
      556 -        s3.get_object()                                       
      557 -            .bucket("nix-channels")                           
      558 -            .key(channel.to_string())                         
      559 -            .send()                                           
      560 -    }))                                                       
      561 -    .await?;                                                  
      562 -    let mut release = None;                                   
      563 -    let re = Regex::new(r"(\d\d\.\d\d)pre\d+\.\w+$").unwrap();
      564 -    for (output, channel) in outputs.into_iter().zip(unstable)
          - {                                                            
      565 -        let Some(url) = output.website_redirect_location else 
          -{                                                             
      566 -            bail!("no redirect URL found for {channel}");     
      567 -        };                                                    
      568 -        let Some(caps) = re.captures(&url) else {             
      569 -            bail!("failed to find prerelease version in {url}"
          -);                                                            
      570 -        };                                                    
      571 -        release = release.max(Some(caps[1].parse().unwrap()));
      572 -    }                                                         
      573 -    Ok(release.unwrap())                                      
      574 -}                                                             
      575 -                                                              
      542  struct PrefixId {
      543      re: Regex,
      544  }

⏺ Now replace the body of Remote::releases() to use Git refs and lib/.version.

⏺ Update(src/main.rs)
  ⎿  Added 26 lines, removed 55 lines
      574          Self { cache, s3 }
      575      }
      576  
      577 -    async fn releases(&self) -> anyhow::Result<Vec<Release>> {
      577 +    fn releases(&self) -> anyhow::Result<Vec<Release>> {      
      578 +        // Stable releases are derived from the `release-*` br
          +anches in Nixpkgs, and the current                            
      579 +        // prerelease is the version in `lib/.version` on `mas
          +ter`. Both come from Git rather than                          
      580 +        // S3 so that we can recognize a new release as soon a
          +s branch-off happens, without having                          
      581 +        // to wait for the corresponding beta channel to publi
          +sh its first build.                                           
      582          let oldest = Release { year: 16, month: 9 };
      583          let mut releases = BTreeSet::new();
      580 -        for (start, re) in [                                  
      581 -            (                                                 
      582 -                "nixos/",                                     
      583 -                Regex::new(r"^nixos/(\d\d\.\d\d)(-small)?/$").
          -unwrap(),                                                     
      584 -            ),                                                
      585 -            (                                                 
      586 -                "nixpkgs/",                                   
      587 -                Regex::new(r"^nixpkgs/(\d\d\.\d\d)-darwin/$").
          -unwrap(),                                                     
      588 -            ),                                                
      589 -        ] {                                                   
      590 -            let mut continuation_token = None;                
      591 -            loop {                                            
      592 -                let output = self                             
      593 -                    .s3                                       
      594 -                    .list_objects_v2()                        
      595 -                    .bucket(BUCKET)                           
      596 -                    .prefix(start)                            
      597 -                    .delimiter("/")                           
      598 -                    .set_continuation_token(continuation_token
          -)                                                             
      599 -                    .send()                                   
      600 -                    .await?;                                  
      601 -                let prefixes = output                         
      602 -                    .common_prefixes                          
      603 -                    .unwrap_or_default()                      
      604 -                    .into_iter()                              
      605 -                    .map(|item| item.prefix.ok_or_else(|| anyh
          -ow!("missing prefix")))                                       
      606 -                    .collect::<anyhow::Result<Vec<String>>>()?
          -;                                                             
      607 -                for prefix in prefixes {                      
      608 -                    if let Some(caps) = re.captures(&prefix) {
      609 -                        let release: Release = caps[1].parse()
          -.unwrap();                                                    
      610 -                        // Omit earlier releases: the bucket h
          -as no `git-revision` objects for them.                        
      611 -                        if release >= oldest {                
      612 -                            releases.insert(release);         
      613 -                        }                                     
      614 -                    }                                         
      615 -                }                                             
      616 -                match output.next_continuation_token {        
      617 -                    Some(token) => continuation_token = Some(t
          -oken),                                                        
      618 -                    None => break,                            
      619 -                };                                            
      620 -            }                                                 
      621 -        }                                                     
      622 -        // Also include releases derived from Nixpkgs `release
          --*` branches. These get created at                            
      623 -        // branch-off, before the corresponding S3 prefixes st
          -art being populated, so without this                          
      624 -        // there's a window during which the new release is un
          -known even though branch-off has                              
      625 -        // already happened.                                  
      584          let output = self
      585              .cache
      586              .git()
      629 -            .args(["ls-remote", "--heads", "origin", "release-
          -*"])                                                          
      587 +            .args([                                           
      588 +                "for-each-ref",                               
      589 +                "--format=%(refname:strip=2)",                
      590 +                "refs/heads/release-*",                       
      591 +            ])                                                
      592              .output()?;
      593          if !output.status.success() {
      594              bail!("failed to list Nixpkgs release branches");
      595          }
      634 -        let re = Regex::new(r"refs/heads/release-(\d\d\.\d\d)$
          -").unwrap();                                                  
      596 +        let re = Regex::new(r"^release-(\d\d\.\d\d)$").unwrap(
          +);                                                            
      597          for line in String::from_utf8(output.stdout)?.lines() 
           {
      636 -            if let Some(caps) = re.captures(line) {           
      637 -                let release: Release = caps[1].parse().unwrap(
          -);                                                            
      638 -                if release >= oldest {                        
      639 -                    releases.insert(release);                 
      640 -                }                                             
      598 +            if let Some(caps) = re.captures(line)             
      599 +                && let Ok(release) = caps[1].parse::<Release>(
          +)                                                             
      600 +                && release >= oldest                          
      601 +            {                                                 
      602 +                releases.insert(release);                     
      603              }
      604          }
      643 -        releases.insert(prerelease().await?);                 
      605 +        let output = self                                     
      606 +            .cache                                            
      607 +            .git_lazy()                                       
      608 +            .args(["show", "master:lib/.version"])            
      609 +            .output()?;                                       
      610 +        if !output.status.success() {                         
      611 +            bail!("failed to read lib/.version from Nixpkgs ma
          +ster");                                                       
      612 +        }                                                     
      613 +        let prerelease: Release = String::from_utf8(output.std
          +out)?.trim().parse()?;                                        
      614 +        releases.insert(prerelease);                          
      615          Ok(releases.into_iter().collect())
      616      }
      617  

⏺ Now update git_revisions to verify each S3-fallback SHA exists locally and
  discard otherwise.

⏺ Update(src/main.rs)
  ⎿  Added 17 lines, removed 3 lines
      636                      bail!("unexpected extra Git output");
      637                  };
      638                  let sha = match line.parse() {
      639 -                    Ok(sha) => sha,                           
      639 +                    Ok(sha) => Some(sha),                     
      640                      Err(_) => {
      641                          let key = format!("{prefix}git-revisio
           n");
      642                          let output = self.s3.get_object().buck
           et(BUCKET).key(key).send().await?;
      643 -                        String::from_utf8(output.body.collect(
          -).await?.to_vec())?.parse()?                                  
      643 +                        let sha: Sha =                        
      644 +                            String::from_utf8(output.body.coll
          +ect().await?.to_vec())?.parse()?;                             
      645 +                        // If the full SHA isn't in our local 
          +Git clone, this S3 build references a                         
      646 +                        // commit that landed after our `git f
          +etch`. Discard it so the cache stays                          
      647 +                        // consistent with the local Git mirro
          +r and isn't ahead of it.                                      
      648 +                        let exists = self                     
      649 +                            .cache                            
      650 +                            .git()                            
      651 +                            .args(["cat-file", "-e", &sha.to_s
          +tring()])                                                     
      652 +                            .stderr(Stdio::null())            
      653 +                            .status()?                        
      654 +                            .success();                       
      655 +                        exists.then_some(sha)                 
      656                      }
      657                  };
      646 -                callback(sha, prefix);                        
      658 +                if let Some(sha) = sha {                      
      659 +                    callback(sha, prefix);                    
      660 +                }                                             
      661              }
      662          }
      663          Ok(())

⏺ Now restructure the Fetch flow: do git fetch first, drop the second one.

⏺ Update(src/main.rs)
  ⎿  Added 17 lines, removed 15 lines
      1196  
      1197              let mut remote = Remote::new(cache).await;
      1198  
      1199 +            // Fetch from Git before reading anything else so
           + that every subsequent step                                  
      1200 +            // (release detection, channel fetching, listing 
           +`master` commits) sees a single                              
      1201 +            // point-in-time view of Nixpkgs. Any commits S3 
           +publishes after this point get                               
      1202 +            // discarded by `Remote::git_revisions` to keep t
           +he cache consistent with the local                           
      1203 +            // Git mirror.                                   
      1204 +            let status = remote                              
      1205 +                .cache                                       
      1206 +                .git()                                       
      1207 +                .args(["fetch", "--no-show-forced-updates"]) 
      1208 +                .status()?;                                  
      1209 +            // Without the `--no-show-forced-updates` flag, G
           +it spends a lot of time figuring out                         
      1210 +            // that all the updates to refs/pull/*/head and r
           +efs/pull/*/merge were forced.                                
      1211 +            if !status.success() {                           
      1212 +                bail!("failed to fetch from Git");           
      1213 +            }                                                
      1214 +                                                             
      1215              remote.cache.releases = {
      1200 -                let releases = remote.releases().await?;     
      1216 +                let releases = remote.releases()?;           
      1217                  let mut lines = String::new();
      1218                  for release in &releases {
      1219                      writeln!(&mut lines, "{release}")?;
     ...
      1229                  )
      1230                  .await?;
      1231  
      1216 -            // We fetch from Git after fetching from S3 so th
           -at, once we're done, all the commit                          
      1217 -            // hashes we got from S3 should also be in our lo
           -cal Git clone.                                               
      1218 -            let status = remote                              
      1219 -                .cache                                       
      1220 -                .git()                                       
      1221 -                .args(["fetch", "--no-show-forced-updates"]) 
      1222 -                .status()?;                                  
      1223 -            // Without the `--no-show-forced-updates` flag, G
           -it prints spends a lot of time figuring                      
      1224 -            // out that all the updates to refs/pull/*/head a
           -nd refs/pull/*/merge were forced.                            
      1225 -            if !status.success() {                           
      1226 -                bail!("failed to fetch from Git");           
      1227 -            }                                                
      1228 -                                                             
      1229 -            // List `master` commits only after `git fetch` s
           -o that we don't miss any new ones.                           
      1232              let output = remote
      1233                  .cache
      1234                  .git()

⏺ Build to flush out any leftover unused imports.

⏺ Bash(cargo build 2>&1 | tail -40)
  ⎿     Compiling npc v0.0.0 (/Users/admin/github/samestep/npc)
         Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.92
     s

⏺ Bash(cargo clippy --all-targets -- -D warnings 2>&1 | tail -30)
  ⎿      Checking npc v0.0.0 (/Users/admin/github/samestep/npc)
         Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.37
     s

⏺ Let me actually run npc fetch against the real services this time.

  Searched for 1 pattern, read 1 file (ctrl+o to expand)

⏺ Bash(echo "Cache dir: $(ls -la ~/Library/Caches/nix-npc 2>/dev/null | head -5
      || echo MISSING)")
  ⎿  Cache dir: drwxr-xr-x - admin 17 May 12:04 bisect

  Listed 1 directory (ctrl+o to expand)

⏺ Let me first do a quick logic sanity check before running the full fetch. I'll
   verify the key git operations work against a fresh small clone.

⏺ Bash(mkdir -p /tmp/npc-test-mirror && cd /tmp/npc-test-mirror && rm -rf
      nixpkgs.git && git -c init.defaultBranch=main clone --mirror
      --filter=tree:0 --depth=1 https:…)
  ⎿  Cloning into bare repository 'nixpkgs.git'...
  ⎿  Shell cwd was reset to /Users/admin/github/samestep/npc

  Listed 1 directory (ctrl+o to expand)
⏺ Bash(cd /tmp/npc-test-mirror/nixpkgs.git && git for-each-ref
      --format='%(refname)' 2>&1 | head -10; echo "---total refs---"; git
      for-each-ref 2>&1 | wc -l)
  ⎿  refs/heads/master
     ---total refs---
     1
  ⎿  Shell cwd was reset to /Users/admin/github/samestep/npc

⏺ Let me just do the real test — clone properly via npc fetch since --depth=1
  clobbered --mirror. I'll let it run in the background.

⏺ Bash(rm -rf /tmp/npc-test-mirror && cargo build --release 2>&1 | tail -3)
  ⎿  Running in the background (↓ to manage)

⏺ Bash(cargo build --release 2>&1 | tail -3)
  ⎿      Blocking waiting for file lock on artifact directory
         Finished `release` profile [optimized] target(s) in 4.40s

⏺ Background command "Build release binary" completed (exit code 0)

  Listed 1 directory (ctrl+o to expand)

⏺ I'll run npc fetch against a temp HOME so we don't clobber the existing cache.
   Let me kick it off in the background.

⏺ Bash(mkdir -p /tmp/npc-home && HOME=/tmp/npc-home ./target/release/npc fetch
      2>&1)
  ⎿  Running in the background (↓ to manage)

  Listed 1 directory (ctrl+o to expand)

✻ Worked for 10m 29s

⏺ Background command "Run npc fetch in isolated HOME (background)" failed with
exit code 1

  Read 1 file (ctrl+o to expand)

⏺ The clone and git fetch succeeded — the failure was an S3 connect timeout on
  the EU bucket, not a code bug. Let me retry; the mirror is cached now so it'll
   skip straight to S3.

⏺ Bash(HOME=/tmp/npc-home ./target/release/npc fetch 2>&1)
  ⎿  Running in the background (↓ to manage)

✻ Sautéed for 21s

⏺ Background command "Retry npc fetch in isolated HOME" failed with exit code 1

  Read 1 file (ctrl+o to expand)

⏺ Bash(curl --max-time 10 -sI https://nix-releases.s3.amazonaws.com/ 2>&1 | head
       -5; echo "---eu---"; curl --max-time 10 -sI
      https://nix-releases.s3.eu-west-1.amazonaw…)
  ⎿  HTTP/1.1 200 OK
     x-amz-id-2: dNe3mkSDJs51B2PKnCw6lrC/0QMB/TGzGPIp1Yw1GSaJ5rJCKIERi1e2/G
     bx67GmVF5KSTkKdylw9yvWEoOVg/tXgvtN98eq
     … +10 lines (ctrl+o to expand)

⏺ S3 is reachable from the shell but the AWS SDK's default 3.1s connect timeout
  is failing — likely contention from many parallel try_join_all requests on a
  constrained connection. This isn't related to my changes (the old code hit the
   same bucket). Let me try once more.

⏺ Bash(HOME=/tmp/npc-home ./target/release/npc fetch 2>&1)
  ⎿  Running in the background (↓ to manage)

✻ Cogitated for 40s

⏺ Background command "Third fetch attempt" completed (exit code 0)
  Listed 1 directory (ctrl+o to expand)

⏺ The fetch succeeded. The releases.txt correctly lists 26.05 — which is exactly
   the test case for this fix (it's 2026-05-25, the day after the 26.05
  branch-off). Let me check the content of the new stable channel files.

  Read 1 file (ctrl+o to expand)

⏺ Fetch worked end-to-end. The list correctly includes both 26.05 and 26.11. Let
   me also verify npc status reads the cache cleanly.

⏺ Bash(HOME=/tmp/npc-home ./target/release/npc status 2>&1 | head -25)
  ⎿  current local time is 2026-05-25 13:20:27 +0000
     last fetched cache at 2026-05-25 13:19:20 +0000
     … +10 lines (ctrl+o to expand)

⏺ Tested end-to-end. Today's state (2026-05-25, just after 26.05 branch-off) is
  actually the exact scenario in question: the cache now lists 26.05 in
  releases.txt, the new git-based detection picks it up from the release-26.05
  branch, and npc status reads cleanly. lib/.version resolves to 26.11 via lazy
  fetch, S3 channel JSONs that have no data yet write as {}, and the new
  git_revisions discard path didn't reject anything this run (everything S3
  referenced landed within the git fetch window).

  Summary of what changed:
  - Cache::git_lazy() added — same as git() but without --no-lazy-fetch, only
  used inside the fetch subcommand.
  - Remote::releases() rewritten: enumerates refs/heads/release-* for stable
  releases and reads master:lib/.version for the prerelease. No S3 calls.
  - prerelease() and its nix-channels S3 client deleted.
  - Remote::git_revisions() now verifies any S3-fallback SHA with git cat-file 
  -e and discards commits that aren't in the local clone.
  - Commands::Fetch flow reordered: git fetch runs first, then S3, then git log 
  master. The post-S3 fetch is gone, so the entire cache reflects one
  point-in-time view of the mirror.

  Side benefit, as you predicted: npc checkout nixpkgs-unstable should no longer
   fail when Git is ahead of S3, because the channel JSON is now capped at the
  same snapshot as the local mirror.

  One caveat to flag: I tested against a state where the 26.05-small and
  26.05-darwin S3 prefixes already exist (so 26.05 would have been detected by
  the old code too on this exact day). The git-ref path is exercised by the
  fetch and the parsing is verified, but the precise "release branch exists,
  zero S3 prefixes" gap can't be reproduced after the fact — only the logic that
   handles it is verified.

✻ Cooked for 1m 6s

@samestep samestep marked this pull request as ready for review May 25, 2026 15:17
@samestep samestep merged commit b6d5823 into main May 25, 2026
1 check passed
@samestep samestep deleted the releases-git branch May 25, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant