@rightkit/release 0.2.154 → 0.2.156

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.
@@ -86,7 +86,9 @@ if (first === "--version" || first === "-v") {
86
86
  run("apple-profile-renew.mjs", args.slice(1));
87
87
  } else if (first === "publish") {
88
88
  const rest = args.slice(1);
89
- if (rest[0] === "cargo") {
89
+ if ((rest[0] === "cargo" || rest[0] === "npm") && rest.includes("--local")) {
90
+ run("local-publish.mjs", rest);
91
+ } else if (rest[0] === "cargo") {
90
92
  run("publish-cargo.mjs", rest.slice(1));
91
93
  } else if (rest[0] === "npm") {
92
94
  run("publication-gate.mjs", rest.slice(1));
@@ -202,6 +204,9 @@ Commands:
202
204
  Publish the gated tarball with the Keychain token
203
205
  (npm-rightkit-publish) and verify registry bytes
204
206
  publish cargo --crate <name> [--dry-run] Test, inspect, scan, and publish one crates.io package
207
+ publish npm --package @rightkit/<name> --local Mac-only local publish under the committed local-build grant
208
+ publish cargo --crate <name> --local (.rightkit-local-build.json): runs the unit's CI tests, packs, writes darwin-only
209
+ evidence, then the same publication gate and publisher; refuses without the grant
205
210
  publish swift [--dry-run] [--evidence <file>] [--workspace <dir>]
206
211
  Check main, a clean tree, and the publication evidence, then tag RightKitSwift at its VERSION on origin
207
212
  inventory check [--json] [--warn-only] [--md <file>] [--no-cache]
package/deps.mjs CHANGED
File without changes
@@ -41,7 +41,7 @@ export const LOCAL_BUILD_FILE = ".rightkit-local-build.json";
41
41
 
42
42
  // Adrian's local-build opt-in (2026-10-14): a committed .rightkit-local-build.json naming this exact
43
43
  // repository allows local build, test and publish. Mirrors the build-control hook's check.
44
- function localBuildGranted(root) {
44
+ export function localBuildGranted(root) {
45
45
  if (!root) return false;
46
46
  try {
47
47
  const grant = JSON.parse(readFileSync(path.join(root, LOCAL_BUILD_FILE), "utf8"));
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ // `right-release publish npm|cargo ... --local`: publish ONE @rightkit npm package or crate from the Mac under
3
+ // Adrian's local-build grant (2026-10-14, committed .rightkit-local-build.json). It does not weaken the
4
+ // publication gate: it runs the unit's own CI tests (ci/gate.config.json, ci/asset-gated.json,
5
+ // scripts/ci/gate.mjs semantics), packs the artifact, writes a schema-1 evidence file with one darwin receipt
6
+ // (hostPolicy "darwin-only"), then calls the EXISTING enforcePublicationGate and the EXISTING publisher
7
+ // (publishGatedTarball for npm, runCargoPublish for cargo). There is no way to skip a test or the evidence.
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+ import { spawnSync } from "./background-process.mjs";
14
+ import { runCargoSync } from "./managed-cargo.mjs";
15
+ import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
16
+ import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
17
+ import { runCargoPublish } from "./publish-cargo.mjs";
18
+ import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
19
+ import { readCargoPackage } from "./crate-graph.mjs";
20
+ import { recordPublished } from "./rightkit-sync.mjs";
21
+
22
+ export const LOCAL_OPERATOR = "agent (local-build grant)";
23
+ export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
24
+ const STEP_TIMEOUT_MS = 30 * 60 * 1000;
25
+
26
+ export class LocalPublishError extends Error {
27
+ constructor(message) { super(message); this.name = "LocalPublishError"; }
28
+ }
29
+ const refuse = (message) => { throw new LocalPublishError(message); };
30
+
31
+ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
32
+ const flag = kind === "npm" ? "--package" : "--crate";
33
+ let unit; let workspace; let local = false;
34
+ for (let i = 0; i < argv.length; i += 1) {
35
+ const arg = argv[i];
36
+ if (arg === "--local") local = true;
37
+ else if (arg === flag) unit = argv[++i];
38
+ else if (arg === "--workspace") workspace = argv[++i];
39
+ else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>, --workspace <dir>, --local)`);
40
+ }
41
+ if (!local) refuse("--local is required");
42
+ if (kind === "npm" && !/^@rightkit\/[a-z0-9-]+$/.test(unit ?? "")) refuse("publish npm --local requires --package @rightkit/<name>");
43
+ if (kind === "cargo" && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(unit ?? "")) refuse("publish cargo --local requires --crate <name>");
44
+ return { kind, unit, workspaceRoot: path.resolve(workspace ?? cwd) };
45
+ }
46
+
47
+ function defaultGit(args, cwd) {
48
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
49
+ if (result.error || result.status !== 0) return null;
50
+ return (result.stdout ?? "").trim();
51
+ }
52
+
53
+ const defaultReadJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
54
+
55
+ async function defaultRegistryHas({ kind, name, version }) {
56
+ if (kind === "npm") {
57
+ const view = spawnSync("npm", ["view", `${name}@${version}`, "version", "--prefer-online", "--registry", "https://registry.npmjs.org/"], { encoding: "utf8", windowsHide: true });
58
+ const out = `${view.stdout ?? ""}${view.stderr ?? ""}`;
59
+ if (!view.error && view.status === 0) return (view.stdout ?? "").trim() === version;
60
+ if (/E404|404 Not Found/i.test(out)) return false;
61
+ throw new Error(`cannot read the npm registry for ${name}@${version}: ${out.trim().slice(0, 200)}`);
62
+ }
63
+ const response = await fetch(cargoIndexUrl(name), { headers: { Accept: "text/plain", "Cache-Control": "no-cache", "User-Agent": "rightkit-release" }, redirect: "error", signal: AbortSignal.timeout(30_000) });
64
+ if (response.status === 404) return false;
65
+ if (!response.ok) throw new Error(`crates.io index answered ${response.status} for ${name}`);
66
+ return (await response.text()).split(/\r?\n/).filter((line) => line.trim()).some((line) => { try { return JSON.parse(line).vers === version; } catch { return false; } });
67
+ }
68
+
69
+ function defaultRun(command, args, options) {
70
+ const settings = { encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: STEP_TIMEOUT_MS, windowsHide: true, ...options };
71
+ return command === "cargo" ? runCargoSync(args, settings) : spawnSync(command, args, settings);
72
+ }
73
+
74
+ async function defaultLoadWorkspaceModules(workspaceRoot) {
75
+ const load = (file) => import(pathToFileURL(path.join(workspaceRoot, "scripts", "ci", file)).href);
76
+ const [results, upload] = await Promise.all([load("test-results.mjs"), load("upload.mjs")]);
77
+ return { results, checkNpmTarball: upload.checkNpmTarball };
78
+ }
79
+
80
+ /** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
81
+ export function localTestPlan({ kind, unit, dir, config, manifest }) {
82
+ if (kind === "npm") {
83
+ if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
84
+ return {
85
+ id: `npm:${dir}`, featureLabels: ["default"],
86
+ steps: [
87
+ { command: "pnpm", args: ["-r", "--workspace-concurrency=1", "run", "build"], test: false },
88
+ { command: "pnpm", args: ["--dir", dir, "run", "test"], test: true },
89
+ ],
90
+ };
91
+ }
92
+ const entry = config?.rust?.[unit] ?? {};
93
+ if (entry.os && !entry.os.includes("macos")) refuse(`${unit} is restricted to ${entry.os.join(", ")} by ci/gate.config.json; it cannot be qualified on this Mac`);
94
+ const featureSets = entry.featureSets ?? [[]];
95
+ const steps = [];
96
+ for (const features of featureSets) {
97
+ steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--show-output"], test: true });
98
+ if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
99
+ }
100
+ steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
101
+ return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
102
+ }
103
+
104
+ const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
105
+ const tarballName = (name, version) => `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`;
106
+ const tail = (text, lines = 40) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
107
+
108
+ export async function runLocalPublish(kind, argv, deps = {}) {
109
+ const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
110
+ const { unit, workspaceRoot } = parseLocalPublishArgs(kind, argv, deps.cwd);
111
+ const platform = deps.platform ?? process.platform;
112
+ const git = deps.git ?? defaultGit;
113
+ const readJson = deps.readJson ?? defaultReadJson;
114
+ const run = deps.run ?? defaultRun;
115
+
116
+ // 1. Preconditions, fail closed.
117
+ if (!(deps.localBuildGranted ?? localBuildGranted)(workspaceRoot)) refuse(`--local needs the local-build grant: a committed ${LOCAL_BUILD_FILE} (allow "build-test-publish") naming this repository at ${workspaceRoot}`);
118
+ if (platform !== "darwin") refuse(`--local publishes from the Mac only (host policy: darwin-only); this host is ${platform}`);
119
+ const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
120
+ const unitPath = path.join(workspaceRoot, dir);
121
+ if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
122
+ let manifest = null; let version;
123
+ if (kind === "npm") {
124
+ manifest = readJson(path.join(unitPath, "package.json")) ?? refuse(`cannot read ${dir}/package.json`);
125
+ if (manifest.name !== unit) refuse(`${dir}/package.json is ${manifest.name}, not ${unit}`);
126
+ if (manifest.private === true) refuse(`${unit} is private and is never published`);
127
+ version = manifest.version;
128
+ } else {
129
+ const pkg = (deps.readCargoPackage ?? readCargoPackage)(unitPath);
130
+ if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crate ${unit}`);
131
+ version = pkg.version;
132
+ }
133
+ if (typeof version !== "string" || !version.trim()) refuse(`${unit} has no literal version`);
134
+ const head = git(["rev-parse", "HEAD"], workspaceRoot);
135
+ if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
136
+ const dirty = git(["status", "--porcelain", "--", dir], workspaceRoot);
137
+ if (dirty === null) refuse(`cannot read git status for ${dir}`);
138
+ if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
139
+ if (await (deps.registryHas ?? defaultRegistryHas)({ kind, name: unit, version })) refuse(`${unit}@${version} is already on the ${kind === "npm" ? "npm" : "crates.io"} registry; bump the version`);
140
+ log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, darwin-only policy)`);
141
+
142
+ // 2. The unit's own tests, exactly as the CI gate defines them.
143
+ const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
144
+ const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
145
+ const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
146
+ const isAssetGated = Object.hasOwn(gatedSection, unit);
147
+ const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest });
148
+ const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
149
+ const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
150
+ // Run the unit's tests under hosted-CI conditions: no inherited credentials (a managed host refuses a
151
+ // token from the environment) and no sibling product checkouts (CI holds rightkit alone).
152
+ const inherited = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([key]) => !/(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i.test(key)));
153
+ const env = { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
154
+ const steps = [];
155
+ for (const step of plan.steps) {
156
+ const commandText = `${step.command} ${step.args.join(" ")}`;
157
+ log(`[right-release] ${plan.id}: ${commandText}`);
158
+ const result = run(step.command, step.args, { cwd: workspaceRoot, env });
159
+ const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
160
+ if (result.error || result.status !== 0) {
161
+ steps.push(row);
162
+ refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
163
+ }
164
+ row.ok = true;
165
+ if (row.isTest) {
166
+ const out = new ResultScanner(); const err = new ResultScanner();
167
+ out.push(result.stdout ?? ""); err.push(result.stderr ?? "");
168
+ const a = out.finish(); const b = err.finish();
169
+ Object.assign(row, { executed: a.executed + b.executed, failed: a.failed + b.failed, ignored: a.ignored + b.ignored, skipped: a.skipped + b.skipped, binaries: a.binaries + b.binaries });
170
+ }
171
+ steps.push(row);
172
+ }
173
+ const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: [], allowMissingAssets: isAssetGated ? [unit] : [] });
174
+ if (verdict.status === "fail" || steps.some((s) => s.failed > 0)) refuse(`${plan.id} not qualified: ${verdict.problems.join("; ") || "failed tests reported"}`);
175
+ if (!(verdict.executed > 0)) refuse(`${plan.id} executed no test`);
176
+ log(`[right-release] ${plan.id}: ${verdict.executed} passed, 0 failed, ${verdict.skipped} skipped, ${verdict.ignored} ignored${isAssetGated ? " (asset-gated: RIGHTKIT_REQUIRE_ASSETS=0, skips recorded)" : ""}`);
177
+
178
+ // 3. Pack the artifact and hash it.
179
+ const scratch = fs.mkdtempSync(path.join(deps.tmpdir ?? os.tmpdir(), "rk-local-publish-"));
180
+ const cleanup = () => fs.rmSync(scratch, { recursive: true, force: true });
181
+ try {
182
+ let artifactFile; const packCommands = [];
183
+ if (kind === "npm") {
184
+ const args = ["--dir", dir, "pack", "--pack-destination", scratch];
185
+ packCommands.push(`pnpm ${args.join(" ")}`);
186
+ const packed = run("pnpm", args, { cwd: workspaceRoot, env });
187
+ if (packed.error || packed.status !== 0) refuse(`pnpm pack failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
188
+ artifactFile = path.join(scratch, tarballName(unit, version));
189
+ if (!fs.existsSync(artifactFile)) refuse(`pnpm pack did not produce ${path.basename(artifactFile)}`);
190
+ const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
191
+ if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
192
+ } else {
193
+ packCommands.push(`cargo package --locked -p ${unit}`);
194
+ const packed = run("cargo", ["package", "--locked", "-p", unit], { cwd: workspaceRoot, env });
195
+ if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
196
+ packCommands.push("cargo metadata --format-version 1 --no-deps");
197
+ const meta = run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
198
+ let targetDir;
199
+ try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
200
+ if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) refuse("cannot read the Cargo target directory");
201
+ artifactFile = path.join(targetDir, "package", `${unit}-${version}.crate`);
202
+ if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
203
+ }
204
+ const artifactSha256 = sha256(fs.readFileSync(artifactFile));
205
+
206
+ // 4. Evidence in the normal schema, then the EXISTING gate.
207
+ const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands];
208
+ const timestamp = (deps.now ?? (() => new Date().toISOString()))();
209
+ const evidence = {
210
+ schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
211
+ hostPolicy: "darwin-only", hostPolicyReason: LOCAL_HOST_POLICY_REASON,
212
+ runs: [{
213
+ host: { os: "darwin", arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
214
+ command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
215
+ passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored,
216
+ requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
217
+ steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
218
+ }],
219
+ };
220
+ const evidencePath = path.resolve(defaultEvidencePath(workspaceRoot, kind, unit));
221
+ fs.mkdirSync(path.dirname(evidencePath), { recursive: true });
222
+ fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
223
+ log(`[right-release] wrote ${evidencePath} (artifact sha256 ${artifactSha256})`);
224
+ const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
225
+ (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
226
+
227
+ // 5. The EXISTING publish path.
228
+ if (kind === "npm") {
229
+ (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
230
+ } else {
231
+ const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath }, undefined, deps.cargoDeps ?? {});
232
+ (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version });
233
+ }
234
+ log(`[right-release] published ${unit}@${version}`);
235
+ return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
236
+ } finally {
237
+ cleanup();
238
+ }
239
+ }
240
+
241
+ const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
242
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
243
+ const [kind, ...rest] = process.argv.slice(2);
244
+ if (kind !== "npm" && kind !== "cargo") { process.stderr.write("right-release publish --local: expected npm or cargo\n"); process.exitCode = 2; }
245
+ else runLocalPublish(kind, rest).catch((error) => { process.stderr.write(`right-release publish ${kind} --local: ${error.message}\n`); process.exitCode = 1; });
246
+ }
package/lsclean.sh CHANGED
File without changes
@@ -19,8 +19,19 @@
19
19
  # (logged; a retry is scheduled).
