channel-scripts: vendor and use zstd-capable nix-index
Vendor nixos-channel-scripts instead of pulling it as a flake input, so the channel mirror no longer depends on a separate repository. The mirror builds the nixos-files.sqlite index with nix-index, but cache.nixos.org now serves .ls listings compressed with zstd, which the nixpkgs nix-index (0.1.10) cannot read. Build the package against a nix-index with zstd listing support until the fix is in nixpkgs.
Jörg Thalheim committed
Jun 20, 2026 at 16:56 UTC
448d763ce0fde258a3131d8cf4ba07a5f25837e2
6 files changed
+602
-29
flake.lock
+42
-24
@@ -193,6 +193,22 @@
193
}
194
},
195
"flake-compat_2": {
196
+ "flake": false,
197
+ "locked": {
198
+ "lastModified": 1767039857,
199
+ "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
200
+ "owner": "edolstra",
201
+ "repo": "flake-compat",
202
+ "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
203
+ "type": "github"
204
+ },
205
+ "original": {
206
+ "owner": "edolstra",
207
+ "repo": "flake-compat",
208
+ "type": "github"
209
+ }
210
+ },
211
+ "flake-compat_3": {
212
"flake": false,
213
"locked": {
214
"lastModified": 1761588595,
@@ -208,7 +224,7 @@
224
"type": "github"
225
}
226
},
211
- "flake-compat_3": {
227
+ "flake-compat_4": {
228
"flake": false,
229
"locked": {
230
"lastModified": 1767039857,
@@ -555,6 +571,28 @@
571
"type": "github"
572
}
573
},
574
+ "nix-index": {
575
+ "inputs": {
576
+ "flake-compat": "flake-compat_2",
577
+ "nixpkgs": [
578
+ "nixpkgs"
579
+ ]
580
+ },
581
+ "locked": {
582
+ "lastModified": 1781966668,
583
+ "narHash": "sha256-4/OeeA8pf5g12YiPM5yZVwvr83Fg7TaNQfv38e3cE5s=",
584
+ "owner": "Mic92",
585
+ "repo": "nix-index",
586
+ "rev": "9cc6166ffef55e7130712f8559865ecc711e7139",
587
+ "type": "github"
588
+ },
589
+ "original": {
590
+ "owner": "Mic92",
591
+ "ref": "zstd-listings",
592
+ "repo": "nix-index",
593
+ "type": "github"
594
+ }
595
+ },
596
"nixocaine": {
597
"inputs": {
598
"iocaine-unstable": "iocaine-unstable",
@@ -580,26 +618,6 @@
618
"url": "https://git.madhouse-project.org/iocaine/nixocaine"
619
}
620
},
583
- "nixos-channel-scripts": {
584
- "inputs": {
585
- "nixpkgs": [
586
- "nixpkgs"
587
- ]
588
- },
589
- "locked": {
590
- "lastModified": 1781506056,
591
- "narHash": "sha256-hqMReyFcAuer7H+ZPgL4DdugcxeU4R57T8mggLDkia8=",
592
- "owner": "NixOS",
593
- "repo": "nixos-channel-scripts",
594
- "rev": "6d6c1337aabf7e209df14d481abcff50bc9a43e0",
595
- "type": "github"
596
- },
597
- "original": {
598
- "owner": "NixOS",
599
- "repo": "nixos-channel-scripts",
600
- "type": "github"
601
- }
602
- },
621
"nixpkgs": {
622
"locked": {
623
"lastModified": 1781483490,
@@ -707,7 +725,7 @@
725
},
726
"pre-commit-hooks": {
727
"inputs": {
710
- "flake-compat": "flake-compat_2",
728
+ "flake-compat": "flake-compat_3",
729
"gitignore": "gitignore",
730
"nixpkgs": [
731
"nixocaine",
@@ -761,8 +779,8 @@
779
"geolite2-asn-mmdb": "geolite2-asn-mmdb",
780
"hydra": "hydra",
781
"nix": "nix",
782
+ "nix-index": "nix-index",
783
"nixocaine": "nixocaine",
765
- "nixos-channel-scripts": "nixos-channel-scripts",
784
"nixpkgs": "nixpkgs",
785
"nixpkgs-swh": "nixpkgs-swh",
786
"nixpkgs-unstable": "nixpkgs-unstable",
@@ -778,7 +796,7 @@
796
"simple-nixos-mailserver": {
797
"inputs": {
798
"blobs": "blobs",
781
- "flake-compat": "flake-compat_3",
799
+ "flake-compat": "flake-compat_4",
800
"git-hooks": "git-hooks",
801
"nixpkgs": [
802
"nixpkgs"
flake.nix
+3
-2
@@ -34,8 +34,9 @@
34
inputs.treefmt-nix.follows = "treefmt-nix";
35
};
36
37
- nixos-channel-scripts = {
38
- url = "github:NixOS/nixos-channel-scripts";
37
+ nix-index = {
38
+ # zstd .ls listing support, pending https://github.com/nix-community/nix-index/pull/320
39
+ url = "github:Mic92/nix-index/zstd-listings";
40
inputs.nixpkgs.follows = "nixpkgs";
41
};
42
modules/hydra-mirror.nix
+6
-3
@@ -15,9 +15,12 @@ let
15
name = "update-${channelName}";
16
value = {
17
description = "Update Channel ${channelName}";
18
- path = with pkgs; [
19
- git
20
- inputs.nixos-channel-scripts.packages.${pkgs.stdenv.hostPlatform.system}.default
18
+ path = [
19
+ pkgs.git
20
+ (pkgs.callPackage ../pkgs/nixos-channel-scripts {
21
+ # nixpkgs nix-index cannot read zstd-compressed .ls listings yet
22
+ nix-index = inputs.nix-index.packages.${pkgs.stdenv.hostPlatform.system}.default;
23
+ })
24
];
25
script = ''
26
# Hardcoded in channel scripts.
pkgs/nixos-channel-scripts/default.nix
new
+88
@@ -0,0 +1,88 @@
1
+{
2
+ lib,
3
+ stdenv,
4
+ pkg-config,
5
+ nixVersions,
6
+ nlohmann_json,
7
+ boost,
8
+ makeWrapper,
9
+ perl,
10
+ perlPackages,
11
+ wget,
12
+ git,
13
+ nix,
14
+ gnutar,
15
+ xz,
16
+ rsync,
17
+ openssh,
18
+ nix-index,
19
+}:
20
+let
21
+ nixos-channel-native-programs = stdenv.mkDerivation {
22
+ name = "nixos-channel-native-programs";
23
+
24
+ strictDeps = true;
25
+
26
+ nativeBuildInputs = [ pkg-config ];
27
+
28
+ buildInputs = [
29
+ nixVersions.nix_2_28
30
+ nlohmann_json
31
+ boost
32
+ ];
33
+
34
+ buildCommand = ''
35
+ mkdir -p $out/bin
36
+
37
+ $CXX \
38
+ -Os -g -Wall \
39
+ -std=c++14 \
40
+ $(pkg-config --libs --cflags nix-store) \
41
+ $(pkg-config --libs --cflags nix-main) \
42
+ -I . \
43
+ ${./index-debuginfo.cc} \
44
+ -o $out/bin/index-debuginfo
45
+ '';
46
+ };
47
+in
48
+stdenv.mkDerivation {
49
+ name = "nixos-channel-scripts";
50
+
51
+ strictDeps = true;
52
+
53
+ nativeBuildInputs = [ makeWrapper ];
54
+
55
+ buildInputs = with perlPackages; [
56
+ perl
57
+ FileSlurp
58
+ LWP
59
+ LWPProtocolHttps
60
+ ListMoreUtils
61
+ DBDSQLite
62
+ NetAmazonS3
63
+ ];
64
+
65
+ buildCommand = ''
66
+ mkdir -p $out/bin
67
+
68
+ cp ${./mirror-nixos-branch.pl} $out/bin/mirror-nixos-branch
69
+ wrapProgram $out/bin/mirror-nixos-branch \
70
+ --set PERL5LIB $PERL5LIB \
71
+ --set XZ_OPT "-T0" \
72
+ --prefix PATH : ${
73
+ lib.makeBinPath [
74
+ wget
75
+ git
76
+ nix
77
+ gnutar
78
+ xz
79
+ rsync
80
+ openssh
81
+ nix-index
82
+ nixos-channel-native-programs
83
+ ]
84
+ }
85
+
86
+ patchShebangs $out/bin
87
+ '';
88
+}
pkgs/nixos-channel-scripts/index-debuginfo.cc
new
+68
@@ -0,0 +1,68 @@
1
+#include <regex>
2
+
3
+#include <nlohmann/json.hpp>
4
+
5
+#include <nix/util/signals.hh>
6
+#include <nix/util/thread-pool.hh>
7
+
8
+#include <nix/store/nar-info.hh>
9
+#include <nix/store/s3-binary-cache-store.hh>
10
+#include <nix/store/sqlite.hh>
11
+
12
+#include <nix/main/shared.hh>
13
+
14
+// cache.nixos.org/debuginfo/<build-id>
15
+// => redirect to NAR
16
+
17
+using namespace nix;
18
+
19
+void mainWrapped(int argc, char * * argv)
20
+{
21
+ initNix();
22
+
23
+ if (argc != 3) throw Error("usage: index-debuginfo DEBUG-DB BINARY-CACHE-URI");
24
+
25
+ Path debugDbPath = argv[1];
26
+ std::string binaryCacheUri = argv[2];
27
+
28
+ if (hasSuffix(binaryCacheUri, "/")) binaryCacheUri.pop_back();
29
+ auto binaryCache = openStore(binaryCacheUri).cast<S3BinaryCacheStore>();
30
+
31
+ ThreadPool threadPool(25);
32
+
33
+ auto doFile = [&](std::string build_id, std::string url, std::string filename) {
34
+ checkInterrupt();
35
+
36
+ nlohmann::json json;
37
+ json["archive"] = url;
38
+ json["member"] = filename;
39
+
40
+ std::string key = "debuginfo/" + build_id;
41
+
42
+ // FIXME: or should we overwrite? The previous link may point
43
+ // to a GC'ed file, so overwriting might be useful...
44
+ if (binaryCache->fileExists(key)) return;
45
+
46
+ printError("redirecting ‘%s’ to ‘%s’", key, filename);
47
+
48
+ binaryCache->upsertFile(key, json.dump(), "application/json");
49
+ };
50
+
51
+ auto db = SQLite(debugDbPath);
52
+
53
+ auto stmt = SQLiteStmt(db, "select build_id, url, filename from DebugInfo;");
54
+ auto query = stmt.use();
55
+
56
+ while (query.next()) {
57
+ threadPool.enqueue(std::bind(doFile, query.getStr(0), query.getStr(1), query.getStr(2)));
58
+ }
59
+
60
+ threadPool.process();
61
+}
62
+
63
+int main(int argc, char * * argv)
64
+{
65
+ return handleExceptions(argv[0], [&]() {
66
+ mainWrapped(argc, argv);
67
+ });
68
+}
pkgs/nixos-channel-scripts/mirror-nixos-branch.pl
new
+395
@@ -0,0 +1,395 @@
1
+#! /usr/bin/env perl
2
+
3
+use strict;
4
+use warnings;
5
+use Data::Dumper;
6
+use Digest::SHA;
7
+use Fcntl qw(:flock);
8
+use File::Basename;
9
+use File::Path;
10
+use File::Slurp;
11
+use File::stat;
12
+use JSON::PP;
13
+use LWP::UserAgent;
14
+use List::MoreUtils qw(uniq);
15
+use Net::Amazon::S3;
16
+use POSIX qw(strftime);
17
+
18
+# Runs the given command, printing the (unescaped) command.
19
+# This command continues on failure.
20
+sub runAllowFailure {
21
+ print STDERR " \$ ", join(" ", @_), "\n";
22
+ system(@_);
23
+}
24
+
25
+# Runs the given command, printing the (unescaped) command.
26
+# This command dies on failure.
27
+sub run {
28
+ my $context = caller(0);
29
+ my $code = runAllowFailure(@_);
30
+ unless ($code == 0) {
31
+ my $exit = $code >> 8;
32
+ my $errno = $code - ($exit << 8);
33
+ die "Command failed with code ($exit) errno ($errno).\n";
34
+ }
35
+
36
+ return $code;
37
+}
38
+
39
+my $channelName = $ARGV[0];
40
+my $releaseUrl = $ARGV[1];
41
+
42
+die "Usage: $0 CHANNEL-NAME RELEASE-URL\n" unless defined $channelName && defined $releaseUrl;
43
+
44
+$channelName =~ /^([a-z]+)-(.*)$/ or die;
45
+my $channelDirRel = $channelName eq "nixpkgs-unstable" ? "nixpkgs" : "$1/$2";
46
+
47
+
48
+# Configuration.
49
+my $TMPDIR = $ENV{'TMPDIR'} // "/tmp";
50
+my $filesCache = "${TMPDIR}/nixos-files.sqlite";
51
+my $bucketReleasesName = "nix-releases";
52
+my $bucketChannelsName = "nix-channels";
53
+my $dryRun = $ENV{'DRY_RUN'} // 0;
54
+
55
+$ENV{'GIT_DIR'} = "/home/hydra-mirror/nixpkgs-channels";
56
+
57
+my $bucketReleases;
58
+my $bucketChannels;
59
+
60
+unless ($dryRun) {
61
+ # S3 setup.
62
+ my $aws_access_key_id = $ENV{'AWS_ACCESS_KEY_ID'} or die "No AWS_ACCESS_KEY_ID given.";
63
+ my $aws_secret_access_key = $ENV{'AWS_SECRET_ACCESS_KEY'} or die "No AWS_SECRET_ACCESS_KEY given.";
64
+
65
+ my $s3 = Net::Amazon::S3->new(
66
+ { aws_access_key_id => $aws_access_key_id,
67
+ aws_secret_access_key => $aws_secret_access_key,
68
+ retry => 1,
69
+ host => "s3-eu-west-1.amazonaws.com",
70
+ });
71
+
72
+ $bucketReleases = $s3->bucket($bucketReleasesName) or die;
73
+
74
+ my $s3_us = Net::Amazon::S3->new(
75
+ { aws_access_key_id => $aws_access_key_id,
76
+ aws_secret_access_key => $aws_secret_access_key,
77
+ retry => 1,
78
+ });
79
+
80
+ $bucketChannels = $s3_us->bucket($bucketChannelsName) or die;
81
+} else {
82
+ print STDERR "WARNING: Running in dry-run.\n";
83
+}
84
+
85
+sub fetch {
86
+ my ($url, $type) = @_;
87
+
88
+ my $ua = LWP::UserAgent->new;
89
+ $ua->default_header('Accept', $type) if defined $type;
90
+
91
+ my $response = $ua->get($url);
92
+ die "could not download $url: ", $response->status_line, "\n" unless $response->is_success;
93
+
94
+ return $response->decoded_content;
95
+}
96
+
97
+my $releaseInfo = decode_json(fetch($releaseUrl, 'application/json'));
98
+
99
+my $releaseId = $releaseInfo->{id} or die;
100
+my $releaseName = $releaseInfo->{nixname} or die;
101
+$releaseName =~ /-([0-9].+)/ or die;
102
+my $releaseVersion = $1;
103
+my $evalId = $releaseInfo->{jobsetevals}->[0] or die;
104
+my $evalUrl = "https://hydra.nixos.org/eval/$evalId";
105
+my $evalInfo = decode_json(fetch($evalUrl, 'application/json'));
106
+my $releasePrefix = "$channelDirRel/$releaseName";
107
+
108
+my $rev = $evalInfo->{jobsetevalinputs}->{nixpkgs}->{revision} or die;
109
+
110
+# Get commit date of $rev as unixtime and formatted string
111
+run("git fetch origin $rev >&2");
112
+my $revUnix = `git show --no-patch --format='%ct' $rev` or die;
113
+my $revDate = strftime("%F %T %Z", localtime($revUnix));
114
+
115
+print STDERR "\nRelease information:\n";
116
+print STDERR " - release is: $releaseName (build $releaseId)\n - eval is: $evalId\n - prefix is: $releasePrefix\n - Git commit is: $rev\n - Git commit date is: $revDate\n\n";
117
+
118
+if ($bucketChannels) {
119
+ # Guard against the channel going back in time.
120
+ my $curRelease = "";
121
+
122
+ if (defined(my $object = $bucketChannels->get_key($channelName))) {
123
+ $curRelease = $object->{'x-amz-website-redirect-location'} // "";
124
+ }
125
+
126
+ if (!defined $ENV{'FORCE'}) {
127
+ print STDERR "previous release is $curRelease\n";
128
+ $! = 0; # Clear errno to avoid reporting non-fork/exec-related issues
129
+ my $d = `NIX_PATH= nix-instantiate --eval -E "builtins.compareVersions (builtins.parseDrvName \\"$curRelease\\").version (builtins.parseDrvName \\"$releaseName\\").version"`;
130
+ if ($? != 0) {
131
+ warn "Could not execute nix-instantiate: exit $?; errno $!\n";
132
+ exit 1;
133
+ }
134
+ chomp $d;
135
+ if ($d == 1) {
136
+ warn("channel would go back in time from $curRelease to $releaseName, bailing out\n");
137
+ exit;
138
+ }
139
+ exit if $d == 0;
140
+ }
141
+}
142
+
143
+if ($bucketReleases && $bucketReleases->head_key("$releasePrefix")) {
144
+ print STDERR "release already exists\n";
145
+} else {
146
+ my $tmpDir = "$TMPDIR/release-$channelName/$releaseName";
147
+ File::Path::make_path($tmpDir);
148
+
149
+ write_file("$tmpDir/src-url", $evalUrl);
150
+ write_file("$tmpDir/git-revision", $rev);
151
+ write_file("$tmpDir/binary-cache-url", "https://cache.nixos.org");
152
+
153
+ if (! -e "$tmpDir/store-paths.xz") {
154
+ my $storePaths = decode_json(fetch("$evalUrl/store-paths", 'application/json'));
155
+ write_file("$tmpDir/store-paths", join("\n", uniq(@{$storePaths})) . "\n");
156
+ }
157
+
158
+ sub downloadFile {
159
+ my ($jobName, $dstName, $productType) = @_;
160
+
161
+ my $buildInfo = decode_json(fetch("$evalUrl/job/$jobName", 'application/json'));
162
+
163
+ my $products = ();
164
+ # Key the products by subtype.
165
+ foreach my $key (keys $buildInfo->{buildproducts}->%*) {
166
+ my $subType = $buildInfo->{buildproducts}->{$key}->{subtype};
167
+ if ($products->{$subType}) {
168
+ die "Job $jobName has multiple products of the same subtype $subType.\nThis is a bad assumption from this script";
169
+ }
170
+ $products->{$subType} = $buildInfo->{buildproducts}->{$key};
171
+ }
172
+ my $size = keys %{$products};
173
+
174
+ if ($size > 1 && !$productType) {
175
+ my $types = join(", ", keys %{$products});
176
+ die "Job $jobName has $size build products. Select the right product by subtype [$types]";
177
+ }
178
+
179
+ my $product;
180
+ if (!$productType) {
181
+ # Take the only element
182
+ my ($key) = keys %{$products};
183
+ $product = $products->{$key};
184
+ } else {
185
+ # Take the selected element
186
+ $product = $products->{$productType};
187
+ }
188
+
189
+ unless ($product) {
190
+ die "No product could be selected for $jobName, with type $productType";
191
+ }
192
+
193
+ my $srcFile = $product->{path} or die "job '$jobName' lacks a store path";
194
+ $dstName //= basename($srcFile);
195
+ my $dstFile = "$tmpDir/" . $dstName;
196
+
197
+ my $sha256_expected = $product->{sha256hash} or die;
198
+
199
+ if (! -e $dstFile) {
200
+ print STDERR "downloading $srcFile to $dstFile...\n";
201
+ write_file("$dstFile.sha256", "$sha256_expected $dstName");
202
+ runAllowFailure("NIX_REMOTE=s3://nix-cache nix --experimental-features nix-command store cat '$srcFile' > '$dstFile.tmp'") == 0
203
+ or die "unable to fetch $srcFile\n";
204
+ rename("$dstFile.tmp", $dstFile) or die;
205
+ }
206
+
207
+ if (-e "$dstFile.sha256") {
208
+ my $sha256_actual = `nix --experimental-features nix-command hash file --base16 --type sha256 '$dstFile'`;
209
+ chomp $sha256_actual;
210
+ if ($sha256_expected ne $sha256_actual) {
211
+ print STDERR "file $dstFile is corrupt $sha256_expected $sha256_actual\n";
212
+ exit 1;
213
+ }
214
+ }
215
+ }
216
+
217
+ if ($channelName =~ /nixos/) {
218
+ downloadFile("nixos.channel", "nixexprs.tar.xz");
219
+ downloadFile("nixpkgs.tarball", "packages.json.br", "json-br");
220
+ downloadFile("nixos.options", "options.json.br", "json-br");
221
+
222
+ # Minimal installer ISOs were dropped from the small channel
223
+ if ($channelName !~ /-small/ ||
224
+ $channelName =~ /nixos-2([0123]\...|4\.05)-small/) {
225
+ downloadFile("nixos.iso_minimal.aarch64-linux");
226
+ downloadFile("nixos.iso_minimal.x86_64-linux");
227
+ }
228
+
229
+ # All of these jobs are not present in small channels
230
+ if ($channelName !~ /-small/) {
231
+ # These jobs were combined into a single job
232
+ if ($channelName =~ /nixos-2[01234]/) {
233
+ if ($channelName =~ /nixos-2[0123]/) {
234
+ downloadFile("nixos.iso_plasma5.aarch64-linux");
235
+ downloadFile("nixos.iso_plasma5.x86_64-linux");
236
+ } else {
237
+ downloadFile("nixos.iso_plasma6.aarch64-linux");
238
+ downloadFile("nixos.iso_plasma6.x86_64-linux");
239
+ }
240
+
241
+ downloadFile("nixos.iso_gnome.aarch64-linux");
242
+ downloadFile("nixos.iso_gnome.x86_64-linux");
243
+ } else {
244
+ downloadFile("nixos.iso_graphical.aarch64-linux");
245
+ downloadFile("nixos.iso_graphical.x86_64-linux");
246
+ }
247
+
248
+ if ($channelName =~ /nixos-2[0123]/) { # i686 dropped for > 23.11
249
+ downloadFile("nixos.iso_minimal.i686-linux");
250
+ }
251
+
252
+ if ($channelName =~ /nixos-2([0123]\...|4\.05)/) {
253
+ downloadFile("nixos.ova.x86_64-linux");
254
+ }
255
+ }
256
+
257
+ } else {
258
+ downloadFile("tarball", "nixexprs.tar.xz", "source-dist");
259
+ downloadFile("tarball", "packages.json.br", "json-br");
260
+ }
261
+
262
+ # Generate the programs.sqlite database and put it in
263
+ # nixexprs.tar.xz. Also maintain the debug info repository at
264
+ # https://cache.nixos.org/debuginfo.
265
+ if ($channelName =~ /nixos/ && -e "$tmpDir/store-paths") {
266
+ File::Path::make_path("$tmpDir/unpack");
267
+ run("tar", "xfJ", "$tmpDir/nixexprs.tar.xz", "-C", "$tmpDir/unpack");
268
+ my $exprDir = glob("$tmpDir/unpack/*");
269
+ run("nix-channel-index", "-o", "$exprDir/programs.sqlite", "-d", "$exprDir/debug.sqlite", "-f", "$exprDir/nixpkgs", "-s", "aarch64-linux", "-s", "x86_64-linux");
270
+ run("index-debuginfo", "$exprDir/debug.sqlite", "s3://nix-cache");
271
+ run("rm", "-f", "$tmpDir/nixexprs.tar.xz", "$exprDir/debug.sqlite");
272
+ unlink("$tmpDir/nixexprs.tar.xz.sha256");
273
+ run("tar", "cfJ", "$tmpDir/nixexprs.tar.xz", "-C", "$tmpDir/unpack", basename($exprDir));
274
+ run("rm", "-rf", "$tmpDir/unpack");
275
+ }
276
+
277
+ if (-e "$tmpDir/store-paths") {
278
+ run("xz", "$tmpDir/store-paths");
279
+ }
280
+
281
+ my $now = strftime("%F %T %Z", localtime);
282
+ my $title = "$channelName release $releaseName";
283
+ my $githubLink = "https://github.com/NixOS/nixpkgs/commits/$rev";
284
+
285
+ my $html = "<html><head>";
286
+ $html .= "<title>$title</title></head>";
287
+ $html .= "<body><h1>$title</h1>";
288
+ $html .= "<p>Released on $now from <a href='$githubLink'>Git commit <tt>$rev</tt></a> from $revDate ";
289
+ $html .= "via <a href='$evalUrl'>Hydra evaluation $evalId</a>.</p>";
290
+ $html .= "<table><thead><tr><th>File name</th><th>Size</th><th>SHA-256 hash</th></tr></thead><tbody>";
291
+
292
+ if ($bucketReleases) {
293
+ # Upload the release to S3.
294
+ for my $fn (sort glob("$tmpDir/*")) {
295
+ my $basename = basename $fn;
296
+ my $key = "$releasePrefix/" . $basename;
297
+
298
+ unless (defined $bucketReleases->head_key($key)) {
299
+ print STDERR "mirroring $fn to s3://$bucketReleasesName/$key...\n";
300
+
301
+ # Default headers
302
+ my $configuration = ();
303
+ $configuration->{content_type} = "application/octet-stream";
304
+
305
+ if ($fn =~ /.sha256|src-url|binary-cache-url|git-revision/) {
306
+ # Text files
307
+ $configuration->{content_type} = "text/plain";
308
+ } elsif ($fn =~ /.json.br$/) {
309
+ # JSON encoded as brotli
310
+ $configuration->{content_type} = "application/json";
311
+ $configuration->{content_encoding} = "br";
312
+ }
313
+
314
+ $bucketReleases->add_key_filename(
315
+ $key, $fn, $configuration
316
+ ) or die $bucketReleases->err . ": " . $bucketReleases->errstr;
317
+ }
318
+
319
+ next if $basename =~ /.sha256$/;
320
+
321
+ my $size = stat($fn)->size;
322
+ my $sha256 = Digest::SHA::sha256_hex(read_file($fn));
323
+ $html .= "<tr>";
324
+ $html .= "<td><a href='/$key'>$basename</a></td>";
325
+ $html .= "<td align='right'>$size</td>";
326
+ $html .= "<td><tt>$sha256</tt></td>";
327
+ $html .= "</tr>";
328
+ }
329
+
330
+ $html .= "</tbody></table></body></html>";
331
+
332
+ $bucketReleases->add_key($releasePrefix, $html,
333
+ { content_type => "text/html" })
334
+ or die $bucketReleases->err . ": " . $bucketReleases->errstr;
335
+ }
336
+
337
+ File::Path::remove_tree($tmpDir);
338
+}
339
+
340
+if ($dryRun) {
341
+ print STDERR "WARNING: dry-run finished...\n";
342
+ exit(0);
343
+}
344
+
345
+# Update the nixos-* branch in the nixpkgs repo.
346
+run("git remote update origin >&2");
347
+run("git push origin $rev:refs/heads/$channelName >&2");
348
+
349
+# maxage=600: Serve from cache for 5 minutes.
350
+# stale-while-revaliadate=1800: Serve from cache while updating in the background for 30 minutes.
351
+# https://web.dev/stale-while-revalidate/
352
+# https://developer.fastly.com/learning/concepts/cache-freshness/
353
+my $cache_control = "maxage=600,stale-while-revalidate=1800,public";
354
+
355
+sub redirect {
356
+ my ($from, $to) = @_;
357
+ $to = "https://releases.nixos.org/" . $to;
358
+ print STDERR "redirect $from -> $to\n";
359
+ $bucketChannels->add_key($from, "", { "x-amz-website-redirect-location" => $to, "cache-control" => $cache_control })
360
+ or die $bucketChannels->err . ": " . $bucketChannels->errstr;
361
+}
362
+
363
+# Update channels on channels.nixos.org.
364
+redirect($channelName, $releasePrefix);
365
+redirect("$channelName/nixexprs.tar.xz", "$releasePrefix/nixexprs.tar.xz?rev=$rev&lastModified=$revUnix");
366
+redirect("$channelName/git-revision", "$releasePrefix/git-revision");
367
+redirect("$channelName/packages.json.br", "$releasePrefix/packages.json.br");
368
+redirect("$channelName/store-paths.xz", "$releasePrefix/store-paths.xz");
369
+
370
+# Create redirects relevant only to NixOS channels.
371
+# FIXME: create only redirects to files that exist.
372
+if ($channelName =~ /nixos/) {
373
+ # Options listing
374
+ redirect("$channelName/options.json.br", "$releasePrefix/options.json.br");
375
+
376
+ # Redirects for latest images.
377
+ for my $arch ("x86_64-linux", "i686-linux", "aarch64-linux") {
378
+ # i686 dropped for > 23.11
379
+ next if $arch eq "i686-linux" && $channelName !~ /nixos-2[0123]/;
380
+
381
+ for my $artifact ("nixos-graphical",
382
+ "nixos-plasma5",
383
+ "nixos-plasma6",
384
+ "nixos-gnome",
385
+ "nixos-minimal",
386
+ )
387
+ {
388
+ redirect("$channelName/latest-$artifact-$arch.iso", "$releasePrefix/$artifact-$releaseVersion-$arch.iso");
389
+ redirect("$channelName/latest-$artifact-$arch.iso.sha256", "$releasePrefix/$artifact-$releaseVersion-$arch.iso.sha256");
390
+ }
391
+
392
+ redirect("$channelName/latest-nixos-$arch.ova", "$releasePrefix/nixos-$releaseVersion-$arch.ova");
393
+ redirect("$channelName/latest-nixos-$arch.ova.sha256", "$releasePrefix/nixos-$releaseVersion-$arch.ova.sha256");
394
+ }
395
+}