@rightkit/release 0.2.99 → 0.2.100

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.
@@ -356,7 +356,7 @@ export function renderPowerShellBootstrap({
356
356
  "New-Item -ItemType Junction -Path $NextLink -Target $VersionRoot | Out-Null",
357
357
  "$Switched = $false; $PriorDetached = $false",
358
358
  "try {",
359
- " if (Test-Path $Current) { Move-Item $Current $PreviousLink; $PriorDetached = $true }; Move-Item $NextLink $Current; $Switched = $true",
359
+ " if (Test-Path $Current) { [IO.Directory]::Move($Current, $PreviousLink); $PriorDetached = $true }; [IO.Directory]::Move($NextLink, $Current); $Switched = $true",
360
360
  " $StableExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")),
361
361
  " $ActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate $VersionRoot $Version); $StatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate $VersionRoot $Version)",
362
362
  " & $StableExe @ActivationArgs; if ($LASTEXITCODE -ne 0) { throw 'Activation failed' }",
@@ -367,7 +367,7 @@ export function renderPowerShellBootstrap({
367
367
  // cleanup fails, catch still has a rollback anchor to restore.
368
368
  " Remove-Item -Force $Journal; if (Test-Path $PreviousLink) { Remove-Item -Force $PreviousLink }; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
369
369
  "} catch {",
370
- " if (($Switched -or $PriorDetached) -and (Test-Path $Current)) { Remove-Item -Force $Current }; if ($PriorDetached -and (Test-Path $PreviousLink)) { Move-Item $PreviousLink $Current }",
370
+ " if (($Switched -or $PriorDetached) -and (Test-Path $Current)) { Remove-Item -Force $Current }; if ($PriorDetached -and (Test-Path $PreviousLink)) { [IO.Directory]::Move($PreviousLink, $Current) }",
371
371
  " 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
372
  " if (Test-Path $VersionRoot) { Remove-Item -Recurse -Force $VersionRoot }; throw",
373
373
  "} finally { if (Test-Path $Work) { Remove-Item -Recurse -Force $Work } }",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.99",
3
+ "version": "0.2.100",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -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 (branch.status !== 0 && !isVerifiedGithubActionsTagCheckout(repoRoot, env)) {
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
- && /windows-sign:\n needs: \[candidate\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\)(?: \|\| \(github\.event_name == 'workflow_dispatch' && inputs\.signed_qualification == true\))? \}\}/.test(windowsSource);
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
- && /macos-sign:\n needs: \[candidate\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\)(?: \|\| \(github\.event_name == 'workflow_dispatch' && inputs\.signed_qualification == true\))? \}\}/.test(macosSource);
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
- && /publish:\n needs: \[[^\n]*installed-qualification[^\n]*\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) && needs\.installed-qualification\.result == 'success'/.test(publishSource)
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"
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "stagedNpm": {
16
16
  "@rightkit/ax": "0.2.1",
17
- "@rightkit/git": "0.2.23",
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.99",
25
+ "@rightkit/release": "0.2.100",
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",