20
20
  #
21
21
  # Retry: after a failed repair (exit 1 or 4) the script gives the task a one-shot time trigger at +2, +5, +15, +60 minutes and then
22
- # hourly, for at most 24 hours from the first failure; the trigger is removed on success, when the package is healthy, or when the
23
- # app is uninstalled. State: retry.json. A later logon or event 751 run still starts a repair at any time.
22
+ # hourly, for the first 24 hours from the first failure. After that it does NOT stop: it keeps exactly one attempt per 24 h window
23
+ # (a one-shot trigger 24 h after the previous attempt; retry.json records dailyMode, windowStartEpoch and windowAttempts), never a
24
+ # tight loop. The trigger is removed on success, when the package is healthy, or when the app is uninstalled. A later logon or
25
+ # event 751 run still starts a repair at any time.
26
+ #
27
+ # Event 751: the task's event trigger fires for every package's 751, and Windows event XPath has no contains(), so the task passes the
28
+ # event's EventRecordID ($(EventRecordID) value query) as -EventRecordID and the script reads that event and exits quietly (no
29
+ # repair, last-result.json untouched) unless it names this app's package (Name_ and the publisher id of the family). Logon and
30
+ # retry runs carry no record id and are never filtered; an unreadable event fails open (the repair proceeds).
31
+ #
32
+ # Receipts: last-result.json / install-result.json carry guardSha256 (sha256 of this script file as it is on disk, computed at run
33
+ # time) and guardSha256Lf (same over the LF-normalised text; equals msixRepairGuard().sha256 only when the deployed copy is byte-exact, including the final newline), plus attemptLaunched (an
34
+ # Add-AppxPackage was actually started this run), launchFailure (why none was started) and lastLaunchEpoch (retry.json).
24
35
  #
