@rightkit/release 0.2.99 → 0.2.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/direct-bootstrap.mjs +5 -4
- package/package.json +1 -1
- package/release-invocation.mjs +47 -1
- package/release.mjs +27 -3
- package/rightkit-versions.json +7 -4
package/direct-bootstrap.mjs
CHANGED
|
@@ -273,6 +273,7 @@ export function renderPowerShellBootstrap({
|
|
|
273
273
|
"$NextLink = Join-Path $InstallRoot '.current-next'",
|
|
274
274
|
"$Journal = Join-Path $InstallRoot 'integration-journal.json'",
|
|
275
275
|
"function Expand-BootstrapArgs($Values, $ActiveVersionRoot, $ActiveVersion) { foreach ($Value in $Values) { ([string]$Value).Replace('{current}', $Current).Replace('{installRoot}', $InstallRoot).Replace('{versionRoot}', $ActiveVersionRoot).Replace('{version}', $ActiveVersion) } }",
|
|
276
|
+
"function Remove-StableJunction([string]$Path) { $Item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue; if ($null -eq $Item) { return }; if ($Item.LinkType -ne 'Junction') { throw ('Stable pointer path is not a junction: ' + $Path) }; [IO.Directory]::Delete($Path) }",
|
|
276
277
|
"function Resolve-HealthPath($Value, [string]$JsonPath) { $CurrentValue = $Value; foreach ($Segment in $JsonPath.Split('.')) { if ($Segment -match '^\\d+$') { $Index = [int]$Segment; if ($null -eq $CurrentValue -or $Index -ge @($CurrentValue).Count) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = @($CurrentValue)[$Index] } else { $Property = $CurrentValue.PSObject.Properties[$Segment]; if ($null -eq $Property) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = $Property.Value } }; return $CurrentValue }",
|
|
277
278
|
"function Assert-Health($Health, [string]$ActiveVersion) { foreach ($Assertion in $Contract.healthAssertions) { $Actual = Resolve-HealthPath $Health ([string]$Assertion.path); if ($Assertion.PSObject.Properties.Name -contains 'equals') { $Expected = $Assertion.equals; if ($Expected -is [string]) { $Expected = $Expected.Replace('{version}', $ActiveVersion).Replace('{current}', $Current).Replace('{installRoot}', $InstallRoot) }; if ($Actual -ne $Expected) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif ($Assertion.nonempty -eq $true) { if ($null -eq $Actual -or ([string]$Actual).Trim().Length -eq 0) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif (@($Actual).Count -lt [int]$Assertion.minCount) { throw ('Health assertion failed: ' + $Assertion.path) } } }",
|
|
278
279
|
...(allowLocalReleaseRoot ? [
|
|
@@ -352,11 +353,11 @@ export function renderPowerShellBootstrap({
|
|
|
352
353
|
"$PriorStatus = $null; if ($PriorTarget) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); try { $PriorStatus = (& $PriorExe @PriorStatusArgs | Out-String).Trim() } catch {} }",
|
|
353
354
|
"@{ schemaVersion=1; priorTarget=$PriorTarget; priorVersion=$PriorVersion; targetVersion=$Version; priorStatus=$PriorStatus } | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $Journal",
|
|
354
355
|
"if ($PreflightArgs.Count) { & $VersionExe @PreflightArgs; if ($LASTEXITCODE -ne 0) { throw 'Activation preflight failed' } }",
|
|
355
|
-
"
|
|
356
|
+
"Remove-StableJunction $NextLink; Remove-StableJunction $PreviousLink",
|
|
356
357
|
"New-Item -ItemType Junction -Path $NextLink -Target $VersionRoot | Out-Null",
|
|
357
358
|
"$Switched = $false; $PriorDetached = $false",
|
|
358
359
|
"try {",
|
|
359
|
-
" if (Test-Path $Current) {
|
|
360
|
+
" if (Test-Path $Current) { Rename-Item -LiteralPath $Current -NewName (Split-Path -Leaf $PreviousLink); $PriorDetached = $true }; Rename-Item -LiteralPath $NextLink -NewName (Split-Path -Leaf $Current); $Switched = $true",
|
|
360
361
|
" $StableExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")),
|
|
361
362
|
" $ActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate $VersionRoot $Version); $StatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate $VersionRoot $Version)",
|
|
362
363
|
" & $StableExe @ActivationArgs; if ($LASTEXITCODE -ne 0) { throw 'Activation failed' }",
|
|
@@ -365,9 +366,9 @@ export function renderPowerShellBootstrap({
|
|
|
365
366
|
" $StableBin = Split-Path $StableExe -Parent; $UserPath = [Environment]::GetEnvironmentVariable('Path','User'); if (-not (($UserPath -split ';') -contains $StableBin)) { [Environment]::SetEnvironmentVariable('Path', (($UserPath.TrimEnd(';') + ';' + $StableBin).Trim(';')), 'User') }",
|
|
366
367
|
// Journal cleanup must succeed before discarding the prior pointer. If
|
|
367
368
|
// cleanup fails, catch still has a rollback anchor to restore.
|
|
368
|
-
" Remove-Item -Force $Journal;
|
|
369
|
+
" Remove-Item -Force $Journal; Remove-StableJunction $PreviousLink; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
|
|
369
370
|
"} catch {",
|
|
370
|
-
" if (
|
|
371
|
+
" if ($Switched -or $PriorDetached) { Remove-StableJunction $Current }; if ($PriorDetached -and (Test-Path $PreviousLink)) { Rename-Item -LiteralPath $PreviousLink -NewName (Split-Path -Leaf $Current) }",
|
|
371
372
|
" if ($PriorTarget -and (Test-Path $Current)) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate ([string]$PriorTarget) $PriorVersion); $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); & $PriorExe @PriorActivationArgs; if ($LASTEXITCODE -ne 0) { throw 'Rollback activation failed' }; $PriorHealthText = (& $PriorExe @PriorStatusArgs | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Rollback health failed' }; $PriorHealth = $PriorHealthText | ConvertFrom-Json; try { Assert-Health $PriorHealth $PriorVersion } catch { throw 'Prior health was not restored' } }",
|
|
372
373
|
" if (Test-Path $VersionRoot) { Remove-Item -Recurse -Force $VersionRoot }; throw",
|
|
373
374
|
"} finally { if (Test-Path $Work) { Remove-Item -Recurse -Force $Work } }",
|
package/package.json
CHANGED
package/release-invocation.mjs
CHANGED
|
@@ -32,7 +32,11 @@ export function assertPrimaryReleaseCheckout(repoRoot, env = process.env) {
|
|
|
32
32
|
throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
|
|
33
33
|
}
|
|
34
34
|
const branch = spawnSync("git", ["symbolic-ref", "--quiet", "HEAD"], { cwd: repoRoot, encoding: "utf8", windowsHide: true });
|
|
35
|
-
if (
|
|
35
|
+
if (
|
|
36
|
+
branch.status !== 0
|
|
37
|
+
&& !isVerifiedGithubActionsTagCheckout(repoRoot, env)
|
|
38
|
+
&& !isVerifiedGithubActionsAdmittedDispatch(repoRoot, env)
|
|
39
|
+
) {
|
|
36
40
|
throw new Error("right-release requires a branch-attached primary Git checkout; detached HEAD is forbidden");
|
|
37
41
|
}
|
|
38
42
|
}
|
|
@@ -63,6 +67,48 @@ function isVerifiedGithubActionsTagCheckout(repoRoot, env) {
|
|
|
63
67
|
return repository?.toLowerCase() === env.GITHUB_REPOSITORY.toLowerCase();
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
// A tag-last release chain never has a tag to check out: the whole point is that
|
|
71
|
+
// publication mints the tag only after qualification passes. Every stage instead
|
|
72
|
+
// checks out one exact commit SHA admitted from the default branch, which leaves
|
|
73
|
+
// HEAD detached. Refusing that would force the retired tag-first shape back.
|
|
74
|
+
//
|
|
75
|
+
// This exemption is deliberately STRICTER than the tag one above. A tag proves
|
|
76
|
+
// only that some ref points at this commit; here the commit must additionally be
|
|
77
|
+
// the exact revision this run was admitted against AND be genuinely reachable
|
|
78
|
+
// from the remote branch the dispatch came from, so a detached HEAD parked on an
|
|
79
|
+
// arbitrary, orphaned, or force-pushed commit is still refused.
|
|
80
|
+
function isVerifiedGithubActionsAdmittedDispatch(repoRoot, env) {
|
|
81
|
+
const sha = env.GITHUB_SHA;
|
|
82
|
+
const admitted = env.RIGHT_GIT_SOURCE_REVISION;
|
|
83
|
+
if (
|
|
84
|
+
env.GITHUB_ACTIONS !== "true"
|
|
85
|
+
|| env.GITHUB_EVENT_NAME !== "workflow_dispatch"
|
|
86
|
+
|| env.GITHUB_REF_TYPE !== "branch"
|
|
87
|
+
|| !/^refs\/heads\/.+$/.test(env.GITHUB_REF ?? "")
|
|
88
|
+
|| !/^[a-f0-9]{40}$/.test(sha ?? "")
|
|
89
|
+
|| !/^[a-f0-9]{40}$/.test(admitted ?? "")
|
|
90
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(env.GITHUB_REPOSITORY ?? "")
|
|
91
|
+
) return false;
|
|
92
|
+
// The checkout must be the exact revision the admission stage bound this run
|
|
93
|
+
// to — not merely some commit the dispatch happened to land on.
|
|
94
|
+
const head = git(repoRoot, ["rev-parse", "HEAD"]).trim();
|
|
95
|
+
if (head !== admitted) return false;
|
|
96
|
+
let origin;
|
|
97
|
+
try {
|
|
98
|
+
origin = git(repoRoot, ["remote", "get-url", "origin"]).trim();
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const repository = origin.match(/(?:github\.com[/:])([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i)?.[1];
|
|
103
|
+
if (repository?.toLowerCase() !== env.GITHUB_REPOSITORY.toLowerCase()) return false;
|
|
104
|
+
// Reachability from the remote branch that was dispatched. A shallow or
|
|
105
|
+
// ref-less checkout cannot prove this, and so does not get the exemption.
|
|
106
|
+
const dispatchBranch = (env.GITHUB_REF ?? "").replace(/^refs\/heads\//, "");
|
|
107
|
+
const remoteRef = `refs/remotes/origin/${dispatchBranch}`;
|
|
108
|
+
if (spawnSync("git", ["rev-parse", "--verify", "--quiet", remoteRef], { cwd: repoRoot, windowsHide: true }).status !== 0) return false;
|
|
109
|
+
return spawnSync("git", ["merge-base", "--is-ancestor", head, remoteRef], { cwd: repoRoot, windowsHide: true }).status === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
66
112
|
export function resolvePrimaryWorktree({ repoRoot, worktrees, gitDir, coreWorktree }) {
|
|
67
113
|
const listed = worktrees.match(/^worktree (.+)$/m)?.[1];
|
|
68
114
|
if (!listed) return undefined;
|
package/release.mjs
CHANGED
|
@@ -694,17 +694,41 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
|
694
694
|
const windowsSource = windowsSpan ? source.slice(windowsSpan.start, windowsSpan.end) : "";
|
|
695
695
|
const macosSource = macosSpan ? source.slice(macosSpan.start, macosSpan.end) : "";
|
|
696
696
|
const publishSource = publishSpan ? source.slice(publishSpan.start, publishSpan.end) : "";
|
|
697
|
+
// Two release-chain shapes are accepted: the retired tag-first lane (a pushed
|
|
698
|
+
// `refs/tags/v*` — or, for signing only, an explicit manual `signed_qualification`
|
|
699
|
+
// dispatch — gates signing/publish) and the current tag-last lane (one admitted
|
|
700
|
+
// dispatch binds every stage to an exact main commit; an `admission` job validates
|
|
701
|
+
// the dispatch envelope and publication itself mints the tag last). Both must prove
|
|
702
|
+
// the same thing — signing is unreachable without qualification actually being
|
|
703
|
+
// requested for this run, and publish is unreachable unless that qualification
|
|
704
|
+
// verified success in this same run — only the mechanism differs.
|
|
705
|
+
const legacySigningGate = (job) => new RegExp(
|
|
706
|
+
`${job}:\\n needs: \\[candidate\\]\\n if: \\$\\{\\{ startsWith\\(github\\.ref, 'refs\\/tags\\/v'\\)(?: \\|\\| \\(github\\.event_name == 'workflow_dispatch' && inputs\\.signed_qualification == true\\))? \\}\\}`,
|
|
707
|
+
);
|
|
708
|
+
const admittedSigningGate = (job) => new RegExp(
|
|
709
|
+
`${job}:\\n needs: \\[admission, candidate\\]\\n if: \\$\\{\\{ needs\\.admission\\.outputs\\.signed_qualification == 'true' \\}\\}`,
|
|
710
|
+
);
|
|
697
711
|
const protectedWindowsSigning = workflow === "release-candidate.yml"
|
|
698
712
|
&& manifest?.candidate?.signWindows === true
|
|
699
|
-
&&
|
|
713
|
+
&& (legacySigningGate("windows-sign").test(windowsSource) || admittedSigningGate("windows-sign").test(windowsSource));
|
|
700
714
|
const protectedMacosSigning = workflow === "release-candidate.yml"
|
|
701
715
|
&& manifest?.candidate?.signMacos === true
|
|
702
|
-
&&
|
|
716
|
+
&& (legacySigningGate("macos-sign").test(macosSource) || admittedSigningGate("macos-sign").test(macosSource));
|
|
703
717
|
const releaseChain = workflow === "release-candidate.yml" ? manifest?.candidate?.releaseChain : undefined;
|
|
704
718
|
const allowedReleaseSecrets = new Set(releaseChain?.publishSecrets ?? []);
|
|
705
719
|
const writePermissions = [...source.matchAll(/^\s*(actions|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|security-events|statuses):\s*write\s*$/gmi)].map((match) => ({ name: match[1].toLowerCase(), index: match.index }));
|
|
720
|
+
// Legacy: publish requires a pushed tag AND same-run installed-qualification
|
|
721
|
+
// success — the tag is the sole gate because tag-first signing already proved
|
|
722
|
+
// qualification before the tag existed. Tag-last: there is no tag to check;
|
|
723
|
+
// instead publish requires the admission job's `publish` output (which itself
|
|
724
|
+
// implies `signed_qualification` was requested — see the admission job's own
|
|
725
|
+
// envelope validation) AND same-run success of BOTH installed-qualification
|
|
726
|
+
// (Windows) and macos-sign (macOS), so publish is unreachable unless every
|
|
727
|
+
// signing/qualification stage actually verified in this exact run.
|
|
728
|
+
const legacyReleasePublishGate = /publish:\n needs: \[[^\n]*installed-qualification[^\n]*\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) && needs\.installed-qualification\.result == 'success'/.test(publishSource);
|
|
729
|
+
const admittedReleasePublishGate = /publish:\n needs: \[(?=[^\n]*\badmission\b)(?=[^\n]*\binstalled-qualification\b)(?=[^\n]*\bmacos-sign\b)[^\n]*\]\n if: \$\{\{ needs\.admission\.outputs\.publish == 'true' && needs\.installed-qualification\.result == 'success' && needs\.macos-sign\.result == 'success' \}\}/.test(publishSource);
|
|
706
730
|
const protectedReleasePublish = releaseChain
|
|
707
|
-
&&
|
|
731
|
+
&& (legacyReleasePublishGate || admittedReleasePublishGate)
|
|
708
732
|
&& /\n permissions:\n contents: write\n/.test(publishSource);
|
|
709
733
|
if (/^\s*permissions:\s*write-all\s*$/mi.test(source)
|
|
710
734
|
|| writePermissions.some(({ name, index }) => name === "id-token"
|
package/rightkit-versions.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"stagedNpm": {
|
|
16
16
|
"@rightkit/ax": "0.2.1",
|
|
17
|
-
"@rightkit/git": "0.2.
|
|
17
|
+
"@rightkit/git": "0.2.25",
|
|
18
18
|
"@rightkit/hooks": "0.1.1",
|
|
19
19
|
"@rightkit/legal": "0.3.1",
|
|
20
20
|
"@rightkit/legal-ui": "0.1.1",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@rightkit/logs": "0.1.4",
|
|
23
23
|
"@rightkit/platform-ui": "0.1.1",
|
|
24
24
|
"@rightkit/qa": "0.2.1",
|
|
25
|
-
"@rightkit/release": "0.2.
|
|
25
|
+
"@rightkit/release": "0.2.101",
|
|
26
26
|
"@rightkit/tauri": "0.1.1",
|
|
27
27
|
"@rightkit/updates": "0.2.4"
|
|
28
28
|
},
|
|
@@ -51,7 +51,9 @@
|
|
|
51
51
|
"0.2.19",
|
|
52
52
|
"0.2.20",
|
|
53
53
|
"0.2.21",
|
|
54
|
-
"0.2.22"
|
|
54
|
+
"0.2.22",
|
|
55
|
+
"0.2.23",
|
|
56
|
+
"0.2.24"
|
|
55
57
|
],
|
|
56
58
|
"@rightkit/legal-ui": [
|
|
57
59
|
"0.1.0"
|
|
@@ -107,7 +109,8 @@
|
|
|
107
109
|
"0.2.95",
|
|
108
110
|
"0.2.96",
|
|
109
111
|
"0.2.97",
|
|
110
|
-
"0.2.98"
|
|
112
|
+
"0.2.98",
|
|
113
|
+
"0.2.99"
|
|
111
114
|
],
|
|
112
115
|
"@rightkit/qa": [
|
|
113
116
|
"0.1.0",
|