Centralize Tensamin packages modules and release automation
This commit is contained in:
parent
423ee32a37
commit
123205c97d
28 changed files with 32292 additions and 10 deletions
100
scripts/deploy-release.py
Normal file
100
scripts/deploy-release.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Commit only the infrastructure pin, deploy that commit, revert on failure."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(*args, **kwargs):
|
||||
return subprocess.check_output(args, text=True, **kwargs).strip()
|
||||
|
||||
|
||||
def main():
|
||||
revision = run("git", "rev-parse", "HEAD")
|
||||
repository = os.environ["NIXOS_FLAKE_REPOSITORY"]
|
||||
branch = os.environ.get("NIXOS_FLAKE_BRANCH", "main")
|
||||
server = os.environ["FORGEJO_SERVER_URL"].rstrip("/")
|
||||
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repository):
|
||||
raise ValueError("Invalid infrastructure repository")
|
||||
with tempfile.TemporaryDirectory(prefix="tensamin-deploy-") as temporary:
|
||||
root = Path(temporary)
|
||||
askpass = root / "askpass"
|
||||
askpass.write_text('#!/bin/sh\ncase "$1" in *Username*) printf "%s\\n" token;; *) printf "%s\\n" "$NIXOS_FLAKE_WRITE_TOKEN";; esac\n')
|
||||
askpass.chmod(0o700)
|
||||
env = os.environ | {"GIT_ASKPASS": str(askpass), "GIT_TERMINAL_PROMPT": "0"}
|
||||
checkout = root / "infrastructure"
|
||||
run("git", "clone", "--branch", branch, "--single-branch", f"{server}/{repository}.git", str(checkout), env=env)
|
||||
original = run("git", "rev-parse", "HEAD", cwd=checkout)
|
||||
# Fail before pushing if the forced command has not adopted the new contract.
|
||||
wrapper = (checkout / "garten/tensamin-prod/services/deploy.nix").read_text()
|
||||
if "<nixos-flake commit SHA>" not in wrapper:
|
||||
raise RuntimeError("Deployment wrapper must accept <nixos-flake commit SHA> before promotion")
|
||||
url = f"git+ssh://git@methanium.net/{os.environ['FORGEJO_REPOSITORY']}?ref=main&rev={revision}"
|
||||
run("nix", "flake", "lock", "--override-input", "prod-pins", url, cwd=checkout)
|
||||
lock = json.loads((checkout / "flake.lock").read_text())
|
||||
if lock["nodes"][lock["nodes"]["root"]["inputs"]["prod-pins"]]["locked"]["rev"] != revision:
|
||||
raise RuntimeError("Infrastructure pin mismatch")
|
||||
run("git", "add", "flake.lock", cwd=checkout)
|
||||
identity = ["git", "-c", "user.name=Tensamin releases", "-c", "user.email=releases@tensamin.net"]
|
||||
changed = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=checkout, check=False).returncode
|
||||
if changed not in {0, 1}:
|
||||
raise RuntimeError("Unable to inspect infrastructure pin changes")
|
||||
if changed:
|
||||
run(*identity, "commit", "-m", f"tensamin-prod: pin {revision}", cwd=checkout)
|
||||
commit = run("git", "rev-parse", "HEAD", cwd=checkout)
|
||||
key = root / "deploy-key"
|
||||
key.write_text(os.environ["TENSAMIN_PROD_DEPLOY_SSH_KEY"] + "\n")
|
||||
key.chmod(0o600)
|
||||
known = root / "known_hosts"
|
||||
known.write_text(os.environ["TENSAMIN_SSH_KNOWN_HOSTS"] + "\n")
|
||||
config = root / "ssh_config"
|
||||
host = os.environ["TENSAMIN_PROD_DEPLOY_HOST"]
|
||||
port = os.environ.get("TENSAMIN_PROD_DEPLOY_PORT", "22")
|
||||
jump_host = os.environ.get("TENSAMIN_PROD_DEPLOY_JUMP_HOST", "")
|
||||
jump_port = os.environ.get("TENSAMIN_PROD_DEPLOY_JUMP_PORT", "7930")
|
||||
for hostname in [host, jump_host]:
|
||||
if hostname and not re.fullmatch(r"[A-Za-z0-9_.:-]+", hostname):
|
||||
raise ValueError("Invalid deployment SSH hostname")
|
||||
for value in [port, jump_port]:
|
||||
if not value.isdecimal() or not 1 <= int(value) <= 65535:
|
||||
raise ValueError("Invalid deployment SSH port")
|
||||
config.write_text(
|
||||
f"Host tensamin-deploy\n HostName {host}\n Port {port}\n User deploy\n"
|
||||
+ (" ProxyJump tensamin-deploy-jump\n" if jump_host else "")
|
||||
+ (f"Host tensamin-deploy-jump\n HostName {jump_host}\n Port {jump_port}\n"
|
||||
" User deploy-jump\n" if jump_host else "")
|
||||
+ f'Host *\n IdentityFile "{key}"\n IdentitiesOnly yes\n'
|
||||
f' StrictHostKeyChecking yes\n UserKnownHostsFile "{known}"\n'
|
||||
" BatchMode yes\n ConnectTimeout 15\n"
|
||||
)
|
||||
ssh = ["ssh", "-F", str(config), "-T", "tensamin-deploy"]
|
||||
# Prepare and validate SSH before publishing an infrastructure change.
|
||||
run("ssh", "-G", "-F", str(config), "tensamin-deploy")
|
||||
if changed:
|
||||
run("git", "push", "origin", f"HEAD:refs/heads/{branch}", cwd=checkout, env=env)
|
||||
try:
|
||||
subprocess.run([*ssh, commit], check=True)
|
||||
# Publication is inside the transaction, so failed publication restores the pin.
|
||||
subprocess.run(["python3", "scripts/release.py", "publish", "--channel", "stable",
|
||||
"--tag", os.environ["RELEASE_TAG"]], check=True)
|
||||
except BaseException:
|
||||
if not changed:
|
||||
raise
|
||||
# A normal revert preserves unrelated concurrent infrastructure commits.
|
||||
run("git", "fetch", "origin", branch, cwd=checkout, env=env)
|
||||
run("git", "reset", "--hard", f"origin/{branch}", cwd=checkout)
|
||||
run(*identity, "revert", "--no-edit", commit, cwd=checkout)
|
||||
run("git", "push", "origin", f"HEAD:refs/heads/{branch}", cwd=checkout, env=env)
|
||||
rollback = run("git", "rev-parse", "HEAD", cwd=checkout)
|
||||
subprocess.run([*ssh, rollback], check=True)
|
||||
raise
|
||||
Path("release-out/deployment.json").write_text(json.dumps({
|
||||
"previous_infrastructure": original, "infrastructure": commit, "prod_pins": revision,
|
||||
}, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
58
scripts/release-build.sh
Normal file
58
scripts/release-build.sh
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root=$(pwd)
|
||||
channel=${1:?Usage: release-build.sh CHANNEL OUTPUT_DIRECTORY}
|
||||
out=$(realpath -m "${2:?Output directory required}")
|
||||
[[ $channel == stable || $channel == canary ]]
|
||||
mkdir -p "$out"
|
||||
export TENSAMIN_CHANNEL=$channel ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
||||
export TENSAMIN_ANDROID_VERSION_CODE=${RELEASE_SEQUENCE:?Release sequence required}
|
||||
system=${3:-$(nix eval --impure --raw --expr 'builtins.currentSystem')}
|
||||
expr="import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"packages\"; channel = \"$channel\"; system = \"$system\"; }"
|
||||
packages=$(nix build --impure --no-link --print-out-paths --expr "$expr")
|
||||
nix build --impure --no-link --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"checks\"; system = \"$system\"; }"
|
||||
arch=$system
|
||||
arch=${arch%-linux}
|
||||
printf '%s\n' "$packages" > "$out/packages-$arch.txt"
|
||||
for name in iota iota-daemon iota-ui omikron omega client client-web mtp-sdk; do
|
||||
# Nix closures, unlike a plain copy of a Nix binary, retain runtime libraries.
|
||||
mapfile -t closure < <(nix-store --query --requisites "$packages/$name")
|
||||
nix-store --export "${closure[@]}" | gzip -n > "$out/$name-linux-$arch.nar.gz"
|
||||
done
|
||||
for binary in iota iota-daemon iota-updater; do
|
||||
cp "$packages/iota-portable/bin/$binary" "$out/$binary-linux-$arch"
|
||||
done
|
||||
tar -czf "$out/iota-portable-linux-$arch.tar.gz" -C "$packages/iota-portable" bin share
|
||||
cp -L "$packages/iota-container" "$out/iota-image-linux-$arch.tar.gz"
|
||||
cp -L "$packages/omikron-container" "$out/omikron-image-linux-$arch.tar.gz"
|
||||
cp -L "$packages/omega-container" "$out/omega-image-linux-$arch.tar.gz"
|
||||
cp "$packages/iota-portable/bin/iota-release" "$out/iota-release-linux-$arch"
|
||||
cp "$packages/iota-portable/bin/iota-bundle" "$out/iota-bundle-linux-$arch"
|
||||
cp -rL "$packages/iota-bundle/share/iota" "$out/iota-contract"
|
||||
# The helpers' relative script paths are part of the Iota contract.
|
||||
mkdir -p "$out/iota-contract/iota-updater" "$out/iota-contract/iota-installer"
|
||||
mv "$out/iota-contract/artifacts.tsv" "$out/iota-contract/iota-updater/"
|
||||
mv "$out/iota-contract/bundle-files.txt" "$out/iota-contract/iota-installer/"
|
||||
mkdir -p "$out/iota-contract/static"
|
||||
mv "$out/iota-contract/static-web" "$out/iota-contract/static/web"
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
source=$(nix build --no-link --print-out-paths ".#packages.$system.client-source")
|
||||
cp -r "$source/." "$work/"
|
||||
chmod -R u+w "$work"
|
||||
rm -rf "$work/.mtp-sdk"
|
||||
cp -rL "$packages/mtp-sdk" "$work/.mtp-sdk"
|
||||
chmod -R u+w "$work/.mtp-sdk"
|
||||
export RELEASE_ROOT=$root RELEASE_OUT=$out RELEASE_WORK=$work RELEASE_ARCH=$arch
|
||||
export TENSAMIN_RELEASE_TAG=${RELEASE_TAG:?} TENSAMIN_RELEASE_VERSION=$RELEASE_TAG
|
||||
export FORGEJO_RELEASE_ASSET_BASE_URL="${FORGEJO_SERVER_URL:?}/${FORGEJO_REPOSITORY:?}/releases/download/$RELEASE_TAG"
|
||||
pushd "$work" >/dev/null
|
||||
nix develop --impure --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"electron\"; system = \"$system\"; }" \
|
||||
--command bash "$root/scripts/release-client.sh" desktop
|
||||
if [[ $arch == x86_64 ]]; then
|
||||
nix develop --impure --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"tauri\"; }" \
|
||||
--command bash "$root/scripts/release-client.sh" android
|
||||
fi
|
||||
popd >/dev/null
|
||||
35
scripts/release-client.sh
Normal file
35
scripts/release-client.sh
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "${RELEASE_WORK:?}"
|
||||
pnpm install --frozen-lockfile
|
||||
case ${1:?} in
|
||||
desktop)
|
||||
pnpm run ci
|
||||
electron_arch=x64
|
||||
[[ $RELEASE_ARCH != aarch64 ]] || electron_arch=arm64
|
||||
pnpm --dir apps/electron exec electron-builder --config electron-builder.config.cjs \
|
||||
--linux --"$electron_arch" --publish never
|
||||
pnpm run copy-releases
|
||||
for asset in releases/*; do
|
||||
[[ $(basename "$asset") == SHA256SUMS ]] && continue
|
||||
name=$(basename "$asset")
|
||||
[[ $name != electron-release-metadata.json ]] || name="electron-release-metadata-$RELEASE_ARCH.json"
|
||||
cp "$asset" "$RELEASE_OUT/$name"
|
||||
done
|
||||
;;
|
||||
android)
|
||||
: "${ANDROID_KEYSTORE_BASE64:?}" "${ANDROID_KEY_ALIAS:?}" "${ANDROID_KEY_PASSWORD:?}" "${ANDROID_STORE_PASSWORD:?}"
|
||||
umask 077
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > release.keystore
|
||||
# Gradle resolves the keystore properties relative to the client root.
|
||||
printf 'storeFile=release.keystore\nkeyAlias=%s\nkeyPassword=%s\nstorePassword=%s\n' \
|
||||
"$ANDROID_KEY_ALIAS" "$ANDROID_KEY_PASSWORD" "$ANDROID_STORE_PASSWORD" > keystore.properties
|
||||
trap 'rm -f release.keystore keystore.properties' EXIT
|
||||
cargo test --locked --manifest-path apps/tauri/src-tauri/Cargo.toml
|
||||
pnpm run build:mobile
|
||||
apk=apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
||||
"$ANDROID_HOME/build-tools/35.0.0/apksigner" verify --verbose "$apk"
|
||||
cp "$apk" "$RELEASE_OUT/Tensamin-$TENSAMIN_RELEASE_TAG.apk"
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
69
scripts/release-env.nix
Normal file
69
scripts/release-env.nix
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{ root, kind ? "tools", channel ? "canary", system ? builtins.currentSystem }:
|
||||
let
|
||||
central = builtins.getFlake (toString root);
|
||||
pkgs = import central.inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ central.inputs.rust-overlay.overlays.default ];
|
||||
};
|
||||
project = central.lib.mkPackages { inherit system; };
|
||||
# Reuse only the client's tool shells, with central nixpkgs and Rust inputs.
|
||||
# Client builds below always consume client-source and mtp-sdk from prod-pins.
|
||||
clientTools = (import (central.inputs.client + "/flake.nix")).outputs {
|
||||
self = central.inputs.client;
|
||||
nixpkgs = central.inputs.nixpkgs // {
|
||||
# The client's old SDK platform-tools pin is absent in central nixpkgs.
|
||||
outPath = pkgs.runCommand "release-client-nixpkgs" {} ''
|
||||
mkdir -p "$out"
|
||||
cat > "$out/default.nix" <<'EOF'
|
||||
args: let
|
||||
pkgs = import ${central.inputs.nixpkgs} args;
|
||||
in pkgs // { androidenv = pkgs.androidenv // {
|
||||
composeAndroidPackages = options: pkgs.androidenv.composeAndroidPackages
|
||||
(options // { platformToolsVersion = "37.0.1"; });
|
||||
}; }
|
||||
EOF
|
||||
'';
|
||||
};
|
||||
rust-overlay = central.inputs.rust-overlay;
|
||||
};
|
||||
branded = name: project.packages.${name}.overrideAttrs {
|
||||
TENSAMIN_CHANNEL = channel;
|
||||
};
|
||||
checked = name: project.packages.${name}.overrideAttrs (old: {
|
||||
doCheck = true;
|
||||
installPhase = ''mkdir -p "$out"'';
|
||||
nativeBuildInputs = old.nativeBuildInputs ++ project.devShells.${name}.nativeBuildInputs;
|
||||
preBuild = (old.preBuild or "") + ''
|
||||
cargo fmt --all --check
|
||||
cargo clippy --locked --offline --workspace --all-targets -- -D warnings
|
||||
'';
|
||||
cargoTestFlags = [ "--workspace" ];
|
||||
});
|
||||
mtpCheck = project.packages.iota.overrideAttrs (old: {
|
||||
pname = "mtp-checks";
|
||||
src = project.packages.mtp-sdk.src;
|
||||
cargoDeps = project.packages.mtp-vendor;
|
||||
cargoBuildFlags = [ "--workspace" ];
|
||||
cargoTestFlags = [ "--workspace" ];
|
||||
doCheck = true;
|
||||
installPhase = ''mkdir -p "$out"'';
|
||||
nativeBuildInputs = old.nativeBuildInputs ++ project.devShells.mtp.nativeBuildInputs;
|
||||
preBuild = ''
|
||||
cargo fmt --all --check
|
||||
cargo clippy --locked --offline --workspace --all-targets -- -D warnings
|
||||
'';
|
||||
});
|
||||
in
|
||||
if kind == "tools" then pkgs.mkShell {
|
||||
packages = with pkgs; [ nix git (python3.withPackages (p: [ p.pyyaml ])) bash coreutils jq zip gnutar gzip openssh skopeo shellcheck ruff ];
|
||||
} else if kind == "electron" || kind == "tauri" then clientTools.devShells.${system}.${kind}
|
||||
else if kind == "checks" then pkgs.linkFarm "release-checks" (map (name: {
|
||||
inherit name;
|
||||
path = checked name;
|
||||
}) [ "iota" "omikron" "omega" ] ++ [ { name = "mtp"; path = mtpCheck; } ])
|
||||
else if kind == "packages" then pkgs.linkFarm "release-packages" (map (name: {
|
||||
inherit name;
|
||||
path = if builtins.elem name [ "client" "client-web" ] then branded name else project.packages.${name};
|
||||
}) [ "iota" "iota-portable" "iota-bundle" "iota-daemon" "iota-ui" "omikron" "omega"
|
||||
"iota-container" "omikron-container" "omega-container" "client" "client-web" "mtp-sdk" ])
|
||||
else throw "Unknown release environment ${kind}"
|
||||
335
scripts/release.py
Normal file
335
scripts/release.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
"""Forgejo release staging, provenance selection, signing and channel refresh."""
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(*args, **kwargs):
|
||||
return subprocess.check_output(args, text=True, **kwargs).strip()
|
||||
|
||||
|
||||
def write(path, data):
|
||||
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||
|
||||
|
||||
class Forgejo:
|
||||
def __init__(self):
|
||||
self.server = os.environ["FORGEJO_SERVER_URL"].rstrip("/")
|
||||
self.repo = os.environ["FORGEJO_REPOSITORY"]
|
||||
self.base = f"{self.server}/api/v1/repos/{self.repo}"
|
||||
self.token = os.environ["RELEASE_TOKEN"]
|
||||
|
||||
def request(self, path, method="GET", data=None, content_type="application/json", raw=False):
|
||||
url = path if path.startswith("https://") else self.base + path
|
||||
# Do not forward the API token to an asset redirect on another host.
|
||||
if urllib.parse.urlparse(url).netloc != urllib.parse.urlparse(self.server).netloc:
|
||||
raise ValueError("Unexpected asset host")
|
||||
if data is not None and not isinstance(data, bytes):
|
||||
data = json.dumps(data).encode()
|
||||
request = urllib.request.Request(url, data=data, method=method, headers={
|
||||
"Authorization": f"token {self.token}", "Content-Type": content_type,
|
||||
})
|
||||
class SameHostRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if urllib.parse.urlparse(newurl).netloc != urllib.parse.urlparse(req.full_url).netloc:
|
||||
raise ValueError("Refusing authenticated cross-host redirect")
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
with urllib.request.build_opener(SameHostRedirect()).open(request, timeout=120) as response:
|
||||
body = response.read()
|
||||
return json.loads(body) if body and not raw and "json" in response.headers.get("Content-Type", "") else body
|
||||
|
||||
def release(self, tag, missing=False):
|
||||
try:
|
||||
return self.request("/releases/tags/" + urllib.parse.quote(tag, safe=""))
|
||||
except urllib.error.HTTPError as error:
|
||||
if missing and error.code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def assets(self, release):
|
||||
result = []
|
||||
for page in range(1, 100):
|
||||
batch = self.request(f"/releases/{release['id']}/assets?limit=50&page={page}")
|
||||
result.extend(batch)
|
||||
if len(batch) < 50:
|
||||
return result
|
||||
raise RuntimeError("Too many release assets")
|
||||
|
||||
def download(self, release, name):
|
||||
asset = next(asset for asset in self.assets(release) if asset["name"] == name)
|
||||
return self.request(asset["browser_download_url"], raw=True)
|
||||
|
||||
def upload(self, release, path):
|
||||
# Forgejo's attachment API takes multipart/form-data, not raw bytes.
|
||||
boundary = "tensamin-" + os.urandom(16).hex()
|
||||
body = (f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; '
|
||||
f'filename="{path.name}"\r\nContent-Type: application/octet-stream\r\n\r\n').encode()
|
||||
body += path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
||||
return self.request(f"/releases/{release['id']}/assets?name=" + urllib.parse.quote(path.name),
|
||||
"POST", body, "multipart/form-data; boundary=" + boundary)
|
||||
|
||||
|
||||
def provenance(directory, channel, tag):
|
||||
lock = json.loads(Path("flake.lock").read_text())
|
||||
sources = {name: lock["nodes"][node]["locked"]
|
||||
for name, node in lock["nodes"]["root"]["inputs"].items() if isinstance(node, str)}
|
||||
files = {}
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file() and path.name not in {"release.json", "SHA256SUMS"}:
|
||||
files[path.name] = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"size": path.stat().st_size}
|
||||
write(directory / "release.json", {
|
||||
"schema": 1, "channel": channel, "tag": tag, "revision": run("git", "rev-parse", "HEAD"),
|
||||
"run_id": os.environ["FORGEJO_RUN_ID"], "sources": sources,
|
||||
"lock_sha256": hashlib.sha256(Path("flake.lock").read_bytes()).hexdigest(),
|
||||
"architectures": [arch for arch in ["x86_64", "aarch64"]
|
||||
if (directory / f"iota-linux-{arch}").exists()],
|
||||
"artifacts": files,
|
||||
})
|
||||
(directory / "SHA256SUMS").write_text("".join(
|
||||
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n"
|
||||
for path in sorted(directory.iterdir()) if path.is_file() and path.name != "SHA256SUMS"))
|
||||
|
||||
|
||||
def sign(directory, channel, tag, sequence):
|
||||
api = Forgejo()
|
||||
old = api.release(channel, missing=True)
|
||||
if old:
|
||||
for asset in api.assets(old):
|
||||
if re.fullmatch(r"iota-update-linux-(x86_64|aarch64)\.json", asset["name"]):
|
||||
manifest = json.loads(api.download(old, asset["name"]))
|
||||
if sequence <= manifest["release_sequence"]:
|
||||
raise RuntimeError("Channel sequence must strictly increase")
|
||||
source_sha = json.loads(Path("flake.lock").read_text())
|
||||
source_sha = source_sha["nodes"][source_sha["nodes"]["root"]["inputs"]["iota"]]["locked"]["rev"]
|
||||
os.environ["IOTA_RELEASE_SOURCE_SHA"] = source_sha
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
published = now.isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
expires = (now + dt.timedelta(days=14)).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
host = run("nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem").removesuffix("-linux")
|
||||
signer = directory / f"iota-release-linux-{host}"
|
||||
verifier = directory / f"iota-bundle-linux-{host}"
|
||||
signer.chmod(0o755)
|
||||
verifier.chmod(0o755)
|
||||
base = f"{api.server}/{api.repo}/releases/download/{tag}"
|
||||
contract = directory / "iota-contract/scripts"
|
||||
for arch in ["x86_64", "aarch64"]:
|
||||
if not (directory / f"iota-linux-{arch}").exists():
|
||||
continue
|
||||
binaries = directory / f"bin-{arch}"
|
||||
binaries.mkdir(exist_ok=True)
|
||||
for binary in ["iota", "iota-daemon", "iota-updater"]:
|
||||
target = binaries / binary
|
||||
target.write_bytes((directory / f"{binary}-linux-{arch}").read_bytes())
|
||||
target.chmod(0o755)
|
||||
manifest = directory / f"iota-update-linux-{arch}.json"
|
||||
run("bash", str(contract / "build-update-manifest.sh"), str(binaries),
|
||||
os.environ["IOTA_BASE_VERSION"], channel, str(sequence), published, expires,
|
||||
"linux", arch, base, str(manifest))
|
||||
public = run(str(signer), "sign", str(manifest), str(manifest) + ".sig")
|
||||
if public != os.environ["IOTA_RELEASE_PUBLIC_KEY"]:
|
||||
raise RuntimeError("Release signing key does not match the pinned public key")
|
||||
url = f"{api.server}/{api.repo}/releases/download/{channel}/{manifest.name}"
|
||||
bundle = directory / f"iota-linux-{arch}.zip"
|
||||
bundle.unlink(missing_ok=True)
|
||||
run("bash", str(contract / "build-release-bundle.sh"), str(binaries),
|
||||
json.loads(manifest.read_text())["product_version"], str(manifest), url, public,
|
||||
url + ".sig", channel, os.environ.get("IOTA_RELEASE_SIGNING_KEY_ID", "primary"), str(bundle))
|
||||
run(str(verifier), str(bundle))
|
||||
|
||||
|
||||
def stage(directory, channel, tag):
|
||||
api = Forgejo()
|
||||
if api.release(tag, missing=True):
|
||||
raise RuntimeError("Immutable release tag already exists")
|
||||
release = api.request("/releases", "POST", {
|
||||
"tag_name": tag, "target_commitish": run("git", "rev-parse", "HEAD"),
|
||||
"name": tag, "body": "Verified central source build. See release.json for provenance.",
|
||||
"draft": True, "prerelease": channel == "canary",
|
||||
})
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file():
|
||||
api.upload(release, path)
|
||||
# Validate staged attachment bytes before any deployment or visibility change.
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file() and hashlib.sha256(api.download(release, path.name)).digest() != hashlib.sha256(path.read_bytes()).digest():
|
||||
raise RuntimeError(f"Staged asset mismatch: {path.name}")
|
||||
write(directory / "stage.json", {"id": release["id"], "tag": tag})
|
||||
|
||||
|
||||
def publish(directory, channel, tag):
|
||||
api = Forgejo()
|
||||
immutable = api.release(tag)
|
||||
if not immutable["draft"]:
|
||||
raise RuntimeError("Expected a staged draft")
|
||||
registry = urllib.parse.urlparse(api.server).netloc
|
||||
auth = directory / ".registry-auth.json"
|
||||
try:
|
||||
subprocess.run(["skopeo", "login", "--authfile", str(auth), "--username",
|
||||
os.environ["FORGEJO_REGISTRY_USER"], "--password-stdin", registry],
|
||||
input=api.token, text=True, check=True)
|
||||
for image in directory.glob("*-image-linux-*.tar.gz"):
|
||||
service, arch = image.name.split("-image-linux-")
|
||||
arch = arch.removesuffix(".tar.gz")
|
||||
run("skopeo", "copy", "--authfile", str(auth), "docker-archive:" + str(image),
|
||||
f"docker://{registry}/{api.repo.split('/')[0]}/{service}:{tag}-{arch}")
|
||||
api.request(f"/releases/{immutable['id']}", "PATCH", {"draft": False})
|
||||
try:
|
||||
update_pointer(api, directory, channel, tag)
|
||||
except Exception:
|
||||
api.request(f"/releases/{immutable['id']}", "PATCH", {"draft": True})
|
||||
raise
|
||||
finally:
|
||||
auth.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def update_pointer(api, directory, channel, tag):
|
||||
pointer = api.release(channel, missing=True)
|
||||
new = pointer is None
|
||||
if new:
|
||||
pointer = api.request("/releases", "POST", {
|
||||
"tag_name": channel, "target_commitish": run("git", "rev-parse", "HEAD"),
|
||||
"name": channel, "draft": True, "prerelease": channel == "canary",
|
||||
})
|
||||
backup = []
|
||||
names = {path.name for path in directory.glob("iota-update-linux-*.json*")}
|
||||
names.update({"channel.json", "electron-release-metadata.json"})
|
||||
# A refresh never moves binaries or changes the immutable release identity.
|
||||
write(directory / "channel.json", {"channel": channel, "tag": tag,
|
||||
"url": f"{api.server}/{api.repo}/releases/tag/{tag}"})
|
||||
electron = [json.loads(path.read_text()) for path in directory.glob("electron-release-metadata-*.json")]
|
||||
if electron:
|
||||
combined = electron[0]
|
||||
combined["artifacts"] = [artifact for entry in electron for artifact in entry["artifacts"]]
|
||||
write(directory / "electron-release-metadata.json", combined)
|
||||
else:
|
||||
names.discard("electron-release-metadata.json")
|
||||
# Remove stale architecture manifests too, so they cannot advertise another build.
|
||||
old_assets = api.assets(pointer)
|
||||
names.update(asset["name"] for asset in old_assets if asset["name"].startswith("iota-update-linux-"))
|
||||
try:
|
||||
for asset in old_assets:
|
||||
if asset["name"] in names:
|
||||
backup.append((asset["name"], api.download(pointer, asset["name"])))
|
||||
api.request(f"/releases/{pointer['id']}/assets/{asset['id']}", "DELETE")
|
||||
for name in sorted(names):
|
||||
path = directory / name
|
||||
if path.exists():
|
||||
api.upload(pointer, path)
|
||||
api.request(f"/releases/{pointer['id']}", "PATCH", {"draft": False,
|
||||
"body": f"Current {channel} build: {tag}. Signed metadata refreshed automatically."})
|
||||
except Exception:
|
||||
for asset in api.assets(pointer):
|
||||
if asset["name"] in names:
|
||||
api.request(f"/releases/{pointer['id']}/assets/{asset['id']}", "DELETE")
|
||||
for name, raw in backup:
|
||||
path = directory / name
|
||||
path.write_bytes(raw)
|
||||
api.upload(pointer, path)
|
||||
if new:
|
||||
api.request(f"/releases/{pointer['id']}", "DELETE")
|
||||
raise
|
||||
|
||||
|
||||
def select(tag):
|
||||
if not re.fullmatch(r"canary-[0-9a-f]{40}-[0-9]+", tag):
|
||||
raise ValueError("Select an immutable canary tag")
|
||||
api = Forgejo()
|
||||
release = api.release(tag)
|
||||
data = json.loads(api.download(release, "release.json"))
|
||||
if release["draft"] or not release["prerelease"] or data["channel"] != "canary" or data["tag"] != tag:
|
||||
raise RuntimeError("Not a published canary")
|
||||
result = api.request(f"/actions/runs/{data['run_id']}")
|
||||
result = result.get("workflow_run", result)
|
||||
if result["conclusion"] != "success" or result["head_sha"] != data["revision"]:
|
||||
raise RuntimeError("Canary workflow has not completed successfully")
|
||||
revision = data["revision"]
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
||||
raise ValueError("Invalid source revision")
|
||||
run("git", "fetch", "origin", revision)
|
||||
run("git", "checkout", "--detach", revision)
|
||||
if hashlib.sha256(Path("flake.lock").read_bytes()).hexdigest() != data["lock_sha256"]:
|
||||
raise RuntimeError("Canary lock provenance mismatch")
|
||||
Path(".release-selection.json").write_text(json.dumps(data))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=["select", "sign", "stage", "publish", "refresh", "provenance"])
|
||||
parser.add_argument("--directory", type=Path, default=Path("release-out"))
|
||||
parser.add_argument("--channel", choices=["stable", "canary"], default="canary")
|
||||
parser.add_argument("--tag")
|
||||
args = parser.parse_args()
|
||||
directory = args.directory.resolve()
|
||||
if args.command == "select":
|
||||
select(args.tag)
|
||||
return
|
||||
sequence = int(os.environ.get("RELEASE_SEQUENCE", str(int(time.time()))))
|
||||
if not 0 < sequence <= 2100000000:
|
||||
raise ValueError("Sequence exceeds Android version-code range")
|
||||
if args.command == "refresh":
|
||||
api = Forgejo()
|
||||
pointer = api.release(args.channel, missing=True)
|
||||
if pointer is None:
|
||||
return
|
||||
tag = json.loads(api.download(pointer, "channel.json"))["tag"]
|
||||
release = api.release(tag)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
data = json.loads(api.download(release, "release.json"))
|
||||
run("git", "fetch", "origin", data["revision"])
|
||||
run("git", "checkout", "--detach", data["revision"])
|
||||
if hashlib.sha256(Path("flake.lock").read_bytes()).hexdigest() != data["lock_sha256"]:
|
||||
raise RuntimeError("Refresh lock provenance mismatch")
|
||||
for asset in api.assets(release):
|
||||
path = directory / asset["name"]
|
||||
if path.name != asset["name"]:
|
||||
raise ValueError("Unsafe asset name")
|
||||
path.write_bytes(api.download(release, path.name))
|
||||
for name, expected in data["artifacts"].items():
|
||||
path = directory / name
|
||||
if hashlib.sha256(path.read_bytes()).hexdigest() != expected["sha256"]:
|
||||
raise RuntimeError(f"Immutable asset mismatch: {name}")
|
||||
host = run("nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem").removesuffix("-linux")
|
||||
# Helpers are Nix-linked binaries. Restore their exact runtime closure.
|
||||
with (directory / f"iota-linux-{host}.nar.gz").open("rb") as archive:
|
||||
unzip = subprocess.Popen(["gzip", "-dc"], stdin=archive, stdout=subprocess.PIPE)
|
||||
subprocess.run(["nix-store", "--import"], stdin=unzip.stdout, check=True)
|
||||
unzip.stdout.close()
|
||||
if unzip.wait() != 0:
|
||||
raise RuntimeError("Unable to restore release helper closure")
|
||||
# Recover the exact contract from the immutable source revision.
|
||||
bundle = run("nix", "build", "--no-link", "--print-out-paths", ".#iota-bundle")
|
||||
run("cp", "-rL", bundle + "/share/iota", str(directory / "iota-contract"))
|
||||
(directory / "iota-contract/static").mkdir(exist_ok=True)
|
||||
(directory / "iota-contract/static-web").rename(directory / "iota-contract/static/web")
|
||||
for name, destination in [("artifacts.tsv", "iota-updater"), ("bundle-files.txt", "iota-installer")]:
|
||||
(directory / "iota-contract" / destination).mkdir(exist_ok=True)
|
||||
(directory / "iota-contract" / name).rename(directory / "iota-contract" / destination / name)
|
||||
sign(directory, args.channel, tag, sequence)
|
||||
update_pointer(api, directory, args.channel, tag)
|
||||
return
|
||||
if not args.tag:
|
||||
parser.error("--tag is required")
|
||||
if args.command == "sign":
|
||||
sign(directory, args.channel, args.tag, sequence)
|
||||
elif args.command == "provenance":
|
||||
provenance(directory, args.channel, args.tag)
|
||||
elif args.command == "stage":
|
||||
stage(directory, args.channel, args.tag)
|
||||
elif args.command == "publish":
|
||||
publish(directory, args.channel, args.tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
114
scripts/releases.md
Normal file
114
scripts/releases.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Central release setup
|
||||
|
||||
Only prod-pins publishes releases. Pushes to main and manual canary runs validate
|
||||
the locked, prepared Rust and client sources, then build combined immutable
|
||||
releases. Stable dispatch requires an immutable `canary-FULL_PROD_SHA-RUN_ID` tag
|
||||
whose central workflow completed successfully. Promotion checks out that exact
|
||||
prod-pins revision, including dependency locks and hashes. It rebuilds with stable
|
||||
client branding. No input update command runs during promotion.
|
||||
|
||||
## Runner and credentials
|
||||
|
||||
Use a trusted `nixos` runner with Nix, Git and Bash available for bootstrap.
|
||||
The workflow's tools use the central nixpkgs and Rust overlay. Source inputs
|
||||
require SSH read access to every locked repository. Configure these secrets:
|
||||
|
||||
- `TENSAMIN_SOURCE_SSH_KEY`, read access to the pinned repositories.
|
||||
- `TENSAMIN_RELEASE_TOKEN`, prod-pins release and package registry write access,
|
||||
and Actions run read access.
|
||||
- `IOTA_RELEASE_SIGNING_KEY`, 64 hex characters encoding the Ed25519 seed.
|
||||
- `TENSAMIN_PROD_DEPLOY_SSH_KEY`, key authorized for the server's forced command.
|
||||
- `NIXOS_FLAKE_WRITE_TOKEN`, infrastructure repository read and write access.
|
||||
- `ANDROID_KEYSTORE_BASE64`, the existing Android keystore encoded as base64.
|
||||
- `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`, `ANDROID_STORE_PASSWORD`, matching
|
||||
the existing Android signing identity. Map existing secret names to these
|
||||
workflow environment entries if their names differ.
|
||||
|
||||
Configure repository variables:
|
||||
|
||||
- `IOTA_RELEASE_PUBLIC_KEY`, pinned public key, 64 hex characters.
|
||||
- `IOTA_RELEASE_SIGNING_KEY_ID`, default `primary`.
|
||||
- `IOTA_BASE_VERSION`, default `0.1.0`.
|
||||
- `FORGEJO_REGISTRY_USER`, token owner's Forgejo username.
|
||||
- `NIXOS_FLAKE_REPOSITORY`, infrastructure repository as `owner/repository`.
|
||||
- `NIXOS_FLAKE_BRANCH`, default `main`.
|
||||
- `TENSAMIN_PROD_DEPLOY_HOST`, deployment SSH hostname.
|
||||
- `TENSAMIN_PROD_DEPLOY_PORT`, default `22`.
|
||||
- `TENSAMIN_PROD_DEPLOY_JUMP_HOST`, optional SSH jump hostname, `methanium.net`
|
||||
for production. The jump user is `deploy-jump`; both hops use the deploy key.
|
||||
- `TENSAMIN_PROD_DEPLOY_JUMP_PORT`, default `7930`.
|
||||
- `TENSAMIN_SSH_KNOWN_HOSTS`, verified host keys for source Git and deployment.
|
||||
|
||||
For the production VM set `TENSAMIN_PROD_DEPLOY_HOST=10.201.0.10` and
|
||||
`TENSAMIN_PROD_DEPLOY_PORT=22`. `TENSAMIN_SSH_KNOWN_HOSTS` must contain verified
|
||||
entries for `methanium.net` on source Git port 22, `[methanium.net]:7930` for the
|
||||
jump host, and `10.201.0.10` for the guest on port 22. These are separate host
|
||||
identities and may have different keys. Include any other locked source SSH
|
||||
hostnames too. The generated SSH config applies the key and known-hosts file to
|
||||
both hops, including rollback deployment.
|
||||
|
||||
The infrastructure forced command must accept `<nixos-flake commit SHA>`, fetch
|
||||
and deploy precisely that commit, validate its prod-pins lock, and return success
|
||||
only after activation and application health checks. It must restore the prior
|
||||
system and checkout on failure. `deploy-release.py` deliberately rejects the old
|
||||
wrapper that accepts `<prod-pins commit SHA>`. It commits only infrastructure
|
||||
`flake.lock`, pushes without force, passes that infrastructure commit through SSH,
|
||||
then publishes stable. If deployment or publication fails, it reverts the pin
|
||||
with a normal Git commit and deploys the rollback commit. A revert conflict or
|
||||
unreachable server fails visibly and needs operator recovery.
|
||||
|
||||
## Artifacts and channels
|
||||
|
||||
Each immutable release contains:
|
||||
|
||||
- `release.json`, exact prod-pins revision, source lock identities, workflow run,
|
||||
architectures, artifact sizes and SHA-256 hashes, plus `SHA256SUMS`.
|
||||
- `iota-linux-ARCH`, `iota-daemon-linux-ARCH`, `iota-updater-linux-ARCH` and signed
|
||||
`iota-update-linux-ARCH.json` with `.sig`, using Iota's typed Rust signer.
|
||||
These executables are musl-static and run on ordinary Linux without Nix.
|
||||
- `iota-portable-linux-ARCH.tar.gz`, per-user binaries under `bin` and static
|
||||
assets under `share/iota/web`. Extract and run `./bin/iota`. The daemon and
|
||||
updater are discovered next to the CLI. Host CA certificates supply TLS trust.
|
||||
- `iota-linux-ARCH.zip`, checked by `iota-bundle`, with channel trust settings
|
||||
and static assets, for system-wide installation through CLI bootstrap.
|
||||
- `PACKAGE-linux-ARCH.nar.gz`, gzip-compressed Nix closure exports for Iota,
|
||||
services, client, web and SDK. Restore with `gzip -dc FILE | nix-store --import`.
|
||||
These are separate Nix artifacts, not the unmanaged Linux installation path.
|
||||
- Docker-loadable Iota, Omikron and Omega images. Registry names are
|
||||
`SERVER/tensamin/SERVICE:IMMUTABLE_TAG-ARCH`.
|
||||
- Signed universal `Tensamin-IMMUTABLE_TAG.apk`, Linux Electron AppImage, deb and
|
||||
rpm assets, and per-architecture Electron release metadata.
|
||||
|
||||
`stable` and `canary` are the only mutable metadata releases. Each has
|
||||
`channel.json`, combined `electron-release-metadata.json`, and Iota update
|
||||
manifests with signatures. Binary URLs always reference an immutable release.
|
||||
Daily refresh re-signs manifests for the same binary identity with a higher
|
||||
sequence and 14-day expiry. Publication and refresh share one concurrency group.
|
||||
Sequences use Unix seconds, must strictly increase, and remain within Android's
|
||||
version-code bound. Do not publish to these channels outside the serialized flow.
|
||||
|
||||
Forgejo attachment replacement is not transactional. On API failure the script
|
||||
restores prior channel attachments and returns failure. Readers can encounter a
|
||||
brief missing or mismatched manifest/signature pair and should retry. Old immutable
|
||||
binary assets remain available. A failed stable promotion retains its draft for
|
||||
inspection. Registry uploads use immutable tags and can leave unused images if a
|
||||
later publication step fails.
|
||||
|
||||
The workflow tries aarch64 using configured Nix builders and execution support.
|
||||
If that attempt fails, the x86_64 release includes `aarch64-unavailable.txt` and
|
||||
`arm-build.log`. Partial aarch64 artifacts are not advertised. Full aarch64
|
||||
Electron packaging requires execution support as well as a builder. Android is
|
||||
built on x86_64 using prepared `client-source` and `mtp-sdk`, never the client's
|
||||
original release SDK dependency. The client's tool shells use central inputs;
|
||||
platform-tools is adjusted to the version available in central nixpkgs.
|
||||
|
||||
## Local validation
|
||||
|
||||
```sh
|
||||
nix develop --impure --expr 'import ./scripts/release-env.nix { root = builtins.toPath (builtins.getEnv "PWD"); }' --command shellcheck scripts/release-build.sh scripts/release-client.sh
|
||||
nix develop --impure --expr 'import ./scripts/release-env.nix { root = builtins.toPath (builtins.getEnv "PWD"); }' --command ruff check scripts/release.py scripts/deploy-release.py
|
||||
```
|
||||
|
||||
Live Forgejo draft uploads, registry writes and deployment are performed only by
|
||||
the release workflow after builds and checks. Enabling stable requires the revised
|
||||
infrastructure wrapper and all signing and deployment credentials above.
|
||||
72
scripts/update-all.py
Normal file
72
scripts/update-all.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Refresh the shared sources, transformed locks, and fixed-output hashes."""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def run(*args, **kwargs):
|
||||
result = subprocess.run(args, text=True, **kwargs)
|
||||
if result.returncode:
|
||||
raise SystemExit(result.stderr if kwargs.get("capture_output") else result.returncode)
|
||||
return result
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--no-update", action="store_true", help="Keep the current flake inputs")
|
||||
parser.add_argument("--override-input", nargs=2, action="append", default=[], metavar=("NAME", "SOURCE"))
|
||||
args = parser.parse_args()
|
||||
root = Path.cwd()
|
||||
if not (root / "packages/hashes.nix").exists():
|
||||
raise SystemExit("Run update-all from the prod-pins checkout")
|
||||
if not args.no_update:
|
||||
run("nix", "flake", "update", *(item for pair in args.override_input for item in ["--override-input", *pair]))
|
||||
|
||||
overrides = [item for pair in args.override_input for item in ["--override-input", *pair]]
|
||||
|
||||
system = run("nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem", capture_output=True).stdout
|
||||
|
||||
|
||||
def build(name):
|
||||
return run("nix", "build", f"path:{root}#packages.{system}.{name}",
|
||||
"--no-link", "--print-out-paths", *overrides, capture_output=True).stdout.strip()
|
||||
|
||||
|
||||
locks = root / "packages/locks"
|
||||
locks.mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="prod-pins-update-") as temporary:
|
||||
for name in ["mtp", "iota", "omikron", "omega", "client"]:
|
||||
source = build(f"{name}-source")
|
||||
dest = Path(temporary) / name
|
||||
shutil.copytree(source, dest, symlinks=True)
|
||||
for path in [dest, *dest.rglob("*")]:
|
||||
if not path.is_symlink():
|
||||
path.chmod(path.stat().st_mode | 0o200)
|
||||
if name == "client":
|
||||
run("pnpm", "install", "--lockfile-only", "--ignore-scripts", "--no-frozen-lockfile", cwd=dest)
|
||||
shutil.copyfile(dest / "pnpm-lock.yaml", locks / "client.yaml")
|
||||
else:
|
||||
run("cargo", "update", "--workspace", cwd=dest)
|
||||
shutil.copyfile(dest / "Cargo.lock", locks / f"{name}.lock")
|
||||
|
||||
hash_file = root / "packages/hashes.nix"
|
||||
for key, output in [(n, f"{n}-vendor") for n in ["mtp", "iota", "omikron", "omega"]] + [("sdk", "sdk-deps"), ("client", "client-deps")]:
|
||||
# Force a fetch even when the previous hash points at a cached result.
|
||||
text = hash_file.read_text()
|
||||
original = text
|
||||
text = re.sub(rf'({key} = ")[^"]+', rf'\g<1>sha256-{"A" * 43}=', text)
|
||||
hash_file.write_text(text)
|
||||
result = subprocess.run(["nix", "build", f"path:{root}#packages.{system}.{output}",
|
||||
"--no-link", "--print-out-paths", *overrides], text=True, capture_output=True)
|
||||
match = re.search(r"got:\s+(sha256-[A-Za-z0-9+/=]+)", result.stderr)
|
||||
if not match:
|
||||
hash_file.write_text(original)
|
||||
raise SystemExit(result.stderr or f"Could not determine {key} hash")
|
||||
hash_file.write_text(re.sub(rf'({key} = ")[^"]+', rf'\g<1>{match[1]}', text))
|
||||
build(output)
|
||||
print(f"Updated {key}: {match[1]}", flush=True)
|
||||
|
||||
print("Updated sources, dependency locks, and verified vendor hashes.")
|
||||
Loading…
Reference in a new issue