mirror of
https://github.com/mrvasil/ctf-ad-mirror-tool-rust.git
synced 2026-10-24 23:49:26 +00:00
Package static Linux x64 releases with first-run launcher
This commit is contained in:
parent
8dcd1feaf1
commit
f264f8a673
56
.github/workflows/linux-release.yml
vendored
Normal file
56
.github/workflows/linux-release.yml
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
name: Linux x64
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
- name: Install pinned Rust and musl build tools
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y musl-tools
|
||||||
|
rustup toolchain install 1.93.1 --profile minimal --component rustfmt --component clippy --target x86_64-unknown-linux-musl
|
||||||
|
rustup override set 1.93.1
|
||||||
|
- name: Verify
|
||||||
|
run: |
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --all-targets --locked -- -D warnings
|
||||||
|
cargo test --all-targets --locked --target x86_64-unknown-linux-musl
|
||||||
|
node --check web/app.js
|
||||||
|
sh -n deploy/run.sh scripts/package-linux.sh
|
||||||
|
- name: Build static release and verify package
|
||||||
|
run: |
|
||||||
|
cargo build --release --locked --target x86_64-unknown-linux-musl
|
||||||
|
sh scripts/package-linux.sh
|
||||||
|
python3 scripts/verify-linux-package.py dist/release/mirror-tool-linux-x64.tar.gz
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: mirror-tool-linux-x64
|
||||||
|
path: dist/release/*
|
||||||
|
if-no-files-found: error
|
||||||
|
release:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
|
with:
|
||||||
|
name: mirror-tool-linux-x64
|
||||||
|
path: release
|
||||||
|
- name: Publish verified archive
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
gh release create "$RELEASE_TAG" release/* --verify-tag --title "Mirror Tool $RELEASE_TAG — Linux x64" --notes 'Ready-to-run static Linux x64 package. Extract and run ./run.sh. First launch creates a private random panel password and an empty configuration. No Rust, Python, Node, glibc or external SQLite required. See included README.md. Flag substitution is disabled by default.'
|
||||||
24
README.md
24
README.md
@ -6,6 +6,30 @@
|
|||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
|
Для Linux x64 скачайте **mirror-tool-linux-x64.tar.gz** и **SHA256SUMS** из
|
||||||
|
[Releases](https://github.com/mrvasil/ctf-ad-mirror-tool-rust/releases/latest)
|
||||||
|
(нужен доступ к приватному репозиторию):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
tar -xzf mirror-tool-linux-x64.tar.gz
|
||||||
|
cd mirror-tool-linux-x64
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Статический musl-бинарник не требует установленной glibc, Rust, Python, Node,
|
||||||
|
Docker или отдельной SQLite. Скрипт создаст пустой `mirror.toml` и случайный пароль
|
||||||
|
в `data/panel-password` (0600); повторный запуск сохраняет настройки и пароль.
|
||||||
|
Панель — `http://127.0.0.1:6700`, пароль прочитайте в другом терминале командой
|
||||||
|
`cat data/panel-password`. Команды/сервисы добавляются через панель; подмена флагов
|
||||||
|
выключена. [Полная инструкция архива](deploy/QUICKSTART.md).
|
||||||
|
|
||||||
|
GitHub Actions проверяет musl-тесты, статическую линковку и запуск архива с TCP,
|
||||||
|
API и перезапуском; push тега `v*` публикует архив и SHA256SUMS в Releases.
|
||||||
|
Обычный push в main сохраняет сборку как Actions artifact, не меняя релиз.
|
||||||
|
|
||||||
|
Для сборки из исходников:
|
||||||
|
|
||||||
Нужен актуальный stable Rust (проверенные версии записаны в `docs/verification.md`).
|
Нужен актуальный stable Rust (проверенные версии записаны в `docs/verification.md`).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
3
agent.md
3
agent.md
@ -45,3 +45,6 @@ User authorized design explanation followed by implementation. Explained random
|
|||||||
|
|
||||||
## Flag protection completed — 2026-10-23
|
## Flag protection completed — 2026-10-23
|
||||||
User explicitly requires optional substitution, default off. Implemented per-service `flag_protection=false`, backwards-compatible omitted config/state fields, settings checkbox and indicator without redesign. Mode is captured for each TCP session; disabling affects new sessions. Random same-format tokens use an XChaCha20-Poly1305 encrypted SQLite vault, durable before forwarding, bounded worker queue, TTL/capacity and fail-closed errors. See docs/flag-protection.md and docs/flag-protection-verification.md. Fresh49 native/50 Orb tests, native fmt/Clippy, JS syntax and both release builds passed. Browser toggled on/off and persisted both; TCP tests verify byte integrity/half-close/replica equality and session mode. Live demo16700 upgraded with ignored persistent synthetic vault, original topology unchanged, protection off. No real hosts or flags used.
|
User explicitly requires optional substitution, default off. Implemented per-service `flag_protection=false`, backwards-compatible omitted config/state fields, settings checkbox and indicator without redesign. Mode is captured for each TCP session; disabling affects new sessions. Random same-format tokens use an XChaCha20-Poly1305 encrypted SQLite vault, durable before forwarding, bounded worker queue, TTL/capacity and fail-closed errors. See docs/flag-protection.md and docs/flag-protection-verification.md. Fresh49 native/50 Orb tests, native fmt/Clippy, JS syntax and both release builds passed. Browser toggled on/off and persisted both; TCP tests verify byte integrity/half-close/replica equality and session mode. Live demo16700 upgraded with ignored persistent synthetic vault, original topology unchanged, protection off. No real hosts or flags used.
|
||||||
|
|
||||||
|
## Portable Linux release in progress
|
||||||
|
User wants a ready-to-run Linux x64 build on GitHub, exact distribution unknown. Package static musl binary with run.sh, empty first-run config and random persistent private password; do not package runtime data or enable substitution. Workflow verifies native Clippy/fmt and musl tests plus archive login/API/TCP/restart smoke, then tag releases publish privately. Main push artifacts do not replace releases. Actual CI/package verification pending; docs in deploy/QUICKSTART.md.
|
||||||
|
|||||||
60
deploy/QUICKSTART.md
Normal file
60
deploy/QUICKSTART.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Mirror Tool — Linux x64
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tar -xzf mirror-tool-linux-x64.tar.gz
|
||||||
|
cd mirror-tool-linux-x64
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Панель: http://127.0.0.1:6700. В другом терминале из этой папки:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat data/panel-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Пароль генерируется случайно при первом запуске и сохраняется с правами0600.
|
||||||
|
Можно передать собственный пароль через MIRROR_PASSWORD (не менее12 символов).
|
||||||
|
Пароль не выводится в логи. Запускать от root не требуется для портов выше1023.
|
||||||
|
|
||||||
|
Для удалённого сервера откройте туннель со своего компьютера:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -N -L 6700:127.0.0.1:6700 user@server
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем откройте http://127.0.0.1:6700 на своём компьютере. Панель по умолчанию
|
||||||
|
доступна только локально; не публикуйте её HTTP-порт в игровой сети.
|
||||||
|
|
||||||
|
Первый запуск создаёт пустой mirror.toml. Добавьте команды и прокси в панели;
|
||||||
|
нажмите название сервиса для настройки слушателя и upstream-порта. Выберите
|
||||||
|
primary, реплики и включите сервис. Порты должны быть свободны. Подмена флагов
|
||||||
|
выключена; системные маршруты/firewall не изменяются. Игровые адреса не угадываются.
|
||||||
|
|
||||||
|
mirror.example.toml содержит полный пример. Для ForcAD добавьте [scoreboard]
|
||||||
|
в mirror.toml и перезапустите процесс. Подробности в docs/configuration.md.
|
||||||
|
`./run.sh check` проверяет конфиг, но не сетевую доступность или ключ кеша.
|
||||||
|
|
||||||
|
Остановка: Ctrl+C или SIGTERM. Для постоянной работы используйте tmux либо
|
||||||
|
приложенный шаблон systemd (требует установки бинарника, пользователя и конфигов,
|
||||||
|
как описано в исходном README проекта). С закрытием SSH foreground-процесс может
|
||||||
|
завершиться. run.sh сам автозапуск и системную службу не устанавливает.
|
||||||
|
|
||||||
|
Сохраняйте mirror.toml и каталог data при обновлении. Архив их не содержит:
|
||||||
|
распаковка обновления не заменяет рабочие настройки/пароль/состояние. Остановите
|
||||||
|
процесс перед обновлением. Настройки команд/сервисов из data/state.json имеют
|
||||||
|
приоритет над TOML. Резервную копию делайте при остановленном процессе.
|
||||||
|
|
||||||
|
Бинарник статический (musl), базовая архитектура x86-64, без требования AVX.
|
||||||
|
Не нужны glibc, Rust, Python, Node, Docker или отдельная SQLite. Для run.sh нужны
|
||||||
|
обычные /bin/sh, uname, dirname, mkdir, od, tr, cat и /dev/urandom. Для распаковки
|
||||||
|
нужен tar с gzip. Linux kernel3.2+ — базовый минимум target Rust; конкретная старая
|
||||||
|
система не считается проверенной только по номеру ядра. Ограничения контейнера,
|
||||||
|
seccomp, права, доступная память, файловые дескрипторы и сеть всё равно действуют.
|
||||||
|
|
||||||
|
Проверка архива: `sha256sum -c SHA256SUMS` до распаковки. BUILD-COMMIT указывает
|
||||||
|
исходный коммит. Репозиторий и Releases приватные: скачивание требует доступа
|
||||||
|
к GitHub. С GitHub CLI: `gh release download --repo mrvasil/ctf-ad-mirror-tool-rust
|
||||||
|
--pattern 'mirror-tool-linux-x64.tar.gz' --pattern SHA256SUMS` (одной строкой).
|
||||||
|
|
||||||
|
Перед игрой проверьте checker и переключение на своих сервисах: зеркалирование
|
||||||
|
TCP-запросов не является репликацией БД. Подробнее: docs/flag-protection.md.
|
||||||
34
deploy/run.sh
Normal file
34
deploy/run.sh
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Portable Linux launcher. Runtime data is never included in release archives.
|
||||||
|
set -eu
|
||||||
|
case "$(uname -s):$(uname -m)" in
|
||||||
|
Linux:x86_64|Linux:amd64) ;;
|
||||||
|
*) echo 'This package requires Linux x86-64.' >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
cd -- "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
umask 077
|
||||||
|
mkdir -p data
|
||||||
|
if [ ! -e mirror.toml ]; then
|
||||||
|
(set -C; cat > mirror.toml <<'CONFIG'
|
||||||
|
# Add teams/services in the panel or use mirror.example.toml as a reference.
|
||||||
|
# Existing data/state.json takes precedence over teams/services in this file.
|
||||||
|
[web]
|
||||||
|
listen = "127.0.0.1:6700"
|
||||||
|
state_file = "data/state.json"
|
||||||
|
CONFIG
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
if [ -z "${MIRROR_PASSWORD:-}" ]; then
|
||||||
|
if [ ! -e data/panel-password ]; then
|
||||||
|
(set -C; od -An -N24 -tx1 /dev/urandom | tr -d ' \n' > data/panel-password)
|
||||||
|
fi
|
||||||
|
MIRROR_PASSWORD=$(cat data/panel-password)
|
||||||
|
if [ "${#MIRROR_PASSWORD}" -lt 12 ]; then
|
||||||
|
echo 'data/panel-password is invalid; restore it or supply MIRROR_PASSWORD.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export MIRROR_PASSWORD
|
||||||
|
echo 'Panel password: read data/panel-password (kept across restarts).'
|
||||||
|
fi
|
||||||
|
echo 'Config: mirror.toml. Default panel: http://127.0.0.1:6700 (see web.listen).'
|
||||||
|
exec ./mirror-tool --config mirror.toml "$@"
|
||||||
25
scripts/package-linux.sh
Normal file
25
scripts/package-linux.sh
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run from the repository root after building the musl target.
|
||||||
|
set -eu
|
||||||
|
binary=${1:-target/x86_64-unknown-linux-musl/release/mirror-tool}
|
||||||
|
out=${2:-dist/release}
|
||||||
|
readelf -h "$binary" | grep -q 'Advanced Micro Devices X86-64'
|
||||||
|
if readelf -l "$binary" | grep -q INTERP || readelf -d "$binary" | grep -q NEEDED; then
|
||||||
|
echo 'Refusing to package a dynamically linked binary.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$out"
|
||||||
|
stage=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$stage"' EXIT HUP INT TERM
|
||||||
|
package="$stage/mirror-tool-linux-x64"
|
||||||
|
mkdir -p "$package/docs"
|
||||||
|
cp "$binary" "$package/mirror-tool"
|
||||||
|
cp deploy/run.sh "$package/run.sh"
|
||||||
|
chmod 755 "$package/mirror-tool" "$package/run.sh"
|
||||||
|
cp mirror.example.toml "$package/"
|
||||||
|
cp deploy/QUICKSTART.md "$package/README.md"
|
||||||
|
cp docs/configuration.md docs/flag-protection.md "$package/docs/"
|
||||||
|
cp deploy/mirror-tool.service "$package/"
|
||||||
|
git rev-parse HEAD > "$package/BUILD-COMMIT"
|
||||||
|
tar -czf "$out/mirror-tool-linux-x64.tar.gz" -C "$stage" mirror-tool-linux-x64
|
||||||
|
(cd "$out" && sha256sum mirror-tool-linux-x64.tar.gz > SHA256SUMS)
|
||||||
116
scripts/verify-linux-package.py
Normal file
116
scripts/verify-linux-package.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test the shipped archive in isolation; never print generated credentials."""
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def free_port():
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return sock.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mirror package ") as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
with tarfile.open(sys.argv[1]) as archive:
|
||||||
|
archive.extractall(root, filter="data")
|
||||||
|
package = root / "mirror-tool-linux-x64"
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("MIRROR_PASSWORD", None)
|
||||||
|
subprocess.run([str(package / "run.sh"), "check"], cwd=root, env=env, check=True)
|
||||||
|
password_path = package / "data/panel-password"
|
||||||
|
password = password_path.read_text()
|
||||||
|
assert len(password) == 48
|
||||||
|
assert stat.S_IMODE(password_path.stat().st_mode) == 0o600
|
||||||
|
config = package / "mirror.toml"
|
||||||
|
web_port = free_port()
|
||||||
|
config.write_text(config.read_text().replace("127.0.0.1:6700", f"127.0.0.1:{web_port}"))
|
||||||
|
original_config = config.read_bytes()
|
||||||
|
base = f"http://127.0.0.1:{web_port}"
|
||||||
|
upstream = socket.socket()
|
||||||
|
upstream.bind(("127.0.0.1", 0))
|
||||||
|
upstream.listen()
|
||||||
|
upstream.settimeout(15)
|
||||||
|
received = bytearray()
|
||||||
|
|
||||||
|
def echo():
|
||||||
|
connection, _ = upstream.accept()
|
||||||
|
with connection:
|
||||||
|
connection.settimeout(10)
|
||||||
|
while data := connection.recv(65536):
|
||||||
|
received.extend(data)
|
||||||
|
connection.sendall(data)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=echo, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
proxy_port = free_port()
|
||||||
|
for restart in (False, True):
|
||||||
|
with (root / "server.log").open("wb") as log:
|
||||||
|
process = subprocess.Popen([str(package / "run.sh")], cwd=root, env=env, stdout=log, stderr=log)
|
||||||
|
try:
|
||||||
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||||
|
for _ in range(100):
|
||||||
|
if process.poll() is not None:
|
||||||
|
raise RuntimeError("packaged process exited during startup")
|
||||||
|
try:
|
||||||
|
with opener.open(base + "/", timeout=1) as response:
|
||||||
|
assert b"html" in response.read().lower()
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("packaged server did not become ready")
|
||||||
|
|
||||||
|
def request(path, value=None, method=None, csrf=None):
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if csrf:
|
||||||
|
headers["x-csrf-token"] = csrf
|
||||||
|
req = urllib.request.Request(base + path, data=None if value is None else json.dumps(value).encode(), headers=headers, method=method)
|
||||||
|
with opener.open(req, timeout=3) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
csrf = request("/api/login", {"password": password})["csrf"]
|
||||||
|
if not restart:
|
||||||
|
snapshot = request("/api/snapshot")
|
||||||
|
assert snapshot["teams"] == [] and snapshot["services"] == []
|
||||||
|
assert snapshot["flag_protection"] is None
|
||||||
|
request("/api/teams/local", {"id": "local", "name": "Local", "address": "127.0.0.1"}, "PUT", csrf)
|
||||||
|
service = {"id": "echo", "name": "Echo", "listen": f"127.0.0.1:{proxy_port}", "upstream_port": upstream.getsockname()[1], "primary": "local", "replicas": [], "enabled": True, "auto_switch": False}
|
||||||
|
request("/api/services/echo", service, "PUT", csrf)
|
||||||
|
payload = bytes(range(256)) * 1024
|
||||||
|
with socket.create_connection(("127.0.0.1", proxy_port), timeout=5) as connection:
|
||||||
|
connection.sendall(payload)
|
||||||
|
connection.shutdown(socket.SHUT_WR)
|
||||||
|
reply = bytearray()
|
||||||
|
while part := connection.recv(65536):
|
||||||
|
reply.extend(part)
|
||||||
|
thread.join(5)
|
||||||
|
assert not thread.is_alive()
|
||||||
|
assert reply == received == payload
|
||||||
|
snapshot = request("/api/snapshot")
|
||||||
|
assert snapshot["services"][0]["flag_protection"] is False
|
||||||
|
assert snapshot["services"][0]["enabled"] is True
|
||||||
|
assert password_path.read_text() == password
|
||||||
|
assert config.read_bytes() == original_config
|
||||||
|
finally:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
raise
|
||||||
|
assert process.returncode == 0
|
||||||
|
upstream.close()
|
||||||
|
print("Package OK: first start, private password, empty topology, login, API persistence/restart, exact 256KiB TCP half-close, flag substitution off.")
|
||||||
@ -44,3 +44,8 @@ One native executable with an async TCP engine and embedded web UI. No Python/ru
|
|||||||
Design limits: stateful tokenization, not stateless same-length AEAD; XChaCha20-Poly1305 protects mappings on disk. Raw visible fixed-shape ASCII only; no TLS/compression/framing adapter. Incomplete possible flags may hold a protocol tail; bounded timeout closes rather than leaking. All clients get reverse translation, so not an authorization boundary. Disabled by default; no real game hosts/flags used.
|
Design limits: stateful tokenization, not stateless same-length AEAD; XChaCha20-Poly1305 protects mappings on disk. Raw visible fixed-shape ASCII only; no TLS/compression/framing adapter. Incomplete possible flags may hold a protocol tail; bounded timeout closes rather than leaking. All clients get reverse translation, so not an authorization boundary. Disabled by default; no real game hosts/flags used.
|
||||||
|
|
||||||
Flag implementation issues recorded: cargo-add combined feature syntax rejected and corrected with separate calls; first module write preceded async mkdir and was retried after explicit directory creation; first test compile warned about unused Arc import, removed before Clippy gate. No failing runtime regressions so far. Final durability review upgraded SQLite synchronous from FULL to EXTRA; final native/Orb gates rerun on that source.
|
Flag implementation issues recorded: cargo-add combined feature syntax rejected and corrected with separate calls; first module write preceded async mkdir and was retried after explicit directory creation; first test compile warned about unused Arc import, removed before Clippy gate. No failing runtime regressions so far. Final durability review upgraded SQLite synchronous from FULL to EXTRA; final native/Orb gates rerun on that source.
|
||||||
|
|
||||||
|
## Portable Linux release — 2026-10-23
|
||||||
|
- [complete] Add static x86_64-unknown-linux-musl packaging, first-run launcher and operator quickstart.
|
||||||
|
- [complete] Add pinned GitHub Actions build/test/artifact and tag-triggered release workflow.
|
||||||
|
- [in progress] Validate actual archive on Linux, publish private GitHub release and verify downloaded asset.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user