Move builds to prod-pins and add explicit update channels
Some checks failed
Validate authentication / Validate authentication (push) Failing after 1s

This commit is contained in:
Alois 2026-10-04 19:27:02 +02:00
commit e71d9c3118
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
15 changed files with 473 additions and 722 deletions

View file

@ -1,248 +0,0 @@
name: Build & Publish Release
env:
NIX_CONFIG: experimental-features = nix-command flakes
on:
workflow_dispatch:
inputs:
release_type:
description: "Release type: 'dev' or 'stable'"
required: true
default: "dev"
type: choice
options:
- dev
- stable
description:
description: "Release description"
required: true
type: string
release_sequence:
description: "Monotonic sequence allocated for this update channel"
required: true
type: string
expires_at:
description: "Signed manifest expiry in RFC 3339 format"
required: true
type: string
concurrency:
group: iota-release-${{ inputs.release_type }}
cancel-in-progress: false
jobs:
build:
name: Build & Publish Release
runs-on: host
steps:
- name: Set up repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Login to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}
DOCKER_PASSWD: ${{ secrets.DOCKER_PASSWD }}
run: |
set -eu
DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')"
DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')"
printf '%s' "$DOCKER_PASSWD" | nix-shell -p docker --run "docker login docker.io --username \"$DOCKER_USER\" --password-stdin"
- name: Build & Push Docker image
run: |
nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest"
- name: Read release metadata
id: version
env:
RELEASE_TYPE: ${{ inputs.release_type }}
run: |
set -eu
VERSION="$(nix eval --raw .#default.version)"
SHORT_SHA="$(git rev-parse --short=7 HEAD)"
case "$RELEASE_TYPE" in
dev)
TAG="${VERSION}-dev-${SHORT_SHA}"
PRERELEASE="true"
;;
stable)
TAG="$VERSION"
PRERELEASE="false"
;;
*)
echo "release_type must be either 'dev' or 'stable'"
exit 1
;;
esac
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
echo "tag=$TAG" >> "$FORGEJO_OUTPUT"
echo "title=$TAG" >> "$FORGEJO_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$FORGEJO_OUTPUT"
ARCH="$(uname -m)"
echo "arch=$ARCH" >> "$FORGEJO_OUTPUT"
echo "asset_name=iota-${TAG}-linux-${ARCH}.zip" >> "$FORGEJO_OUTPUT"
echo "update_manifest_name=iota-update-${TAG}-linux-${ARCH}.json" >> "$FORGEJO_OUTPUT"
echo "channel_tag=iota-updates-${RELEASE_TYPE}-linux-${ARCH}" >> "$FORGEJO_OUTPUT"
echo "channel_manifest_name=iota-update-linux-${ARCH}.json" >> "$FORGEJO_OUTPUT"
- name: Build and validate installer bundle
env:
TAG: ${{ steps.version.outputs.tag }}
ASSET_NAME: ${{ steps.version.outputs.asset_name }}
ARCH: ${{ steps.version.outputs.arch }}
UPDATE_MANIFEST_NAME: ${{ steps.version.outputs.update_manifest_name }}
CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }}
CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }}
RELEASE_TYPE: ${{ inputs.release_type }}
RELEASE_SEQUENCE: ${{ inputs.release_sequence }}
EXPIRES_AT: ${{ inputs.expires_at }}
SERVER_URL: ${{ forgejo.server_url }}
REPO: ${{ forgejo.repository }}
TOKEN: ${{ forgejo.token }}
IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }}
run: |
set -eu
: "${IOTA_RELEASE_SIGNING_KEY:?IOTA_RELEASE_SIGNING_KEY secret is required}"
nix build "git+file://$PWD?submodules=1#default" --print-build-logs
mkdir -p dist/bin
install -m755 result/bin/iota dist/bin/iota
install -m755 result/bin/iota-daemon dist/bin/iota-daemon
install -m755 result/bin/iota-updater dist/bin/iota-updater
UPDATE_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${TAG}"
UPDATE_CHANNEL_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${CHANNEL_TAG}"
export UPDATE_CHANNEL_BASE_URL
nix-shell -p curl jq --run '
set -eu
CHANNEL_STATUS="$(curl -L -sS -w "%{http_code}" -o previous-channel-manifest.json \
-H "Authorization: token $TOKEN" \
"$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME")"
case "$CHANNEL_STATUS" in
200)
jq -e \
--argjson proposed "$RELEASE_SEQUENCE" \
--arg version "$TAG" \
"(.release_sequence < \$proposed) or (.release_sequence == \$proposed and .product_version == \$version)" \
previous-channel-manifest.json >/dev/null || {
echo "release sequence must increase, or identify the same release during a refresh"
exit 1
}
;;
404) ;;
*)
echo "could not read current channel manifest: HTTP $CHANNEL_STATUS"
exit 1
;;
esac
'
PUBLISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
export ARCH EXPIRES_AT PUBLISHED_AT RELEASE_SEQUENCE RELEASE_TYPE TAG UPDATE_BASE_URL UPDATE_MANIFEST_NAME
nix-shell -p coreutils jq --run 'bash scripts/build-update-manifest.sh dist/bin "$TAG" "$RELEASE_TYPE" "$RELEASE_SEQUENCE" "$PUBLISHED_AT" "$EXPIRES_AT" linux "$ARCH" "$UPDATE_BASE_URL" "dist/$UPDATE_MANIFEST_NAME"'
UPDATE_PUBLIC_KEY="$(result/bin/iota-release sign "dist/$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME.sig")"
export UPDATE_PUBLIC_KEY
nix-shell -p jq zip --run 'bash scripts/build-release-bundle.sh \
dist/bin \
"$TAG" \
"dist/$UPDATE_MANIFEST_NAME" \
"$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME" \
"$UPDATE_PUBLIC_KEY" \
"$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME.sig" \
"$RELEASE_TYPE" \
primary \
"dist/$ASSET_NAME"'
result/bin/iota-bundle "dist/$ASSET_NAME"
- name: Create release and upload release assets
env:
TOKEN: ${{ forgejo.token }}
API: ${{ forgejo.api_url }}
REPO: ${{ forgejo.repository }}
SHA: ${{ forgejo.sha }}
TAG: ${{ steps.version.outputs.tag }}
TITLE: ${{ steps.version.outputs.title }}
PRERELEASE: ${{ steps.version.outputs.prerelease }}
ASSET_NAME: ${{ steps.version.outputs.asset_name }}
UPDATE_MANIFEST_NAME: ${{ steps.version.outputs.update_manifest_name }}
CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }}
CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }}
RELEASE_TYPE: ${{ inputs.release_type }}
DESCRIPTION: ${{ inputs.description }}
run: |
nix-shell -p curl jq --run '
set -eu
HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json \
-H "Authorization: token $TOKEN" \
"$API/repos/$REPO/releases/tags/$TAG")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Release $TAG already exists."
RELEASE_ID="$(jq -r .id release_out.json)"
else
echo "Creating release for $TAG"
RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg name "$TITLE" \
--arg body "$DESCRIPTION" \
--arg target "$SHA" \
--argjson prerelease "$PRERELEASE" \
'"'"'{ tag_name: $tag, name: $name, body: $body, target_commitish: $target, draft: false, prerelease: $prerelease }'"'"')")"
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
fi
upload_asset() {
TARGET_RELEASE_ID="$1"
ASSET="$2"
PATHNAME="$3"
curl -fsS -X POST "$API/repos/$REPO/releases/$TARGET_RELEASE_ID/assets?name=$ASSET" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$PATHNAME"
}
upload_asset "$RELEASE_ID" iota dist/bin/iota
upload_asset "$RELEASE_ID" iota-daemon dist/bin/iota-daemon
upload_asset "$RELEASE_ID" iota-updater dist/bin/iota-updater
upload_asset "$RELEASE_ID" "$UPDATE_MANIFEST_NAME.sig" "dist/$UPDATE_MANIFEST_NAME.sig"
upload_asset "$RELEASE_ID" "$ASSET_NAME" "dist/$ASSET_NAME"
upload_asset "$RELEASE_ID" "$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME"
CHANNEL_STATUS=$(curl -s -w "%{http_code}" -o channel_out.json \
-H "Authorization: token $TOKEN" \
"$API/repos/$REPO/releases/tags/$CHANNEL_TAG")
if [ "$CHANNEL_STATUS" = "200" ]; then
CHANNEL_RELEASE_ID="$(jq -r .id channel_out.json)"
else
CHANNEL_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$CHANNEL_TAG" \
--arg name "Iota $RELEASE_TYPE update channel" \
--arg target "$SHA" \
'"'"'{ tag_name: $tag, name: $name, body: "Managed by the release workflow.", target_commitish: $target, draft: false, prerelease: true }'"'"')")"
CHANNEL_RELEASE_ID="$(echo "$CHANNEL_JSON" | jq -r .id)"
fi
CHANNEL_ASSETS="$(curl -fsS \
-H "Authorization: token $TOKEN" \
"$API/repos/$REPO/releases/$CHANNEL_RELEASE_ID/assets")"
for CHANNEL_ASSET in "$CHANNEL_MANIFEST_NAME" "$CHANNEL_MANIFEST_NAME.sig"; do
echo "$CHANNEL_ASSETS" | jq -r --arg name "$CHANNEL_ASSET" '"'"'.[] | select(.name == $name) | .id'"'"' | while read -r ASSET_ID; do
[ -n "$ASSET_ID" ] || continue
curl -fsS -X DELETE "$API/repos/$REPO/releases/assets/$ASSET_ID" \
-H "Authorization: token $TOKEN"
done
done
upload_asset "$CHANNEL_RELEASE_ID" "$CHANNEL_MANIFEST_NAME.sig" "dist/$UPDATE_MANIFEST_NAME.sig"
upload_asset "$CHANNEL_RELEASE_ID" "$CHANNEL_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME"
'

