install.sh (POSIX sh) and install.ps1 pick the archive for the machine, check it against SHA256SUMS and install into ~/.local/bin, $PREFIX/bin or /usr/local/bin (--system, sudo only when needed and announced). make site-release adds a release's archives under /releases/<version>/ and /releases/latest/ and the scripts at the root of website/build, building the archives from the tag when dist/ doesn't hold them. install_test.go runs install.sh against a local server in make test.
- commit
- f5e685aca851f44389acfa5e89ed6d692b596a49
- parent
- 27054a2ee38b35ce6f7c7a1f63103a2acef9f76c
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 0.9 of 3, Jev's estimate
| M | Makefile | 10 | +- |
| A | scripts/ | 77 | ++++++++++ |
| A | scripts/ | 152 | ++++++++++++++++++++ |
| A | scripts/ | 228 | ++++++++++++++++++++++++++++++ |
| A | scripts/ | 83 | +++++++++++ |
@@ -7,7 +7,7 @@ GHOSTTY_SRC := $(DEPS)/ghostty-src
77 GHOSTTY_OUT := $(DEPS)/ghostty88 GHOSTTY_STAMP := $(GHOSTTY_OUT)/.built-$(GHOSTTY_COMMIT)99 10-10+1111 1212 build:1313 CGO_ENABLED=0 go build -o bin/012 ./cmd/012@@ -147,6 +147,14 @@ SITE_NODE := NODE_OPTIONS=--disable-warning=ExperimentalWarning
147147 site: site-deps148148 cd website && $(SITE_NODE) npm run build149149 150+# The site as published: the build above plus a release's archives and151+# SHA256SUMS under /releases/<version>/ and /releases/latest/, and152+# install.sh and install.ps1 at the root. VERSION is the newest version153+# tag unless set; the archives are dist/'s when it holds that version,154+# else built from the tag. See docs/contributing/releasing.md.155+site-release: site156+ VERSION=$(VERSION) scripts/site-release.sh157+150158 site-serve: site-deps151159 cd website && $(SITE_NODE) npm run start152160 @@ -0,0 +1,77 @@
1+# install.ps1 - installs a released 012 binary on Windows. Served at the2+# docs site's root with the release archives under /releases/; see3+# docs/getting-started/install.md.4+#5+# irm https://f58b.n.zip/install.ps1 | iex6+#7+# It picks the archive for this architecture, checks its SHA256 against8+# the release's SHA256SUMS, puts 012.exe in9+# %LOCALAPPDATA%\Programs\012\bin and adds that folder to the user's10+# PATH. It never asks for administrator rights.11+#12+# $env:O12_VERSION a release other than the latest, like v1.2.313+# $env:O12_INSTALL_DIR another folder for 012.exe14+# $env:O12_BASE_URL the site serving /releases/ (https://f58b.n.zip)15+16+$ErrorActionPreference = 'Stop'17+$ProgressPreference = 'SilentlyContinue'18+19+function Install-012 {20+ $base = if ($env:O12_BASE_URL) { $env:O12_BASE_URL } else { 'https://f58b.n.zip' }21+ $version = if ($env:O12_VERSION) { $env:O12_VERSION } else { 'latest' }22+ if ($version -match '^[0-9]') { $version = "v$version" }23+ $dir = if ($env:O12_INSTALL_DIR) { $env:O12_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'Programs\012\bin' }24+25+ $arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }26+ switch ($arch) {27+ 'AMD64' { $arch = 'amd64' }28+ 'ARM64' { $arch = 'arm64' }29+ default { throw "012 install: no release for $arch; build from source: go install github.com/FelineStateMachine/012/cmd/012@latest" }30+ }31+32+ $tmp = Join-Path ([IO.Path]::GetTempPath()) ("012-install-" + [Guid]::NewGuid())33+ New-Item -ItemType Directory -Path $tmp | Out-Null34+ try {35+ $url = "$base/releases/$version"36+ $sums = Join-Path $tmp 'SHA256SUMS'37+ Invoke-WebRequest -UseBasicParsing -Uri "$url/SHA256SUMS" -OutFile $sums38+39+ # A line of SHA256SUMS: the digest, two spaces, 012_1.2.3_windows_amd64.zip.40+ $line = Get-Content $sums | Where-Object { $_ -match "^([0-9a-f]{64}) (012_\S+_windows_$arch\.zip)$" } | Select-Object -First 141+ if (-not $line) { throw "012 install: release $version has no archive for windows/$arch" }42+ $null = $line -match "^([0-9a-f]{64}) (012_\S+_windows_$arch\.zip)$"43+ $want = $Matches[1]44+ $file = $Matches[2]45+ $name = $file -replace '\.zip$', ''46+ $got_version = 'v' + ($name -replace '^012_', '' -replace "_windows_$arch$", '')47+48+ Write-Host "012 install: downloading $url/$file"49+ $zip = Join-Path $tmp $file50+ Invoke-WebRequest -UseBasicParsing -Uri "$url/$file" -OutFile $zip51+ $got = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash.ToLowerInvariant()52+ if ($got -ne $want) { throw "012 install: $file has SHA256 $got, SHA256SUMS says $want; nothing installed" }53+ Write-Host "012 install: SHA256 checked against SHA256SUMS: $want"54+55+ Expand-Archive -Path $zip -DestinationPath $tmp -Force56+ $exe = Join-Path $tmp "$name\012.exe"57+ if (-not (Test-Path $exe)) { throw "012 install: $file holds no 012.exe" }58+ New-Item -ItemType Directory -Force -Path $dir | Out-Null59+ Copy-Item -Force $exe (Join-Path $dir '012.exe')60+ Write-Host "012 install: installed 012 $got_version (windows/$arch) as $(Join-Path $dir '012.exe')"61+62+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')63+ $parts = @()64+ if ($userPath) { $parts = $userPath -split ';' | Where-Object { $_ } }65+ if ($parts -notcontains $dir) {66+ [Environment]::SetEnvironmentVariable('Path', (($parts + $dir) -join ';'), 'User')67+ $env:Path = "$env:Path;$dir"68+ Write-Host "012 install: added $dir to your user PATH; new terminals find 012"69+ } else {70+ Write-Host "012 install: run 012 to start"71+ }72+ } finally {73+ Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue74+ }75+}76+77+Install-012@@ -0,0 +1,152 @@
1+2+# install.sh - installs a released 012 binary on macOS or Linux. Served3+# at the docs site's root with the release archives under /releases/;4+# see docs/getting-started/install.md.5+#6+# curl -fsSL https://f58b.n.zip/install.sh | sh7+# curl -fsSL https://f58b.n.zip/install.sh | sh -s -- --system8+#9+# It picks the archive for this OS and architecture, checks its SHA25610+# against the release's SHA256SUMS, and puts 012 in ~/.local/bin, in11+# $PREFIX/bin when PREFIX is set, or in /usr/local/bin with --system.12+# sudo is only run for --system when that directory isn't writable, and13+# the command is printed first.14+#15+# --version v1.2.3 a release other than the latest (or O12_VERSION)16+# --system install into /usr/local/bin17+# O12_BASE_URL the site serving /releases/ (https://f58b.n.zip)18+set -eu19+20+base=${O12_BASE_URL:-https://f58b.n.zip}21+version=${O12_VERSION:-latest}22+system=023+24+say() { printf '012 install: %s\n' "$*"; }25+die() {26+ printf '012 install: %s\n' "$*" >&227+ exit 128+}29+30+while [ $# -gt 0 ]; do31+ case $1 in32+ --version)33+ [ $# -ge 2 ] || die "--version needs a version, like v1.2.3"34+ version=$235+ shift 236+ ;;37+ --version=*)38+ version=${1#--version=}39+ shift40+ ;;41+ --system)42+ system=143+ shift44+ ;;45+ -h | --help)46+ sed -n '2,17p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' || true47+ exit 048+ ;;49+ *) die "unknown option $1 (--version v1.2.3, --system)" ;;50+ esac51+done52+53+case $version in54+latest | v[0-9]*) ;;55+[0-9]*) version=v$version ;;56+*) die "--version must be a release like v1.2.3 (got $version)" ;;57+esac58+59+os=$(uname -s)60+case $os in61+Darwin) os=darwin ;;62+Linux) os=linux ;;63+MINGW* | MSYS* | CYGWIN* | Windows*)64+ die "on Windows, run in PowerShell: irm $base/install.ps1 | iex"65+ ;;66+*) die "no release for $os; build from source: go install github.com/FelineStateMachine/012/cmd/012@latest" ;;67+esac68+69+arch=$(uname -m)70+case $arch in71+x86_64 | amd64) arch=amd64 ;;72+arm64 | aarch64) arch=arm64 ;;73+*) die "no release for $arch; build from source: go install github.com/FelineStateMachine/012/cmd/012@latest" ;;74+esac75+# A shell under Rosetta reports x86_64 on an Apple silicon Mac.76+if [ "$os/$arch" = darwin/amd64 ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null || echo 0)" = 1 ]; then77+ arch=arm6478+fi79+80+if command -v curl >/dev/null 2>&1; then81+ fetch() { curl -fsSL -o "$2" "$1"; }82+elif command -v wget >/dev/null 2>&1; then83+ fetch() { wget -q -O "$2" "$1"; }84+else85+ die "needs curl or wget"86+fi87+88+if command -v sha256sum >/dev/null 2>&1; then89+ sha256() { sha256sum "$1" | cut -d ' ' -f 1; }90+elif command -v shasum >/dev/null 2>&1; then91+ sha256() { shasum -a 256 "$1" | cut -d ' ' -f 1; }92+elif command -v openssl >/dev/null 2>&1; then93+ sha256() { openssl dgst -sha256 -r "$1" | cut -d ' ' -f 1; }94+else95+ die "needs sha256sum, shasum or openssl to check the download"96+fi97+98+if [ "$system" = 1 ]; then99+ bindir=/usr/local/bin100+elif [ -n "${PREFIX:-}" ]; then101+ bindir=$PREFIX/bin102+else103+ [ -n "${HOME:-}" ] || die "HOME is not set; set PREFIX to install into \$PREFIX/bin"104+ bindir=$HOME/.local/bin105+fi106+107+tmp=$(mktemp -d 2>/dev/null || mktemp -d -t 012install)108+trap 'rm -rf "$tmp"' EXIT109+trap 'exit 1' INT TERM110+111+url=$base/releases/$version112+fetch "$url/SHA256SUMS" "$tmp/SHA256SUMS" || die "couldn't download $url/SHA256SUMS"113+114+# A line of SHA256SUMS: the digest, two spaces, 012_1.2.3_linux_amd64.tar.gz.115+line=$(grep " 012_[^ ]*_${os}_${arch}\.tar\.gz\$" "$tmp/SHA256SUMS" | head -n 1 || true)116+[ -n "$line" ] || die "release $version has no archive for $os/$arch"117+want=${line%% *}118+file=${line##* }119+dir=${file%.tar.gz}120+got_version=${dir#012_}121+got_version=v${got_version%_"${os}"_"${arch}"}122+123+say "downloading $url/$file"124+fetch "$url/$file" "$tmp/$file" || die "couldn't download $url/$file"125+got=$(sha256 "$tmp/$file")126+[ "$got" = "$want" ] || die "$file has SHA256 $got, SHA256SUMS says $want; nothing installed"127+say "SHA256 checked against SHA256SUMS: $want"128+129+tar -xzf "$tmp/$file" -C "$tmp" || die "couldn't unpack $file"130+[ -f "$tmp/$dir/012" ] || die "$file holds no 012 binary"131+132+if [ ! -d "$bindir" ] && ! mkdir -p "$bindir" 2>/dev/null; then133+ [ "$system" = 1 ] || die "couldn't create $bindir"134+fi135+if [ -d "$bindir" ] && [ -w "$bindir" ]; then136+ cp "$tmp/$dir/012" "$bindir/012.new"137+ chmod 755 "$bindir/012.new"138+ mv -f "$bindir/012.new" "$bindir/012"139+elif [ "$system" = 1 ]; then140+ command -v sudo >/dev/null 2>&1 || die "$bindir isn't writable and there is no sudo; run as root or leave out --system"141+ say "$bindir isn't writable by $(id -un); running: sudo install -m 755 012 $bindir/012"142+ sudo mkdir -p "$bindir"143+ sudo install -m 755 "$tmp/$dir/012" "$bindir/012"144+else145+ die "$bindir isn't writable; set PREFIX to another directory"146+fi147+148+say "installed 012 $got_version ($os/$arch) as $bindir/012"149+case ":${PATH:-}:" in150+*":$bindir:"*) say "run 012 to start" ;;151+*) say "$bindir is not on your PATH; add it (export PATH=\"$bindir:\$PATH\") or run $bindir/012" ;;152+esac@@ -0,0 +1,228 @@
1+// Package install holds the install scripts served at the docs site's2+// root (install.sh, install.ps1) and the tests that run install.sh3+// against a local server standing in for the site's /releases/.4+package install5+6+import (7+ "archive/tar"8+ "bytes"9+ "compress/gzip"10+ "crypto/sha256"11+ "encoding/hex"12+ "fmt"13+ "net/http"14+ "net/http/httptest"15+ "os"16+ "os/exec"17+ "path/filepath"18+ "runtime"19+ "strings"20+ "testing"21+)22+23+// release is a fake release: a SHA256SUMS and an archive per Unix24+// platform whose 012 is a shell script printing its version.25+type release struct {26+ files map[string][]byte27+}28+29+func newRelease(t *testing.T, version string) release {30+ t.Helper()31+ r := release{files: map[string][]byte{}}32+ var sums strings.Builder33+ for _, goos := range []string{"darwin", "linux"} {34+ for _, arch := range []string{"amd64", "arm64"} {35+ name := fmt.Sprintf("012_%s_%s_%s", strings.TrimPrefix(version, "v"), goos, arch)36+ bin := fmt.Sprintf("#!/bin/sh\necho 012 %s %s/%s\n", version, goos, arch)37+ data := tarball(t, name, bin)38+ r.files[name+".tar.gz"] = data39+ sum := sha256.Sum256(data)40+ fmt.Fprintf(&sums, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)41+ }42+ }43+ r.files["SHA256SUMS"] = []byte(sums.String())44+ return r45+}46+47+func tarball(t *testing.T, dir, bin string) []byte {48+ t.Helper()49+ var buf bytes.Buffer50+ gz := gzip.NewWriter(&buf)51+ tw := tar.NewWriter(gz)52+ for _, f := range []struct {53+ name string54+ body string55+ mode int6456+ }{{dir + "/012", bin, 0o755}, {dir + "/LICENSE", "license\n", 0o644}} {57+ if err := tw.WriteHeader(&tar.Header{Name: f.name, Mode: f.mode, Size: int64(len(f.body)), Typeflag: tar.TypeReg}); err != nil {58+ t.Fatal(err)59+ }60+ if _, err := tw.Write([]byte(f.body)); err != nil {61+ t.Fatal(err)62+ }63+ }64+ if err := tw.Close(); err != nil {65+ t.Fatal(err)66+ }67+ if err := gz.Close(); err != nil {68+ t.Fatal(err)69+ }70+ return buf.Bytes()71+}72+73+// serve stands in for the site: /releases/<version>/<file>, with latest74+// as one of the versions.75+func serve(t *testing.T, releases map[string]release) *httptest.Server {76+ t.Helper()77+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {78+ version, file, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/releases/"), "/")79+ data, found := releases[version].files[file]80+ if !ok || !found {81+ http.NotFound(w, r)82+ return83+ }84+ _, _ = w.Write(data)85+ }))86+ t.Cleanup(srv.Close)87+ return srv88+}89+90+// shells are the POSIX shells install.sh runs under: sh, and dash when91+// it's installed, as Debian's /bin/sh is.92+func shells(t *testing.T) []string {93+ t.Helper()94+ if runtime.GOOS == "windows" {95+ t.Skip("install.sh is for macOS and Linux")96+ }97+ out := []string{"sh"}98+ if _, err := exec.LookPath("dash"); err == nil {99+ out = append(out, "dash")100+ }101+ return out102+}103+104+type run struct {105+ shell string106+ env []string107+ args []string108+ stdin bool // the script piped to the shell, as curl | sh does109+}110+111+func (r run) do(t *testing.T, home, base string) (string, error) {112+ t.Helper()113+ script, err := filepath.Abs("install.sh")114+ if err != nil {115+ t.Fatal(err)116+ }117+ var cmd *exec.Cmd118+ if r.stdin {119+ cmd = exec.Command(r.shell, append([]string{"-s", "--"}, r.args...)...)120+ f, err := os.Open(script)121+ if err != nil {122+ t.Fatal(err)123+ }124+ defer f.Close()125+ cmd.Stdin = f126+ } else {127+ cmd = exec.Command(r.shell, append([]string{script}, r.args...)...)128+ }129+ cmd.Dir = home130+ cmd.Env = append([]string{"HOME=" + home, "PATH=" + os.Getenv("PATH"), "O12_BASE_URL=" + base}, r.env...)131+ out, err := cmd.CombinedOutput()132+ return string(out), err133+}134+135+func TestInstallsTheLatestIntoLocalBin(t *testing.T) {136+ srv := serve(t, map[string]release{"latest": newRelease(t, "v9.9.9")})137+ for _, sh := range shells(t) {138+ for _, stdin := range []bool{false, true} {139+ t.Run(fmt.Sprintf("%s/stdin=%v", sh, stdin), func(t *testing.T) {140+ home := t.TempDir()141+ out, err := run{shell: sh, stdin: stdin}.do(t, home, srv.URL)142+ if err != nil {143+ t.Fatalf("install.sh: %v\n%s", err, out)144+ }145+ bin := filepath.Join(home, ".local", "bin", "012")146+ got, err := exec.Command(bin).Output()147+ if err != nil {148+ t.Fatalf("running the installed 012: %v\n%s", err, out)149+ }150+ if !strings.HasPrefix(string(got), "012 v9.9.9 "+runtime.GOOS+"/") {151+ t.Errorf("installed 012 prints %q", got)152+ }153+ for _, want := range []string{"SHA256 checked", "installed 012 v9.9.9", bin, "is not on your PATH"} {154+ if !strings.Contains(out, want) {155+ t.Errorf("output lacks %q:\n%s", want, out)156+ }157+ }158+ })159+ }160+ }161+}162+163+func TestInstallsAVersionUnderPrefix(t *testing.T) {164+ srv := serve(t, map[string]release{165+ "latest": newRelease(t, "v9.9.9"),166+ "v1.2.3": newRelease(t, "v1.2.3"),167+ })168+ for _, sh := range shells(t) {169+ t.Run(sh, func(t *testing.T) {170+ home := t.TempDir()171+ prefix := filepath.Join(home, "opt")172+ out, err := run{shell: sh, env: []string{"PREFIX=" + prefix}, args: []string{"--version", "1.2.3"}}.do(t, home, srv.URL)173+ if err != nil {174+ t.Fatalf("install.sh: %v\n%s", err, out)175+ }176+ got, err := exec.Command(filepath.Join(prefix, "bin", "012")).Output()177+ if err != nil || !strings.HasPrefix(string(got), "012 v1.2.3 ") {178+ t.Fatalf("installed 012 prints %q (%v)\n%s", got, err, out)179+ }180+ if _, err := os.Stat(filepath.Join(home, ".local")); !os.IsNotExist(err) {181+ t.Errorf("PREFIX set, but ~/.local was touched: %v", err)182+ }183+ })184+ }185+}186+187+func TestRefusesAnArchiveThatFailsItsChecksum(t *testing.T) {188+ rel := newRelease(t, "v9.9.9")189+ for name, data := range rel.files {190+ if strings.HasSuffix(name, ".tar.gz") {191+ rel.files[name] = append(append([]byte{}, data...), 0)192+ }193+ }194+ srv := serve(t, map[string]release{"latest": rel})195+ for _, sh := range shells(t) {196+ t.Run(sh, func(t *testing.T) {197+ home := t.TempDir()198+ out, err := run{shell: sh}.do(t, home, srv.URL)199+ if err == nil {200+ t.Fatalf("install.sh succeeded with a bad archive:\n%s", out)201+ }202+ if !strings.Contains(out, "nothing installed") {203+ t.Errorf("output doesn't say why:\n%s", out)204+ }205+ if _, err := os.Stat(filepath.Join(home, ".local", "bin", "012")); !os.IsNotExist(err) {206+ t.Errorf("012 was installed anyway: %v", err)207+ }208+ })209+ }210+}211+212+func TestFailsOnAMissingReleaseOrOption(t *testing.T) {213+ srv := serve(t, map[string]release{"latest": newRelease(t, "v9.9.9")})214+ for _, tc := range []struct {215+ args []string216+ want string217+ }{218+ {[]string{"--version", "v0.0.1"}, "couldn't download"},219+ {[]string{"--version", "next"}, "must be a release"},220+ {[]string{"--frobnicate"}, "unknown option"},221+ } {222+ home := t.TempDir()223+ out, err := run{shell: "sh", args: tc.args}.do(t, home, srv.URL)224+ if err == nil || !strings.Contains(out, tc.want) {225+ t.Errorf("install.sh %v: err %v, output lacks %q:\n%s", tc.args, err, tc.want, out)226+ }227+ }228+}@@ -0,0 +1,83 @@
1+2+# site-release.sh - what `make site-release` does after `make site`:3+# puts a release's archives and SHA256SUMS in the built site under4+# /releases/<version>/ and /releases/latest/, and the install scripts at5+# its root. See docs/contributing/releasing.md.6+#7+# VERSION=v1.2.3 the release to serve; the newest version tag by default8+# SITE_URL the site's address, which the install scripts download from9+#10+# The archives come from dist/ when it holds that version; otherwise11+# `make dist` builds them in a temporary checkout of the tag, so the12+# site can be published from a later commit, and they're kept in dist/.13+set -euo pipefail14+15+root=$(cd "$(dirname "$0")/.." && pwd)16+cd "$root"17+18+site="$root/website/build"19+if [ ! -f "$site/index.html" ]; then20+ echo "site-release: no site in website/build; run make site first" >&221+ exit 122+fi23+24+version=${VERSION:-$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1)}25+if [ -z "$version" ]; then26+ echo "site-release: no version tag to serve; set VERSION" >&227+ exit 128+fi29+if ! git rev-parse -q --verify "refs/tags/$version" >/dev/null; then30+ echo "site-release: $version isn't a tag" >&231+ exit 132+fi33+34+# dist/ holds this version when every archive in SHA256SUMS is named for it.35+holds() {36+ [ -f "$1/SHA256SUMS" ] && [ -s "$1/SHA256SUMS" ] &&37+ ! grep -qv " 012_${version#v}_" "$1/SHA256SUMS"38+}39+40+dist="$root/dist"41+if ! holds "$dist"; then42+ tmp=$(mktemp -d)43+ trap 'git -C "$root" worktree remove --force "$tmp/src" 2>/dev/null || true; rm -rf "$tmp"' EXIT44+ echo "site-release: building $version in a checkout of the tag"45+ git worktree add -q --detach "$tmp/src" "$version"46+ make -C "$tmp/src" dist VERSION="$version"47+ holds "$tmp/src/dist" || {48+ echo "site-release: make dist left no archives for $version" >&249+ exit 150+ }51+ rm -rf "$dist"52+ cp -R "$tmp/src/dist" "$dist"53+fi54+(55+ cd "$dist"56+ if command -v sha256sum >/dev/null 2>&1; then57+ sha256sum -c --quiet SHA256SUMS58+ else59+ shasum -a 256 -c --quiet SHA256SUMS60+ fi61+)62+63+# The scripts name https://f58b.n.zip; a site built for another SITE_URL64+# serves scripts that download from itself.65+url=${SITE_URL:-https://f58b.n.zip}66+url=${url%/}67+68+rm -rf "$site/releases"69+for dir in "$version" latest; do70+ mkdir -p "$site/releases/$dir"71+ (cd "$dist" && cp SHA256SUMS 012_* "$site/releases/$dir/")72+ {73+ printf '<!doctype html>\n<meta charset="utf-8">\n<title>012 %s</title>\n' "$version"74+ printf '<h1>012 %s</h1>\n<p>Checksums: <a href="SHA256SUMS">SHA256SUMS</a>. ' "$version"75+ printf 'Install with <code>curl -fsSL %s/install.sh | sh</code>.</p>\n<ul>\n' "$url"76+ (cd "$dist" && for f in 012_*; do printf '<li><a href="%s">%s</a></li>\n' "$f" "$f"; done)77+ printf '</ul>\n'78+ } >"$site/releases/$dir/index.html"79+done80+for f in install.sh install.ps1; do81+ sed "s|https://f58b\.n\.zip|$url|g" "scripts/install/$f" >"$site/$f"82+done83+echo "site-release: $version in website/build/releases/{$version,latest}, install.sh and install.ps1 at the root"