@rightkit/release 0.2.100 → 0.2.102

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.
@@ -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 ? [
@@ -301,7 +302,7 @@ export function renderPowerShellBootstrap({
301
302
  "Invoke-WebRequest ($ReleaseBase + '/release-manifest.json') -OutFile $ManifestPath",
302
303
  "Invoke-WebRequest ($ReleaseBase + '/release-manifest.cat') -OutFile $SignaturePath",
303
304
  ]),
304
- "$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath); Import-Module Microsoft.PowerShell.Security -ErrorAction Stop",
305
+ "$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath); Import-Module Microsoft.PowerShell.Security -ErrorAction SilentlyContinue",
305
306
  "$Catalog = Test-FileCatalog -CatalogFilePath $SignaturePath -Path $ManifestPath -Detailed; if ([string]$Catalog.Status -ne 'Valid') { throw 'Release manifest catalog binding is invalid' }",
306
307
  "$CatalogSignature = Get-AuthenticodeSignature -FilePath $SignaturePath; if ([string]$CatalogSignature.Status -ne 'Valid' -or -not $CatalogSignature.SignerCertificate) { throw 'Release manifest signature is invalid' }; $SignerCertificate = $CatalogSignature.SignerCertificate",
307
308
  "$SignerSubject = 'CN=' + $SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)",
@@ -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
- "if (Test-Path $NextLink) { Remove-Item -Force $NextLink }; if (Test-Path $PreviousLink) { Remove-Item -Force $PreviousLink }",
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) { [IO.Directory]::Move($Current, $PreviousLink); $PriorDetached = $true }; [IO.Directory]::Move($NextLink, $Current); $Switched = $true",
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; if (Test-Path $PreviousLink) { Remove-Item -Force $PreviousLink }; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
369
+ " Remove-Item -Force $Journal; Remove-StableJunction $PreviousLink; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
369
370
  "} catch {",
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
+ " 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.100",
3
+ "version": "0.2.102",
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",
@@ -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.100",
25
+ "@rightkit/release": "0.2.102",
26
26
  "@rightkit/tauri": "0.1.1",
27
27
  "@rightkit/updates": "0.2.4"
28
28
  },