View file

@ -32,3 +32,12 @@ jobs:
- name: Check release binaries
run: nix develop -c cargo check -p iota -p iota-daemon -p iota-updater -p iota-installer --locked
- name: Check updater and installer formatting
run: nix develop -c cargo fmt -p iota-updater -p iota-installer -p iota --check
- name: Run updater and installer tests
run: nix develop -c cargo test -p iota-updater -p iota-installer --locked
- name: Check release scripts
run: bash -n scripts/build-update-manifest.sh scripts/build-release-bundle.sh

155
flake.lock generated
View file

@ -1,21 +1,54 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"client": {
"flake": false,
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
"lastModified": 1791114583,
"narHash": "sha256-J4j6LI+erWFp+maryBRN08Ra90BKLjv0NOhVC7subEY=",
"ref": "dev",
"rev": "d08a00a94acda0d1622de5960fa74750bf7d64db",
"revCount": 646,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/client"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
"ref": "dev",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/client"
}
},
"iota": {
"flake": false,
"locked": {
"lastModified": 1791114268,
"narHash": "sha256-iKNN+La/hAKXxJL6wYti2jlZ4uS2C5dRkQyuVnciPwU=",
"ref": "main",
"rev": "3d824fde58f1a9ff3c2df45311f7dc658e9700dd",
"revCount": 327,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/iota"
},
"original": {
"ref": "main",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/iota"
}
},
"mtp": {
"flake": false,
"locked": {
"lastModified": 1791113691,
"narHash": "sha256-r7LAe6KUuVCXLzyTNo1vqQeXxmfsXuzFV+qV6teZd+Y=",
"ref": "master",
"rev": "ad489265deacadd6de52ed2129d071803a0e7247",
"revCount": 215,
"type": "git",
"url": "ssh://git@methanium.net/methanium/mtp"
},
"original": {
"ref": "master",
"type": "git",
"url": "ssh://git@methanium.net/methanium/mtp"
}
},
"mtp-type-maps": {
@ -23,68 +56,112 @@
"locked": {
"lastModified": 1790572556,
"narHash": "sha256-ugNZR2Be9N9UjG0aVN8Yrk4hYOVC2edjtC9xBhbW35w=",
"ref": "refs/heads/main",
"ref": "main",
"rev": "4f18c7a0d9b04d38a77fbb011c4f0b21c25bf7bf",
"revCount": 28,
"type": "git",
"url": "https://git.methanium.net/tensamin/mtp-type-maps"
"url": "ssh://git@methanium.net/tensamin/mtp-type-maps"
},
"original": {
"ref": "main",
"type": "git",
"url": "https://git.methanium.net/tensamin/mtp-type-maps"
"url": "ssh://git@methanium.net/tensamin/mtp-type-maps"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784497964,
"narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=",
"owner": "nixos",
"lastModified": 1790981744,
"narHash": "sha256-sm6DclXJudZfP/pcDBQQsRqyMxBcQsuw+7yQr4rBBLE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163",
"rev": "55ba7f49ef2962b42cbd126522b7df5f95037679",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"omega": {
"flake": false,
"locked": {
"lastModified": 1782614948,
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c",
"type": "github"
"lastModified": 1791114269,
"narHash": "sha256-56Nh5XnH7SK1P0kdtai/ZKcuQr8YlgDo5ndBf67YnFo=",
"ref": "main",
"rev": "993fa5ead0cfe5f5f0ab1ecd12ffe0f343380489",
"revCount": 157,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/omega"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
"ref": "main",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/omega"
}
},
"omikron": {
"flake": false,
"locked": {
"lastModified": 1791114268,
"narHash": "sha256-x6jc6l3LC3jTG/5YOuch32spGXfUzhO20M3g/Qmq2FY=",
"ref": "main",
"rev": "39371ad398d13aa1792c1ff58d2fb72f7be15787",
"revCount": 211,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/omikron"
},
"original": {
"ref": "main",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/omikron"
}
},
"prod-pins": {
"inputs": {
"client": "client",
"iota": "iota",
"mtp": "mtp",
"mtp-type-maps": "mtp-type-maps",
"nixpkgs": "nixpkgs",
"omega": "omega",
"omikron": "omikron",
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1791134396,
"narHash": "sha256-2HDKbqt9xNt7shkkASMmP7UGyz69NO/23j4IOXxADEs=",
"ref": "main",
"rev": "123205c97d1c75977ada8a3a80bd8323e19773bb",
"revCount": 3,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/prod-pins"
},
"original": {
"ref": "main",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/prod-pins"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"mtp-type-maps": "mtp-type-maps",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
"prod-pins": "prod-pins"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"prod-pins",
"nixpkgs"
]
},
"locked": {
"lastModified": 1784526465,
"narHash": "sha256-L37teKC6oINWG4PGZLIqbphMWvSQ0PEz+aWxAk+rIDw=",
"lastModified": 1791101754,
"narHash": "sha256-y/GF+9B0t0TZ0nQiSRMqDXpr76yT7sdDbS/3GNLhzkE=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "58c6334db52d51fc5dd8877c90b01f00cf8a696b",
"rev": "dbc715a4b7c0ace63b9769a032d1dd34cd89e5bd",
"type": "github"
},
"original": {

411
flake.nix
View file

@ -1,389 +1,34 @@
{
description = "Iota";
description = "Iota development and verification";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
mtp-type-maps = {
url = "git+https://git.methanium.net/tensamin/mtp-type-maps";
flake = false;
};
};
inputs.prod-pins.url = "git+ssh://git@methanium.net/tensamin/prod-pins?ref=main";
outputs =
inputs@{
self,
nixpkgs,
flake-parts,
rust-overlay,
...
}:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
perSystem =
{
self',
pkgs,
system,
...
}:
let
rustPkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
rustToolchain = rustPkgs.rust-bin.stable.latest.default.override {
extensions = [
"rust-src"
"rust-analyzer"
"clippy"
"rustfmt"
];
};
commonBuildInputs = with pkgs; [
openssl
sqlite
];
commonNativeBuildInputs = with pkgs; [
cmake
perl
pkg-config
];
in
{
packages = {
default = pkgs.rustPlatform.buildRustPackage {
pname = "iota";
version = "0.1.0";
src = ./.;
cargoBuildFlags = [
"-p"
"iota"
"-p"
"iota-daemon"
"-p"
"iota-updater"
"-p"
"iota-installer"
];
cargoLock = {
lockFile = ./Cargo.lock;
allowBuiltinFetchGit = true;
};
nativeBuildInputs = commonNativeBuildInputs;
buildInputs = commonBuildInputs;
dontUseCmakeConfigure = true;
MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml";
passthru.dataDir = "/var/lib/iota";
};
iota-daemon = self'.packages.default.overrideAttrs (old: {
pname = "iota-daemon";
cargoBuildFlags = [
"-p"
"iota-daemon"
];
postInstall = ''
for f in $out/bin/*; do
if [ "$(basename "$f")" != "iota-daemon" ]; then
rm "$f"
fi
done
'';
});
iota-ui = self'.packages.default.overrideAttrs (old: {
pname = "iota-ui";
cargoBuildFlags = [
"-p"
"iota"
];
postInstall = ''
for f in $out/bin/*; do
if [ "$(basename "$f")" != "iota" ]; then
rm "$f"
fi
done
if [ -f "$out/bin/iota" ]; then
mv "$out/bin/iota" "$out/bin/iota-ui"
fi
'';
});
};
devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
rustToolchain
git
cmake
perl
pkg-config
];
buildInputs = commonBuildInputs;
};
};
flake = {
nixosModules.default =
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.iota;
defaultPackage =
self.packages.${pkgs.stdenv.hostPlatform.system}.default
or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}");
configFormat = pkgs.formats.yaml { };
effectiveSettings = lib.recursiveUpdate {
port = cfg.port;
web = {
mode = "network";
bind = cfg.bindAddress;
port = cfg.port;
required = true;
}
// lib.optionalAttrs (cfg.certFile != null) {
certificate = "${cfg.stateDir}/tls/cert.pem";
key = "${cfg.stateDir}/tls/key.pem";
};
} cfg.settings;
sourceConfigFile =
if cfg.settingsFile != null then
cfg.settingsFile
else
configFormat.generate "iota-config.yaml" effectiveSettings;
configFile = "${cfg.stateDir}/config.yaml";
descriptionText = "Tensamin Iota daemon";
in
{
options.services.iota = {
enable = lib.mkEnableOption "Enable the Iota service.";
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/iota";
description = "Persistent mutable Iota state.";
};
cacheDir = lib.mkOption {
type = lib.types.str;
default = "/var/cache/iota";
};
runtimeDir = lib.mkOption {
type = lib.types.str;
default = "/run/iota";
};
logDir = lib.mkOption {
type = lib.types.str;
default = "/var/log/iota";
};
assetDir = lib.mkOption {
type = lib.types.str;
default = "${cfg.package}/share/iota/web";
};
certFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Path to the SSL certificate file (cert.pem).";
};
keyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Path to the SSL private key file (cert.key).";
};
environmentFiles = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Environment files to load for the Iota service.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to open the firewall for ports used by Iota.";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "IP address to bind the HTTP server to.";
};
port = lib.mkOption {
type = lib.types.port;
default = 1984;
description = "TCP and UDP port for protocol-only Iota.";
};
omegaApiUrl = lib.mkOption {
type = lib.types.str;
default = "https://omega.tensamin.net";
description = "Omega discovery API URL.";
};
package = lib.mkOption {
type = lib.types.package;
default = defaultPackage;
description = "The Iota package to use.";
};
settings = lib.mkOption {
type = lib.types.attrs;
default = { };
description = "Configuration attributes for Iota, written to YAML.";
};
settingsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to an existing YAML file to use instead of generating from settings.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = (cfg.certFile == null) == (cfg.keyFile == null);
message = "services.iota: certFile and keyFile must be set together.";
}
];
users.users.iota = {
isSystemUser = true;
group = "iota";
home = cfg.stateDir;
createHome = true;
description = "Iota service user";
shell = pkgs.bash;
};
users.groups.iota = { };
systemd.sockets.iota = {
description = "${descriptionText} IPC socket";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "/run/iota/iota.sock";
SocketMode = "0660";
SocketUser = "iota";
SocketGroup = "iota";
DirectoryMode = "0750";
Backlog = 5;
RemoveOnStop = "true";
};
};
systemd.services.iota = {
description = descriptionText;
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"iota.socket"
];
requires = [ "iota.socket" ];
environment.OMEGA_API_URL = cfg.omegaApiUrl;
serviceConfig = {
Type = "simple";
User = "iota";
Group = "iota";
ExecStart = "${cfg.package}/bin/iota-daemon";
Restart = "on-failure";
RestartSec = "5s";
RuntimeDirectory = "iota";
RuntimeDirectoryMode = "0750";
RuntimeDirectoryPreserve = "yes";
StateDirectory = "iota";
StateDirectoryMode = "0750";
CacheDirectory = "iota";
CacheDirectoryMode = "0750";
LogsDirectory = "iota";
LogsDirectoryMode = "0750";
# Exit code 75 = restart requested
RestartPreventExitStatus = "0";
RestartForceExitStatus = "75";
TimeoutStopSec = "10";
KillMode = "mixed";
KillSignal = "SIGTERM";
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
ReadWritePaths = [
cfg.stateDir
cfg.cacheDir
cfg.runtimeDir
cfg.logDir
];
ReadOnlyPaths = [
cfg.assetDir
];
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
Environment = [
"IOTA_SOCKET=/run/iota/iota.sock"
"IOTA_CONFIG_FILE=${configFile}"
"IOTA_STATE_DIR=${cfg.stateDir}"
"IOTA_CACHE_DIR=${cfg.cacheDir}"
"IOTA_RUNTIME_DIR=${cfg.runtimeDir}"
"IOTA_LOG_DIR=${cfg.logDir}"
"IOTA_ASSET_DIR=${cfg.assetDir}"
"IOTA_DEPLOYMENT_MODE=system_socket_activated"
"IOTA_SUPERVISOR=systemd"
];
ExecStartPre = "+${pkgs.writeShellScript "iota-setup" ''
# ponytail: Preserve daemon-assigned IDs; remove config.yaml to reseed changed declarative settings.
if [ ! -e ${configFile} ]; then
install -m 0640 -o iota -g iota ${sourceConfigFile} ${configFile}
fi
${lib.optionalString (cfg.certFile != null) ''
install -d -m 0700 -o iota -g iota ${cfg.stateDir}/tls
install -m 0644 -o iota -g iota ${cfg.certFile} ${cfg.stateDir}/tls/cert.pem
install -m 0600 -o iota -g iota ${cfg.keyFile} ${cfg.stateDir}/tls/key.pem
''}
''}";
}
// lib.optionalAttrs (cfg.environmentFiles != [ ]) {
EnvironmentFile = cfg.environmentFiles;
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
allowedUDPPorts = [ cfg.port ];
};
};
};
outputs = { self, prod-pins, ... }:
let
forSystems = prod-pins.inputs.nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
project = system: prod-pins.lib.mkPackages {
inherit system;
sources.iota = self;
};
in {
checks = forSystems (system: { inherit ((project system).packages) iota; });
devShells = forSystems (system: {
default = (project system).devShells.iota.overrideAttrs (old: {
TENSAMIN_SOURCE = (project system).packages.iota.transformedSource;
shellHook = (old.shellHook or "") + ''
# Stage authoritative dependencies without changing the checkout.
export TENSAMIN_CHECKOUT="$PWD"
workRoot="/tmp/tensamin-dev-$UID/$(basename "$TENSAMIN_SOURCE")"
if [ ! -d "$workRoot" ]; then
mkdir -p "$(dirname "$workRoot")"
stage="$(mktemp -d "$workRoot.XXXXXX")"
cp -R "$TENSAMIN_SOURCE/." "$stage/" &&
chmod -R u+w "$stage" &&
mv -T "$stage" "$workRoot" || exit 1
fi
cd "$workRoot"
'';
});
});
};
}

View file

@ -5,6 +5,5 @@ systemd/iota-daemon.service
systemd/iota-daemon.socket
systemd/sysusers.d/iota.conf
systemd/iota-update.service
systemd/iota-update.timer
systemd/update.env
manifest.json

View file

@ -31,6 +31,14 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
let product_version = product_version(staging.path())?;
validate_update_environment(staging.path())?;
for directory in ["/usr/local/lib/systemd/system", "/etc/systemd/system"] {
let timer = Path::new(directory).join("iota-update.timer");
if timer.exists() {
run("systemctl", &["disable", "--now", "iota-update.timer"])?;
fs::remove_file(timer).context("remove legacy unattended update timer")?;
}
}
render_daemon_service(&staging.path().join("systemd/iota-daemon.service"))?;
let version_dir = format!(
@ -267,6 +275,9 @@ fn validate_update_environment(staging: &Path) -> Result<()> {
if entries.len() != 5 {
bail!("updater environment contains unexpected variables");
}
if !matches!(entries["IOTA_UPDATE_CHANNEL"], "stable" | "canary") {
bail!("update channel must be stable or canary");
}
let public_key = entries
.get("IOTA_UPDATE_PUBLIC_KEY")
.context("updater environment is missing IOTA_UPDATE_PUBLIC_KEY")?;
@ -395,10 +406,10 @@ mod tests {
fn rejects_bundle_missing_contract_member() {
let directory = tempfile::tempdir().unwrap();
let bundle = directory.path().join("release.zip");
write_bundle(&bundle, Some("systemd/iota-update.timer"), "0.1.0");
write_bundle(&bundle, Some("systemd/iota-update.service"), "0.1.0");
let error = validate_linux_bundle(&bundle).unwrap_err();
assert!(error.to_string().contains("systemd/iota-update.timer"));
assert!(error.to_string().contains("systemd/iota-update.service"));
}
#[test]

View file

@ -0,0 +1,83 @@
use anyhow::{Context, Result, bail};
use std::{collections::BTreeMap, fs, io::Write, path::Path};
const CONFIG: &str = "/etc/iota/update.env";
const RELEASES: &str = "https://git.methanium.net/tensamin/prod-pins/releases/download";
pub fn manifest_url(channel: &str, architecture: &str) -> Result<String> {
if !matches!(channel, "stable" | "canary") {
bail!("update channel must be stable or canary");
}
if !matches!(architecture, "x86_64" | "aarch64") {
bail!("unsupported update architecture: {architecture}");
}
Ok(format!(
"{RELEASES}/{channel}/iota-update-linux-{architecture}.json"
))
}
pub(crate) fn load() -> Result<BTreeMap<String, String>> {
let contents = fs::read_to_string(CONFIG).context("read /etc/iota/update.env")?;
let mut entries = BTreeMap::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (name, value) = line.split_once('=').context("invalid update.env entry")?;
if value.is_empty() || value.chars().any(char::is_whitespace) {
bail!("invalid update.env value for {name}");
}
if entries.insert(name.to_owned(), value.to_owned()).is_some() {
bail!("duplicate update.env entry {name}");
}
}
for name in [
"IOTA_UPDATE_CHANNEL",
"IOTA_UPDATE_PUBLIC_KEY",
"IOTA_UPDATE_SIGNING_KEY_ID",
] {
if !entries.contains_key(name) {
bail!("update.env is missing {name}");
}
}
let url = manifest_url(&entries["IOTA_UPDATE_CHANNEL"], std::env::consts::ARCH)?;
if entries.get("IOTA_UPDATE_MANIFEST") != Some(&url)
|| entries.get("IOTA_UPDATE_SIGNATURE") != Some(&format!("{url}.sig"))
{
bail!("update.env URLs do not match the selected prod-pins channel");
}
Ok(entries)
}
pub fn selected_channel() -> Result<String> {
Ok(load()?["IOTA_UPDATE_CHANNEL"].clone())
}
pub fn select_channel(channel: &str) -> Result<()> {
let url = manifest_url(channel, std::env::consts::ARCH)?;
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
.map_err(|error| anyhow::anyhow!(error))?;
let transaction = crate::transaction::UpdateTransaction::from_paths(&paths)?;
let _lock = transaction.acquire()?;
let mut entries = load()?;
entries.insert("IOTA_UPDATE_CHANNEL".into(), channel.into());
entries.insert("IOTA_UPDATE_MANIFEST".into(), url.clone());
entries.insert("IOTA_UPDATE_SIGNATURE".into(), format!("{url}.sig"));
let path = Path::new(CONFIG);
let parent = path.parent().context("update.env has no parent")?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary
.as_file()
.set_permissions(fs::metadata(path)?.permissions())?;
for (name, value) in entries {
writeln!(temporary, "{name}={value}")?;
}
temporary.as_file().sync_all()?;
temporary
.persist(path)
.map_err(|error| error.error)
.context("save update channel")?;
fs::File::open(parent)?.sync_all()?;
Ok(())
}

View file

@ -1,3 +1,4 @@
pub mod config;
pub mod manifest;
pub mod transaction;
@ -80,9 +81,10 @@ struct UpdatePolicy {
impl UpdatePolicy {
fn from_environment() -> Result<Self> {
let config = config::load()?;
Ok(Self {
channel: required_environment(CHANNEL_ENV)?,
signing_key_id: required_environment(SIGNING_KEY_ID_ENV)?,
channel: config[CHANNEL_ENV].clone(),
signing_key_id: config[SIGNING_KEY_ID_ENV].clone(),
activation: ActivationPolicy::from_environment()?,
})
}
@ -105,6 +107,8 @@ struct PendingActivation {
product_version: String,
daemon_was_active: bool,
selected: bool,
#[serde(default)]
existing_target: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -172,21 +176,21 @@ impl DaemonSupervisor for SystemdSupervisor {
}
pub async fn check_update() -> Result<bool> {
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
.map_err(|error| anyhow::anyhow!(error))?;
let transaction = UpdateTransaction::from_paths(&paths)?;
let _lock = transaction.acquire()?;
let policy = UpdatePolicy::from_environment()?;
let Some(release) = configured_release().await? else {
return Ok(false);
};
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
.map_err(|error| anyhow::anyhow!(error))?;
let policy = UpdatePolicy::from_environment()?;
let transaction = UpdateTransaction::from_paths(&paths)?;
let _lock = transaction.acquire()?;
let state = load_or_initialize_update_state(&paths)?;
if state.pending_activation.is_some() {
bail!("an interrupted update requires iota-updater apply recovery");
}
let decision = evaluate_candidate(
&release.manifest,
current_version(&paths.install_root)?.as_deref(),
current_channel_version(&paths.install_root, &policy.channel)?.as_deref(),
state.channels.get(&policy.channel),
&policy.channel,
&policy.signing_key_id,
@ -214,6 +218,12 @@ async fn apply_update_with_observer(
) -> Result<bool> {
let transaction = UpdateTransaction::from_paths(paths)?;
let _lock = transaction.acquire()?;
let configured_policy = UpdatePolicy::from_environment()?;
if policy.channel != configured_policy.channel
|| policy.signing_key_id != configured_policy.signing_key_id
{
bail!("update configuration changed; retry apply");
}
let mut state = load_or_initialize_update_state(paths)?;
recover_interrupted_activation(
paths,
@ -228,7 +238,7 @@ async fn apply_update_with_observer(
};
let decision = evaluate_candidate(
&release.manifest,
current_version(&paths.install_root)?.as_deref(),
current_channel_version(&paths.install_root, &policy.channel)?.as_deref(),
state.channels.get(&policy.channel),
&policy.channel,
&policy.signing_key_id,
@ -281,7 +291,24 @@ async fn apply_update_with_observer(
UpdatePhase::ActivationStarted,
Some(&release.manifest.product_version),
);
let activation = match transaction.activate(&release.manifest.product_version) {
let activation = match if state
.pending_activation
.as_ref()
.is_some_and(|pending| pending.existing_target)
{
let previous_target = transaction.current_target()?;
transaction
.rollback(&release.manifest.product_version)
.map(|()| Activation {
previous_target,
new_target: transaction
.root
.join("versions")
.join(&release.manifest.product_version),
})
} else {
transaction.activate(&release.manifest.product_version)
} {
Ok(activation) => activation,
Err(error) => {
remove_unselected_candidate(&state, &transaction.root)?;
@ -395,7 +422,7 @@ fn remove_unselected_candidate(state: &UpdateState, install_root: &Path) -> Resu
return Ok(());
};
validate_pending_candidate(pending, install_root)?;
if pending.new_target.exists() {
if !pending.existing_target && pending.new_target.exists() {
fs::remove_dir_all(&pending.new_target).with_context(|| {
format!(
"remove unselected candidate release {}",
@ -430,8 +457,20 @@ fn begin_activation_state(
.root
.join("versions")
.join(&manifest.product_version);
if new_target.exists() {
bail!("release version already exists: {}", new_target.display());
let existing_target = new_target.exists();
if existing_target {
let mut installed = read_installed_manifest(&new_target)?;
// Refreshing signed expiry does not change the installed release contents.
installed.published_at = manifest.published_at.clone();
installed.expires_at = manifest.expires_at.clone();
if manifest::canonical_bytes(&installed)? != manifest::canonical_bytes(manifest)? {
bail!("existing release version has different signed metadata");
}
for artifact in
select_host_artifacts(manifest, std::env::consts::OS, std::env::consts::ARCH)?
{
manifest::verify_artifact(&new_target.join(&artifact.path), &artifact)?;
}
}
let mut updated = state.clone();
updated.pending_activation = Some(PendingActivation {
@ -442,6 +481,7 @@ fn begin_activation_state(
product_version: manifest.product_version.clone(),
daemon_was_active,
selected: true,
existing_target,
});
save_update_state(&paths.update_status_file(), &updated)?;
*state = updated;
@ -603,7 +643,11 @@ fn evaluate_candidate(
);
}
if manifest.release_sequence == state.highest_accepted_sequence {
return Ok(CandidateDecision::NoUpdate);
return Ok(if current_version.is_none() {
CandidateDecision::Install
} else {
CandidateDecision::NoUpdate
});
}
}
if current_version == Some(manifest.product_version.as_str()) {
@ -754,14 +798,6 @@ fn record_failed_sequence(
Ok(())
}
fn required_environment(name: &'static str) -> Result<String> {
let value = std::env::var(name).with_context(|| format!("{name} is required"))?;
if value.is_empty() {
bail!("{name} must not be empty");
}
Ok(value)
}
fn environment_u64(name: &'static str, default: u64) -> Result<u64> {
let value = match std::env::var(name) {
Ok(value) => value
@ -848,22 +884,14 @@ struct ConfiguredRelease {
}
async fn configured_release() -> Result<Option<ConfiguredRelease>> {
let Some(manifest_location) = std::env::var_os(MANIFEST_ENV) else {
return Ok(None);
};
let manifest_location = manifest_location
.into_string()
.map_err(|_| anyhow::anyhow!("{MANIFEST_ENV} must be valid UTF-8"))?;
if manifest_location.is_empty() {
bail!("{MANIFEST_ENV} must not be empty");
}
let signature_location =
std::env::var(SIGNATURE_ENV).unwrap_or_else(|_| format!("{manifest_location}.sig"));
let key_text = std::env::var(PUBLIC_KEY_ENV)
.with_context(|| format!("{PUBLIC_KEY_ENV} is required when {MANIFEST_ENV} is set"))?;
configured_release_from_locations(&manifest_location, &signature_location, &key_text)
.await
.map(Some)
let config = config::load()?;
configured_release_from_locations(
&config[MANIFEST_ENV],
&config[SIGNATURE_ENV],
&config[PUBLIC_KEY_ENV],
)
.await
.map(Some)
}
async fn configured_release_from_locations(
@ -990,6 +1018,11 @@ fn current_version(install_root: &Path) -> Result<Option<String>> {
.map(str::to_owned))
}
fn current_channel_version(install_root: &Path, channel: &str) -> Result<Option<String>> {
let manifest = read_installed_manifest(&install_root.join("current"))?;
Ok((manifest.channel == channel).then_some(manifest.product_version))
}
fn read_installed_manifest(release_dir: &Path) -> Result<ReleaseManifest> {
let manifest_path = release_dir.join("manifest.json");
let bytes = fs::read(&manifest_path).with_context(|| {
@ -1507,6 +1540,7 @@ mod tests {
product_version: "1.1.0".into(),
daemon_was_active: false,
selected: true,
existing_target: false,
});
recover_interrupted_activation(
&paths,

View file

@ -5,7 +5,15 @@ async fn main() -> Result<()> {
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
match command.as_str() {
"check" => println!("{}", iota_updater::check_update().await?),
"status" => println!("updater ready"),
"status" => println!("channel: {}", iota_updater::config::selected_channel()?),
"channel" => {
if let Some(channel) = std::env::args().nth(2) {
iota_updater::config::select_channel(&channel)?;
println!("Selected {channel}. Run check, then apply to update.");
} else {
println!("{}", iota_updater::config::selected_channel()?);
}
}
"apply" => {
struct StderrObserver;
impl iota_updater::UpdateObserver for StderrObserver {
@ -27,7 +35,7 @@ async fn main() -> Result<()> {
}
_ => {
return Err(anyhow::anyhow!(
"usage: iota-updater check|status|apply|rollback VERSION"
"usage: iota-updater check|status|apply|channel [stable|canary]|rollback VERSION"
));
}
}

View file

@ -355,6 +355,11 @@ struct UpdateArgs {
#[derive(Subcommand, Debug)]
enum UpdateAction {
Check,
Apply,
Channel {
#[arg(value_parser = ["stable", "canary"])]
channel: Option<String>,
},
}
#[derive(Args, Debug)]
struct CommunityArgs {
@ -523,6 +528,10 @@ pub enum Command {
limit: usize,
},
UpdateCheck,
UpdateApply,
UpdateChannel {
channel: Option<String>,
},
CommunityList,
TermsStatus {
system: bool,
@ -710,6 +719,8 @@ impl CliInvocation {
Some(CliCommand::Logs { limit }) => Command::Logs { limit },
Some(CliCommand::Update(update)) => match update.action {
UpdateAction::Check => Command::UpdateCheck,
UpdateAction::Apply => Command::UpdateApply,
UpdateAction::Channel { channel } => Command::UpdateChannel { channel },
},
Some(CliCommand::Community(community)) => match community.action {
CommunityAction::List => Command::CommunityList,

View file

@ -69,6 +69,33 @@ async fn run() -> Result<(), StartupError> {
iota_installer::bootstrap_linux_bundle(Path::new(&bundle), operator.as_deref())
.map_err(|error| StartupError::Other(format!("Bootstrap failed: {error}")))
}
command @ (Command::UpdateCheck | Command::UpdateApply | Command::UpdateChannel { .. }) => {
let mut updater = std::process::Command::new(
iota_paths::current_version_link().join("bin/iota-updater"),
);
match command {
Command::UpdateCheck => {
updater.arg("check");
}
Command::UpdateApply => {
updater.arg("apply");
}
Command::UpdateChannel { channel } => {
updater.arg("channel");
if let Some(channel) = channel {
updater.arg(channel);
}
}
_ => unreachable!(),
}
let status = updater
.status()
.map_err(|error| StartupError::Other(format!("Run updater: {error}")))?;
if !status.success() {
return Err(StartupError::Other(format!("Updater failed with {status}")));
}
Ok(())
}
command => {
let endpoints = resolve_endpoints()?;
if matches!(
@ -406,6 +433,8 @@ fn print_help() {
println!(" components Show component health");
println!(" logs [--limit N] Show recent log entries");
println!(" update check Check for updates");
println!(" update apply Apply the selected channel's signed update");
println!(" update channel [stable|canary] Show or select the update channel");
println!(" community list List communities");
println!(" terms status Show terms acceptance status");
println!(" terms show <DOC> Show a terms document");
@ -717,6 +746,7 @@ async fn run_command(
Command::Components => LocalRequest::ListComponents,
Command::Logs { limit } => LocalRequest::GetLogs { limit },
Command::UpdateCheck => LocalRequest::CheckUpdate,
Command::UpdateApply | Command::UpdateChannel { .. } => unreachable!(),
Command::CommunityList => LocalRequest::ListCommunities,
Command::UsersRelease {
confirmed: false, ..

View file

@ -20,6 +20,15 @@ contract="$repository_directory/iota-installer/bundle-files.txt"
staging_directory="$(mktemp -d)"
trap 'rm -rf "$staging_directory"' EXIT
case "$update_channel" in stable|canary) ;; *) echo "channel must be stable or canary" >&2; exit 2 ;; esac
architecture="$(jq -r '.artifacts[0].architecture' "$release_manifest")"
case "$architecture" in x86_64|aarch64) ;; *) echo "unsupported update architecture" >&2; exit 2 ;; esac
expected_url="https://git.methanium.net/tensamin/prod-pins/releases/download/$update_channel/iota-update-linux-$architecture.json"
if [[ "$update_manifest_url" != "$expected_url" || "$update_signature_url" != "$expected_url.sig" ]]; then
echo "update URLs must match the selected prod-pins channel" >&2
exit 2
fi
if [[ ! "$product_version" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then
echo "product version contains unsupported characters: $product_version" >&2
exit 2

View file

@ -22,6 +22,16 @@ staging_directory="$(mktemp -d)"
artifacts="$staging_directory/artifacts.jsonl"
trap 'rm -rf "$staging_directory"' EXIT
case "$channel" in stable|canary) ;; *) echo "channel must be stable or canary" >&2; exit 2 ;; esac
case "$operating_system/$architecture" in linux/x86_64|linux/aarch64) ;; *) echo "unsupported update platform" >&2; exit 2 ;; esac
source_sha="${IOTA_RELEASE_SOURCE_SHA:-$(git -C "$repository_directory" rev-parse HEAD)}"
if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "IOTA_RELEASE_SOURCE_SHA must be a full source commit SHA" >&2
exit 2
fi
product_version="${product_version}-${channel}-${source_sha}"
signing_key_id="${IOTA_RELEASE_SIGNING_KEY_ID:-primary}"
if [[ ! "$release_sequence" =~ ^[1-9][0-9]*$ ]]; then
echo "release sequence must be a positive integer" >&2
exit 2
@ -42,7 +52,7 @@ while IFS=$'\t' read -r role artifact_path; do
--arg os "$operating_system" \
--arg architecture "$architecture" \
--arg path "$artifact_path" \
--arg url "$base_url/$asset_name" \
--arg url "$base_url/$asset_name-$operating_system-$architecture" \
--arg sha256 "$(sha256sum "$source_path" | cut -d ' ' -f 1)" \
--argjson size "$(stat -c %s "$source_path")" \
'{role: $role, os: $os, architecture: $architecture, path: $path, url: $url, sha256: $sha256, size: $size}' \
@ -56,7 +66,7 @@ jq -s \
--argjson release_sequence "$release_sequence" \
--arg published_at "$published_at" \
--arg expires_at "$expires_at" \
--arg release_signing_key_id "primary" \
--arg release_signing_key_id "$signing_key_id" \
'{
product_version: $product_version,
channel: $channel,

84
scripts/releases.md Normal file
View file

@ -0,0 +1,84 @@
# Unmanaged signed releases
Publishing belongs to prod-pins. Iota only builds and verifies release inputs.
## Operator commands
Run as root for the unmanaged system installation:
```sh
iota update channel stable
iota update channel canary
iota update check
iota update apply
```
`iota update channel` shows the selected channel. Selection atomically persists
`/etc/iota/update.env` without fetching or applying a release. The standalone
`iota-updater` supports the same commands. Check and apply read this file directly,
including the pinned public key and key ID. Process environment cannot replace
the trust configuration. Channel selection preserves both trust values and all
per-channel anti-replay state.
The manifest URL is
`https://git.methanium.net/tensamin/prod-pins/releases/download/CHANNEL/iota-update-linux-ARCH.json`.
The signature URL appends `.sig`. CHANNEL is `stable` or `canary`, ARCH is
`x86_64` or `aarch64`. Apply can select a previously accepted target when the
active release belongs to the other channel. It still rejects older sequences,
sequence/version reuse and failed activation sequences. Same-channel explicit
rollback remains respected.
The initial ZIP installer remains supported. It preserves existing update.env
and removes a previously provisioned unattended update timer. The update service
is an explicit oneshot apply operation with no timer.
## Release script contracts
```sh
bash scripts/build-update-manifest.sh BINARY_DIRECTORY BASE_VERSION CHANNEL RELEASE_SEQUENCE PUBLISHED_AT EXPIRES_AT linux ARCH BASE_URL OUTPUT.json
iota-release sign OUTPUT.json OUTPUT.json.sig
bash scripts/build-release-bundle.sh BINARY_DIRECTORY PRODUCT_VERSION OUTPUT.json MANIFEST_URL PUBLIC_KEY SIGNATURE_URL CHANNEL SIGNING_KEY_ID OUTPUT.zip
iota-bundle OUTPUT.zip
```
The binary directory contains `iota`, `iota-daemon` and `iota-updater`.
The manifest script emits PRODUCT_VERSION as `BASE_VERSION-CHANNEL-FULL_SOURCE_SHA`.
Read `.product_version` from the generated manifest for the bundle command.
Use an immutable release BASE_URL in prod-pins. Upload the binaries there as
`iota-linux-ARCH`, `iota-daemon-linux-ARCH` and `iota-updater-linux-ARCH`.
The signed artifact paths remain `bin/iota`, `bin/iota-daemon` and
`bin/iota-updater`, with SHA-256 hashes and byte lengths.
Publish the manifest and signature under each mutable `stable` or `canary`
release as `iota-update-linux-ARCH.json` and `iota-update-linux-ARCH.json.sig`.
Sequences must increase per channel. Serialize publication per channel and
reject a lower sequence or reuse with a different product version. Keep
immutable binary assets available for already published manifests.
## Signature and workflow inputs
`IOTA_RELEASE_SIGNING_KEY` is a secret containing a 32-byte Ed25519 seed as
64 hexadecimal characters. `iota-release sign` prints the derived 32-byte
public key as hex. Compare it with the expected pinned public key before
publishing or building installer trust configuration.
The detached signature is 64 Ed25519 bytes encoded as 128 hexadecimal
characters. It signs `serde_json::to_vec` of the typed `ReleaseManifest`, in
the Rust declaration field order, with nested artifacts in their declaration
field order. It does not sign the pretty-printed JSON file bytes. Use the Rust
signer rather than a generic JSON canonicalizer.
The prod-pins workflow needs:
- `IOTA_RELEASE_SIGNING_KEY`, secret signing seed.
- `IOTA_RELEASE_SOURCE_SHA`, full 40-character Iota source commit SHA. Defaults
to the local repository HEAD, so set it explicitly when building elsewhere.
- `IOTA_RELEASE_SIGNING_KEY_ID`, defaults to `primary`.
- Expected pinned public key, supplied as the bundle PUBLIC_KEY argument.
- Base product version, channel, positive release sequence, RFC 3339 publication
and expiry timestamps, architecture and immutable artifact BASE_URL.
- A Forgejo API token with release upload access to `tensamin/prod-pins`.
Build the Rust release binaries plus `iota-release` and `iota-bundle`. Shell
scripts require bash, git, coreutils, jq and zip. No Iota publishing workflow
remains; verification runs in `.forgejo/workflows/validate.yml`.

View file

@ -1,11 +0,0 @@
[Unit]
Description=Check for Tensamin Iota updates daily
[Timer]
OnBootSec=15min
OnUnitActiveSec=24h
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target