436 lines
19 KiB
Bash
Executable File
436 lines
19 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# create-release-manifest.sh — Build a release manifest for the Archipelago update system.
|
|
#
|
|
# Generates a JSON manifest with version info, changelog, and SHA256 hashes for
|
|
# each component, matching the format expected by core/archipelago/src/update.rs.
|
|
#
|
|
# Usage:
|
|
# ./scripts/create-release-manifest.sh --version 0.2.0 --date 2026-04-01
|
|
#
|
|
# The script reads built artifacts from the build output directories and produces
|
|
# a manifest.json file suitable for hosting at the UPDATE_MANIFEST_URL.
|
|
|
|
set -euo pipefail
|
|
|
|
# Defaults
|
|
VERSION=""
|
|
RELEASE_DATE=""
|
|
OUTPUT_FILE="manifest.json"
|
|
BACKEND_BINARY=""
|
|
FRONTEND_ARCHIVE=""
|
|
OS_PLAN=""
|
|
TARGET_SPECIFIC=0
|
|
APPLICATION_ONLY=0
|
|
BASE_URL="https://source.archipelago-foundation.org/lfg2025/archy/releases/download"
|
|
|
|
usage() {
|
|
echo "Usage: $0 --version VERSION [--date DATE] [--output FILE]"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " --version VERSION Release version (e.g., 0.2.0) [required]"
|
|
echo " --date DATE Release date (YYYY-MM-DD) [default: today]"
|
|
echo " --output FILE Output manifest path [default: manifest.json]"
|
|
echo " --backend PATH Path to backend binary [default: auto-detect]"
|
|
echo " --frontend PATH Path to frontend archive [default: auto-detect]"
|
|
echo " --os-plan PATH Exact release-bound OS plan [required unless --application-only]"
|
|
echo " --application-only Public application OTA; no target-bound OS transaction"
|
|
echo " --target-specific Use a content-addressed OS-plan asset (shared app artifacts)"
|
|
echo " --base-url URL Base download URL [default: Gitea release attachments]"
|
|
exit 1
|
|
}
|
|
|
|
# Parse arguments
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--version) VERSION="$2"; shift 2 ;;
|
|
--date) RELEASE_DATE="$2"; shift 2 ;;
|
|
--output) OUTPUT_FILE="$2"; shift 2 ;;
|
|
--backend) BACKEND_BINARY="$2"; shift 2 ;;
|
|
--frontend) FRONTEND_ARCHIVE="$2"; shift 2 ;;
|
|
--os-plan) OS_PLAN="$2"; shift 2 ;;
|
|
--target-specific) TARGET_SPECIFIC=1; shift ;;
|
|
--application-only) APPLICATION_ONLY=1; shift ;;
|
|
--base-url) BASE_URL="$2"; shift 2 ;;
|
|
-h|--help) usage ;;
|
|
*) echo "Unknown option: $1"; usage ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$VERSION" ]; then
|
|
echo "Error: --version is required"
|
|
usage
|
|
fi
|
|
|
|
if [ "$APPLICATION_ONLY" = 1 ]; then
|
|
[ -z "$OS_PLAN" ] && [ "$TARGET_SPECIFIC" = 0 ] || {
|
|
echo 'Error: application-only and target OS transaction modes are mutually exclusive' >&2
|
|
exit 1
|
|
}
|
|
else
|
|
[ -n "$OS_PLAN" ] && [ -f "$OS_PLAN" ] && [ ! -L "$OS_PLAN" ] || {
|
|
echo 'Error: --os-plan requires the reviewed exact OS transaction; a package helper alone is not an OS release input' >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
if [ -z "$RELEASE_DATE" ]; then
|
|
RELEASE_DATE=$(date +%Y-%m-%d)
|
|
fi
|
|
|
|
# Find project root
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
|
|
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]] || { echo 'Invalid release version' >&2; exit 1; }
|
|
SOURCE_COMMIT=$(python3 "$SCRIPT_DIR/release_payload.py" source --version "$VERSION")
|
|
[ ! -e "$OUTPUT_FILE" ] || { echo 'Refusing to overwrite existing manifest (possibly signed)' >&2; exit 1; }
|
|
python3 "$SCRIPT_DIR/release_payload.py" companion
|
|
|
|
# Auto-detect backend binary
|
|
if [ -z "$BACKEND_BINARY" ]; then
|
|
BACKEND_BINARY="$PROJECT_ROOT/core/target/release/archipelago"
|
|
fi
|
|
python3 "$SCRIPT_DIR/release_payload.py" native "$BACKEND_BINARY"
|
|
|
|
# Auto-detect frontend archive.
|
|
# Layout: flat tarball (`./index.html`, `./assets/…`, `./aiui/…`) so the
|
|
# Rust updater can unpack it directly into /opt/archipelago/web-ui/.
|
|
# Using `-C web/dist neode-ui` would produce a `neode-ui/` prefix which
|
|
# breaks the installer and returns 403 on every fleet UI — see
|
|
# feedback_release_tarball_layout.md.
|
|
if [ -z "$FRONTEND_ARCHIVE" ]; then
|
|
FRONTEND_DIST="$PROJECT_ROOT/web/dist/neode-ui"
|
|
if [ -d "$FRONTEND_DIST" ]; then
|
|
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
|
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
|
|
echo "Staging frontend archive in $STAGING_DIR..."
|
|
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
|
|
# Validate the actual native chunk and build-time source binding.
|
|
python3 "$SCRIPT_DIR/release_payload.py" ui "$STAGING_DIR" --commit "$SOURCE_COMMIT"
|
|
# OTA bridge for nodes running older updaters: they only know how to
|
|
# apply the backend binary and frontend archive. Carry host runtime
|
|
# assets inside the frontend tarball; the new backend promotes them
|
|
# from /opt/archipelago/web-ui/archipelago-runtime on first startup.
|
|
RUNTIME_DIR="$STAGING_DIR/archipelago-runtime"
|
|
mkdir -p "$RUNTIME_DIR"
|
|
for runtime_path in apps scripts docker; do
|
|
if [ -d "$PROJECT_ROOT/$runtime_path" ]; then
|
|
echo " Including runtime $runtime_path/"
|
|
cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path"
|
|
fi
|
|
done
|
|
python3 "$PROJECT_ROOT/scripts/check-app-build-contexts.py" "$RUNTIME_DIR"
|
|
# KEEP IN SYNC with the `for unit in [...]` array in
|
|
# core/archipelago/src/bootstrap.rs (run_runtime_assets). A unit that
|
|
# bootstrap installs but this list does not ship simply never reaches a
|
|
# node: bootstrap looks for it in the runtime payload, does not find
|
|
# it, and silently installs nothing. There is no error to notice.
|
|
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
|
|
# D09 preparation travels on the old-updater frontend bridge. Copy the
|
|
# accepted helper only; never replace a customized backend service unit.
|
|
# This is payload delivery, not invocation or workspace activation.
|
|
# Missing source is a packaging error, not an optional runtime asset.
|
|
cp "$PROJECT_ROOT/image-recipe/configs/prepare-terminal-workspace.sh" \
|
|
"$RUNTIME_DIR/image-recipe/configs/prepare-terminal-workspace.sh"
|
|
# The privileged D09 owner is preinstalled by the guarded deployment
|
|
# lane before this update is admitted. Carry its exact source and
|
|
# owned postimage in the immutable payload as rollback/reprovisioning
|
|
# inputs; bootstrap must not activate them from the managed process.
|
|
for d09_asset in archipelago-d09-lifecycle.service \
|
|
50-d09-lifecycle.conf \
|
|
60-terminal-workspace.conf; do
|
|
test -f "$PROJECT_ROOT/image-recipe/configs/$d09_asset" || {
|
|
echo "Error: missing D09 lifecycle asset $d09_asset" >&2
|
|
exit 1
|
|
}
|
|
cp "$PROJECT_ROOT/image-recipe/configs/$d09_asset" \
|
|
"$RUNTIME_DIR/image-recipe/configs/$d09_asset"
|
|
done
|
|
for unit in archipelago-doctor.service archipelago-doctor.timer \
|
|
archipelago-host-secrets-audit.service \
|
|
archipelago-npm-bridge.service archipelago-npm-bridge.timer; do
|
|
if [ -f "$PROJECT_ROOT/image-recipe/configs/$unit" ]; then
|
|
echo " Including runtime unit $unit"
|
|
cp "$PROJECT_ROOT/image-recipe/configs/$unit" "$RUNTIME_DIR/image-recipe/configs/$unit"
|
|
fi
|
|
done
|
|
if [ -f "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" ]; then
|
|
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
|
|
echo " Including runtime nginx-archipelago.conf"
|
|
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
|
|
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
|
|
fi
|
|
# Packaged radio tools ride the runtime payload: OTA-only nodes never
|
|
# get them any other way — the v1.7.117 rollout left fleet nodes with
|
|
# a stale archy-reticulum-daemon (exits on the new --enable-transport
|
|
# flag → mesh dead) and no archy-rnodeconf at all (Flash LoRa fails
|
|
# with "No such file or directory"). bootstrap.rs promotes these to
|
|
# /usr/local/bin on first startup after the update.
|
|
for tool in archy-reticulum-daemon archy-rnodeconf archy-esptool; do
|
|
if [ -f "$PROJECT_ROOT/reticulum-daemon/dist/$tool" ]; then
|
|
mkdir -p "$RUNTIME_DIR/radio-tools"
|
|
echo " Including radio tool $tool"
|
|
cp "$PROJECT_ROOT/reticulum-daemon/dist/$tool" "$RUNTIME_DIR/radio-tools/$tool"
|
|
else
|
|
echo " ERROR: reticulum-daemon/dist/$tool missing — run reticulum-daemon/build.sh first" >&2
|
|
rm -rf "$STAGING_DIR"
|
|
exit 1
|
|
fi
|
|
done
|
|
rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
|
|
find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
|
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete
|
|
# Complete helper/config domain, including C01 network and typed helpers.
|
|
# This is delivery only; it does not grant installation authority.
|
|
python3 "$SCRIPT_DIR/release_payload.py" stage "$STAGING_DIR" --version "$VERSION" --commit "$SOURCE_COMMIT"
|
|
# Force world-readable perms on every entry BEFORE tar, so the
|
|
# archive's internal mode bits are 755/644 regardless of what
|
|
# the staging dir's umask gave us. Without this, mktemp -d
|
|
# creates the staging dir at 700, that 700 gets baked into the
|
|
# tarball's root `./` entry, and every node that extracts the
|
|
# archive ends up with /opt/archipelago/web-ui at 700 — which
|
|
# causes nginx (www-data) to return 500 "permission denied" on
|
|
# every page. Bit us on the v1.7.38 + v1.7.39 rollouts.
|
|
chmod 755 "$STAGING_DIR"
|
|
find "$STAGING_DIR" -type d -exec chmod 755 {} +
|
|
find "$STAGING_DIR" -type f -exec chmod 644 {} +
|
|
echo "Creating frontend archive $FRONTEND_ARCHIVE..."
|
|
# --mode is a belt-and-braces in case a file's on-disk perms
|
|
# drift again; forces 755 dir / 644 file in the archive too.
|
|
tar --owner=0 --group=0 \
|
|
--mode='u=rwX,go=rX' \
|
|
-czf "$FRONTEND_ARCHIVE" \
|
|
-C "$STAGING_DIR" .
|
|
# Verify the archive root entry is world-readable before we
|
|
# declare success — catches regressions in tar-flag handling
|
|
# (BSD tar, busybox tar) that might silently drop --mode.
|
|
# SIGPIPE-safe: use awk to read only the first line and exit,
|
|
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
|
|
# don't kill the whole `set -euo pipefail` script.
|
|
root_mode=$({ tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null || true; } | awk 'NR==1{print $1; exit}')
|
|
case "$root_mode" in
|
|
drwxr-xr-x|drwxr-x*x*)
|
|
echo " Tarball root perms OK: $root_mode"
|
|
;;
|
|
*)
|
|
echo " ERROR: tarball root perms are $root_mode (want drwxr-xr-x) — aborting release"
|
|
rm -f "$FRONTEND_ARCHIVE"
|
|
rm -rf "$STAGING_DIR"
|
|
exit 1
|
|
;;
|
|
esac
|
|
rm -rf "$STAGING_DIR"
|
|
fi
|
|
fi
|
|
|
|
# Compute SHA256 hash
|
|
sha256_of() {
|
|
if command -v sha256sum &>/dev/null; then
|
|
sha256sum "$1" | awk '{print $1}'
|
|
else
|
|
shasum -a 256 "$1" | awk '{print $1}'
|
|
fi
|
|
}
|
|
|
|
# File size in bytes
|
|
size_of() {
|
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
stat -f%z "$1"
|
|
else
|
|
stat -c%s "$1"
|
|
fi
|
|
}
|
|
|
|
# Get current version from Cargo.toml
|
|
CURRENT_VERSION=$(grep '^version' "$PROJECT_ROOT/core/archipelago/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
|
|
|
echo "Building release manifest v${VERSION}"
|
|
echo " Current version: ${CURRENT_VERSION}"
|
|
echo " Release date: ${RELEASE_DATE}"
|
|
echo " Output: ${OUTPUT_FILE}"
|
|
|
|
# Build components array
|
|
COMPONENTS="[]"
|
|
|
|
if [ -f "$BACKEND_BINARY" ]; then
|
|
HASH=$(sha256_of "$BACKEND_BINARY")
|
|
SIZE=$(size_of "$BACKEND_BINARY")
|
|
echo " Backend binary: ${BACKEND_BINARY} (${SIZE} bytes, sha256: ${HASH})"
|
|
COMPONENTS=$(echo "$COMPONENTS" | python3 -c "
|
|
import sys, json
|
|
c = json.load(sys.stdin)
|
|
c.append({
|
|
'name': 'archipelago',
|
|
'current_version': '$CURRENT_VERSION',
|
|
'new_version': '$VERSION',
|
|
'download_url': '$BASE_URL/v$VERSION/archipelago',
|
|
'sha256': '$HASH',
|
|
'size_bytes': $SIZE
|
|
})
|
|
print(json.dumps(c))
|
|
")
|
|
else
|
|
echo " Warning: Backend binary not found at $BACKEND_BINARY"
|
|
fi
|
|
|
|
if [ -n "$FRONTEND_ARCHIVE" ] && [ -f "$FRONTEND_ARCHIVE" ]; then
|
|
[ "$(basename "$FRONTEND_ARCHIVE")" = "archipelago-frontend-${VERSION}.tar.gz" ] || {
|
|
echo 'Error: frontend filename must match the exact release version' >&2; exit 1;
|
|
}
|
|
python3 "$SCRIPT_DIR/release_payload.py" archive "$FRONTEND_ARCHIVE" --version "$VERSION" --commit "$SOURCE_COMMIT"
|
|
# Both explicit and automatically assembled archives must deliver the
|
|
# lifecycle assets. Older updaters carry these via the frontend payload;
|
|
# hashing an archive alone does not establish that the helpers are inside.
|
|
python3 - "$FRONTEND_ARCHIVE" "$PROJECT_ROOT" <<'PY'
|
|
import os
|
|
import pathlib
|
|
import stat
|
|
import sys
|
|
import tarfile
|
|
|
|
paths = (
|
|
'scripts/ota-crash-guard.sh',
|
|
'scripts/d09-lifecycle-client.py',
|
|
'scripts/d09-lifecycle-owner.py',
|
|
'scripts/provision-d09-lifecycle.py',
|
|
'scripts/d09_wire.py',
|
|
'scripts/d09_store.py',
|
|
'scripts/d09_worker.py',
|
|
'scripts/d09-wire-schema.json',
|
|
'image-recipe/configs/prepare-terminal-workspace.sh',
|
|
'image-recipe/configs/archipelago-d09-lifecycle.service',
|
|
'image-recipe/configs/50-d09-lifecycle.conf',
|
|
'image-recipe/configs/60-terminal-workspace.conf',
|
|
)
|
|
expected = {}
|
|
for path in paths:
|
|
source = pathlib.Path(sys.argv[2]) / path
|
|
if source.is_symlink():
|
|
raise SystemExit('Error: lifecycle runtime source must be a regular file')
|
|
# O_NONBLOCK prevents a substituted FIFO from blocking before fstat.
|
|
fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
with os.fdopen(fd, 'rb') as stream:
|
|
metadata = os.fstat(stream.fileno())
|
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 1024 * 1024:
|
|
raise SystemExit('Error: lifecycle runtime source type/size invalid')
|
|
expected['archipelago-runtime/' + path] = stream.read(1024 * 1024 + 1)
|
|
present = set()
|
|
ancestors = {str(parent) for name in expected for parent in pathlib.PurePosixPath(name).parents}
|
|
seen_ancestors = set()
|
|
with tarfile.open(sys.argv[1], 'r|gz') as archive:
|
|
for member in archive:
|
|
name = member.name.removeprefix('./').rstrip('/') or '.'
|
|
if name != '.' and any(part in ('', '.', '..') for part in name.split('/')):
|
|
raise SystemExit('Error: noncanonical frontend archive path')
|
|
if name in ancestors:
|
|
if name in seen_ancestors or not member.isdir() or member.mode != 0o755:
|
|
raise SystemExit('Error: lifecycle runtime ancestor type/mode/duplicate mismatch')
|
|
seen_ancestors.add(name)
|
|
if name in expected:
|
|
if name in present or member.mode != 0o644:
|
|
raise SystemExit('Error: frontend lifecycle runtime asset duplicate/mode mismatch')
|
|
if not member.isreg() or member.size != len(expected[name]):
|
|
raise SystemExit('Error: frontend lifecycle runtime asset type/size mismatch')
|
|
if archive.extractfile(member).read(member.size) != expected[name]:
|
|
raise SystemExit('Error: frontend lifecycle runtime asset source mismatch')
|
|
present.add(name)
|
|
if present != set(expected):
|
|
raise SystemExit('Error: frontend archive missing lifecycle runtime assets')
|
|
PY
|
|
HASH=$(sha256_of "$FRONTEND_ARCHIVE")
|
|
SIZE=$(size_of "$FRONTEND_ARCHIVE")
|
|
ARCHIVE_NAME=$(basename "$FRONTEND_ARCHIVE")
|
|
echo " Frontend archive: ${FRONTEND_ARCHIVE} (${SIZE} bytes, sha256: ${HASH})"
|
|
COMPONENTS=$(echo "$COMPONENTS" | python3 -c "
|
|
import sys, json
|
|
c = json.load(sys.stdin)
|
|
c.append({
|
|
'name': '$ARCHIVE_NAME',
|
|
'current_version': '$CURRENT_VERSION',
|
|
'new_version': '$VERSION',
|
|
'download_url': '$BASE_URL/v$VERSION/$ARCHIVE_NAME',
|
|
'sha256': '$HASH',
|
|
'size_bytes': $SIZE
|
|
})
|
|
print(json.dumps(c))
|
|
")
|
|
else
|
|
echo "Error: Frontend archive not found" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Read changelog from CHANGELOG.md if available
|
|
COMPONENTS=$(python3 - "$COMPONENTS" "$OS_PLAN" "$VERSION" "$BASE_URL" "$SCRIPT_DIR" "$TARGET_SPECIFIC" "$APPLICATION_ONLY" <<'PY'
|
|
import json, pathlib, sys
|
|
sys.path.insert(0, sys.argv[5])
|
|
from release_payload import os_component
|
|
components = json.loads(sys.argv[1])
|
|
if sys.argv[7] != '1':
|
|
components.append(os_component(pathlib.Path(sys.argv[2]), sys.argv[3], sys.argv[4], content_addressed=sys.argv[6] == '1'))
|
|
print(json.dumps(components))
|
|
PY
|
|
)
|
|
|
|
# Read changelog from CHANGELOG.md if available
|
|
CHANGELOG="[]"
|
|
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
|
|
if [ -f "$CHANGELOG_FILE" ]; then
|
|
# Extract entries for this version (lines between ## vVERSION and next ##)
|
|
ENTRIES=$(python3 -c "
|
|
import re, sys
|
|
content = open('$CHANGELOG_FILE').read()
|
|
pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)'
|
|
m = re.search(pattern, content, re.DOTALL)
|
|
if m:
|
|
for line in m.group(1).splitlines():
|
|
line = line.strip()
|
|
if not line.startswith('- '):
|
|
continue
|
|
text = line[2:].strip()
|
|
if text.lower().startswith('validation '):
|
|
continue
|
|
print(text)
|
|
" 2>/dev/null || echo "")
|
|
if [ -n "$ENTRIES" ]; then
|
|
CHANGELOG=$(echo "$ENTRIES" | python3 -c "
|
|
import sys, json
|
|
lines = [l.strip() for l in sys.stdin if l.strip()]
|
|
print(json.dumps(lines))
|
|
")
|
|
fi
|
|
fi
|
|
|
|
# If no changelog entries found, add a default
|
|
if [ "$CHANGELOG" = "[]" ]; then
|
|
CHANGELOG="[\"Update to version ${VERSION}\"]"
|
|
fi
|
|
|
|
# Generate manifest
|
|
test "$SOURCE_COMMIT" = "$(python3 "$SCRIPT_DIR/release_payload.py" source --version "$VERSION")"
|
|
python3 -c "
|
|
import json
|
|
manifest = {
|
|
'version': '$VERSION',
|
|
'source_commit': '$SOURCE_COMMIT',
|
|
'release_date': '$RELEASE_DATE',
|
|
'changelog': $CHANGELOG,
|
|
'components': $COMPONENTS
|
|
}
|
|
if '$APPLICATION_ONLY' == '1':
|
|
manifest['application_update'] = {'version': 2, 'mode': 'signed-application-runtime'}
|
|
else:
|
|
manifest['os_update'] = {'version': 1, 'plan_sha256': next(c['sha256'] for c in manifest['components'] if c['name'] == 'os-update-plan.json')}
|
|
print(json.dumps(manifest, indent=2))
|
|
" > "$OUTPUT_FILE"
|
|
|
|
echo ""
|
|
echo "Manifest written to: $OUTPUT_FILE"
|
|
echo ""
|
|
cat "$OUTPUT_FILE"
|
|
echo ""
|
|
echo "Next steps:"
|
|
echo " 1. Review and sign the manifest above"
|
|
echo " 2. Keep it under releases/pending/v$VERSION/ — do NOT replace the live manifest"
|
|
echo " 3. Run scripts/publish-release-assets.sh $VERSION gitea-vps2"
|
|
echo " (uploads + verifies assets; feed promotion is a separate reviewed lead operation)"
|