package/verifier.mjs ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic release verifier (Gate 6).
4
+ *
5
+ * Three capability grants exist so that no single stage can authorize the next
6
+ * one for itself: BUILD_AUTHORIZED admits compilation, SIGNING_AUTHORIZED
7
+ * admits production signing of exact package digests, and RELEASE_AUTHORIZED
8
+ * admits public promotion of exact qualified bytes.
9
+ *
10
+ * The verifier is a pure function of receipts. It never reads a `decision`
11
+ * field supplied by its caller: an input that already claims AUTHORIZED is
12
+ * rejected outright, because the whole point of the gate is that neither a
13
+ * human nor a model can hand-edit its way to a grant.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+
17
+ export const GRANTS = Object.freeze(["BUILD_AUTHORIZED", "SIGNING_AUTHORIZED", "RELEASE_AUTHORIZED"]);
18
+
19
+ const SHA1_40 = /^[a-f0-9]{40}$/;
20
+ const SHA256_64 = /^[a-f0-9]{64}$/;
21
+
22
+ /** Receipts each grant requires, in the order the pipeline produces them. */
23
+ export const REQUIRED_RECEIPTS = Object.freeze({
24
+ BUILD_AUTHORIZED: Object.freeze([
25
+ "source_sha",
26
+ "verifier_sha",
27
+ "obligation_schema_digest",
28
+ "ci_receipt",
29
+ "static_source_closure_receipt",
30
+ "platform_preflight_receipt",
31
+ ]),
32
+ SIGNING_AUTHORIZED: Object.freeze([
33
+ "build_input_digest",
34
+ "windows_candidate_sha256",
35
+ "macos_candidate_sha256",
36
+ "unsigned_transaction_receipt",
37
+ ]),
38
+ RELEASE_AUTHORIZED: Object.freeze([
39
+ "signing_authorization_receipt",
40
+ "windows_signed_sha256",
41
+ "macos_notarized_sha256",
42
+ "windows_install_receipt",
43
+ "macos_install_receipt",
44
+ "compatibility_matrix_receipt",
45
+ "real_client_contract_receipt",
46
+ "channel_stage_receipt",
47
+ ]),
48
+ });
49
+
50
+ /** Fields that must look like a 64-character lowercase SHA-256. */
51
+ const DIGEST_FIELDS = new Set([
52
+ "obligation_schema_digest",
53
+ "build_input_digest",
54
+ "windows_candidate_sha256",
55
+ "macos_candidate_sha256",
56
+ "windows_signed_sha256",
57
+ "macos_notarized_sha256",
58
+ ]);
59
+
60
+ /** A grant only carries the receipts it and its predecessors require. */
61
+ export function receiptsFor(grant) {
62
+ const order = GRANTS.slice(0, GRANTS.indexOf(grant) + 1);
63
+ return order.flatMap((each) => REQUIRED_RECEIPTS[each]);
64
+ }
65
+
66
+ function receiptIssues(receipts, names) {
67
+ const issues = [];
68
+ for (const name of names) {
69
+ const value = receipts[name];
70
+ if (value === undefined || value === null || value === "") {
71
+ issues.push(`missing receipt: ${name}`);
72
+ continue;
73
+ }
74
+ if (typeof value !== "string") {
75
+ issues.push(`receipt must be a string: ${name}`);
76
+ continue;
77
+ }
78
+ if (name === "source_sha" && !SHA1_40.test(value)) issues.push("source_sha must be a 40-character lowercase commit SHA");
79
+ if (name === "verifier_sha" && !SHA1_40.test(value)) issues.push("verifier_sha must be a 40-character lowercase commit SHA");
80
+ if (DIGEST_FIELDS.has(name) && !SHA256_64.test(value)) issues.push(`${name} must be a 64-character lowercase SHA-256`);
81
+ }
82
+ return issues;
83
+ }
84
+
85
+ /**
86
+ * Computes one grant. Returns a decision object; never throws for an ordinary
87
+ * refusal, because a refusal is a normal, expected outcome that must be
88
+ * reportable rather than exceptional.
89
+ */
90
+ export function computeGrant(grant, input = {}, { now = Date.now(), maxAgeSeconds = 3600 } = {}) {
91
+ if (!GRANTS.includes(grant)) throw new Error(`unknown grant: ${grant}`);
92
+
93
+ const issues = [];
94
+ // A caller-supplied decision is an attempt to skip the gate.
95
+ if (Object.hasOwn(input, "decision")) issues.push("input must not supply a decision; the verifier computes it");
96
+ if (Object.hasOwn(input, "grant")) issues.push("input must not supply a grant; the verifier computes it");
97
+
98
+ const receipts = input.receipts ?? {};
99
+ if (typeof receipts !== "object" || receipts === null || Array.isArray(receipts)) {
100
+ return decision(grant, false, ["receipts must be an object"], input);
101
+ }
102
+ issues.push(...receiptIssues(receipts, receiptsFor(grant)));
103
+
104
+ // Short-lived: a stale authorization must not be replayable against bytes
105
+ // that were rebuilt or re-signed after it was issued.
106
+ const issuedAt = input.issuedAt;
107
+ if (typeof issuedAt !== "string" || Number.isNaN(Date.parse(issuedAt))) {
108
+ issues.push("issuedAt must be an ISO-8601 timestamp");
109
+ } else {
110
+ const ageSeconds = (now - Date.parse(issuedAt)) / 1000;
111
+ if (ageSeconds < 0) issues.push("issuedAt is in the future");
112
+ else if (ageSeconds > maxAgeSeconds) issues.push(`authorization input is stale: ${Math.round(ageSeconds)}s old, limit ${maxAgeSeconds}s`);
113
+ }
114
+
115
+ return decision(grant, issues.length === 0, issues, input);
116
+ }
117
+
118
+ function decision(grant, granted, issues, input) {
119
+ const receipts = input.receipts ?? {};
120
+ const bound = {};
121
+ for (const name of receiptsFor(grant)) if (typeof receipts[name] === "string") bound[name] = receipts[name];
122
+ const body = {
123
+ schemaVersion: 1,
124
+ kind: "rightkit-release-authorization",
125
+ grant,
126
+ decision: granted ? "AUTHORIZED" : "REFUSED",
127
+ issues,
128
+ boundReceipts: bound,
129
+ issuedAt: typeof input.issuedAt === "string" ? input.issuedAt : null,
130
+ };
131
+ // The digest covers the bound receipts, so an authorization cannot be moved
132
+ // onto a different source revision or different artifact bytes.
133
+ body.authorizationDigest = createHash("sha256")
134
+ .update(JSON.stringify({ grant: body.grant, decision: body.decision, boundReceipts: bound, issuedAt: body.issuedAt }))
135
+ .digest("hex");
136
+ return body;
137
+ }
138
+
139
+ const invokedDirectly = process.argv[1] && process.argv[1].endsWith("verifier.mjs");
140
+ if (invokedDirectly) {
141
+ const grant = process.argv[2];
142
+ let input = {};
143
+ try { input = JSON.parse(process.argv[3] ?? "{}"); }
144
+ catch (error) { process.stderr.write(`verifier: input is not JSON: ${error.message}\n`); process.exit(2); }
145
+ const result = computeGrant(grant, input);
146
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
147
+ process.exitCode = result.decision === "AUTHORIZED" ? 0 : 1;
148
+ }