25
36
  # Uninstall cleanup: a run that finds the package absent (twice, 10 s apart) removes this folder and the task. It runs at the next
26
37
  # logon, the next event 751, or the next retry tick; Windows has no "package removed" trigger for a per-user task.
@@ -31,7 +42,7 @@
31
42
  # Requirement: the app's data directory must be physical (MSIX fileSystemWrite virtualization disabled), otherwise the packaged app
32
43
  # writes this folder into its private LocalCache and the task cannot see it.
33
44
  [CmdletBinding()]
34
- param([switch]$Install, [string]$Pfn = '', [string]$Feed = '')
45
+ param([switch]$Install, [string]$Pfn = '', [string]$Feed = '', [string]$EventRecordID = '')
35
46
  $ErrorActionPreference = 'Stop'
36
47
  $ProgressPreference = 'SilentlyContinue' # Windows PowerShell 5.1 renders a progress bar per downloaded chunk: a 30 MB Invoke-WebRequest -OutFile then takes minutes
37
48
  $GuardVersion = 2
@@ -44,9 +55,25 @@ $ResultPath = Join-Path $Dir $(if ($Install) { 'install-result.json' } else { 'l
44
55
  $Mode = $(if ($Install) { 'install' } else { 'repair' })
45
56
  $RetryDelaysMinutes = @(2, 5, 15, 60) # then hourly
46
57
  $RetryWindowHours = 24
58
+ $DailyWindowHours = 24 # after the first window: one attempt per window
59
+ $script:Launched = $false
60
+ $script:LaunchFailure = ''
47
61
  $PfnPattern = '^[A-Za-z0-9][A-Za-z0-9.\-]*_[a-z0-9]{13}$'
48
62
  $script:Ctx = ''
49
63
 
64
+ function Get-ScriptHashes {
65
+ $h = [ordered]@{ guardSha256 = ''; guardSha256Lf = '' }
66
+ try {
67
+ $bytes = [IO.File]::ReadAllBytes($PSCommandPath)
68
+ $sha = [Security.Cryptography.SHA256]::Create()
69
+ $h.guardSha256 = ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant()
70
+ $lf = (New-Object Text.UTF8Encoding($false)).GetString($bytes).Replace("`r`n", "`n")
71
+ $h.guardSha256Lf = ([BitConverter]::ToString($sha.ComputeHash((New-Object Text.UTF8Encoding($false)).GetBytes($lf))) -replace '-', '').ToLowerInvariant()
72
+ } catch { }
73
+ return $h
74
+ }
75
+ $ScriptHashes = Get-ScriptHashes
76
+
50
77
  function Log([string]$m) {
51
78
  try {
52
79
  if ((Test-Path -LiteralPath $LogPath) -and ((Get-Item -LiteralPath $LogPath).Length -gt 262144)) { Move-Item -LiteralPath $LogPath -Destination ($LogPath + '.old') -Force }
@@ -61,8 +88,11 @@ function Write-Json([string]$path, $obj) {
61
88
  } catch { Log ('could not write ' + $path + ': ' + $_.Exception.Message) }
62
89
  }
63
90
  function Write-Result([string]$status, [int]$code, [string]$detail) {
64
- $r = [ordered]@{ status = $status; code = $code; mode = $Mode; timeUtc = (Get-Date).ToUniversalTime().ToString('o'); guardVersion = $GuardVersion; detail = $detail }
91
+ $r = [ordered]@{ status = $status; code = $code; mode = $Mode; timeUtc = (Get-Date).ToUniversalTime().ToString('o'); guardVersion = $GuardVersion; guardSha256 = $ScriptHashes.guardSha256; guardSha256Lf = $ScriptHashes.guardSha256Lf; detail = $detail }
92
+ if (-not $Install) { $r.attemptLaunched = [bool]$script:Launched; $r.launchFailure = [string]$script:LaunchFailure }
65
93
  $st = Read-Json $RetryPath
94
+ if ($st -and $st.lastLaunchEpoch) { $r.lastLaunchUtc = ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds([double]$st.lastLaunchEpoch).ToString('o') }
95
+ if ($st -and $st.dailyMode) { $r.retryDailyMode = $true }
66
96
  if ($st -and $st.nextEpoch) { $r.nextRetryUtc = ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds([double]$st.nextEpoch).ToString('o'); $r.retryAttempt = [int]$st.attempt }
67
97
  Write-Json $ResultPath $r
68
98
  }
@@ -79,7 +109,7 @@ function Get-TaskXml([string]$family, $retryLocal) {
79
109
  $id = [Security.Principal.WindowsIdentity]::GetCurrent()
80
110
  $esc = { param($s) [Security.SecurityElement]::Escape([string]$s) }
81
111
  $sub = '<QueryList><Query Id="0" Path="Microsoft-Windows-AppXDeploymentServer/Operational"><Select Path="Microsoft-Windows-AppXDeploymentServer/Operational">*[System[(EventID=751)]]</Select></Query></QueryList>'
82
- $taskArgs = '--headless powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $PSCommandPath + '"'
112
+ $taskArgs = '--headless powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $PSCommandPath + '" -EventRecordID $(EventRecordID)'
83
113
  $retryXml = ''
84
114
  if ($retryLocal) { $retryXml = '<TimeTrigger><StartBoundary>' + ([datetime]$retryLocal).ToString('yyyy-MM-ddTHH:mm:ss') + '</StartBoundary><Enabled>true</Enabled></TimeTrigger>' }
85
115
  return @"
@@ -87,7 +117,7 @@ function Get-TaskXml([string]$family, $retryLocal) {
87
117
  <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
88
118
  <RegistrationInfo><Description>Repair guard for $(& $esc $family): re-adds the app from its App Installer feed when an interrupted update leaves it NeedsRemediation; retries after a failure. Removes itself when the app is uninstalled.</Description></RegistrationInfo>
89
119
  <Triggers>
90
- <EventTrigger><Enabled>true</Enabled><Subscription>$(& $esc $sub)</Subscription></EventTrigger>
120
+ <EventTrigger><Enabled>true</Enabled><Subscription>$(& $esc $sub)</Subscription><ValueQueries><Value name="EventRecordID">Event/System/EventRecordID</Value></ValueQueries></EventTrigger>
91
121
  <LogonTrigger><Enabled>true</Enabled><UserId>$(& $esc $id.Name)</UserId></LogonTrigger>
92
122
  $retryXml
93
123
  </Triggers>
@@ -127,19 +157,41 @@ function Set-Retry([string]$family, [string]$why) {
127
157
  $now = [datetime]::UtcNow
128
158
  $nowEpoch = [math]::Floor(($now - $epoch0.ToUniversalTime()).TotalSeconds)
129
159
  $st = Read-Json $RetryPath
130
- $attempt = 1; $first = $nowEpoch
131
- if ($st -and $st.firstFailureEpoch -and (($nowEpoch - [double]$st.firstFailureEpoch) -lt ($RetryWindowHours * 3600))) { $attempt = [int]$st.attempt + 1; $first = [double]$st.firstFailureEpoch }
160
+ $attempt = 1; $first = $nowEpoch; $daily = $false; $windowStart = $null; $windowAttempts = 0; $lastLaunch = $null
161
+ if ($st -and $st.firstFailureEpoch) {
162
+ $attempt = [int]$st.attempt + 1; $first = [double]$st.firstFailureEpoch
163
+ if ($st.dailyMode) { $daily = $true; if ($st.windowStartEpoch) { $windowStart = [double]$st.windowStartEpoch }; if ($st.windowAttempts) { $windowAttempts = [int]$st.windowAttempts } }
164
+ }
165
+ # only a started installer counts as a launch; a failure before that (feed down, refused, corrupt) keeps the previous value
166
+ if ($st -and $st.lastLaunchEpoch) { $lastLaunch = [double]$st.lastLaunchEpoch }
167
+ if ($script:Launched) { $lastLaunch = $nowEpoch }
168
+ if (-not $daily -and (($nowEpoch - $first) -ge ($RetryWindowHours * 3600))) { $daily = $true }
132
169
  $delay = $(if ($attempt -le $RetryDelaysMinutes.Count) { $RetryDelaysMinutes[$attempt - 1] } else { 60 })
133
170
  $next = $nowEpoch + ($delay * 60)
134
- if (($next - $first) -gt ($RetryWindowHours * 3600)) {
135
- Write-Json $RetryPath ([ordered]@{ attempt = $attempt; firstFailureEpoch = $first; gaveUp = $true; reason = $why })
136
- try { Register-GuardTask $family $null } catch { }
137
- Log ('retry window of ' + $RetryWindowHours + ' h used up after ' + ($attempt - 1) + ' retries; no further automatic retry (next logon / event 751 starts again)')
138
- return
171
+ if (-not $daily -and (($next - $first) -gt ($RetryWindowHours * 3600))) { $daily = $true }
172
+ if ($daily) {
173
+ # first 24 h used up: one attempt per window, the next one a full window after this attempt (never a tight loop)
174
+ $windowStart = $nowEpoch; $windowAttempts += 1
175
+ $delay = $DailyWindowHours * 60
176
+ $next = $nowEpoch + ($delay * 60)
139
177
  }
140
- Write-Json $RetryPath ([ordered]@{ attempt = $attempt; firstFailureEpoch = $first; nextEpoch = $next; delayMinutes = $delay; reason = $why })
178
+ $o = [ordered]@{ attempt = $attempt; firstFailureEpoch = $first; nextEpoch = $next; delayMinutes = $delay; reason = $why }
179
+ if ($daily) { $o.dailyMode = $true; $o.windowStartEpoch = $windowStart; $o.windowAttempts = $windowAttempts }
180
+ if ($lastLaunch) { $o.lastLaunchEpoch = $lastLaunch }
181
+ Write-Json $RetryPath $o
141
182
  $local = $epoch0.ToUniversalTime().AddSeconds($next).ToLocalTime()
142
- try { Register-GuardTask $family $local; Log ('retry ' + $attempt + ' scheduled in ' + $delay + ' min at ' + $local.ToString('s')) } catch { Log ('could not schedule the retry: ' + $_.Exception.Message) }
183
+ try { Register-GuardTask $family $local; Log ('retry ' + $attempt + ' scheduled in ' + $delay + ' min at ' + $local.ToString('s') + $(if ($daily) { ' (daily window ' + $windowAttempts + ')' } else { '' })) } catch { Log ('could not schedule the retry: ' + $_.Exception.Message) }
184
+ }
185
+ function Test-ForeignEvent([string]$family, [string]$recordId) {
186
+ # '' = proceed (not an event run, or the event names this package, or it could not be read); else why the event is not ours
187
+ if (-not $recordId -or $recordId -notmatch '^\d{1,20}$') { return '' }
188
+ try {
189
+ $e = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -FilterXPath ('*[System[EventID=751 and EventRecordID=' + $recordId + ']]') -MaxEvents 1 -ErrorAction Stop
190
+ $text = [string]$e.Message + ' ' + [string]$e.ToXml()
191
+ $name = $family.Substring(0, $family.LastIndexOf('_')); $pub = $family.Substring($family.LastIndexOf('_') + 1)
192
+ if (($text.IndexOf($name + '_', [StringComparison]::OrdinalIgnoreCase) -ge 0) -and ($text.IndexOf($pub, [StringComparison]::OrdinalIgnoreCase) -ge 0)) { return '' }
193
+ return ('event 751 record ' + $recordId + ' does not name ' + $family)
194
+ } catch { Log ('event ' + $recordId + ' unreadable, proceeding: ' + $_.Exception.Message); return '' }
143
195
  }
144
196
 
145
197
  # ------------------------------------------------------------------ package state
@@ -264,6 +316,9 @@ if ((Split-Path -Leaf $Dir) -ne 'repair') { Finish 'refused' 2 ('script folder i
264
316
  if ($feedUri -and -not (Test-AllowedUri $feedUri)) { Finish 'refused' 2 ('guard.json feed uri is not https (or http to loopback): ' + $feedUri) }
265
317
  if ($msix -and -not (Test-AllowedUri $msix)) { Finish 'refused' 2 ('guard.json msix uri is not https (or http to loopback): ' + $msix) }
266
318
  $script:Ctx = $family
319
+ $foreign = Test-ForeignEvent $family $EventRecordID
320
+ if ($foreign) { Log ('ignored: ' + $foreign); exit 0 }
321
+ Log ('guard sha256 ' + $ScriptHashes.guardSha256 + ' (lf ' + $ScriptHashes.guardSha256Lf + ')')
267
322
  $mutex = [Threading.Mutex]::new($false, ('Local\' + $family + '-Repair'))
268
323
  if (-not $mutex.WaitOne(0)) { Log 'another repair run is active; exiting (last-result.json left to that run)'; exit 0 }
269
324
  $tmpMsix = ''
@@ -295,6 +350,7 @@ try {
295
350
  }
296
351
  if (-not $expect.name -or -not $expect.publisher) {
297
352
  Log 'FAILED: expected package identity unknown; refusing to install anything'
353
+ $script:LaunchFailure = 'identity unknown: no installer started'
298
354
  Set-Retry $family 'identity unknown'
299
355
  Write-Result 'failed' 1 'expected package identity unknown'
300
356
  exit 1
@@ -312,6 +368,7 @@ try {
312
368
  $feedOk = $true
313
369
  if ($fi.uri) { $msix = $fi.uri } # newer than the one recorded
314
370
  Log ('Add-AppxPackage -AppInstallerFile ' + $feedUri + ' (declares ' + $fi.version + ')')
371
+ $script:Launched = $true
315
372
  try { Add-AppxPackage -AppInstallerFile $feedUri -ErrorAction Stop } catch { Log ('-AppInstallerFile failed: ' + $_.Exception.Message + ' | ' + $_.FullyQualifiedErrorId); $why += ('-AppInstallerFile failed: ' + $_.Exception.Message) }
316
373
  if (Test-Healthy $family) {
317
374
  $now = @(Get-Family $family) | Select-Object -First 1
@@ -334,6 +391,7 @@ try {
334
391
  if ($bad) { Log ('REFUSED msix ' + $msix + ': ' + $bad); $why += ('msix refused: ' + $bad) }
335
392
  else {
336
393
  Log ('Add-AppxPackage -Path ' + $msix + ' (' + $mi.kind + ' ' + $mi.version + ')')
394
+ $script:Launched = $true
337
395
  try { Add-AppxPackage -Path $tmpMsix -ErrorAction Stop } catch { Log ('-Path failed: ' + $_.Exception.Message + ' | ' + $_.FullyQualifiedErrorId); $why += ('-Path failed: ' + $_.Exception.Message) }
338
396
  if (Test-Healthy $family) {
339
397
  $now = @(Get-Family $family) | Select-Object -First 1
@@ -345,6 +403,7 @@ try {
345
403
  }
346
404
  }
347
405
  } else { $why += 'no msix uri recorded' }
406
+ if (-not $script:Launched) { $script:LaunchFailure = 'no installer started: ' + ($why -join ' | ') }
348
407
  $detail = 'FAILED: package still ' + (Get-StateText $family) + '. Reasons: ' + ($why -join ' | ')
349
408
  Set-Retry $family 'repair failed'
350
409
  Write-Result 'failed' 1 $detail
package/msix-signer.mjs CHANGED
@@ -182,19 +182,26 @@ export function signerCertificate(parsed) {
182
182
  return id.serial === parsed.signer.serial && id.issuer.equals(parsed.signer.issuer);
183
183
  } catch { return false; }
184
184
  });
185
- if (match.length !== 1) throw new Error(`signer certificate not found in the signature (${match.length} matches)`);
186
- return new X509Certificate(match[0]);
185
+ // Real Azure Artifact Signing packages embed the signer certificate more than once: drop byte-identical
186
+ // duplicates, then, if distinct certificates still match, keep the one that is not a CA.
187
+ const distinct = [...new Map(match.map((c) => [c.toString("hex"), c])).values()];
188
+ const leaves = distinct.length > 1 ? distinct.filter((c) => { try { return !new X509Certificate(c).ca; } catch { return false; } }) : distinct;
189
+ if (leaves.length !== 1) throw new Error(`signer certificate not found in the signature (${match.length} matches, ${distinct.length} distinct, ${leaves.length} non-CA)`);
190
+ return new X509Certificate(leaves[0]);
187
191
  }
188
192
 
189
193
  // --- subject rendering ---------------------------------------------------------------------------
190
194
  const needsQuote = (v) => /[,+"\\<>;=\r\n]/.test(v) || /^[ #]|\s$/.test(v);
191
- /** Node renders subject as "K=V" lines; join as an RFC 2253-style display string like .NET Subject ("CN=a, O=b"). */
195
+ // Windows (.NET X500DistinguishedName, Get-AuthenticodeSignature, MSIX Identity/@Publisher) names these
196
+ // attributes differently from OpenSSL/Node.
197
+ const WINDOWS_ATTRIBUTE = { ST: "S", emailAddress: "E" };
198
+ /** Render the subject exactly as Windows does: most specific RDN first ("CN=a, O=b, L=c, S=d, C=e"), Windows attribute names. */
192
199
  export function renderSubject(x509) {
193
200
  return String(x509.subject).split("\n").filter(Boolean).map((line) => {
194
201
  const at = line.indexOf("=");
195
202
  const key = line.slice(0, at); const value = line.slice(at + 1);
196
- return `${key}=${needsQuote(value) ? `"${value.replace(/"/g, '""')}"` : value}`;
197
- }).join(", ");
203
+ return `${WINDOWS_ATTRIBUTE[key] ?? key}=${needsQuote(value) ? `"${value.replace(/"/g, '""')}"` : value}`;
204
+ }).reverse().join(", ");
198
205
  }
199
206
 
200
207
  /** Read the signer certificate subject of a signed .msix / .msixbundle from AppxSignature.p7x. Throws MSIX_PUBLISHER_MISMATCH. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.154",
3
+ "version": "0.2.156",
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",
@@ -29,14 +29,6 @@
29
29
  "!standalone-clone-evidence.json"
30
30
  ],
31
31
  "sideEffects": false,
32
- "scripts": {
33
- "test": "node --test --test-concurrency=1 --test-force-exit *.test.mjs",
34
- "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
35
- "test:signer:windows": "node sign-release-manifest.integration.mjs",
36
- "prepublishOnly": "pnpm test && pnpm test:signer:windows && pnpm test:registry-parity",
37
- "doctor:all": "node --test right-suite-contract.test.mjs",
38
- "verify:standalone": "node standalone-clone-verify.mjs"
39
- },
40
32
  "publishConfig": {
41
33
  "registry": "https://registry.npmjs.org/",
42
34
  "access": "public"
@@ -46,8 +38,14 @@
46
38
  "url": "git+https://github.com/Orthic-Labs/rightkit.git",
47
39
  "directory": "packages/release"
48
40
  },
49
- "packageManager": "pnpm@11.24.0",
50
41
  "dependencies": {
51
42
  "smol-toml": "1.9.0"
43
+ },
44
+ "scripts": {
45
+ "test": "node --test --test-concurrency=1 --test-force-exit *.test.mjs",
46
+ "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
47
+ "test:signer:windows": "node sign-release-manifest.integration.mjs",
48
+ "doctor:all": "node --test right-suite-contract.test.mjs",
49
+ "verify:standalone": "node standalone-clone-verify.mjs"
52
50
  }
53
- }
51
+ }
package/release.mjs CHANGED
File without changes
@@ -27,7 +27,7 @@
27
27
  "@rightkit/platform-ui": "0.2.7",
28
28
  "@rightkit/qa": "0.4.7",
29
29
  "@rightkit/qa-chrome": "0.1.5",
30
- "@rightkit/release": "0.2.154",
30
+ "@rightkit/release": "0.2.156",
31
31
  "@rightkit/settings-ui": "0.4.3",
32
32
  "@rightkit/tauri": "0.2.0",
33
33
  "@rightkit/theme": "0.2.3"
@@ -190,7 +190,8 @@
190
190
  "0.2.148",
191
191
  "0.2.149",
192
192
  "0.2.150",
193
- "0.2.151"
193
+ "0.2.151",
194
+ "0.2.155"
194
195
  ],
195
196
  "@rightkit/qa": [
196
197
  "0.1.0",
@@ -443,7 +444,7 @@
443
444
  "@rightkit/platform-ui": "0.2.7",
444
445
  "@rightkit/qa": "0.4.7",
445
446
  "@rightkit/qa-chrome": "0.1.5",
446
- "@rightkit/release": "0.2.151",
447
+ "@rightkit/release": "0.2.155",
447
448
  "@rightkit/settings-ui": "0.4.3",
448
449
  "@rightkit/shell": "0.1.1",
449
450
  "@rightkit/tauri": "0.2.0",
package/upload-large.mjs CHANGED
File without changes