fix(updates): установка пересобранных релизов в рамках одного тега
opkg считает пакет уже установленным при равенстве версий, поэтому
пересборка под тем же тегом молча не применялась: задание завершалось
успехом, билд оставался старым. Добавлен --force-reinstall в
pkg_install_files_command (opkg) и в install.sh; apk не тронут, он
всегда пишет переданный файл.
Детект пересборки больше не зависит только от SHA, которого нет в
релизах старше f974e4a0: добавлен отпечаток билда по published_at и
метаданным ipk-ассета, решение вынесено в tachyon_build_differs с
префиксным сравнением SHA разной длины. Отпечаток установленного
билда пишется в state-файл и попадает в extra.
Telegram уведомляет о пересборках: check_notified_updates пропускает
outdated_same_release, дедуп идёт по версии и SHA, текст показывает
короткие SHA вместо пары одинаковых версий.
Кэш проверки обновлений сохраняет current_sha из результата действия
вместо безусловного latest, иначе неудачная установка выглядела как
"актуально".
Фронт пересобран из TS, ручная правка main.js снята. Добавлен
describeSameReleaseBuild с фолбэк-текстом, когда SHA недоступен.
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
OpenWrt package for proxy / anti-censorship orchestration (sing-box + zapret v1/v2 + ByeDPI + Telegram bot + self-healing watchdog). Fork of Forkop / Podkop Plus. Ships two packages: `tachyon` (backend, ucode) and `luci-app-tachyon` (web UI).
|
||||||
|
|
||||||
|
Backend logic is **ucode** — OpenWrt's native scripting language. Not Lua, not JS. Frontend is TypeScript compiled into LuCI-style JS.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend tests
|
||||||
|
|
||||||
|
No ucode toolchain on the Windows host. Everything runs in the container built from `tests/Dockerfile.test` (tag `tachyon-ucode:test`, ucode v0.0.20250529, built with `-DUBUS_SUPPORT=OFF -DUCI_SUPPORT=OFF -DULOOP_SUPPORT=OFF`).
|
||||||
|
|
||||||
|
Build the image once, then run through PowerShell (Git-Bash mangles the `-v G:/tachyon:/repo` path) and append `exit $LASTEXITCODE` so an inner failure actually surfaces:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker build -t tachyon-ucode:test -f tests/Dockerfile.test tests
|
||||||
|
docker run --rm -v 'G:/tachyon:/repo' -w /repo tachyon-ucode:test bash -c 'bash tests/run_all.sh'; exit $LASTEXITCODE
|
||||||
|
docker run --rm -v 'G:/tachyon:/repo' -w /repo tachyon-ucode:test bash -c 'bash tests/event_bus.sh'; exit $LASTEXITCODE
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tests/run_all.sh` = syntax lint + every `tests/*.sh` serially. Auto-discovers; new tests need no registration.
|
||||||
|
- `tests/ucode_syntax_lint.sh` = compile check only (`ucode -c` and `ucode -S -c` over every `.uc`).
|
||||||
|
- `docker_e2e_test.sh` and `container_entrypoint.sh` are excluded from `run_all.sh` — they need Docker themselves.
|
||||||
|
- CI (`.github/workflows/backend-ci.yml`) runs the same set with `xargs -P4`.
|
||||||
|
|
||||||
|
Container gotchas: `ucode -e '...'` combined with `-L` aborts with `double free or corruption` — write snippets to a real `.uc` file. No `python3`; use `sed`/`perl`. `mktemp --suffix` unsupported.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd fe-app-tachyon
|
||||||
|
npm install # npm, not yarn (package-lock.json is authoritative)
|
||||||
|
npm run build # tsc --noEmit, then tsup
|
||||||
|
npm test # vitest
|
||||||
|
npm run ci # format + lint + test --run + build
|
||||||
|
npm run locales:actualize # extract calls -> pot -> ru po -> distribute
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash build.sh <x.y.z> [output-dir]
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Linux + apt (auto-installs deps); on WSL the repo is copied to the native FS first. Version must be semver or the script exits. Output lands in `dist/release-final/` as `.ipk` + `.apk`.
|
||||||
|
|
||||||
|
### Lint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shellcheck --severity=error build.sh install.sh tachyon/files/etc/init.d/* luci-app-tachyon/root/etc/uci-defaults/* tests/**/*.sh
|
||||||
|
cd fe-app-tachyon && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend: ucode module tree
|
||||||
|
|
||||||
|
All modules live under `tachyon/files/usr/lib/`, installed to `/usr/lib/tachyon/`. Loaded via `ucode -L <lib_dir>` with dotted requires that mirror the directory layout: `require("core.uci")` → `core/uci.uc`, `require("providers.zapret.runtime")` → `providers/zapret/runtime.uc`.
|
||||||
|
|
||||||
|
Layers, roughly bottom-up:
|
||||||
|
|
||||||
|
- `core/` — no domain knowledge. `common` (string/shell helpers), `uci` (config read/write), `helpers` (tag naming, file predicates), `ip`, `url`, `constants`, `packages`, `events` (the bus).
|
||||||
|
- `config/` — UCI schema layer: `validator`, `migration` (forkop/podkop → tachyon), `rule`, `domain`, `connections`.
|
||||||
|
- `singbox/` — config generation. `generator` orchestrates, with `generator_outbounds` / `generator_routes` / `route` / `dns*` / `country` / `priority` as parts.
|
||||||
|
- `providers/` — one subtree per DPI-bypass engine (`zapret`, `zapret2`, `nfqueue`, `byedpi`), each with the same `runtime` / `validator` / `check` triad, plus shared `rules.uc` and `status.uc`.
|
||||||
|
- `service/` — lifecycle and orchestration: `initd` (called by `/etc/init.d/tachyon`), `lifecycle`, `reload`, `state`, `api`, `telegram`, `ui`, `watchdog`, `event_controller`, `package`.
|
||||||
|
- `nft/`, `dns/`, `routing/`, `diagnostics/`, `subscription/`, `components/`, `server/` — focused single-concern modules.
|
||||||
|
|
||||||
|
Every module is **both a library and a CLI**. The bottom of each `.uc` file is an `if (mode == "...")` dispatch chain over `ARGV`, which is how the shell tests exercise pure functions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ucode -L "$TACHYON_LIB" "$TACHYON_LIB/core/helpers.uc" outbound-tag proxy # -> proxy-out
|
||||||
|
```
|
||||||
|
|
||||||
|
When adding a function you want tested, add a dispatch arm for it — that is the established test seam.
|
||||||
|
|
||||||
|
### Event bus, not timers
|
||||||
|
|
||||||
|
`core/events.uc` is a domain-agnostic pub/sub bus (per-subscriber cooldown, publish-side dedup, priority ordering, handler error isolation, `clock(true)` monotonic timing so NTP steps at boot don't skew windows). It performs no I/O, which is what makes it unit-testable.
|
||||||
|
|
||||||
|
The strict split around it:
|
||||||
|
|
||||||
|
- `service/event_controller.uc` **observes only** — push sources (ubus, syslog, honeypot FIFO) and probe tiers (fast 15s / normal adaptive 120–300s / slow 300s) turn system state into facts on the bus. Event names live in an `EV` constant table so a typo is a load-time error.
|
||||||
|
- `service/watchdog.uc` **repairs only** — subscribes to those facts. Its registrations go through a `subscribe()` wrapper that `die()`s if `bus.on()` rejects the handler (see the hoisting hazard below).
|
||||||
|
|
||||||
|
Do not add probing to a healer or repair to the controller.
|
||||||
|
|
||||||
|
### Entrypoints
|
||||||
|
|
||||||
|
- `/usr/bin/tachyon` (`tachyon/files/usr/bin/tachyon`) — ucode CLI dispatcher. Resolves the module for a subcommand, shells out with `ucode -L`, normalizes the exit status, and restores the dnsmasq failsafe if the loader fails on a lifecycle command.
|
||||||
|
- `/etc/init.d/tachyon` — thin procd shim; delegates every action to `service/initd.uc`. Deliberately minimal shell.
|
||||||
|
- Environment overrides everywhere: `TACHYON_LIB`, `TACHYON_CONFIG_NAME`, `TACHYON_BIN`, `TACHYON_SERVICE_INIT`, plus per-module `SB_*` tag overrides. Tests rely on these — keep new constants overridable via `getenv(...) || default`.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
`fe-app-tachyon/src/` → tsup → `luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js`. The tsup `onSuccess` hook rewrites the emitted `export { ... }` into `return baseclass.extend({ ... })`, and **throws if that block is absent** — this is intentional LuCI interop, not a bug to fix. Anything the LuCI views need must be re-exported from `src/main.ts`.
|
||||||
|
|
||||||
|
`main.js` is generated — never edit it directly. The sibling files in that directory (`dashboard.js`, `settings.js`, `section.js`, …) are hand-written LuCI views that consume `main.js`.
|
||||||
|
|
||||||
|
`__COMPILED_VERSION_VARIABLE__` in `src/constants.ts` is a literal placeholder sed-replaced at build time. Leave it alone.
|
||||||
|
|
||||||
|
## Landmines
|
||||||
|
|
||||||
|
**ucode does not hoist function declarations.** Not at file scope, not nested. A name used before its declaration resolves as a global and comes back `null`. It's lexical, not timing — a closure created before a sibling `function` is declared never captures it, so deferring the call doesn't help. Both `ucode -c` and `ucode -S -c` accept the broken file; you get a runtime `Type error: left-hand side is not a function`. Combined with the bus this fails silently: `bus.on()` returns `false` for a non-function handler, so a misordered handler registers nothing, emits nothing, throws nothing, and a repair just quietly stops happening. Guards: `tests/forward_references.sh` (static decl-after-use scan + actually invoking the push handlers) and `tests/watchdog_subscriptions.sh`.
|
||||||
|
|
||||||
|
**Contract tests exist and will fail you.** `package_contract.sh` checks `tachyon/Makefile` `DEPENDS` against what the code actually invokes; `shell_inventory.sh` fails if any `.sh` reappears under `tachyon/files/usr/lib/` (the runtime is ucode-only — a shell owner returning is a regression); `config_contract_matrix.sh` pins the UCI schema. Adding a new external binary or config option means updating the matching contract.
|
||||||
|
|
||||||
|
**Atomic writes.** JSON configs, subscription cache, diagnostics, runtime state, and UCI backups all go through tmp + `mv`. Persistent staging lives in `/etc/.tachyon/` so it survives reboot. Follow this when writing new state.
|
||||||
|
|
||||||
|
**`/etc/config/tachyon` must be `chmod 600` root:root** — it holds the Telegram bot token. postinst/postupgrade re-assert this; so should any code that rewrites the file.
|
||||||
|
|
||||||
|
**postupgrade is deliberately violent.** It kills the procd `flock`-waiting processes before starting via `/usr/bin/tachyon start` (which registers with procd directly), because `rc.common`'s `flock -w 1000` plus `retry_start_on_wan_up` retriggers deadlocks across an upgrade. Read the comment in `tachyon/Makefile` before touching it.
|
||||||
|
|
||||||
|
**Line endings: LF, enforced by `.gitattributes`.** Never commit CRLF from Windows.
|
||||||
|
|
||||||
|
**Legacy migration is a supported path** — configs from `forkop`, `forkop_plus`, and `podkop` are migrated in postinst and `config/migration.uc`. Don't drop those code paths.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Each shell test is standalone: `set -eo pipefail`, a local `ucode()` wrapper that injects `-L "$TACHYON_LIB"`, `fail()`/`assert_eq()` helpers, exit non-zero on mismatch. No framework, no shared harness beyond `tests/helpers/`.
|
||||||
|
- Branches: `main` for releases, `rc/**` for release candidates.
|
||||||
|
- Backend CI only triggers on `tachyon/files/**`, `tachyon/Makefile`, `build.sh`, `tests/**` — a frontend-only change won't run it.
|
||||||
+977
-907
File diff suppressed because it is too large
Load Diff
+943
-905
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: TACHYON\n"
|
"Project-Id-Version: TACHYON\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-08-03 20:40+0300\n"
|
"POT-Creation-Date: 2026-08-09 16:50+0300\n"
|
||||||
"PO-Revision-Date: 2026-08-03 20:40+0300\n"
|
"PO-Revision-Date: 2026-08-09 16:50+0300\n"
|
||||||
"Last-Translator: Dushnilin\n"
|
"Last-Translator: Dushnilin\n"
|
||||||
"Language-Team: none\n"
|
"Language-Team: none\n"
|
||||||
"Language: ru\n"
|
"Language: ru\n"
|
||||||
@@ -374,6 +374,9 @@ msgstr "Путь к файлу кэша"
|
|||||||
msgid "Cache file path cannot be empty"
|
msgid "Cache file path cannot be empty"
|
||||||
msgstr "Путь к файлу кэша не может быть пустым"
|
msgstr "Путь к файлу кэша не может быть пустым"
|
||||||
|
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Cannot receive checks result"
|
msgid "Cannot receive checks result"
|
||||||
msgstr "Не удалось получить результаты проверки"
|
msgstr "Не удалось получить результаты проверки"
|
||||||
|
|
||||||
@@ -527,6 +530,9 @@ msgstr "Настройки сервиса Tachyon"
|
|||||||
msgid "Configure User-Agent in the item settings"
|
msgid "Configure User-Agent in the item settings"
|
||||||
msgstr "Настройте User-Agent в настройках элемента"
|
msgstr "Настройте User-Agent в настройках элемента"
|
||||||
|
|
||||||
|
msgid "Conflict"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Connect through"
|
msgid "Connect through"
|
||||||
msgstr "Подключаться через"
|
msgstr "Подключаться через"
|
||||||
|
|
||||||
@@ -1337,6 +1343,9 @@ msgstr "Установить"
|
|||||||
msgid "Install another build:"
|
msgid "Install another build:"
|
||||||
msgstr "Установить другую сборку:"
|
msgstr "Установить другую сборку:"
|
||||||
|
|
||||||
|
msgid "Installed build → Available build"
|
||||||
|
msgstr "Установленная сборка → Доступная сборка"
|
||||||
|
|
||||||
msgid "Installed version is newer than release"
|
msgid "Installed version is newer than release"
|
||||||
msgstr "Установлена версия новее релиза"
|
msgstr "Установлена версия новее релиза"
|
||||||
|
|
||||||
@@ -2207,6 +2216,9 @@ msgstr "Reality short ID"
|
|||||||
msgid "Reality short ID must contain 1-8 hex digits"
|
msgid "Reality short ID must contain 1-8 hex digits"
|
||||||
msgstr "Reality short ID должен содержать 1-8 hex-символов"
|
msgstr "Reality short ID должен содержать 1-8 hex-символов"
|
||||||
|
|
||||||
|
msgid "Rebuilt release"
|
||||||
|
msgstr "Пересобранный релиз"
|
||||||
|
|
||||||
msgid "Reconnect Threshold"
|
msgid "Reconnect Threshold"
|
||||||
msgstr "Порог переподключений"
|
msgstr "Порог переподключений"
|
||||||
|
|
||||||
@@ -2273,6 +2285,9 @@ msgstr "Magic Header: Ответ (S4)"
|
|||||||
msgid "Restart Tachyon"
|
msgid "Restart Tachyon"
|
||||||
msgstr "Перезапустить Tachyon"
|
msgstr "Перезапустить Tachyon"
|
||||||
|
|
||||||
|
msgid "Restarts"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Resume updates"
|
msgid "Resume updates"
|
||||||
msgstr "Продолжить обновление"
|
msgstr "Продолжить обновление"
|
||||||
|
|
||||||
@@ -3074,6 +3089,9 @@ msgstr "Неизвестный флаг NFQWS2."
|
|||||||
msgid "Unspecified IP address is not allowed"
|
msgid "Unspecified IP address is not allowed"
|
||||||
msgstr "Неуказанный IP-адрес не разрешён"
|
msgstr "Неуказанный IP-адрес не разрешён"
|
||||||
|
|
||||||
|
msgid "Unstable"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Unsupported security mode for this protocol"
|
msgid "Unsupported security mode for this protocol"
|
||||||
msgstr "Этот протокол не поддерживает выбранный режим защиты"
|
msgstr "Этот протокол не поддерживает выбранный режим защиты"
|
||||||
|
|
||||||
@@ -3083,14 +3101,11 @@ msgstr "Обновить"
|
|||||||
msgid "Update is available"
|
msgid "Update is available"
|
||||||
msgstr "Доступно обновление"
|
msgstr "Доступно обновление"
|
||||||
|
|
||||||
msgid "Update is available:"
|
|
||||||
msgstr "Доступна новая версия:"
|
|
||||||
|
|
||||||
msgid "Update is available for current release"
|
msgid "Update is available for current release"
|
||||||
msgstr "Доступно обновление текущего релиза"
|
msgstr "Доступно обновление текущего релиза"
|
||||||
|
|
||||||
msgid "Update is available for current release:"
|
msgid "Update is available:"
|
||||||
msgstr "Доступно обновление текущего релиза:"
|
msgstr "Доступна новая версия:"
|
||||||
|
|
||||||
msgid "Update subscriptions"
|
msgid "Update subscriptions"
|
||||||
msgstr "Обновить подписки"
|
msgstr "Обновить подписки"
|
||||||
@@ -3122,8 +3137,8 @@ msgstr "URL для проверки доступности сервера"
|
|||||||
msgid "URL used to test server latency"
|
msgid "URL used to test server latency"
|
||||||
msgstr "URL для тестирования задержки сервера"
|
msgstr "URL для тестирования задержки сервера"
|
||||||
|
|
||||||
msgid "URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>. Entries are resolved via both dnsmasq and sing-box DNS."
|
msgid "URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>."
|
||||||
msgstr "URL удалённых файлов хостов для загрузки и применения. Поддерживается стандартный формат hosts: <code>IP domain</code>. Записи разрешаются через dnsmasq и DNS sing-box."
|
msgstr ""
|
||||||
|
|
||||||
msgid "URLTest"
|
msgid "URLTest"
|
||||||
msgstr "URLTest"
|
msgstr "URLTest"
|
||||||
|
|||||||
@@ -651,10 +651,7 @@ export const TachyonShellMethods = {
|
|||||||
const installedVersion = await readTachyonVersion();
|
const installedVersion = await readTachyonVersion();
|
||||||
const targetVersion = expectedLatestVersion || installedVersion;
|
const targetVersion = expectedLatestVersion || installedVersion;
|
||||||
|
|
||||||
if (
|
if (targetVersion && installedVersion === targetVersion) {
|
||||||
targetVersion &&
|
|
||||||
installedVersion === targetVersion
|
|
||||||
) {
|
|
||||||
if (!selfUpdateVersionMatchedAt) {
|
if (!selfUpdateVersionMatchedAt) {
|
||||||
selfUpdateVersionMatchedAt = Date.now();
|
selfUpdateVersionMatchedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
shouldRefreshComponentStateBeforeRender,
|
shouldRefreshComponentStateBeforeRender,
|
||||||
shouldResetCheckResultsOnMount,
|
shouldResetCheckResultsOnMount,
|
||||||
} from './checkResultLifecycle';
|
} from './checkResultLifecycle';
|
||||||
|
import { describeSameReleaseBuild } from './sameReleaseBuild';
|
||||||
import { TachyonShellMethods } from '../../methods';
|
import { TachyonShellMethods } from '../../methods';
|
||||||
import {
|
import {
|
||||||
logger,
|
logger,
|
||||||
@@ -1106,28 +1107,20 @@ function renderComponentCard(card: ComponentCard) {
|
|||||||
} else if (checkResult.status === 'outdated_same_release') {
|
} else if (checkResult.status === 'outdated_same_release') {
|
||||||
labelText = _('Update is available for current release');
|
labelText = _('Update is available for current release');
|
||||||
// Show short commit SHAs so user understands it's the same version, different build
|
// Show short commit SHAs so user understands it's the same version, different build
|
||||||
const currentSha = checkResult.current_sha
|
const build = describeSameReleaseBuild({
|
||||||
? checkResult.current_sha.substring(0, 8)
|
currentSha: checkResult.current_sha,
|
||||||
: '';
|
latestSha: checkResult.latest_sha,
|
||||||
const latestSha = checkResult.latest_sha
|
});
|
||||||
? checkResult.latest_sha.substring(0, 8)
|
latestValueNodes.push(
|
||||||
: '';
|
E(
|
||||||
if (currentSha || latestSha) {
|
'span',
|
||||||
const shaText =
|
{
|
||||||
currentSha && latestSha
|
class: 'tachyon_updates-page__component__sha-info',
|
||||||
? `${currentSha} → ${latestSha}`
|
title: _('Installed build → Available build'),
|
||||||
: currentSha || latestSha;
|
},
|
||||||
latestValueNodes.push(
|
build.kind === 'sha' ? build.text : _('Rebuilt release'),
|
||||||
E(
|
),
|
||||||
'span',
|
);
|
||||||
{
|
|
||||||
class: 'tachyon_updates-page__component__sha-info',
|
|
||||||
title: _('Installed build → Available build'),
|
|
||||||
},
|
|
||||||
shaText,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (checkResult.status === 'latest') {
|
} else if (checkResult.status === 'latest') {
|
||||||
labelText = _('Latest version is installed');
|
labelText = _('Latest version is installed');
|
||||||
} else if (checkResult.status === 'dev') {
|
} else if (checkResult.status === 'dev') {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* A release tag can be rebuilt: the same version number then covers more than
|
||||||
|
* one build. What the user needs to see is *which* build, and the answer comes
|
||||||
|
* from whichever identity the release carries — a commit SHA when the build
|
||||||
|
* stamps one, nothing at all for releases published before it did.
|
||||||
|
*/
|
||||||
|
export function describeSameReleaseBuild({
|
||||||
|
currentSha,
|
||||||
|
latestSha,
|
||||||
|
}: {
|
||||||
|
currentSha?: string | null;
|
||||||
|
latestSha?: string | null;
|
||||||
|
}) {
|
||||||
|
const current = (currentSha || '').substring(0, 8);
|
||||||
|
const latest = (latestSha || '').substring(0, 8);
|
||||||
|
|
||||||
|
if (current && latest) {
|
||||||
|
return { kind: 'sha' as const, text: `${current} → ${latest}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current || latest) {
|
||||||
|
return { kind: 'sha' as const, text: current || latest };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without this the label claims an update is available and shows nothing to
|
||||||
|
// back the claim up.
|
||||||
|
return { kind: 'fallback' as const, text: '' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { describeSameReleaseBuild } from '../sameReleaseBuild';
|
||||||
|
|
||||||
|
describe('same-release build description', () => {
|
||||||
|
it('shows a truncated transition when both builds are identified', () => {
|
||||||
|
expect(
|
||||||
|
describeSameReleaseBuild({
|
||||||
|
currentSha: 'abc1234def5678',
|
||||||
|
latestSha: 'ffff9999aaaa',
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'sha', text: 'abc1234d → ffff9999' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the single known build when only one side has a sha', () => {
|
||||||
|
expect(
|
||||||
|
describeSameReleaseBuild({ currentSha: 'abc1234def', latestSha: '' }),
|
||||||
|
).toEqual({ kind: 'sha', text: 'abc1234d' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
describeSameReleaseBuild({ currentSha: null, latestSha: 'ffff9999aaaa' }),
|
||||||
|
).toEqual({ kind: 'sha', text: 'ffff9999' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when the release carries no sha at all', () => {
|
||||||
|
// Releases published before the build stamped a commit SHA are told apart by
|
||||||
|
// publish timestamps and asset size, so there is nothing to print here — but
|
||||||
|
// the label still has to say something, or it claims an update with no
|
||||||
|
// evidence behind it.
|
||||||
|
expect(describeSameReleaseBuild({})).toEqual({
|
||||||
|
kind: 'fallback',
|
||||||
|
text: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
describeSameReleaseBuild({ currentSha: '', latestSha: null }),
|
||||||
|
).toEqual({ kind: 'fallback', text: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-1
@@ -1776,7 +1776,10 @@ pkg_install_files() {
|
|||||||
return $rc
|
return $rc
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
run_logged_timeout "opkg" 120 opkg install --force-overwrite --force-downgrade "$@"
|
# --force-reinstall: a release tag can be rebuilt, so the .ipk version may
|
||||||
|
# equal the installed one. Without it opkg treats that as "already
|
||||||
|
# installed" and the rebuild is never written.
|
||||||
|
run_logged_timeout "opkg" 120 opkg install --force-overwrite --force-downgrade --force-reinstall "$@"
|
||||||
local rc=$?
|
local rc=$?
|
||||||
if [ $rc -ne 0 ]; then
|
if [ $rc -ne 0 ]; then
|
||||||
log_line "FAIL opkg install failed (exit $rc)"
|
log_line "FAIL opkg install failed (exit $rc)"
|
||||||
|
|||||||
@@ -3557,9 +3557,10 @@ var TachyonShellMethods = {
|
|||||||
if (transientRpc.shouldContinue(failure.error)) {
|
if (transientRpc.shouldContinue(failure.error)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (component === "tachyon" && action === "install") {
|
if (component === "tachyon" && (action === "install" || action === "reinstall")) {
|
||||||
const installedVersion = expectedLatestVersion ? await readTachyonVersion() : "";
|
const installedVersion = await readTachyonVersion();
|
||||||
if (expectedLatestVersion && installedVersion === expectedLatestVersion) {
|
const targetVersion = expectedLatestVersion || installedVersion;
|
||||||
|
if (targetVersion && installedVersion === targetVersion) {
|
||||||
if (!selfUpdateVersionMatchedAt) {
|
if (!selfUpdateVersionMatchedAt) {
|
||||||
selfUpdateVersionMatchedAt = Date.now();
|
selfUpdateVersionMatchedAt = Date.now();
|
||||||
}
|
}
|
||||||
@@ -13868,6 +13869,22 @@ function shouldExposeCheckResults({
|
|||||||
return mounted && cacheResolved;
|
return mounted && cacheResolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/tachyon/tabs/updates/sameReleaseBuild.ts
|
||||||
|
function describeSameReleaseBuild({
|
||||||
|
currentSha,
|
||||||
|
latestSha
|
||||||
|
}) {
|
||||||
|
const current = (currentSha || "").substring(0, 8);
|
||||||
|
const latest = (latestSha || "").substring(0, 8);
|
||||||
|
if (current && latest) {
|
||||||
|
return { kind: "sha", text: `${current} \u2192 ${latest}` };
|
||||||
|
}
|
||||||
|
if (current || latest) {
|
||||||
|
return { kind: "sha", text: current || latest };
|
||||||
|
}
|
||||||
|
return { kind: "fallback", text: "" };
|
||||||
|
}
|
||||||
|
|
||||||
// src/tachyon/tabs/updates/initController.ts
|
// src/tachyon/tabs/updates/initController.ts
|
||||||
var updatesLifecycleRegistered = false;
|
var updatesLifecycleRegistered = false;
|
||||||
var updatesControllerInitialized = false;
|
var updatesControllerInitialized = false;
|
||||||
@@ -14645,21 +14662,20 @@ function renderComponentCard(card) {
|
|||||||
}
|
}
|
||||||
} else if (checkResult.status === "outdated_same_release") {
|
} else if (checkResult.status === "outdated_same_release") {
|
||||||
labelText = _("Update is available for current release");
|
labelText = _("Update is available for current release");
|
||||||
const currentSha = checkResult.current_sha ? checkResult.current_sha.substring(0, 8) : "";
|
const build = describeSameReleaseBuild({
|
||||||
const latestSha = checkResult.latest_sha ? checkResult.latest_sha.substring(0, 8) : "";
|
currentSha: checkResult.current_sha,
|
||||||
if (currentSha || latestSha) {
|
latestSha: checkResult.latest_sha
|
||||||
const shaText = currentSha && latestSha ? `${currentSha} \u2192 ${latestSha}` : currentSha || latestSha;
|
});
|
||||||
latestValueNodes.push(
|
latestValueNodes.push(
|
||||||
E(
|
E(
|
||||||
"span",
|
"span",
|
||||||
{
|
{
|
||||||
class: "tachyon_updates-page__component__sha-info",
|
class: "tachyon_updates-page__component__sha-info",
|
||||||
title: _("Installed build \u2192 Available build")
|
title: _("Installed build \u2192 Available build")
|
||||||
},
|
},
|
||||||
shaText
|
build.kind === "sha" ? build.text : _("Rebuilt release")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} else if (checkResult.status === "latest") {
|
} else if (checkResult.status === "latest") {
|
||||||
labelText = _("Latest version is installed");
|
labelText = _("Latest version is installed");
|
||||||
} else if (checkResult.status === "dev") {
|
} else if (checkResult.status === "dev") {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: TACHYON\n"
|
"Project-Id-Version: TACHYON\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-08-03 20:40+0300\n"
|
"POT-Creation-Date: 2026-08-09 16:50+0300\n"
|
||||||
"PO-Revision-Date: 2026-08-03 20:40+0300\n"
|
"PO-Revision-Date: 2026-08-09 16:50+0300\n"
|
||||||
"Last-Translator: Dushnilin\n"
|
"Last-Translator: Dushnilin\n"
|
||||||
"Language-Team: none\n"
|
"Language-Team: none\n"
|
||||||
"Language: ru\n"
|
"Language: ru\n"
|
||||||
@@ -374,6 +374,9 @@ msgstr "Путь к файлу кэша"
|
|||||||
msgid "Cache file path cannot be empty"
|
msgid "Cache file path cannot be empty"
|
||||||
msgstr "Путь к файлу кэша не может быть пустым"
|
msgstr "Путь к файлу кэша не может быть пустым"
|
||||||
|
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Cannot receive checks result"
|
msgid "Cannot receive checks result"
|
||||||
msgstr "Не удалось получить результаты проверки"
|
msgstr "Не удалось получить результаты проверки"
|
||||||
|
|
||||||
@@ -527,6 +530,9 @@ msgstr "Настройки сервиса Tachyon"
|
|||||||
msgid "Configure User-Agent in the item settings"
|
msgid "Configure User-Agent in the item settings"
|
||||||
msgstr "Настройте User-Agent в настройках элемента"
|
msgstr "Настройте User-Agent в настройках элемента"
|
||||||
|
|
||||||
|
msgid "Conflict"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Connect through"
|
msgid "Connect through"
|
||||||
msgstr "Подключаться через"
|
msgstr "Подключаться через"
|
||||||
|
|
||||||
@@ -1337,6 +1343,9 @@ msgstr "Установить"
|
|||||||
msgid "Install another build:"
|
msgid "Install another build:"
|
||||||
msgstr "Установить другую сборку:"
|
msgstr "Установить другую сборку:"
|
||||||
|
|
||||||
|
msgid "Installed build → Available build"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Installed version is newer than release"
|
msgid "Installed version is newer than release"
|
||||||
msgstr "Установлена версия новее релиза"
|
msgstr "Установлена версия новее релиза"
|
||||||
|
|
||||||
@@ -2207,6 +2216,9 @@ msgstr "Reality short ID"
|
|||||||
msgid "Reality short ID must contain 1-8 hex digits"
|
msgid "Reality short ID must contain 1-8 hex digits"
|
||||||
msgstr "Reality short ID должен содержать 1-8 hex-символов"
|
msgstr "Reality short ID должен содержать 1-8 hex-символов"
|
||||||
|
|
||||||
|
msgid "Rebuilt release"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Reconnect Threshold"
|
msgid "Reconnect Threshold"
|
||||||
msgstr "Порог переподключений"
|
msgstr "Порог переподключений"
|
||||||
|
|
||||||
@@ -2273,6 +2285,9 @@ msgstr "Magic Header: Ответ (S4)"
|
|||||||
msgid "Restart Tachyon"
|
msgid "Restart Tachyon"
|
||||||
msgstr "Перезапустить Tachyon"
|
msgstr "Перезапустить Tachyon"
|
||||||
|
|
||||||
|
msgid "Restarts"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Resume updates"
|
msgid "Resume updates"
|
||||||
msgstr "Продолжить обновление"
|
msgstr "Продолжить обновление"
|
||||||
|
|
||||||
@@ -3074,6 +3089,9 @@ msgstr "Неизвестный флаг NFQWS2."
|
|||||||
msgid "Unspecified IP address is not allowed"
|
msgid "Unspecified IP address is not allowed"
|
||||||
msgstr "Неуказанный IP-адрес не разрешён"
|
msgstr "Неуказанный IP-адрес не разрешён"
|
||||||
|
|
||||||
|
msgid "Unstable"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Unsupported security mode for this protocol"
|
msgid "Unsupported security mode for this protocol"
|
||||||
msgstr "Этот протокол не поддерживает выбранный режим защиты"
|
msgstr "Этот протокол не поддерживает выбранный режим защиты"
|
||||||
|
|
||||||
@@ -3083,14 +3101,11 @@ msgstr "Обновить"
|
|||||||
msgid "Update is available"
|
msgid "Update is available"
|
||||||
msgstr "Доступно обновление"
|
msgstr "Доступно обновление"
|
||||||
|
|
||||||
msgid "Update is available:"
|
|
||||||
msgstr "Доступна новая версия:"
|
|
||||||
|
|
||||||
msgid "Update is available for current release"
|
msgid "Update is available for current release"
|
||||||
msgstr "Доступно обновление текущего релиза"
|
msgstr "Доступно обновление текущего релиза"
|
||||||
|
|
||||||
msgid "Update is available for current release:"
|
msgid "Update is available:"
|
||||||
msgstr "Доступно обновление текущего релиза:"
|
msgstr "Доступна новая версия:"
|
||||||
|
|
||||||
msgid "Update subscriptions"
|
msgid "Update subscriptions"
|
||||||
msgstr "Обновить подписки"
|
msgstr "Обновить подписки"
|
||||||
@@ -3122,8 +3137,8 @@ msgstr "URL для проверки доступности сервера"
|
|||||||
msgid "URL used to test server latency"
|
msgid "URL used to test server latency"
|
||||||
msgstr "URL для тестирования задержки сервера"
|
msgstr "URL для тестирования задержки сервера"
|
||||||
|
|
||||||
msgid "URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>. Entries are resolved via both dnsmasq and sing-box DNS."
|
msgid "URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>."
|
||||||
msgstr "URL удалённых файлов хостов для загрузки и применения. Поддерживается стандартный формат hosts: <code>IP domain</code>. Записи разрешаются через dnsmasq и DNS sing-box."
|
msgstr ""
|
||||||
|
|
||||||
msgid "URLTest"
|
msgid "URLTest"
|
||||||
msgstr "URLTest"
|
msgstr "URLTest"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ const TACHYON_COMMIT_SHA = getenv("TACHYON_COMMIT_SHA") || constants.TACHYON_COM
|
|||||||
const TACHYON_RELEASE_REPO = getenv("TACHYON_RELEASE_REPO") || constants.TACHYON_RELEASE_REPO || "Dushnilin/tachyon";
|
const TACHYON_RELEASE_REPO = getenv("TACHYON_RELEASE_REPO") || constants.TACHYON_RELEASE_REPO || "Dushnilin/tachyon";
|
||||||
const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon";
|
const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon";
|
||||||
const SYSTEM_INFO_CACHE_FILE = getenv("TACHYON_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json";
|
const SYSTEM_INFO_CACHE_FILE = getenv("TACHYON_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json";
|
||||||
|
// Persistent, not RUNTIME_STATE_DIR: the fingerprint describes the build that is
|
||||||
|
// on disk, so it has to survive a reboot the same way the package does.
|
||||||
|
const TACHYON_BUILD_STATE_FILE = getenv("TACHYON_BUILD_STATE_FILE") || "/etc/.tachyon/build-state";
|
||||||
const COMPONENT_LOCK_DIR = getenv("UPDATES_LOCK_DIR") || RUNTIME_STATE_DIR + "/component-action.lock";
|
const COMPONENT_LOCK_DIR = getenv("UPDATES_LOCK_DIR") || RUNTIME_STATE_DIR + "/component-action.lock";
|
||||||
const TMP_STALE_TTL_MINUTES = getenv("UPDATES_TMP_STALE_TTL_MINUTES") || "30";
|
const TMP_STALE_TTL_MINUTES = getenv("UPDATES_TMP_STALE_TTL_MINUTES") || "30";
|
||||||
const TMP_FILE_STALE_TTL_MINUTES = getenv("UPDATES_TMP_FILE_STALE_TTL_MINUTES") || "10";
|
const TMP_FILE_STALE_TTL_MINUTES = getenv("UPDATES_TMP_FILE_STALE_TTL_MINUTES") || "10";
|
||||||
@@ -405,8 +408,14 @@ function pkg_install_name_downgrade(package_name, package_version) {
|
|||||||
command_success(command_from_args([ "opkg", "install", "--force-downgrade", package_name ]) + " </dev/null");
|
command_success(command_from_args([ "opkg", "install", "--force-downgrade", package_name ]) + " </dev/null");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pkg_install_files_command(files) {
|
// force_reinstall matters only for opkg: installing an .ipk whose version equals
|
||||||
|
// the installed one is a no-op unless --force-reinstall is passed, so a rebuild
|
||||||
|
// published under the same tag would silently not be applied. apk always writes
|
||||||
|
// the file it is handed, so its argument list stays untouched.
|
||||||
|
function pkg_install_files_command(files, force_reinstall) {
|
||||||
let args = is_apk() ? [ "apk", "add", "--allow-untrusted" ] : [ "opkg", "install", "--force-overwrite", "--force-downgrade" ];
|
let args = is_apk() ? [ "apk", "add", "--allow-untrusted" ] : [ "opkg", "install", "--force-overwrite", "--force-downgrade" ];
|
||||||
|
if (!is_apk() && force_reinstall)
|
||||||
|
push(args, "--force-reinstall");
|
||||||
for (let file in files)
|
for (let file in files)
|
||||||
push(args, file);
|
push(args, file);
|
||||||
return command_from_args(args) + " </dev/null";
|
return command_from_args(args) + " </dev/null";
|
||||||
@@ -762,6 +771,49 @@ function write_tachyon_latest_version_cache(value, timestamp) {
|
|||||||
write_file("/tmp/tachyon.latest-version.cache", as_string(value) + "\n" + as_string(timestamp) + "\n");
|
write_file("/tmp/tachyon.latest-version.cache", as_string(value) + "\n" + as_string(timestamp) + "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A release tag can be rebuilt, so the version alone cannot answer "is the build
|
||||||
|
// on disk the build the release publishes now?". The fingerprint recorded at
|
||||||
|
// install time answers it, including for releases that carry no commit SHA.
|
||||||
|
function read_tachyon_build_fingerprint() {
|
||||||
|
let fields = split(trim(read_file(TACHYON_BUILD_STATE_FILE)), "\t");
|
||||||
|
if (length(fields) < 2 || trim(as_string(fields[0])) != trim(as_string(TACHYON_VERSION)))
|
||||||
|
return "";
|
||||||
|
return trim(as_string(fields[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function write_tachyon_build_fingerprint(version, fingerprint) {
|
||||||
|
if (as_string(version) == "" || as_string(fingerprint) == "")
|
||||||
|
return;
|
||||||
|
let dir = replace(TACHYON_BUILD_STATE_FILE, /\/[^\/]*$/, "");
|
||||||
|
if (dir != "")
|
||||||
|
ensure_dir(dir);
|
||||||
|
write_file(TACHYON_BUILD_STATE_FILE, as_string(version) + "\t" + as_string(fingerprint) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called after a successful install/reinstall. TACHYON_COMMIT_SHA still holds the
|
||||||
|
// SHA of the build that is running this code, not the one just written to disk,
|
||||||
|
// so the release we installed from is the only source for the new identity.
|
||||||
|
function record_tachyon_installed_build(version) {
|
||||||
|
let release_json = latest_tachyon_release_json();
|
||||||
|
if (release_json == "")
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let sha = trim(helper_output_input(release_json, "release-commit-sha", []));
|
||||||
|
if (sha != "" && match(sha, /^[0-9a-fA-F]{7,40}$/) == null)
|
||||||
|
sha = "";
|
||||||
|
let fingerprint = trim(helper_output_input(release_json, "release-build-fingerprint", []));
|
||||||
|
write_tachyon_build_fingerprint(version, fingerprint);
|
||||||
|
|
||||||
|
if (sha == "" && fingerprint == "")
|
||||||
|
return null;
|
||||||
|
let extra = { current_sha: sha, latest_sha: sha };
|
||||||
|
if (fingerprint != "") {
|
||||||
|
extra.current_build = fingerprint;
|
||||||
|
extra.latest_build = fingerprint;
|
||||||
|
}
|
||||||
|
return extra;
|
||||||
|
}
|
||||||
|
|
||||||
function retry_resolve(description, fn) {
|
function retry_resolve(description, fn) {
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
if (fn())
|
if (fn())
|
||||||
@@ -2196,21 +2248,33 @@ function check_tachyon() {
|
|||||||
|
|
||||||
// Fetch remote commit SHA for information display
|
// Fetch remote commit SHA for information display
|
||||||
let remote_sha = "";
|
let remote_sha = "";
|
||||||
|
let remote_fingerprint = "";
|
||||||
let local_sha = TACHYON_COMMIT_SHA != "" && TACHYON_COMMIT_SHA != "unknown" ? TACHYON_COMMIT_SHA : "";
|
let local_sha = TACHYON_COMMIT_SHA != "" && TACHYON_COMMIT_SHA != "unknown" ? TACHYON_COMMIT_SHA : "";
|
||||||
|
let local_fingerprint = read_tachyon_build_fingerprint();
|
||||||
if (release_json != "") {
|
if (release_json != "") {
|
||||||
remote_sha = trim(helper_output_input(release_json, "release-commit-sha", []));
|
remote_sha = trim(helper_output_input(release_json, "release-commit-sha", []));
|
||||||
if (remote_sha != "" && match(remote_sha, /^[0-9a-fA-F]{7,40}$/) == null)
|
if (remote_sha != "" && match(remote_sha, /^[0-9a-fA-F]{7,40}$/) == null)
|
||||||
remote_sha = "";
|
remote_sha = "";
|
||||||
|
remote_fingerprint = trim(helper_output_input(release_json, "release-build-fingerprint", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
let sha_extra = local_sha != "" ? { current_sha: local_sha, latest_sha: remote_sha } : null;
|
let sha_extra = null;
|
||||||
|
if (local_sha != "" || remote_fingerprint != "" || local_fingerprint != "") {
|
||||||
|
sha_extra = { current_sha: local_sha, latest_sha: remote_sha };
|
||||||
|
if (local_fingerprint != "")
|
||||||
|
sha_extra.current_build = local_fingerprint;
|
||||||
|
if (remote_fingerprint != "")
|
||||||
|
sha_extra.latest_build = remote_fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
if (status == "latest") {
|
if (status == "latest") {
|
||||||
if (local_sha != "" && remote_sha != "" &&
|
// Same tag can carry several builds. Decide via SHA when both sides have
|
||||||
substr(remote_sha, 0, length(local_sha)) != local_sha &&
|
// one, otherwise via the fingerprint recorded at install time.
|
||||||
substr(local_sha, 0, length(remote_sha)) != remote_sha) {
|
if (helper_success("tachyon-build-differs", [ local_sha, remote_sha, local_fingerprint, remote_fingerprint ])) {
|
||||||
status = "outdated_same_release";
|
status = "outdated_same_release";
|
||||||
updates_log("Tachyon build update found for current release (" + TACHYON_VERSION + "): " + local_sha + " -> " + remote_sha);
|
updates_log("Tachyon build update found for current release (" + TACHYON_VERSION + "): " +
|
||||||
|
(local_sha != "" && remote_sha != "" ? local_sha + " -> " + remote_sha :
|
||||||
|
local_fingerprint + " -> " + remote_fingerprint));
|
||||||
action_success("tachyon", "check_update", "Update is available for current release", TACHYON_VERSION, latest_version, 0, status, release_url, sha_extra);
|
action_success("tachyon", "check_update", "Update is available for current release", TACHYON_VERSION, latest_version, 0, status, release_url, sha_extra);
|
||||||
}
|
}
|
||||||
updates_log("Tachyon is already up to date (" + TACHYON_VERSION + ")");
|
updates_log("Tachyon is already up to date (" + TACHYON_VERSION + ")");
|
||||||
@@ -2286,7 +2350,7 @@ function reinstall_tachyon() {
|
|||||||
let reinstall_files = [ app_file, backend_file ];
|
let reinstall_files = [ app_file, backend_file ];
|
||||||
if (i18n_file != "")
|
if (i18n_file != "")
|
||||||
push(reinstall_files, i18n_file);
|
push(reinstall_files, i18n_file);
|
||||||
if (!run_logged_retrying("Reinstalling Tachyon packages", pkg_install_files_command(reinstall_files)))
|
if (!run_logged_retrying("Reinstalling Tachyon packages", pkg_install_files_command(reinstall_files, true)))
|
||||||
action_fail("tachyon", "reinstall", "Failed to reinstall Tachyon packages", TACHYON_VERSION, latest_version);
|
action_fail("tachyon", "reinstall", "Failed to reinstall Tachyon packages", TACHYON_VERSION, latest_version);
|
||||||
|
|
||||||
remove_file("/var/luci-indexcache");
|
remove_file("/var/luci-indexcache");
|
||||||
@@ -2302,7 +2366,8 @@ function reinstall_tachyon() {
|
|||||||
if (new_version == "")
|
if (new_version == "")
|
||||||
new_version = latest_version;
|
new_version = latest_version;
|
||||||
updates_log("Tachyon reinstalled to " + new_version);
|
updates_log("Tachyon reinstalled to " + new_version);
|
||||||
action_success("tachyon", "reinstall", "Tachyon has been reinstalled", new_version, latest_version, 1, "latest", release.release_url);
|
let build_extra = record_tachyon_installed_build(new_version);
|
||||||
|
action_success("tachyon", "reinstall", "Tachyon has been reinstalled", new_version, latest_version, 1, "latest", release.release_url, build_extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
function install_tachyon() {
|
function install_tachyon() {
|
||||||
@@ -2330,7 +2395,10 @@ function install_tachyon() {
|
|||||||
let install_files = [ app_file, backend_file ];
|
let install_files = [ app_file, backend_file ];
|
||||||
if (i18n_file != "")
|
if (i18n_file != "")
|
||||||
push(install_files, i18n_file);
|
push(install_files, i18n_file);
|
||||||
if (!run_logged_retrying("Installing Tachyon packages", pkg_install_files_command(install_files)))
|
// Installing the same tag means a rebuild of the current release; opkg skips
|
||||||
|
// it as "already installed" unless it is forced.
|
||||||
|
let same_release_build = trim(as_string(latest_version)) == trim(as_string(TACHYON_VERSION));
|
||||||
|
if (!run_logged_retrying("Installing Tachyon packages", pkg_install_files_command(install_files, same_release_build)))
|
||||||
action_fail("tachyon", "install", "Failed to install Tachyon packages", TACHYON_VERSION, latest_version);
|
action_fail("tachyon", "install", "Failed to install Tachyon packages", TACHYON_VERSION, latest_version);
|
||||||
|
|
||||||
remove_file("/var/luci-indexcache");
|
remove_file("/var/luci-indexcache");
|
||||||
@@ -2346,7 +2414,8 @@ function install_tachyon() {
|
|||||||
if (new_version == "")
|
if (new_version == "")
|
||||||
new_version = latest_version;
|
new_version = latest_version;
|
||||||
updates_log("Tachyon updated to " + new_version);
|
updates_log("Tachyon updated to " + new_version);
|
||||||
action_success("tachyon", "install", "Tachyon has been installed", new_version, latest_version, 1, "latest", release.release_url);
|
let build_extra = record_tachyon_installed_build(new_version);
|
||||||
|
action_success("tachyon", "install", "Tachyon has been installed", new_version, latest_version, 1, "latest", release.release_url, build_extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatch_sing_box(action) {
|
function dispatch_sing_box(action) {
|
||||||
@@ -2439,6 +2508,8 @@ else if (mode == "latest-tachyon-version")
|
|||||||
print(latest_tachyon_version(), "\n");
|
print(latest_tachyon_version(), "\n");
|
||||||
else if (mode == "tachyon-release-metadata")
|
else if (mode == "tachyon-release-metadata")
|
||||||
print(fetch_tachyon_latest_release_metadata(), "\n");
|
print(fetch_tachyon_latest_release_metadata(), "\n");
|
||||||
|
else if (mode == "pkg-install-files-command")
|
||||||
|
print(pkg_install_files_command(slice(ARGV, 2), ARGV[1] == "1"), "\n");
|
||||||
else {
|
else {
|
||||||
warn("Usage: components/action.uc <component-action|latest-tachyon-version|tachyon-release-metadata> ...\n");
|
warn("Usage: components/action.uc <component-action|latest-tachyon-version|tachyon-release-metadata> ...\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
|
|||||||
@@ -271,21 +271,101 @@ function release_metadata_tsv() {
|
|||||||
print(tag, "\t", as_string(release.html_url || ""), "\n");
|
print(tag, "\t", as_string(release.html_url || ""), "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function release_commit_sha() {
|
function release_commit_sha_value(release) {
|
||||||
let release = object_or_empty(read_stdin_json());
|
release = object_or_empty(release);
|
||||||
let sha = as_string(release.target_commitish || "");
|
let sha = as_string(release.target_commitish || "");
|
||||||
if (sha != "" && match(sha, /^[0-9a-fA-F]{7,40}$/) != null) {
|
if (sha != "" && match(sha, /^[0-9a-fA-F]{7,40}$/) != null)
|
||||||
print(sha, "\n");
|
return sha;
|
||||||
return;
|
|
||||||
}
|
|
||||||
let body = as_string(release.body || "");
|
let body = as_string(release.body || "");
|
||||||
if (body != "") {
|
if (body != "") {
|
||||||
let m = match(body, /(?:[Cc]ommit|[Ss][Hh][Aa]):?\s*([0-9a-fA-F]{7,40})/);
|
// ucode's regex engine rejects non-capturing groups, so the two spellings
|
||||||
if (m && m[1]) {
|
// are matched separately rather than as one alternation.
|
||||||
print(m[1], "\n");
|
for (let pattern in [ /[Cc]ommit:?[ \t]*([0-9a-fA-F]{7,40})/,
|
||||||
return;
|
/[Ss][Hh][Aa]:?[ \t]*([0-9a-fA-F]{7,40})/ ]) {
|
||||||
|
let m = match(body, pattern);
|
||||||
|
if (m && m[1])
|
||||||
|
return as_string(m[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function release_commit_sha() {
|
||||||
|
let sha = release_commit_sha_value(read_stdin_json());
|
||||||
|
if (sha != "")
|
||||||
|
print(sha, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rebuild published under an existing tag keeps tag_name identical, so the tag
|
||||||
|
// alone cannot tell two builds apart. Releases created before the workflow began
|
||||||
|
// stamping "Commit: <sha>" into the body have no usable SHA either — for those
|
||||||
|
// this fingerprint is the only signal that the assets were replaced.
|
||||||
|
//
|
||||||
|
// Preference order: the release SHA, then the publish/upload timestamps and the
|
||||||
|
// size of the backend asset. Timestamps alone would miss a re-upload that keeps
|
||||||
|
// published_at, and size alone collides across near-identical builds; together
|
||||||
|
// they change whenever the artifacts do.
|
||||||
|
function release_build_fingerprint_value(release) {
|
||||||
|
release = object_or_empty(release);
|
||||||
|
|
||||||
|
let sha = release_commit_sha_value(release);
|
||||||
|
if (sha != "")
|
||||||
|
return "sha:" + sha;
|
||||||
|
|
||||||
|
let parts = [];
|
||||||
|
let published = as_string(release.published_at || release.created_at || "");
|
||||||
|
if (published != "")
|
||||||
|
push(parts, "pub=" + published);
|
||||||
|
|
||||||
|
for (let asset in array_or_empty(release.assets)) {
|
||||||
|
if (type(asset) != "object")
|
||||||
|
continue;
|
||||||
|
let name = as_string(asset.name || "");
|
||||||
|
if (!str_startswith(name, "tachyon_"))
|
||||||
|
continue;
|
||||||
|
let updated = as_string(asset.updated_at || asset.created_at || "");
|
||||||
|
if (updated != "")
|
||||||
|
push(parts, "upd=" + updated);
|
||||||
|
if (asset.size != null)
|
||||||
|
push(parts, "size=" + as_string(asset.size));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length(parts) == 0)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
return "build:" + join("|", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function release_build_fingerprint() {
|
||||||
|
let fingerprint = release_build_fingerprint_value(read_stdin_json());
|
||||||
|
if (fingerprint != "")
|
||||||
|
print(fingerprint, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decides whether the installed build differs from the published one. Exits 0
|
||||||
|
// when they differ (an update exists), 1 when they match or when there is not
|
||||||
|
// enough information to tell — an unknown state must never be reported as an
|
||||||
|
// available update, or the UI would offer an install that changes nothing.
|
||||||
|
function tachyon_build_differs(local_sha, remote_sha, local_fingerprint, remote_fingerprint) {
|
||||||
|
local_sha = trim(as_string(local_sha));
|
||||||
|
remote_sha = trim(as_string(remote_sha));
|
||||||
|
|
||||||
|
// Build.sh and the OpenWrt Makefile both stamp a short SHA, while the GitHub
|
||||||
|
// release may carry the full one; compare on the shorter of the two.
|
||||||
|
if (local_sha != "" && remote_sha != "") {
|
||||||
|
let shorter = length(local_sha) < length(remote_sha) ? length(local_sha) : length(remote_sha);
|
||||||
|
return substr(local_sha, 0, shorter) != substr(remote_sha, 0, shorter);
|
||||||
|
}
|
||||||
|
|
||||||
|
local_fingerprint = trim(as_string(local_fingerprint));
|
||||||
|
remote_fingerprint = trim(as_string(remote_fingerprint));
|
||||||
|
if (local_fingerprint != "" && remote_fingerprint != "")
|
||||||
|
return local_fingerprint != remote_fingerprint;
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openwrt_release_value(path, key) {
|
function openwrt_release_value(path, key) {
|
||||||
@@ -1240,6 +1320,10 @@ else if (mode == "release-metadata-tsv")
|
|||||||
release_metadata_tsv();
|
release_metadata_tsv();
|
||||||
else if (mode == "release-commit-sha")
|
else if (mode == "release-commit-sha")
|
||||||
release_commit_sha();
|
release_commit_sha();
|
||||||
|
else if (mode == "release-build-fingerprint")
|
||||||
|
release_build_fingerprint();
|
||||||
|
else if (mode == "tachyon-build-differs")
|
||||||
|
exit(tachyon_build_differs(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1);
|
||||||
else if (mode == "openwrt-release-value")
|
else if (mode == "openwrt-release-value")
|
||||||
openwrt_release_value(ARGV[1], ARGV[2]);
|
openwrt_release_value(ARGV[1], ARGV[2]);
|
||||||
else if (mode == "openwrt-release-series")
|
else if (mode == "openwrt-release-series")
|
||||||
|
|||||||
@@ -1443,13 +1443,25 @@ function cache_component_update_check_result(value, notify_update) {
|
|||||||
cached.current_sha = as_string(value.current_sha);
|
cached.current_sha = as_string(value.current_sha);
|
||||||
if (as_string(value.latest_sha) != "")
|
if (as_string(value.latest_sha) != "")
|
||||||
cached.latest_sha = as_string(value.latest_sha);
|
cached.latest_sha = as_string(value.latest_sha);
|
||||||
|
// Build fingerprints answer "is this the build the release publishes now?" for
|
||||||
|
// releases that carry no commit SHA at all.
|
||||||
|
if (as_string(value.current_build) != "")
|
||||||
|
cached.current_build = as_string(value.current_build);
|
||||||
|
if (as_string(value.latest_build) != "")
|
||||||
|
cached.latest_build = as_string(value.latest_build);
|
||||||
if (!write_state_file(path, cached))
|
if (!write_state_file(path, cached))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (arg_bool(notify_update) && cached.status == "outdated" &&
|
// A rebuild keeps latest_version, so the build identity has to take part in the
|
||||||
(as_string(previous.status) != "outdated" ||
|
// "is this new to us?" test or same-tag rebuilds would never be logged.
|
||||||
as_string(previous.latest_version) != cached.latest_version)) {
|
let build_key = as_string(cached.latest_sha) != "" ? as_string(cached.latest_sha) : as_string(cached.latest_build);
|
||||||
log_message("[component-update] " + component + " " + cached.latest_version, "info");
|
let previous_build_key = as_string(previous.latest_sha) != "" ? as_string(previous.latest_sha) : as_string(previous.latest_build);
|
||||||
|
if (arg_bool(notify_update) && (cached.status == "outdated" || cached.status == "outdated_same_release") &&
|
||||||
|
(as_string(previous.status) != cached.status ||
|
||||||
|
as_string(previous.latest_version) != cached.latest_version ||
|
||||||
|
previous_build_key != build_key)) {
|
||||||
|
log_message("[component-update] " + component + " " + cached.latest_version +
|
||||||
|
(cached.status == "outdated_same_release" && build_key != "" ? " (build " + build_key + ")" : ""), "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -1483,6 +1495,10 @@ function update_component_check_cache_from_action(value) {
|
|||||||
latest_version = current_ver;
|
latest_version = current_ver;
|
||||||
if (current_ver == "" || current_ver != latest_version)
|
if (current_ver == "" || current_ver != latest_version)
|
||||||
current_ver = latest_version;
|
current_ver = latest_version;
|
||||||
|
// The action reports which build it actually put on disk. Carrying it into
|
||||||
|
// the cache is what keeps a same-tag rebuild from re-appearing as an update
|
||||||
|
// right after it was installed — and, if the install silently changed
|
||||||
|
// nothing, from being hidden behind a bare "latest".
|
||||||
cache_component_update_check_result({
|
cache_component_update_check_result({
|
||||||
success: true,
|
success: true,
|
||||||
component,
|
component,
|
||||||
@@ -1491,7 +1507,11 @@ function update_component_check_cache_from_action(value) {
|
|||||||
current_version: current_ver,
|
current_version: current_ver,
|
||||||
latest_version,
|
latest_version,
|
||||||
release_url: as_string(value.release_url),
|
release_url: as_string(value.release_url),
|
||||||
status: "latest"
|
status: "latest",
|
||||||
|
current_sha: as_string(value.current_sha),
|
||||||
|
latest_sha: as_string(value.latest_sha),
|
||||||
|
current_build: as_string(value.current_build),
|
||||||
|
latest_build: as_string(value.latest_build)
|
||||||
}, false);
|
}, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1247,6 +1247,36 @@ function exec_close_connections(token, chat_id) {
|
|||||||
send_message(token, chat_id, "❌ <b>Ошибка сброса соединений.</b>\nsing-box Clash API недоступен.", "HTML", [[{text:"⬅️ Меню", callback_data:"/menu"}]]);
|
send_message(token, chat_id, "❌ <b>Ошибка сброса соединений.</b>\nsing-box Clash API недоступен.", "HTML", [[{text:"⬅️ Меню", callback_data:"/menu"}]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rebuild published under an existing tag keeps its version, so "1.2.66 ➡️
|
||||||
|
// 1.2.66" is all the version fields can say. These helpers name the build
|
||||||
|
// instead: the commit SHA when the release carries one, else the fingerprint
|
||||||
|
// derived from publish timestamps and asset size.
|
||||||
|
function build_label(sha, fingerprint) {
|
||||||
|
sha = as_string(sha);
|
||||||
|
if (sha != "")
|
||||||
|
return length(sha) > 7 ? substr(sha, 0, 7) : sha;
|
||||||
|
fingerprint = as_string(fingerprint);
|
||||||
|
if (fingerprint == "")
|
||||||
|
return "";
|
||||||
|
let published = match(fingerprint, /pub=([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2})/);
|
||||||
|
if (published)
|
||||||
|
return as_string(published[1]) + " " + as_string(published[2]);
|
||||||
|
return length(fingerprint) > 24 ? substr(fingerprint, 0, 24) : fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_transition(comp) {
|
||||||
|
let from = build_label(comp.current_sha, comp.current_build);
|
||||||
|
let to = build_label(comp.latest_sha, comp.latest_build);
|
||||||
|
if (from == "" || to == "" || from == to)
|
||||||
|
return to != "" ? "<code>" + escape_html(to) + "</code>" : "";
|
||||||
|
return "<code>" + escape_html(from) + "</code> ➡️ <code>" + escape_html(to) + "</code>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_identity_key(comp) {
|
||||||
|
let sha = as_string(comp.latest_sha);
|
||||||
|
return sha != "" ? sha : as_string(comp.latest_build);
|
||||||
|
}
|
||||||
|
|
||||||
function exec_check_updates(token, chat_id, msg_id) {
|
function exec_check_updates(token, chat_id, msg_id) {
|
||||||
let out = command_output_from_args(["/usr/bin/tachyon", "component_update_check_cache"]);
|
let out = command_output_from_args(["/usr/bin/tachyon", "component_update_check_cache"]);
|
||||||
let text = "📦 <b>Обновления компонентов</b>\n\n";
|
let text = "📦 <b>Обновления компонентов</b>\n\n";
|
||||||
@@ -1268,7 +1298,13 @@ function exec_check_updates(token, chat_id, msg_id) {
|
|||||||
let lat = comp.latest_version || "?";
|
let lat = comp.latest_version || "?";
|
||||||
if (!comp.success) {
|
if (!comp.success) {
|
||||||
text += "• <b>" + title + "</b>: ❌ Ошибка проверки\n";
|
text += "• <b>" + title + "</b>: ❌ Ошибка проверки\n";
|
||||||
} else if (comp.status == "outdated" || comp.status == "outdated_same_release") {
|
} else if (comp.status == "outdated_same_release") {
|
||||||
|
let transition = build_transition(comp);
|
||||||
|
text += "• <b>" + title + "</b>: <code>" + cur + "</code> — новая сборка релиза" +
|
||||||
|
(transition != "" ? " " + transition : "") + " ⚠️\n";
|
||||||
|
push(keyboard, [{text: "🔄 Обновить " + title, callback_data: "/update_component " + name}]);
|
||||||
|
has_updates = true;
|
||||||
|
} else if (comp.status == "outdated") {
|
||||||
text += "• <b>" + title + "</b>: <code>" + cur + "</code> ➡️ <code>" + lat + "</code> ⚠️\n";
|
text += "• <b>" + title + "</b>: <code>" + cur + "</code> ➡️ <code>" + lat + "</code> ⚠️\n";
|
||||||
push(keyboard, [{text: "🔄 Обновить " + title, callback_data: "/update_component " + name}]);
|
push(keyboard, [{text: "🔄 Обновить " + title, callback_data: "/update_component " + name}]);
|
||||||
has_updates = true;
|
has_updates = true;
|
||||||
@@ -2529,20 +2565,33 @@ function check_notified_updates(token, admin_ids) {
|
|||||||
let name = comp.component || "";
|
let name = comp.component || "";
|
||||||
if (name == "") continue;
|
if (name == "") continue;
|
||||||
if (comp.success !== true) continue;
|
if (comp.success !== true) continue;
|
||||||
if (comp.status == "outdated") {
|
if (comp.status == "outdated" || comp.status == "outdated_same_release") {
|
||||||
let latest = comp.latest_version;
|
let latest = comp.latest_version;
|
||||||
if (notified[name] != latest) {
|
// Keyed by version *and* build: a rebuild under an existing tag
|
||||||
|
// leaves the version untouched, so a version-only key would
|
||||||
|
// suppress its notification forever. Entries written by older
|
||||||
|
// builds hold a bare version and simply miss once.
|
||||||
|
let identity = build_identity_key(comp);
|
||||||
|
let key = as_string(latest) + (identity != "" ? ":" + identity : "");
|
||||||
|
if (notified[name] != key) {
|
||||||
let title = (name == "sing_box") ? "sing-box" : name;
|
let title = (name == "sing_box") ? "sing-box" : name;
|
||||||
let cur = comp.current_version || "?";
|
let cur = comp.current_version || "?";
|
||||||
let msg = "📦 <b>Доступно обновление компонента!</b>\n" + title + ": <code>" + escape_html(cur) + "</code> ➡️ <code>" + escape_html(as_string(latest)) + "</code>";
|
let msg;
|
||||||
|
if (comp.status == "outdated_same_release") {
|
||||||
|
let transition = build_transition(comp);
|
||||||
|
msg = "📦 <b>Новая сборка текущего релиза!</b>\n" + title + ": <code>" + escape_html(cur) + "</code>" +
|
||||||
|
(transition != "" ? "\nСборка: " + transition : "");
|
||||||
|
} else {
|
||||||
|
msg = "📦 <b>Доступно обновление компонента!</b>\n" + title + ": <code>" + escape_html(cur) + "</code> ➡️ <code>" + escape_html(as_string(latest)) + "</code>";
|
||||||
|
}
|
||||||
let kb = [[{text: "🔄 Обновить " + title, callback_data: "/update_component " + name}]];
|
let kb = [[{text: "🔄 Обновить " + title, callback_data: "/update_component " + name}]];
|
||||||
|
|
||||||
let admins = split(admin_ids, /,/);
|
let admins = split(admin_ids, /,/);
|
||||||
for (let admin in admins) {
|
for (let admin in admins) {
|
||||||
let cid = trim(admin);
|
let cid = trim(admin);
|
||||||
if (cid != "") send_message(token, cid, msg, "HTML", kb);
|
if (cid != "") send_message(token, cid, msg, "HTML", kb);
|
||||||
}
|
}
|
||||||
notified[name] = latest;
|
notified[name] = key;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ cat >"$fake_lib/components/action.uc" <<'UCODE'
|
|||||||
#!/usr/bin/env ucode
|
#!/usr/bin/env ucode
|
||||||
let component = ARGV[1] || "tachyon";
|
let component = ARGV[1] || "tachyon";
|
||||||
let action = ARGV[2] || "check_update";
|
let action = ARGV[2] || "check_update";
|
||||||
print(sprintf("%J\n", {
|
let result = {
|
||||||
success: true,
|
success: true,
|
||||||
component,
|
component,
|
||||||
action,
|
action,
|
||||||
@@ -71,7 +71,16 @@ print(sprintf("%J\n", {
|
|||||||
release_url: "https://example.com/release",
|
release_url: "https://example.com/release",
|
||||||
changed: 0,
|
changed: 0,
|
||||||
status: "outdated"
|
status: "outdated"
|
||||||
}));
|
};
|
||||||
|
// An install reports which build it actually put on disk; a same-tag rebuild is
|
||||||
|
// only distinguishable by that value.
|
||||||
|
if (getenv("TEST_ACTION_CURRENT_SHA")) {
|
||||||
|
result.current_sha = getenv("TEST_ACTION_CURRENT_SHA");
|
||||||
|
result.latest_sha = getenv("TEST_ACTION_CURRENT_SHA");
|
||||||
|
result.status = "latest";
|
||||||
|
result.changed = 1;
|
||||||
|
}
|
||||||
|
print(sprintf("%J\n", result));
|
||||||
UCODE
|
UCODE
|
||||||
|
|
||||||
cat >"$fake_lib/providers/zapret/runtime.uc" <<'UCODE'
|
cat >"$fake_lib/providers/zapret/runtime.uc" <<'UCODE'
|
||||||
@@ -115,6 +124,28 @@ TEST_COMPONENT_UPDATE_CHECK_ENABLED=0 updates_ucode \
|
|||||||
[ ! -e "$cache_dir/zapret.json" ] ||
|
[ ! -e "$cache_dir/zapret.json" ] ||
|
||||||
fail "manual checks must not be cached while automatic checks are disabled"
|
fail "manual checks must not be cached while automatic checks are disabled"
|
||||||
|
|
||||||
|
# A successful install must record the build identity it installed, not just
|
||||||
|
# "latest". A release tag can be rebuilt, so without the SHA the next check has
|
||||||
|
# nothing to compare against and either re-offers the same build or hides an
|
||||||
|
# install that silently changed nothing.
|
||||||
|
rm -rf "$cache_dir"
|
||||||
|
rm -f "$state_file"
|
||||||
|
TEST_ACTION_CURRENT_SHA=abc1234 updates_ucode \
|
||||||
|
component-action-worker "$state_file" "$output_file" tachyon install
|
||||||
|
[ -s "$cache_dir/tachyon.json" ] ||
|
||||||
|
fail "a successful install must refresh the Tachyon cache entry"
|
||||||
|
|
||||||
|
install_cache="$(updates_ucode component-update-check-cache)"
|
||||||
|
node -e '
|
||||||
|
const value = JSON.parse(process.argv[1]);
|
||||||
|
const entry = value.results.find((item) => item.component === "tachyon");
|
||||||
|
if (!entry) process.exit(1);
|
||||||
|
if (entry.status !== "latest") process.exit(1);
|
||||||
|
if (entry.current_sha !== "abc1234") process.exit(1);
|
||||||
|
if (entry.latest_sha !== "abc1234") process.exit(1);
|
||||||
|
' "$install_cache" || fail "install must cache the installed build sha"
|
||||||
|
|
||||||
|
rm -rf "$cache_dir"
|
||||||
rm -f "$state_file" "$timestamp_file"
|
rm -f "$state_file" "$timestamp_file"
|
||||||
updates_ucode component-updates-if-due
|
updates_ucode component-updates-if-due
|
||||||
[ -s "$cache_dir/tachyon.json" ] ||
|
[ -s "$cache_dir/tachyon.json" ] ||
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
ACTION_UC="$ROOT_DIR/tachyon/files/usr/lib/components/action.uc"
|
||||||
|
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq() {
|
||||||
|
local expected="$1"
|
||||||
|
local actual="$2"
|
||||||
|
local label="$3"
|
||||||
|
|
||||||
|
[ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# is_apk() probes for an apk binary, so the package manager under test is chosen
|
||||||
|
# by what is on PATH. Both fake dirs shadow the real tools.
|
||||||
|
mkdir -p "$WORK_DIR/opkg-bin" "$WORK_DIR/apk-bin"
|
||||||
|
printf '#!/bin/sh\nexit 0\n' >"$WORK_DIR/opkg-bin/opkg"
|
||||||
|
printf '#!/bin/sh\nexit 0\n' >"$WORK_DIR/apk-bin/apk"
|
||||||
|
chmod +x "$WORK_DIR/opkg-bin/opkg" "$WORK_DIR/apk-bin/apk"
|
||||||
|
|
||||||
|
install_command() {
|
||||||
|
local pm_dir="$1"
|
||||||
|
local force="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
PATH="$WORK_DIR/$pm_dir:$PATH" command ucode -L "$TACHYON_LIB" "$ACTION_UC" \
|
||||||
|
pkg-install-files-command "$force" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# opkg treats an .ipk whose version equals the installed one as already
|
||||||
|
# installed. Without --force-reinstall a rebuild published under the same tag is
|
||||||
|
# downloaded, "installed" successfully, and never actually written.
|
||||||
|
OPKG_FORCED="$(install_command opkg-bin 1 /tmp/a.ipk /tmp/b.ipk)"
|
||||||
|
case "$OPKG_FORCED" in
|
||||||
|
*--force-reinstall*) ;;
|
||||||
|
*) fail "opkg install must carry --force-reinstall when forced, got: $OPKG_FORCED" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# A plain version upgrade needs no forcing; opkg installs it on its own.
|
||||||
|
OPKG_PLAIN="$(install_command opkg-bin 0 /tmp/a.ipk /tmp/b.ipk)"
|
||||||
|
case "$OPKG_PLAIN" in
|
||||||
|
*--force-reinstall*) fail "opkg install must not force when not asked, got: $OPKG_PLAIN" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for expected_flag in --force-overwrite --force-downgrade; do
|
||||||
|
case "$OPKG_PLAIN" in
|
||||||
|
*"$expected_flag"*) ;;
|
||||||
|
*) fail "opkg install lost $expected_flag, got: $OPKG_PLAIN" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$OPKG_PLAIN" in
|
||||||
|
*"/tmp/a.ipk"*"/tmp/b.ipk"*) ;;
|
||||||
|
*) fail "opkg install must keep the file list in order, got: $OPKG_PLAIN" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apk add always writes the file it is handed, and has no --force-reinstall, so
|
||||||
|
# forcing must not alter its argument list.
|
||||||
|
APK_FORCED="$(install_command apk-bin 1 /tmp/a.apk)"
|
||||||
|
APK_PLAIN="$(install_command apk-bin 0 /tmp/a.apk)"
|
||||||
|
assert_eq "$APK_PLAIN" "$APK_FORCED" "apk command must be identical whether or not reinstall is forced"
|
||||||
|
|
||||||
|
case "$APK_PLAIN" in
|
||||||
|
*--force-reinstall*) fail "apk has no --force-reinstall, got: $APK_PLAIN" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Arguments are shell-quoted individually by command_from_args.
|
||||||
|
case "$APK_PLAIN" in
|
||||||
|
*"'apk' 'add' '--allow-untrusted'"*"'/tmp/a.apk'"*) ;;
|
||||||
|
*) fail "apk install command malformed, got: $APK_PLAIN" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf 'PASS: pkg install force reinstall\n'
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
UPDATER_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updater.uc"
|
||||||
|
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
|
||||||
|
|
||||||
|
ucode() {
|
||||||
|
command ucode -L "$TACHYON_LIB" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq() {
|
||||||
|
local expected="$1"
|
||||||
|
local actual="$2"
|
||||||
|
local label="$3"
|
||||||
|
|
||||||
|
[ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'"
|
||||||
|
}
|
||||||
|
|
||||||
|
fingerprint() {
|
||||||
|
printf '%s' "$1" | ucode "$UPDATER_UC" release-build-fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
commit_sha() {
|
||||||
|
printf '%s' "$1" | ucode "$UPDATER_UC" release-commit-sha
|
||||||
|
}
|
||||||
|
|
||||||
|
# A release whose body carries "Commit: <sha>" is identified by that sha, so the
|
||||||
|
# fingerprint must not fall back to timestamps and lose precision.
|
||||||
|
BODY_SHA_RELEASE='{"tag_name":"1.2.66","html_url":"https://example.invalid/r","target_commitish":"main","body":"Notes\nCommit: a1b2c3d4e5f6\n","published_at":"2026-08-01T10:00:00Z","assets":[{"name":"tachyon_1.2.66_all.ipk","updated_at":"2026-08-01T10:05:00Z","size":123456}]}'
|
||||||
|
assert_eq "sha:a1b2c3d4e5f6" "$(fingerprint "$BODY_SHA_RELEASE")" "fingerprint prefers body commit sha"
|
||||||
|
assert_eq "a1b2c3d4e5f6" "$(commit_sha "$BODY_SHA_RELEASE")" "commit sha read from body"
|
||||||
|
|
||||||
|
# target_commitish holds a branch name on every release cut from a branch, so it
|
||||||
|
# must not be mistaken for a sha.
|
||||||
|
BRANCH_ONLY_RELEASE='{"tag_name":"1.2.66","target_commitish":"main","body":"No commit line here","published_at":"2026-08-01T10:00:00Z","assets":[{"name":"tachyon_1.2.66_all.ipk","updated_at":"2026-08-01T10:05:00Z","size":123456}]}'
|
||||||
|
assert_eq "" "$(commit_sha "$BRANCH_ONLY_RELEASE")" "branch name is not a commit sha"
|
||||||
|
assert_eq "build:pub=2026-08-01T10:00:00Z|upd=2026-08-01T10:05:00Z|size=123456" \
|
||||||
|
"$(fingerprint "$BRANCH_ONLY_RELEASE")" \
|
||||||
|
"fingerprint falls back to publish stamps and asset size"
|
||||||
|
|
||||||
|
# A rebuild replaces the asset, so the fingerprint has to move even when
|
||||||
|
# published_at stays exactly the same.
|
||||||
|
REBUILT_RELEASE='{"tag_name":"1.2.66","target_commitish":"main","body":"No commit line here","published_at":"2026-08-01T10:00:00Z","assets":[{"name":"tachyon_1.2.66_all.ipk","updated_at":"2026-08-02T11:30:00Z","size":123999}]}'
|
||||||
|
[ "$(fingerprint "$BRANCH_ONLY_RELEASE")" != "$(fingerprint "$REBUILT_RELEASE")" ] ||
|
||||||
|
fail "rebuilt release must not share a fingerprint with the original build"
|
||||||
|
|
||||||
|
# created_at stands in for a release that has not been published yet.
|
||||||
|
CREATED_ONLY_RELEASE='{"tag_name":"1.2.66","target_commitish":"main","created_at":"2026-07-30T08:00:00Z","assets":[]}'
|
||||||
|
assert_eq "build:pub=2026-07-30T08:00:00Z" "$(fingerprint "$CREATED_ONLY_RELEASE")" \
|
||||||
|
"created_at substitutes for published_at"
|
||||||
|
|
||||||
|
# Assets that are not the backend package must not drive the fingerprint.
|
||||||
|
FOREIGN_ASSET_RELEASE='{"tag_name":"1.2.66","target_commitish":"main","published_at":"2026-08-01T10:00:00Z","assets":[{"name":"luci-app-tachyon_1.2.66_all.ipk","updated_at":"2026-08-01T10:09:00Z","size":777}]}'
|
||||||
|
assert_eq "build:pub=2026-08-01T10:00:00Z" "$(fingerprint "$FOREIGN_ASSET_RELEASE")" \
|
||||||
|
"only the tachyon_ backend asset contributes"
|
||||||
|
|
||||||
|
# Nothing identifiable at all must produce no fingerprint rather than a constant
|
||||||
|
# one, or every release would look identical.
|
||||||
|
assert_eq "" "$(fingerprint '{"tag_name":"1.2.66","target_commitish":"main"}')" \
|
||||||
|
"release without stamps yields no fingerprint"
|
||||||
|
assert_eq "" "$(fingerprint '{}')" "empty release yields no fingerprint"
|
||||||
|
|
||||||
|
differs() {
|
||||||
|
if ucode "$UPDATER_UC" tachyon-build-differs "$1" "$2" "$3" "$4" >/dev/null 2>&1; then
|
||||||
|
printf 'yes'
|
||||||
|
else
|
||||||
|
printf 'no'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq no "$(differs abc1234 abc1234 '' '')" "equal shas match"
|
||||||
|
assert_eq yes "$(differs abc1234 def5678 '' '')" "different shas differ"
|
||||||
|
# build.sh and the OpenWrt Makefile stamp a short sha while the release may carry
|
||||||
|
# the full one; the comparison happens on the shorter of the two.
|
||||||
|
assert_eq no "$(differs abc1234 abc1234ffffffffffffffffffffffffffffffff '' '')" \
|
||||||
|
"short local sha is a prefix of the full remote sha"
|
||||||
|
assert_eq yes "$(differs abc1234 abc1299ffffffffffffffffffffffffffffffff '' '')" \
|
||||||
|
"prefix mismatch within the short sha differs"
|
||||||
|
|
||||||
|
assert_eq no "$(differs '' '' 'build:pub=A' 'build:pub=A')" "equal fingerprints match"
|
||||||
|
assert_eq yes "$(differs '' '' 'build:pub=A' 'build:pub=B')" "different fingerprints differ"
|
||||||
|
|
||||||
|
# An unknown state must never be reported as an available update: the UI would
|
||||||
|
# offer an install that changes nothing.
|
||||||
|
assert_eq no "$(differs '' '' '' '')" "no information means no update"
|
||||||
|
assert_eq no "$(differs abc1234 '' '' '')" "remote sha alone missing means no update"
|
||||||
|
assert_eq no "$(differs '' def5678 '' '')" "local sha alone missing means no update"
|
||||||
|
assert_eq no "$(differs '' '' 'build:pub=A' '')" "remote fingerprint missing means no update"
|
||||||
|
assert_eq no "$(differs '' '' '' 'build:pub=B')" "local fingerprint missing means no update"
|
||||||
|
|
||||||
|
# When both shas are known they decide, even if the fingerprints disagree.
|
||||||
|
assert_eq no "$(differs abc1234 abc1234 'build:pub=A' 'build:pub=B')" \
|
||||||
|
"matching shas win over disagreeing fingerprints"
|
||||||
|
|
||||||
|
printf 'PASS: tachyon build fingerprint\n'
|
||||||
Reference in New Issue
Block a user