Release v2.0.0
CI / Windows baseline (push) Canceled after 0s

This commit is contained in:
2026-09-11 17:25:12 +03:00
parent efda8eb98f
commit fd79606052
10 changed files with 454 additions and 87 deletions
+175
View File
@@ -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,
+49 -15
View File
@@ -8,6 +8,7 @@
[switch]$PlanOnly,
[switch]$Publish,
[switch]$Resume,
[switch]$Replace,
[switch]$Force
)
@@ -373,12 +374,19 @@ 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
}
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
@@ -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)" }