@@ -83,9 +83,12 @@ ProxyWarden управляет службой только после точно
|
||||
.\release.cmd -PlanOnly # только JSON-план: без записи, сборки и сети
|
||||
.\release.cmd -Version 1.2.1 # версия без вопроса
|
||||
.\release.cmd -Version 1.2.1 -Resume # повторить только неудачный push
|
||||
.\release.cmd -Version 2.0.0 -Replace # пересобрать ещё не выпущенную версию с заменой тега
|
||||
```
|
||||
|
||||
Не меняйте исходники во время сборки. Существующие теги не перезаписываются; при расхождении с удалённой веткой сценарий останавливается до изменения версий. При ошибке сборки изменения версии остаются локально для исправления, commit/tag/push не выполняются. При неудачном push готовая папка и локальный commit/tag сохраняются; `-Resume` проверяет исходники и SHA-256 перед повторной отправкой.
|
||||
Не меняйте исходники во время сборки. По умолчанию существующие теги не перезаписываются; при расхождении с удалённой веткой сценарий останавливается до изменения версий. При ошибке сборки изменения версии остаются локально для исправления, commit/tag/push не выполняются. При неудачном push готовая папка и локальный commit/tag сохраняются; `-Resume` проверяет исходники и SHA-256 перед повторной отправкой.
|
||||
|
||||
Если версия ещё не выложена пользователям, `-Version X.Y.Z -Replace` заново выполняет проверки и сборку с текущими изменениями. После сборки предыдущая папка сохраняется рядом как `proxywarden-vX.Y.Z-replaced-...`, а выбранный тег обновляется локально и в origin. История ветки сохраняется. Отправка использует `--force-with-lease` только для этого тега: если он изменился на сервере с начала операции, замена отклоняется. При сбое отправки используется обычный `-Version X.Y.Z -Resume`, который сохраняет первоначальное условие замены. `-Replace` требует явного номера версии и не совмещается с `-Resume`.
|
||||
|
||||
Для локальной подготовки без commit/tag/push остаётся `scripts/prepare-release.ps1 -Version X.Y.Z`. Автоматические проверки не заменяют Windows VM/UAC/driver/routing acceptance: в manifest это отмечается отдельно.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
@@ -134,6 +135,17 @@ test("PlanOnly is offline and leaves versions/index/refs unchanged", (t) => {
|
||||
assert.equal(plan.details.targetVersion, "1.2.1");
|
||||
assert.equal(f.git("status", "--porcelain"), before);
|
||||
assert.equal(f.git("rev-parse", "HEAD"), head);
|
||||
const replacement = f.release("-Version", "1.2.0", "-Replace", "-PlanOnly");
|
||||
assert.equal(replacement.status, 0, replacement.stderr);
|
||||
const replacementPlan = JSON.parse(replacement.stdout);
|
||||
assert.equal(replacementPlan.changed, false);
|
||||
assert.equal(replacementPlan.details.replace, true);
|
||||
assert.equal(
|
||||
replacementPlan.details.git.replaceOnlyVersionTagWithLease,
|
||||
true,
|
||||
);
|
||||
assert.equal(f.git("status", "--porcelain"), before);
|
||||
assert.equal(f.git("rev-parse", "HEAD"), head);
|
||||
});
|
||||
|
||||
test("release commits exact dirty source, versions both locks, tags and atomically pushes", (t) => {
|
||||
@@ -172,6 +184,169 @@ test("failed build creates no commit/tag/push and preserves existing staging", (
|
||||
assert.equal(f.git("tag", "--list"), "");
|
||||
});
|
||||
|
||||
for (const remoteOnly of [false, true]) {
|
||||
test(`replacement rebuilds the same version and preserves the old folder (remote-only tag: ${remoteOnly})`, (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldTag = f.git("rev-parse", "refs/tags/v1.2.1");
|
||||
const oldCommit = f.git("rev-parse", "HEAD");
|
||||
const oldManifest = f.manifest();
|
||||
if (remoteOnly) f.git("tag", "-d", "v1.2.1");
|
||||
f.write("feature.txt", "updated before publishing");
|
||||
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
const newTag = f.git("rev-parse", "refs/tags/v1.2.1");
|
||||
assert.notEqual(newTag, oldTag);
|
||||
assert.equal(
|
||||
f.git("rev-parse", "v1.2.1^{commit}"),
|
||||
f.git("rev-parse", "HEAD"),
|
||||
);
|
||||
assert.equal(
|
||||
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
|
||||
newTag,
|
||||
);
|
||||
assert.equal(f.git("rev-parse", "HEAD~1"), oldCommit);
|
||||
assert.equal(f.manifest().gitRelease.previousRemoteTag, oldTag);
|
||||
assert.equal(f.manifest().gitRelease.status, "pushed");
|
||||
const backups = readdirSync(join(f.repo, "releases")).filter((name) =>
|
||||
name.startsWith("proxywarden-v1.2.1-replaced-"),
|
||||
);
|
||||
assert.equal(backups.length, 1);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(f.repo, "releases", backups[0], "release-manifest.json"),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
oldManifest,
|
||||
);
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(
|
||||
f.repo,
|
||||
"releases",
|
||||
backups[0],
|
||||
"artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
"test artifact",
|
||||
);
|
||||
assert.equal(f.git("status", "--porcelain"), "");
|
||||
});
|
||||
}
|
||||
|
||||
test("failed replacement build preserves the previous release and refs", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
"if ($Replace) { throw 'Synthetic replacement build failure' }",
|
||||
);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldManifest = f.manifest();
|
||||
const oldRefs = f.git("show-ref");
|
||||
f.write("feature.txt", "work in progress");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Synthetic replacement build failure/);
|
||||
assert.equal(f.git("show-ref"), oldRefs);
|
||||
assert.deepEqual(f.manifest(), oldManifest);
|
||||
assert.deepEqual(readdirSync(join(f.repo, "releases")), [
|
||||
"proxywarden-v1.2.1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a failed replacement push resumes using the original tag lease", (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldRefs = f.git("ls-remote", "origin");
|
||||
f.write("feature.txt", "replacement");
|
||||
const hook = join(f.remote, "hooks/pre-receive");
|
||||
writeFileSync(hook, "#!/bin/sh\nexit 1\n");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(f.manifest().gitRelease.status, "pending-push");
|
||||
assert.equal(f.git("ls-remote", "origin"), oldRefs);
|
||||
const replacementTag = f.git("rev-parse", "refs/tags/v1.2.1");
|
||||
rmSync(hook);
|
||||
const resumed = f.release("-Version", "1.2.1", "-Resume");
|
||||
assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr);
|
||||
assert.equal(f.manifest().gitRelease.status, "pushed");
|
||||
assert.equal(
|
||||
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
|
||||
replacementTag,
|
||||
);
|
||||
const repeat = f.release("-Version", "1.2.1", "-Resume");
|
||||
assert.equal(repeat.status, 0, repeat.stdout + repeat.stderr);
|
||||
});
|
||||
|
||||
test("replacement never forces the branch when it advances during the build", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
`if ($Replace) {
|
||||
$otherCommit = 'Concurrent remote commit' | & git commit-tree 'HEAD^{tree}' -p HEAD
|
||||
Invoke-Git @('push', 'origin', "${"$"}{otherCommit}:refs/heads/master") | Out-Null
|
||||
}`,
|
||||
);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldTag = f.git("ls-remote", "origin", "refs/tags/v1.2.1");
|
||||
f.write("feature.txt", "replacement");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /non-fast-forward|fetch first/);
|
||||
const remoteBranch = f
|
||||
.git("ls-remote", "origin", "refs/heads/master")
|
||||
.split(/\s/)[0];
|
||||
assert.notEqual(remoteBranch, f.git("rev-parse", "HEAD"));
|
||||
assert.equal(
|
||||
f.git("show", "-s", "--format=%s", remoteBranch),
|
||||
"Concurrent remote commit",
|
||||
);
|
||||
assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), oldTag);
|
||||
});
|
||||
|
||||
test("a concurrent remote tag change is preserved, including on Resume", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
`if ($Replace) {
|
||||
$otherCommit = Invoke-Git @('rev-parse', 'HEAD~1')
|
||||
Invoke-Git @('--git-dir', (Join-Path $RepoRoot '../origin.git'), 'update-ref', 'refs/tags/v1.2.1', $otherCommit) | Out-Null
|
||||
}`,
|
||||
);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldBranch = f.git("ls-remote", "origin", "refs/heads/master");
|
||||
const concurrentTag = f.git("rev-parse", "HEAD~1");
|
||||
f.write("feature.txt", "replacement");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /stale info/);
|
||||
const resumed = f.release("-Version", "1.2.1", "-Resume");
|
||||
assert.notEqual(resumed.status, 0);
|
||||
assert.match(resumed.stderr, /stale info/);
|
||||
assert.equal(
|
||||
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
|
||||
concurrentTag,
|
||||
);
|
||||
assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), oldBranch);
|
||||
});
|
||||
|
||||
test("a concurrent local tag change is not overwritten by replacement", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
"if ($Replace) { Invoke-Git @('tag', '-f', 'v1.2.1', 'HEAD~1') | Out-Null }",
|
||||
);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldRemote = f.git("ls-remote", "origin");
|
||||
const concurrentTag = f.git("rev-parse", "HEAD~1");
|
||||
f.write("feature.txt", "replacement");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Local version tag changed/);
|
||||
assert.equal(f.git("rev-parse", "refs/tags/v1.2.1"), concurrentTag);
|
||||
assert.equal(f.git("ls-remote", "origin"), oldRemote);
|
||||
});
|
||||
|
||||
test("source edit during build refuses to tag an artifact from another tree", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
|
||||
+47
-13
@@ -8,6 +8,7 @@
|
||||
[switch]$PlanOnly,
|
||||
[switch]$Publish,
|
||||
[switch]$Resume,
|
||||
[switch]$Replace,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
@@ -373,13 +374,20 @@ function New-ReleaseDirectory {
|
||||
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
|
||||
|
||||
if (Test-Path -LiteralPath $releaseDir) {
|
||||
if ($Publish -or -not $Force) { throw "Release directory already exists: $releaseDir. Use -Resume for a failed push, or choose another version." }
|
||||
if (-not $Replace -and ($Publish -or -not $Force)) { throw "Release directory already exists: $releaseDir. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push." }
|
||||
if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) {
|
||||
throw "Refusing to remove release directory outside OutputRoot: $releaseDir"
|
||||
throw "Refusing to replace release directory outside OutputRoot: $releaseDir"
|
||||
}
|
||||
if ($Replace) {
|
||||
$backupDir = "$releaseDir-replaced-$(Get-Date -Format 'yyyyMMdd-HHmmss')-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
|
||||
if (-not (Test-IsSubPath -Parent $root -Child $backupDir)) { throw 'Release backup must stay inside OutputRoot.' }
|
||||
Move-Item -LiteralPath $releaseDir -Destination $backupDir
|
||||
Write-Host "Предыдущая сборка сохранена: $backupDir"
|
||||
} else {
|
||||
Write-Host "Replacing existing release directory: $releaseDir"
|
||||
Remove-Item -LiteralPath $releaseDir -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null
|
||||
$releaseDir
|
||||
@@ -654,16 +662,18 @@ function Get-ReleaseGitContext {
|
||||
Invoke-Git @('var', 'GIT_COMMITTER_IDENT') | Out-Null
|
||||
$remote = Invoke-Git @('remote', 'get-url', '--push', 'origin')
|
||||
$tag = "v$TargetVersion"
|
||||
if (-not $Resume -and (Test-GitTag $tag)) { throw "Tag $tag already exists. Use -Version $TargetVersion -Resume for a failed push, or choose another version." }
|
||||
$remoteTag = Invoke-Git @('ls-remote', '--tags', 'origin', "refs/tags/$tag", "refs/tags/$tag^{}")
|
||||
if (-not $Resume -and $remoteTag) { throw "Remote tag $tag already exists. Choose another version." }
|
||||
$localTag = if (Test-GitTag $tag) { Invoke-Git @('rev-parse', "refs/tags/$tag") } else { '' }
|
||||
if (-not $Resume -and -not $Replace -and $localTag) { throw "Tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, -Resume to retry its push, or choose another version." }
|
||||
$remoteTag = Invoke-Git @('ls-remote', '--refs', '--tags', 'origin', "refs/tags/$tag")
|
||||
if (-not $Resume -and -not $Replace -and $remoteTag) { throw "Remote tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or choose another version." }
|
||||
$remoteTagId = if ($remoteTag) { ($remoteTag -split '\s+')[0] } else { '' }
|
||||
$remoteBranch = Invoke-Git @('ls-remote', '--heads', 'origin', "refs/heads/$branch")
|
||||
if ($remoteBranch) {
|
||||
Invoke-Git @('fetch', '--no-tags', 'origin', "refs/heads/$branch") | Out-Null
|
||||
& git merge-base --is-ancestor FETCH_HEAD HEAD
|
||||
if ($LASTEXITCODE -ne 0) { throw "The origin/$branch branch has changes not in HEAD. Integrate them before releasing; automatic merge is not performed." }
|
||||
}
|
||||
return @{ branch = $branch; head = $headCommit; remote = $remote; tag = $tag }
|
||||
return @{ branch = $branch; head = $headCommit; remote = $remote; tag = $tag; replace = [bool]$Replace; previousLocalTag = $localTag; previousRemoteTag = $remoteTagId }
|
||||
}
|
||||
|
||||
function Complete-ReleaseGit {
|
||||
@@ -692,13 +702,27 @@ function Push-Release {
|
||||
(Invoke-Git @('remote', 'get-url', '--push', 'origin')) -ne $Context.remote) {
|
||||
throw 'HEAD, branch or origin changed before push.'
|
||||
}
|
||||
if (Test-GitTag $Context.tag) {
|
||||
if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) { throw 'Existing tag points to another commit.' }
|
||||
$localTag = if (Test-GitTag $Context.tag) { Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)") } else { '' }
|
||||
if ($Context.replace -and $localTag -ne $Context.previousLocalTag -and
|
||||
(-not $Resume -or -not $localTag -or (Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit)) {
|
||||
throw 'Local version tag changed during the release. Replacement refused.'
|
||||
}
|
||||
if ($localTag) {
|
||||
if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) {
|
||||
if (-not $Context.replace) { throw 'Existing tag points to another commit.' }
|
||||
Invoke-Git @('tag', '-a', '-f', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null
|
||||
}
|
||||
} else {
|
||||
Invoke-Git @('tag', '-a', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null
|
||||
}
|
||||
# One atomic push; never force or push unrelated tags. A failure leaves a resumable local release.
|
||||
Invoke-Git @('push', '--atomic', 'origin', "${Commit}:refs/heads/$($Context.branch)", "refs/tags/$($Context.tag):refs/tags/$($Context.tag)") | Write-Host
|
||||
$tagObject = Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)")
|
||||
$pushArgs = @('push', '--atomic')
|
||||
if ($Context.replace) {
|
||||
# Lease only this tag, never the branch. Keep the original expectation across Resume.
|
||||
$pushArgs += "--force-with-lease=refs/tags/$($Context.tag):$($Context.previousRemoteTag)"
|
||||
}
|
||||
$pushArgs += @('origin', "${Commit}:refs/heads/$($Context.branch)", "${tagObject}:refs/tags/$($Context.tag)")
|
||||
Invoke-Git $pushArgs | Write-Host
|
||||
}
|
||||
|
||||
function Resume-Release {
|
||||
@@ -723,6 +747,11 @@ function Resume-Release {
|
||||
if (-not (Test-IsSubPath $releaseDir $path) -or
|
||||
(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $artifact.sha256) { throw 'Release artifact checksum mismatch.' }
|
||||
}
|
||||
if ($manifest.gitRelease.PSObject.Properties['replace'] -and $manifest.gitRelease.replace) {
|
||||
$Context.replace = $true
|
||||
$Context.previousLocalTag = $manifest.gitRelease.previousLocalTag
|
||||
$Context.previousRemoteTag = $manifest.gitRelease.previousRemoteTag
|
||||
}
|
||||
Push-Release -Context $Context -Commit $manifest.gitCommit
|
||||
$manifest.gitRelease.status = 'pushed'
|
||||
Write-JsonFile -Path $manifestPath -Value $manifest
|
||||
@@ -813,6 +842,7 @@ function New-PlanResult {
|
||||
skipBuild = [bool]$SkipBuild
|
||||
publish = [bool]$Publish
|
||||
resume = [bool]$Resume
|
||||
replace = [bool]$Replace
|
||||
git = [ordered]@{
|
||||
branch = Get-GitValue @('symbolic-ref', '--quiet', '--short', 'HEAD')
|
||||
remote = 'origin'
|
||||
@@ -820,6 +850,8 @@ function New-PlanResult {
|
||||
includedChanges = Get-GitValue @('status', '--short')
|
||||
commitAfterSuccessfulBuild = [bool]$Publish
|
||||
atomicPush = [bool]$Publish
|
||||
replaceOnlyVersionTagWithLease = [bool]$Replace
|
||||
preservePreviousReleaseDirectory = [bool]$Replace
|
||||
}
|
||||
manifests = @(
|
||||
$PackageJsonPath,
|
||||
@@ -845,6 +877,7 @@ try {
|
||||
Push-Location $RepoRoot
|
||||
|
||||
if ($Resume -and (-not $Publish -or -not $Version -or $Bump)) { throw '-Resume requires -Publish -Version X.Y.Z.' }
|
||||
if ($Replace -and (-not $Publish -or -not $Version -or $Bump -or $Resume)) { throw '-Replace requires -Publish -Version X.Y.Z and cannot be combined with -Resume or -Bump.' }
|
||||
if ($Version -and $Bump) { throw 'Use either -Version or -Bump.' }
|
||||
if ($Publish -and -not $PlanOnly -and ($SkipTests -or $SkipBuild -or $Force)) { throw 'A published release requires checks and a fresh build; SkipTests, SkipBuild and Force are not allowed.' }
|
||||
if ($Publish -and -not $PlanOnly -and -not $Resume) {
|
||||
@@ -869,6 +902,7 @@ try {
|
||||
Write-Host ""
|
||||
Write-Host "Preparing ProxyWarden release $targetVersion..."
|
||||
Write-Host "Repository: $RepoRoot"
|
||||
if ($Replace) { Write-Host "Пересборка невыпущенного релиза v$targetVersion с заменой тега. Предыдущая папка будет сохранена рядом." }
|
||||
|
||||
$gitContext = $null
|
||||
if ($Publish) {
|
||||
@@ -876,8 +910,8 @@ try {
|
||||
if ($Resume) { Resume-Release -TargetVersion $targetVersion -Context $gitContext; return }
|
||||
}
|
||||
$releasePath = Get-ReleasePath $targetVersion
|
||||
if ((Test-Path -LiteralPath $releasePath) -and ($Publish -or -not $Force)) {
|
||||
throw "Release directory already exists: $releasePath. Use -Resume for a failed push, or choose another version."
|
||||
if ((Test-Path -LiteralPath $releasePath) -and -not $Replace -and ($Publish -or -not $Force)) {
|
||||
throw "Release directory already exists: $releasePath. Use -Version $targetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push."
|
||||
}
|
||||
if ($Publish -and (Test-IsSubPath $RepoRoot $releasePath)) {
|
||||
& git check-ignore --quiet -- (Join-Path $releasePath 'release-manifest.json')
|
||||
@@ -907,7 +941,7 @@ try {
|
||||
|
||||
if ($Publish) {
|
||||
$commit = Complete-ReleaseGit -Context $gitContext -SourceTree $sourceTree -TargetVersion $targetVersion
|
||||
$gitRelease = [ordered]@{ branch = $gitContext.branch; remote = $gitContext.remote; tag = $gitContext.tag; sourceTree = $sourceTree; status = 'pending-push' }
|
||||
$gitRelease = [ordered]@{ branch = $gitContext.branch; remote = $gitContext.remote; tag = $gitContext.tag; sourceTree = $sourceTree; status = 'pending-push'; replace = $gitContext.replace; previousLocalTag = $gitContext.previousLocalTag; previousRemoteTag = $gitContext.previousRemoteTag }
|
||||
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease
|
||||
try { Push-Release -Context $gitContext -Commit $commit }
|
||||
catch { throw "Push failed; local release is preserved. Retry: .\release.cmd -Version $targetVersion -Resume. $($_.Exception.Message)" }
|
||||
|
||||
@@ -359,6 +359,35 @@ fn collision_and_unsafe_zip_reach_no_mutating_host_or_service_runner() {
|
||||
assert!(unsafe_host.calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_install_rejects_invalid_service_xml_before_committing_ownership() {
|
||||
let temp = TestDir::new();
|
||||
let root = temp.path.join("sing-box");
|
||||
let (runtime_path, runtime_proof) = runtime_package(&temp, "1.13.19", PackageSource::Bundled);
|
||||
let (wrapper_path, wrapper_proof) = wrapper_package(&temp);
|
||||
let mut promoted = promoted_snapshot(
|
||||
&root,
|
||||
"1.13.19",
|
||||
"2.12.0",
|
||||
SingBoxNativeServiceState::Stopped,
|
||||
);
|
||||
assert!(!promoted.receipt_valid && !promoted.marker_valid);
|
||||
promoted.service_xml_matches = false;
|
||||
let mut host = FakeHost::new(root.clone(), vec![missing_snapshot(&root), promoted]);
|
||||
|
||||
let error = install_singbox_native_core(
|
||||
&mut host,
|
||||
package_view(&runtime_path, &runtime_proof),
|
||||
package_view(&wrapper_path, &wrapper_proof),
|
||||
)
|
||||
.expect_err("invalid XML must fail before committing ownership");
|
||||
|
||||
assert_eq!(error, SingBoxNativeError::OwnershipMismatch);
|
||||
assert!(host.metadata.is_none());
|
||||
assert_eq!(host.rollback, Some((SingBoxNativeMode::Install, true)));
|
||||
assert!(!host.calls.contains(&CallKind::StartService));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_receipt_and_config_mismatch_run_no_service_mutation() {
|
||||
let temp = TestDir::new();
|
||||
|
||||
@@ -1257,7 +1257,10 @@ impl SingBoxNativeHost for SystemSingBoxNativeHost {
|
||||
let mut receipt_files_match = false;
|
||||
let mut marker_valid = false;
|
||||
let mut marker_files_match = false;
|
||||
let mut service_xml_matches = false;
|
||||
// Freshly promoted files are checked before the receipt/marker is committed.
|
||||
// XML validity must not depend on that later ownership metadata.
|
||||
let service_xml_matches =
|
||||
root_exists && install_root_trusted && verify_service_xml(self.root.path()).is_ok();
|
||||
let mut marker = None;
|
||||
if root_exists && install_root_trusted {
|
||||
if let Ok((verified_receipt, verified_marker, receipt_match, marker_match)) =
|
||||
@@ -1267,7 +1270,6 @@ impl SingBoxNativeHost for SystemSingBoxNativeHost {
|
||||
marker_valid = true;
|
||||
receipt_files_match = receipt_match;
|
||||
marker_files_match = marker_match;
|
||||
service_xml_matches = verify_service_xml(self.root.path()).is_ok();
|
||||
self.last_receipt = Some(verified_receipt);
|
||||
self.last_marker = Some(verified_marker.clone());
|
||||
marker = Some(verified_marker);
|
||||
@@ -1284,7 +1286,7 @@ impl SingBoxNativeHost for SystemSingBoxNativeHost {
|
||||
self.promoted_has_runtime_config,
|
||||
)
|
||||
.is_ok()
|
||||
&& verify_service_xml(self.root.path()).is_ok()
|
||||
&& service_xml_matches
|
||||
} else {
|
||||
receipt_files_match && marker_files_match && service_xml_matches
|
||||
};
|
||||
|
||||
+4
-23
@@ -37,7 +37,6 @@ import {
|
||||
type CommandError,
|
||||
type LocalSingBoxStatusResponse,
|
||||
type PingServerResponse,
|
||||
type ProxyTargetCheckResponse,
|
||||
type ProxiFyreSetupStatus,
|
||||
type SingBoxSetupStatus,
|
||||
} from "../api/tauriCommands";
|
||||
@@ -105,9 +104,7 @@ import {
|
||||
routeChainSegments,
|
||||
safeProxyError,
|
||||
pingTone,
|
||||
proxyCheckNoticeKind,
|
||||
proxyCheckNoticeTitle,
|
||||
proxyCheckText,
|
||||
type ProxyCheckResult,
|
||||
changesApplyButtonLabel,
|
||||
routeProxyCheckTarget,
|
||||
profileItemInput,
|
||||
@@ -230,9 +227,7 @@ export function App() {
|
||||
const [serverPings, setServerPings] = useState<
|
||||
Record<string, PingServerResponse>
|
||||
>({});
|
||||
const [proxyCheck, setProxyCheck] = useState<ProxyTargetCheckResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [proxyCheck, setProxyCheck] = useState<ProxyCheckResult | null>(null);
|
||||
const [adminStatus, setAdminStatus] = useState<AdminStatusResponse | null>(
|
||||
null,
|
||||
);
|
||||
@@ -374,7 +369,6 @@ export function App() {
|
||||
singbox,
|
||||
routeMode: savedSnapshot?.routeMode ?? routeMode,
|
||||
singBoxStatus,
|
||||
proxyCheck,
|
||||
artifacts,
|
||||
});
|
||||
|
||||
@@ -1202,11 +1196,7 @@ export function App() {
|
||||
try {
|
||||
target = routeProxyCheckTarget(routeMode, proxyInput, singBoxStatus);
|
||||
} catch (error) {
|
||||
showNotice({
|
||||
kind: "error",
|
||||
title: "Маршрут не проверен",
|
||||
text: errorMessage(error),
|
||||
});
|
||||
setProxyCheck({ failure: errorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1223,18 +1213,9 @@ export function App() {
|
||||
const result = await pingProxyTarget(target.host, target.port);
|
||||
if (!current()) return;
|
||||
setProxyCheck(result);
|
||||
showNotice({
|
||||
kind: proxyCheckNoticeKind(result),
|
||||
title: proxyCheckNoticeTitle(result),
|
||||
text: proxyCheckText(result),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
showNotice({
|
||||
kind: "error",
|
||||
title: "Маршрут не проверен",
|
||||
text: errorMessage(error),
|
||||
});
|
||||
setProxyCheck({ failure: errorMessage(error) });
|
||||
} finally {
|
||||
if (request === probeRequest.current) setIsProxyChecking(false);
|
||||
}
|
||||
|
||||
+123
-5
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ApplyConfigurationResult,
|
||||
PingServerResponse,
|
||||
ProxyTargetCheckResponse,
|
||||
} from "../api/tauriCommands";
|
||||
import type { ComponentStatus } from "../domain/types";
|
||||
import {
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
pingSummary,
|
||||
routeChainSegments,
|
||||
summaryRouteChainSegments,
|
||||
systemSummaryState,
|
||||
type SummaryStateInput,
|
||||
} from "./viewModel";
|
||||
|
||||
const runningProxiFyre: ComponentStatus = {
|
||||
@@ -129,9 +132,9 @@ describe("App view helpers", () => {
|
||||
ip: "203.0.113.10",
|
||||
},
|
||||
{
|
||||
id: "ipify",
|
||||
name: "ipify",
|
||||
url: "https://api.ipify.org",
|
||||
id: "cloudflare-speed",
|
||||
name: "Cloudflare Speed",
|
||||
url: "https://speed.cloudflare.com/meta",
|
||||
ok: false,
|
||||
error: "timeout",
|
||||
},
|
||||
@@ -147,12 +150,13 @@ describe("App view helpers", () => {
|
||||
expect(view).toMatchObject({
|
||||
checked: true,
|
||||
endpoint: "192.168.50.111:8080",
|
||||
tone: "warning",
|
||||
tone: "muted",
|
||||
title: "Ответили 1 из 2 контрольных точек",
|
||||
});
|
||||
expect(view.probes.map((probe) => probe.id)).toEqual([
|
||||
"tcp",
|
||||
"cloudflare-trace",
|
||||
"ipify",
|
||||
"cloudflare-speed",
|
||||
]);
|
||||
expect(view.details).toContainEqual({
|
||||
label: "Cloudflare Trace",
|
||||
@@ -163,6 +167,120 @@ describe("App view helpers", () => {
|
||||
latency: "21 ms",
|
||||
status: "Доступна",
|
||||
});
|
||||
expect(view.probes[2]).toMatchObject({
|
||||
label: "CF speed",
|
||||
tone: "error",
|
||||
status: "Ошибка",
|
||||
detail: "https://speed.cloudflare.com/meta · timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps diagnostic failures separate from service and configuration status", () => {
|
||||
const ready: SummaryStateInput = {
|
||||
isLoading: false,
|
||||
isDetectingComponents: false,
|
||||
proxyfier: runningProxiFyre,
|
||||
singbox: undefined,
|
||||
routeMode: "external",
|
||||
singBoxStatus: null,
|
||||
artifacts: [
|
||||
{
|
||||
component: "proxyfier",
|
||||
sourceMatchesPrepared: true,
|
||||
generatedExists: true,
|
||||
activation: "confirmed",
|
||||
},
|
||||
],
|
||||
};
|
||||
const failed: ProxyTargetCheckResponse = {
|
||||
tag: "external",
|
||||
server: "proxy.example.test",
|
||||
serverPort: 1080,
|
||||
ok: false,
|
||||
error: "timeout",
|
||||
probes: [],
|
||||
};
|
||||
|
||||
for (const proxyCheck of [
|
||||
null,
|
||||
failed,
|
||||
{ failure: "Command failed" },
|
||||
{ ...failed, ok: true },
|
||||
]) {
|
||||
const input = { ...ready, proxyCheck };
|
||||
expect(systemSummaryState(input)).toMatchObject({ tone: "ok" });
|
||||
expect(
|
||||
systemSummaryState({
|
||||
...input,
|
||||
proxyfier: { ...runningProxiFyre, state: "stopped", running: false },
|
||||
}),
|
||||
).toMatchObject({ tone: "warning", title: "Требует внимания" });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the TCP success when all HTTPS probes fail", () => {
|
||||
const view = connectionCheckView({
|
||||
routeMode: "external",
|
||||
proxyInput: "proxy.example.test:1080",
|
||||
proxyCheck: {
|
||||
tag: "external",
|
||||
server: "proxy.example.test",
|
||||
serverPort: 1080,
|
||||
ok: false,
|
||||
latency: 14,
|
||||
error: "HTTPS probes failed",
|
||||
probes: [
|
||||
{
|
||||
id: "cloudflare-speed",
|
||||
name: "Cloudflare Speed",
|
||||
url: "https://speed.cloudflare.com/meta",
|
||||
ok: false,
|
||||
error: "timeout",
|
||||
},
|
||||
],
|
||||
},
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
isDetectingComponents: false,
|
||||
isProxyChecking: false,
|
||||
});
|
||||
|
||||
expect(view).toMatchObject({
|
||||
tone: "muted",
|
||||
title: "Ответили 0 из 1 контрольных точек",
|
||||
});
|
||||
expect(view.probes[0]).toMatchObject({
|
||||
id: "tcp",
|
||||
tone: "ok",
|
||||
status: "Доступен",
|
||||
latency: "14 ms",
|
||||
detail: "TCP-соединение с SOCKS5 endpoint",
|
||||
});
|
||||
expect(view.probes[1]).toMatchObject({ tone: "error", status: "Ошибка" });
|
||||
});
|
||||
|
||||
it("reports a diagnostic command failure without inventing failed probes", () => {
|
||||
const view = connectionCheckView({
|
||||
routeMode: "external",
|
||||
proxyInput: "proxy.example.test:1080",
|
||||
proxyCheck: { failure: "Проверка прервана: timeout" },
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
isDetectingComponents: false,
|
||||
isProxyChecking: false,
|
||||
});
|
||||
|
||||
expect(view).toMatchObject({
|
||||
tone: "muted",
|
||||
title: "Проверка не выполнена",
|
||||
text: "Проверка прервана: timeout",
|
||||
checked: true,
|
||||
loading: false,
|
||||
probes: [],
|
||||
});
|
||||
expect(view.disabledReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows the result surface while the route check is running", () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectionCheckView } from "../viewModel";
|
||||
import { connectionCheckView, type ConnectionCheckView } from "../viewModel";
|
||||
import { ConnectionCheckPanel } from "./ConnectionCheckPanel";
|
||||
|
||||
describe("ConnectionCheckPanel", () => {
|
||||
it("renders the result summary, every probe, and hover details", () => {
|
||||
const check: ConnectionCheckView = {
|
||||
tone: "warning",
|
||||
title: "Частичный ответ",
|
||||
tone: "muted",
|
||||
title: "Ответили 2 из 3 контрольных точек",
|
||||
text: "Доступны 2 из 3 контрольных точек.",
|
||||
endpoint: "192.168.50.111:8080",
|
||||
endpointLabel: "Внешний SOCKS5",
|
||||
@@ -64,4 +64,27 @@ describe("ConnectionCheckPanel", () => {
|
||||
expect(markup).toContain("Технические детали");
|
||||
expect(markup).toContain('aria-live="polite"');
|
||||
});
|
||||
|
||||
it("shows a command failure inline and allows retry without an empty tooltip", () => {
|
||||
const check = connectionCheckView({
|
||||
routeMode: "external",
|
||||
proxyInput: "proxy.example.test:1080",
|
||||
proxyCheck: { failure: "Проверка прервана: timeout" },
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
isDetectingComponents: false,
|
||||
isProxyChecking: false,
|
||||
});
|
||||
const markup = renderToStaticMarkup(
|
||||
<ConnectionCheckPanel check={check} onCheck={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Проверка не выполнена");
|
||||
expect(markup).toContain("Проверка прервана: timeout");
|
||||
expect(markup).toContain("Проверить");
|
||||
expect(markup).not.toContain("disabled=");
|
||||
expect(markup).not.toContain('role="tooltip"');
|
||||
expect(markup).not.toContain("Требует внимания");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export function ConnectionCheckPanel({
|
||||
const detailsId = useId();
|
||||
const buttonDisabled = Boolean(check.disabledReason);
|
||||
const showResult = check.loading || check.checked;
|
||||
const hasDetails = check.details.length > 0 || check.probes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="connection-check" aria-label="Проверка соединения">
|
||||
@@ -61,7 +62,9 @@ export function ConnectionCheckPanel({
|
||||
<div
|
||||
className={`connection-result ${check.loading ? "checking" : check.tone}`}
|
||||
tabIndex={check.loading ? undefined : 0}
|
||||
aria-describedby={check.loading ? undefined : detailsId}
|
||||
aria-describedby={
|
||||
check.loading || !hasDetails ? undefined : detailsId
|
||||
}
|
||||
>
|
||||
<span className="connection-result-indicator" aria-hidden="true">
|
||||
{check.loading ? (
|
||||
@@ -84,7 +87,7 @@ export function ConnectionCheckPanel({
|
||||
: check.text}
|
||||
</p>
|
||||
</div>
|
||||
{!check.loading ? (
|
||||
{!check.loading && hasDetails ? (
|
||||
<span className="connection-detail-hint">
|
||||
Подробнее при наведении
|
||||
</span>
|
||||
@@ -125,7 +128,7 @@ export function ConnectionCheckPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!check.loading ? (
|
||||
{!check.loading && hasDetails ? (
|
||||
<div
|
||||
className="connection-result-details"
|
||||
id={detailsId}
|
||||
|
||||
+33
-34
@@ -83,10 +83,12 @@ export interface RouteChainSegment {
|
||||
tone: StatusTone;
|
||||
details: string[];
|
||||
}
|
||||
export type ProxyCheckResult = ProxyTargetCheckResponse | { failure: string };
|
||||
|
||||
export interface ConnectionCheckInput {
|
||||
routeMode: RouteMode;
|
||||
proxyInput: string;
|
||||
proxyCheck: ProxyTargetCheckResponse | null;
|
||||
proxyCheck: ProxyCheckResult | null;
|
||||
singbox: ComponentStatus | undefined;
|
||||
singBoxStatus: LocalSingBoxStatusResponse | null;
|
||||
selectedServer: SubscriptionServer | null;
|
||||
@@ -125,7 +127,6 @@ export interface SummaryStateInput {
|
||||
singbox: ComponentStatus | undefined;
|
||||
routeMode: RouteMode;
|
||||
singBoxStatus: LocalSingBoxStatusResponse | null;
|
||||
proxyCheck: ProxyTargetCheckResponse | null;
|
||||
}
|
||||
|
||||
export interface SummaryState {
|
||||
@@ -209,14 +210,6 @@ export function systemSummaryState(input: SummaryStateInput): SummaryState {
|
||||
text: "Службы работают, но конфигурация этого запуска не подтверждена. Проверка доступности прокси не проверяет маршрутизацию приложений.",
|
||||
};
|
||||
|
||||
if (input.proxyCheck && !input.proxyCheck.ok) {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "Маршрут не прошел проверку",
|
||||
text: proxyCheckText(input.proxyCheck),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: "ok",
|
||||
title: "Конфиг передан службам",
|
||||
@@ -247,6 +240,26 @@ export function routeEndpointLabel(
|
||||
export function connectionCheckView(
|
||||
input: ConnectionCheckInput,
|
||||
): ConnectionCheckView {
|
||||
if (input.proxyCheck && "failure" in input.proxyCheck) {
|
||||
return {
|
||||
tone: "muted",
|
||||
title: "Проверка не выполнена",
|
||||
text: input.proxyCheck.failure,
|
||||
endpoint: routeEndpointLabel(
|
||||
input.routeMode,
|
||||
input.proxyInput,
|
||||
input.singBoxStatus,
|
||||
),
|
||||
endpointLabel:
|
||||
input.routeMode === "external"
|
||||
? "Внешний SOCKS5"
|
||||
: "Local SOCKS listener",
|
||||
details: [],
|
||||
probes: [],
|
||||
checked: true,
|
||||
loading: input.isProxyChecking,
|
||||
};
|
||||
}
|
||||
if (input.routeMode === "external") {
|
||||
const proxyValidation = input.proxyInput.trim()
|
||||
? safeProxyError(input.proxyInput)
|
||||
@@ -551,32 +564,15 @@ export function pingTone(ping: PingServerResponse): StatusTone {
|
||||
}
|
||||
|
||||
export function proxyCheckTone(check: ProxyTargetCheckResponse): StatusTone {
|
||||
if (!check.ok) return "error";
|
||||
if (check.probes.some((probe) => !probe.ok)) return "warning";
|
||||
if (!check.ok || check.probes.some((probe) => !probe.ok)) return "muted";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export function proxyCheckNoticeKind(
|
||||
check: ProxyTargetCheckResponse,
|
||||
): Notice["kind"] {
|
||||
if (!check.ok) return "error";
|
||||
return check.probes.some((probe) => !probe.ok) ? "info" : "success";
|
||||
}
|
||||
|
||||
export function proxyCheckTitle(check: ProxyTargetCheckResponse) {
|
||||
if (!check.ok) return "Маршрут не прошел";
|
||||
if (!check.probes.length)
|
||||
return check.ok ? "Прокси ответил" : "Не удалось подключиться к прокси";
|
||||
const okCount = check.probes.filter((probe) => probe.ok).length;
|
||||
if (check.probes.length && okCount < check.probes.length)
|
||||
return "Маршрут частично отвечает";
|
||||
return "Маршрут отвечает";
|
||||
}
|
||||
|
||||
export function proxyCheckNoticeTitle(check: ProxyTargetCheckResponse) {
|
||||
if (!check.ok) return "Проверка маршрута не прошла";
|
||||
const okCount = check.probes.filter((probe) => probe.ok).length;
|
||||
if (check.probes.length && okCount < check.probes.length)
|
||||
return "Проверка частично прошла";
|
||||
return "Проверка маршрута прошла";
|
||||
return `Ответили ${okCount} из ${check.probes.length} контрольных точек`;
|
||||
}
|
||||
|
||||
export function proxyCheckText(check: ProxyTargetCheckResponse) {
|
||||
@@ -635,11 +631,14 @@ export function proxyCheckProbes(
|
||||
{
|
||||
id: "tcp",
|
||||
label: "SOCKS5",
|
||||
tone: check.error && !check.ok ? "error" : "ok",
|
||||
status: check.ok ? "Доступен" : "Ошибка",
|
||||
tone: check.ok || check.probes.length > 0 ? "ok" : "error",
|
||||
status: check.ok || check.probes.length > 0 ? "Доступен" : "Нет ответа",
|
||||
ip: null,
|
||||
latency: check.latency != null ? `${check.latency} ms` : null,
|
||||
detail: check.error ?? "TCP-соединение с SOCKS5 endpoint",
|
||||
detail:
|
||||
check.probes.length > 0
|
||||
? "TCP-соединение с SOCKS5 endpoint"
|
||||
: (check.error ?? "TCP-соединение с SOCKS5 endpoint"),
|
||||
},
|
||||
...check.probes.map((probe) => ({
|
||||
id: probe.id,
|
||||
|
||||
Reference in New Issue
Block a user