mirror of
https://github.com/youwen5/blog.git
synced 2025-01-17 20:52:09 -08:00
posts: add nix update script post
This commit is contained in:
parent
9caa25dec0
commit
1ba909eb8e
1 changed files with 336 additions and 0 deletions
336
src/posts/2024-12-28-nix-update-scripts-made-easy.md
Normal file
336
src/posts/2024-12-28-nix-update-scripts-made-easy.md
Normal file
|
@ -0,0 +1,336 @@
|
|||
---
|
||||
author: "Youwen Wu"
|
||||
authorTwitter: "@youwen"
|
||||
desc: "keep your flakes up to date"
|
||||
image: "https://wallpapercave.com/wp/wp12329537.png"
|
||||
keywords: "nix, update, zen browser"
|
||||
lang: "en"
|
||||
title: "Nix automatic hash updates made easy"
|
||||
---
|
||||
|
||||
Nix users often create flakes to package software out of tree, like this [Zen
|
||||
Browser flake](https://github.com/youwen5/zen-browser-flake) I've been
|
||||
maintaining. Keeping them up to date is a hassle though, since you have to
|
||||
update the Subresource Integrity (SRI) hashes that Nix uses to ensure
|
||||
reproducibility.
|
||||
|
||||
Here's a neat method I've been using to cleanly handle automatic hash updates.
|
||||
I use [Nushell](https://www.nushell.sh/) to easily work with data, prefetch
|
||||
some hashes, and put it all in a JSON file that can be read by Nix at build
|
||||
time.
|
||||
|
||||
First, let's create a file called `update.nu`. At the top, place this shebang:
|
||||
|
||||
```nu
|
||||
#!/usr/bin/env -S nix shell nixpkgs#nushell --command nu
|
||||
```
|
||||
|
||||
This will execute the script in a Nushell environment, which is fetched by Nix.
|
||||
|
||||
## Get the up to date URLs
|
||||
|
||||
We need to obtain the latest version of whatever software we want to update.
|
||||
In this case, I'll use GitHub releases as my source of truth.
|
||||
|
||||
You can use the GitHub API to fetch metadata about all the releases of a repository.
|
||||
|
||||
```
|
||||
https://api.github.com/repos/($repo)/releases
|
||||
```
|
||||
|
||||
Roughly speaking, the raw JSON returned by the GitHub releases API looks something like:
|
||||
|
||||
```
|
||||
[
|
||||
{tag_name: "foo", prerelease: false, ...},
|
||||
{tag_name: "bar", prerelease: true, ...},
|
||||
{tag_name: "foobar", prerelease: false, ...},
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
Note that the ordering of the objects in the array is chronological.
|
||||
|
||||
> Even if you aren't using GitHub releases, as long as there is a reliable way to
|
||||
> programmatically fetch the latest download URLs of whatever software you're
|
||||
> packaging, you can adapt this approach for your specific case.
|
||||
|
||||
We use Nushell's `http get` to make a network request. Nushell will
|
||||
automatically detect and parse the JSON reponse into a Nushell table.
|
||||
|
||||
In my case, Zen Browser frequently publishes prerelease "twilight" builds which
|
||||
we don't want to update to. So, we ignore any releases tagged "twilight" or
|
||||
marked "prerelease" by filtering them out with the `where` selector.
|
||||
|
||||
Finally, we retrieve the tag name of the item at the first index, which would
|
||||
be the latest release (since the JSON array was chronologically sorted).
|
||||
|
||||
|
||||
```nu
|
||||
#!/usr/bin/env -S nix shell nixpkgs#nushell --command nu
|
||||
|
||||
# get the latest tag of the latest release that isn't a prerelease
|
||||
def get_latest_release [repo: string] {
|
||||
try {
|
||||
http get $"https://api.github.com/repos/($repo)/releases"
|
||||
| where prerelease == false
|
||||
| where tag_name != "twilight"
|
||||
| get tag_name
|
||||
| get 0
|
||||
} catch { |err| $"Failed to fetch latest release, aborting: ($err.msg)" }
|
||||
}
|
||||
```
|
||||
|
||||
## Prefetching SRI hashes
|
||||
|
||||
Now that we have the latest tags, we can easily obtain the latest download URLs, which are of the form:
|
||||
|
||||
```
|
||||
https://github.com/zen-browser/desktop/releases/download/$tag/zen.linux-x86_64.tar.bz2
|
||||
https://github.com/zen-browser/desktop/releases/download/$tag/zen.aarch64-x86_64.tar.bz2
|
||||
```
|
||||
|
||||
However, we still need the corresponding SRI hashes to pass to Nix.
|
||||
|
||||
```nix
|
||||
src = fetchurl {
|
||||
url = "https://github.com/zen-browser/desktop/releases/download/1.0.2-b.5/zen.linux-x86_64.tar.bz2";
|
||||
hash = "sha256-00000000000000000000000000000000000000000000";
|
||||
};
|
||||
```
|
||||
|
||||
The easiest way to obtain these new hashes is to update the URL and then set
|
||||
the hash property to an empty string (`""`). Nix will spit out an hash mismatch
|
||||
error with the correct hash. However, this is inconvenient for automated
|
||||
command line scripting.
|
||||
|
||||
The Nix documentation mentions
|
||||
[nix-prefetch-url](https://nix.dev/manual/nix/2.18/command-ref/nix-prefetch-url)
|
||||
as a way to obtain these hashes, but as usual, it doesn't work quite right and
|
||||
has also been replaced by a more powerful but underdocumented experimental
|
||||
feature instead.
|
||||
|
||||
The [nix store
|
||||
prefetch-file](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-store-prefetch-file)
|
||||
command does what `nix-prefetch-url` is supposed to do, but handles the caveats
|
||||
that lead to the wrong hash being produced automatically.
|
||||
|
||||
Let's write a Nushell function that outputs the SRI hash of the given URL. We
|
||||
tell `prefetch-file` to output structured JSON that we can parse.
|
||||
|
||||
Since Nushell _is_ a shell, we can directly invoke shell commands like usual,
|
||||
and then process their output with pipes.
|
||||
|
||||
```nu
|
||||
def get_nix_hash [url: string] {
|
||||
nix store prefetch-file --hash-type sha256 --json $url | from json | get hash
|
||||
}
|
||||
```
|
||||
|
||||
Cool! Now `get_nix_hash` can give us SRI hashes that look like this:
|
||||
|
||||
```
|
||||
sha256-K3zTCLdvg/VYQNsfeohw65Ghk8FAjhOl8hXU6REO4/s=
|
||||
```
|
||||
|
||||
## Putting it all together
|
||||
|
||||
Now that we're able to fetch the latest release, obtain the download URLs, and
|
||||
compute their SRI hashes, we have all the information we need to make an
|
||||
automated update. However, these URLs are typically hardcoded in our Nix
|
||||
expressions. The question remains as to how to update these values.
|
||||
|
||||
A common way I've seen updates performed is using something like `sed` to
|
||||
modify the Nix expressions in place. However, there's actually a more
|
||||
maintainable and easy to understand approach.
|
||||
|
||||
Let's have our Nushell script generate the URLs and hashes and place them in a
|
||||
JSON file! Then, we'll be able to read the JSON file from Nix and obtain the
|
||||
URL and hash.
|
||||
|
||||
```nu
|
||||
def generate_sources [] {
|
||||
let tag = get_latest_release "zen-browser/desktop"
|
||||
let prev_sources = open ./sources.json
|
||||
|
||||
if $tag == $prev_sources.version {
|
||||
# everything up to date
|
||||
return $tag
|
||||
}
|
||||
|
||||
# generate the download URLs with the new tag
|
||||
let x86_64_url = $"https://github.com/zen-browser/desktop/releases/download/($tag)/zen.linux-x86_64.tar.bz2"
|
||||
let aarch64_url = $"https://github.com/zen-browser/desktop/releases/download/($tag)/zen.linux-aarch64.tar.bz2"
|
||||
|
||||
# create a Nushell record that maps cleanly to JSON
|
||||
let sources = {
|
||||
# add a version field as well for convenience
|
||||
version: $tag
|
||||
|
||||
x86_64-linux: {
|
||||
url: $x86_64_url
|
||||
hash: (get_nix_hash $x86_64_url)
|
||||
}
|
||||
aarch64-linux: {
|
||||
url: $aarch64_url
|
||||
hash: (get_nix_hash $aarch64_url)
|
||||
}
|
||||
}
|
||||
|
||||
echo $sources | save --force "sources.json"
|
||||
|
||||
return $tag
|
||||
}
|
||||
```
|
||||
|
||||
Running this script with
|
||||
|
||||
```bash
|
||||
chmod +x ./update.nu
|
||||
./update.nu
|
||||
```
|
||||
|
||||
gives us the file `sources.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.2-b.5",
|
||||
"x86_64-linux": {
|
||||
"url": "https://github.com/zen-browser/desktop/releases/download/1.0.2-b.5/zen.linux-x86_64.tar.bz2",
|
||||
"hash": "sha256-K3zTCLdvg/VYQNsfeohw65Ghk8FAjhOl8hXU6REO4/s="
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"url": "https://github.com/zen-browser/desktop/releases/download/1.0.2-b.5/zen.linux-aarch64.tar.bz2",
|
||||
"hash": "sha256-NwIYylGal2QoWhWKtMhMkAAJQ6iNHfQOBZaxTXgvxAk="
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now, let's read this from Nix. My file organization looks like the following:
|
||||
|
||||
```
|
||||
./
|
||||
| flake.nix
|
||||
| zen-browser-unwrapped.nix
|
||||
| ...other files...
|
||||
```
|
||||
|
||||
`zen-browser-unwrapped.nix` contains the derivation for Zen Browser. Let's add
|
||||
`version`, `url`, and `hash` to its inputs:
|
||||
|
||||
```nix
|
||||
{
|
||||
stdenv,
|
||||
fetchurl,
|
||||
# add these below
|
||||
version,
|
||||
url,
|
||||
hash,
|
||||
...
|
||||
}:
|
||||
stdenv.mkDerivation {
|
||||
# inherit version from inputs
|
||||
inherit version;
|
||||
pname = "zen-browser-unwrapped";
|
||||
|
||||
src = fetchurl {
|
||||
# inherit the URL and hash we obtain from the inputs
|
||||
inherit url hash;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Then in `flake.nix`, let's provide the derivation with the data from `sources.json`:
|
||||
|
||||
```nix
|
||||
let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
in
|
||||
{
|
||||
# rest of file omitted for simplicity
|
||||
packages = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
# parse sources.json into a Nix attrset
|
||||
sources = builtins.fromJSON (builtins.readFile ./sources.json);
|
||||
in
|
||||
rec {
|
||||
zen-browser-unwrapped = pkgs.callPackage ./zen-browser-unwrapped.nix {
|
||||
inherit (sources.${system}) hash url;
|
||||
inherit (sources) version;
|
||||
|
||||
# if the above is difficult to understand, it is equivalent to the following:
|
||||
hash = sources.${system}.hash;
|
||||
url = sources.${system}.url;
|
||||
version = sources.version;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Now, running `nix build .#zen-browser-unwrapped` will be able to use the hashes
|
||||
and URLs from `sources.json` to build the package!
|
||||
|
||||
## Automating it in CI
|
||||
|
||||
We now have a script that can automatically fetch releases and generate hashes
|
||||
and URLs, as well as a way for Nix to use the outputted JSON to build
|
||||
derivations. All that's left is to fully automate it using CI!
|
||||
|
||||
We are going to use GitHub actions for this, as it's free and easy and you're
|
||||
probably already hosting on GitHub.
|
||||
|
||||
Ensure you've set up actions for your repo and given it sufficient permissions.
|
||||
|
||||
We're gonna run it on a cron timer that checks for updates at 8 PM PST every day.
|
||||
|
||||
We use DeterminateSystems' actions to help set up Nix. Then, we simply run our
|
||||
update script. Since we made the script return the tag it fetched, we can store
|
||||
it in a variable and then use it in our commit message.
|
||||
|
||||
```
|
||||
name: Update to latest version, and update flake inputs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update:
|
||||
name: Update flake inputs and browser
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check flake inputs
|
||||
uses: DeterminateSystems/flake-checker-action@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: Set up magic Nix cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
|
||||
- name: Check for update and perform update
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
chmod +x ./update.nu
|
||||
export ZEN_LATEST_VER="$(./update.nu)"
|
||||
|
||||
git add -A
|
||||
git commit -m "github-actions: update to $ZEN_LATEST_VER" || echo "Latest version is $ZEN_LATEST_VER, no updates found"
|
||||
|
||||
nix flake update --commit-lock-file
|
||||
|
||||
git push
|
||||
```
|
||||
|
||||
Now, our repository will automatically check for and perform updates every day!
|
Loading…
Reference in a new issue