@rightkit/git 0.2.31 → 0.2.32

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/README.md CHANGED
@@ -51,6 +51,35 @@ Set `"ciAllOs": true` to run the rust-hybrid `ci` gate on every `matrix.os` runn
51
51
 
52
52
  Omit the key, or set it to `false`, and the rendered `ci.yml` keeps one runner, preferring the first `windows-*` entry. `ciAllOs: true` is rejected for other profiles.
53
53
 
54
+ ## Incremental CI and dev artifacts (rust-hybrid)
55
+
56
+ Both keys are opt-in; unset, the rendered `ci.yml` is byte-identical.
57
+
58
+ `"ciIncremental": true` switches Rust caching to `cache-workspace-crates: true`, sets `CARGO_INCREMENTAL=1` for the ci gate, and caches `~/Library/Developer/Xcode/DerivedData` on macOS legs (key: runner + `Cargo.lock` + `Package.resolved` + `project.pbxproj` hash).
59
+
60
+ `devArtifact` publishes fast development builds as workflow artifacts, separate from the release-candidate lane. It runs on push to the default branch only, never on pull requests.
61
+
62
+ ```json
63
+ {
64
+ "profile": "rust-hybrid",
65
+ "matrix": { "os": ["xcode-27", "windows-2025"], "node": ["26.8.1"] },
66
+ "ciAllOs": true,
67
+ "ciIncremental": true,
68
+ "devArtifact": {
69
+ "mac": "sign",
70
+ "windows": "unsigned",
71
+ "retentionDays": 7,
72
+ "macAppPath": "target/release/bundle/macos/Pulse.app",
73
+ "windowsGlob": "target/release/*.exe"
74
+ }
75
+ }
76
+ ```
77
+
78
+ - `mac: "sign"` needs `macAppPath` (the built `.app`, repository-relative) and a macOS runner in the ci gate (`ciAllOs`). The unsigned app is zipped with `ditto` and handed to a separate `dev-mac-sign` job that installs the Developer ID identity from the `release` environment secrets, runs `codesign --force --deep --strict --options runtime`, verifies, zips and uploads `dev-mac-<sha>`. No notarization, admission or ownership gate. Optional `macEntitlements` passes `--entitlements`.
79
+ - `windows: "unsigned"` needs `windowsGlob` and a `windows-*` runner in the ci gate; matched files upload as `dev-windows-<sha>`.
80
+ - The `dev-mac-sign` job uses the `release` environment, so that environment's protection rules (required reviewers, branch policy) apply. The job only exists for pushes to the default branch.
81
+ - Install a mac dev build with `right-release install-app --dev --zip <file>` (Developer ID-signed, not notarized).
82
+
54
83
  ## Entry points
55
84
 
56
85
  No importable API. The package has no `main` and no `exports` map. The supported surface is the `right-git` bin.
package/manifest.mjs CHANGED
@@ -62,6 +62,8 @@ const TOP_LEVEL_KEYS = new Set([
62
62
  "qaEvidencePath",
63
63
  "qaEvidenceRetentionDays",
64
64
  "ciAllOs",
65
+ "ciIncremental",
66
+ "devArtifact",
65
67
  "candidate",
66
68
  "ownership",
67
69
  ]);
@@ -197,6 +199,17 @@ export function validateManifest(manifest) {
197
199
  }
198
200
  }
199
201
 
202
+ // ciIncremental (rust-hybrid ci only): opt-in incremental Rust + DerivedData caching.
203
+ if (manifest.ciIncremental !== undefined) {
204
+ if (typeof manifest.ciIncremental !== "boolean") {
205
+ errors.push("ciIncremental must be a boolean");
206
+ } else if (manifest.ciIncremental === true && profile !== "rust-hybrid") {
207
+ errors.push("ciIncremental is only valid for profile 'rust-hybrid'");
208
+ }
209
+ }
210
+
211
+ validateDevArtifact(manifest, profile, errors);
212
+
200
213
  const rustCache = manifest.rustCache;
201
214
  if (rustCache !== undefined) {
202
215
  if (profile !== "rust-hybrid") {
@@ -536,6 +549,208 @@ function releaseEnvironment(manifest, role) {
536
549
  return typeof configured === "string" && configured.length > 0 ? configured : "release";
537
550
  }
538
551
 
552
+ // Dev artifacts (rust-hybrid ci only): fast, non-release builds on push to the default branch.
553
+ const DEV_ARTIFACT_DEFAULT_RETENTION_DAYS = 7;
554
+ const DEV_ARTIFACT_MAX_RETENTION_DAYS = 90;
555
+ const DEV_MAC_PATH_RE = /^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/ -]*$/;
556
+ const DEV_WINDOWS_GLOB_RE = /^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/*?-]*$/;
557
+ const CACHE_ACTION_PIN = "actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4";
558
+
559
+ function effectiveCiRunners(manifest) {
560
+ const os = manifest.matrix?.os;
561
+ if (!Array.isArray(os) || os.length === 0) return [];
562
+ if (manifest.ciAllOs === true) return os;
563
+ return [os.find((runner) => typeof runner === "string" && runner.startsWith("windows-")) ?? os[0]];
564
+ }
565
+
566
+ function validateDevArtifact(manifest, profile, errors) {
567
+ const dev = manifest.devArtifact;
568
+ if (dev === undefined) return;
569
+ if (profile !== "rust-hybrid") {
570
+ errors.push("devArtifact is only valid for profile 'rust-hybrid'");
571
+ return;
572
+ }
573
+ if (dev === null || typeof dev !== "object" || Array.isArray(dev)) {
574
+ errors.push("devArtifact must be an object");
575
+ return;
576
+ }
577
+ for (const key of Object.keys(dev)) {
578
+ if (!["mac", "windows", "retentionDays", "macAppPath", "macEntitlements", "windowsGlob"].includes(key)) errors.push(`unknown devArtifact field: ${key}`);
579
+ }
580
+ if (dev.mac !== undefined && dev.mac !== "sign" && dev.mac !== "none") errors.push("devArtifact.mac must be 'sign' or 'none'");
581
+ if (dev.windows !== undefined && dev.windows !== "unsigned" && dev.windows !== "none") errors.push("devArtifact.windows must be 'unsigned' or 'none'");
582
+ if (dev.mac !== "sign" && dev.windows !== "unsigned") errors.push("devArtifact requires mac 'sign' and/or windows 'unsigned'");
583
+ if (dev.retentionDays !== undefined && (!Number.isInteger(dev.retentionDays) || dev.retentionDays < 1 || dev.retentionDays > DEV_ARTIFACT_MAX_RETENTION_DAYS)) {
584
+ errors.push(`devArtifact.retentionDays must be an integer from 1 to ${DEV_ARTIFACT_MAX_RETENTION_DAYS}`);
585
+ }
586
+ const runners = effectiveCiRunners(manifest).filter((runner) => typeof runner === "string");
587
+ if (dev.mac === "sign") {
588
+ if (typeof dev.macAppPath !== "string" || !DEV_MAC_PATH_RE.test(dev.macAppPath) || !dev.macAppPath.endsWith(".app")) {
589
+ errors.push("devArtifact.macAppPath must be a safe repository-relative path ending in .app when mac is 'sign'");
590
+ }
591
+ if (dev.macEntitlements !== undefined && (typeof dev.macEntitlements !== "string" || !GATE_PATH_RE.test(dev.macEntitlements))) {
592
+ errors.push("devArtifact.macEntitlements must be a safe repository-relative path");
593
+ }
594
+ if (!runners.some((runner) => MACOS_RUNNER_RE.test(runner))) {
595
+ errors.push("devArtifact.mac 'sign' needs the ci gate to run on a macOS runner (set ciAllOs and include a macos-* or xcode-* runner in matrix.os)");
596
+ }
597
+ } else if (dev.macAppPath !== undefined || dev.macEntitlements !== undefined) {
598
+ errors.push("devArtifact.macAppPath and macEntitlements require mac 'sign'");
599
+ }
600
+ if (dev.windows === "unsigned") {
601
+ if (typeof dev.windowsGlob !== "string" || !DEV_WINDOWS_GLOB_RE.test(dev.windowsGlob)) {
602
+ errors.push("devArtifact.windowsGlob must be a safe repository-relative glob when windows is 'unsigned'");
603
+ }
604
+ if (!runners.some((runner) => /^windows-/i.test(runner))) {
605
+ errors.push("devArtifact.windows 'unsigned' needs the ci gate to run on a windows-* runner");
606
+ }
607
+ } else if (dev.windowsGlob !== undefined) {
608
+ errors.push("devArtifact.windowsGlob requires windows 'unsigned'");
609
+ }
610
+ }
611
+
612
+ const DEV_PUSH_CONDITION = "github.event_name == 'push' && github.ref_name == github.event.repository.default_branch";
613
+
614
+ /** ciIncremental: Xcode DerivedData cache step (leading newline; "" when unset). */
615
+ function derivedDataStep(manifest) {
616
+ if (manifest.ciIncremental !== true) return "";
617
+ return [
618
+ "",
619
+ " - name: Cache Xcode DerivedData",
620
+ " if: ${{ runner.os == 'macOS' }}",
621
+ ` uses: ${CACHE_ACTION_PIN}`,
622
+ " with:",
623
+ " path: ~/Library/Developer/Xcode/DerivedData",
624
+ " key: ${{ runner.os }}-${{ matrix.os }}-deriveddata-${{ hashFiles('**/Cargo.lock', '**/Package.resolved', '**/*.xcodeproj/project.pbxproj') }}",
625
+ " restore-keys: |",
626
+ " ${{ runner.os }}-${{ matrix.os }}-deriveddata-",
627
+ ].join("\n");
628
+ }
629
+
630
+ /**
631
+ * Dev artifact steps appended to the ci job (unsigned, no secrets) plus the
632
+ * separate signing job. Returns "" when devArtifact is unset.
633
+ */
634
+ export function devArtifactCi(manifest) {
635
+ const dev = manifest.devArtifact;
636
+ if (dev === undefined) return "";
637
+ const retention = dev.retentionDays ?? DEV_ARTIFACT_DEFAULT_RETENTION_DAYS;
638
+ const parts = [];
639
+ if (dev.windows === "unsigned") {
640
+ parts.push([
641
+ " - name: Upload unsigned dev build (Windows)",
642
+ ` if: \${{ ${DEV_PUSH_CONDITION} && runner.os == 'Windows' }}`,
643
+ ` uses: ${UPLOAD_ARTIFACT_PIN}`,
644
+ " with:",
645
+ " name: dev-windows-${{ github.sha }}",
646
+ ` path: ${dev.windowsGlob}`,
647
+ ` retention-days: ${retention}`,
648
+ " if-no-files-found: error",
649
+ "",
650
+ ].join("\n"));
651
+ }
652
+ if (dev.mac === "sign") {
653
+ parts.push([
654
+ " - name: Package unsigned dev app (macOS)",
655
+ ` if: \${{ ${DEV_PUSH_CONDITION} && runner.os == 'macOS' }}`,
656
+ " shell: bash",
657
+ " env:",
658
+ ` DEV_APP_PATH: ${JSON.stringify(dev.macAppPath)}`,
659
+ " run: |",
660
+ " set -euo pipefail",
661
+ " test -d \"$DEV_APP_PATH\"",
662
+ " mkdir -p \"$RUNNER_TEMP/dev-unsigned\"",
663
+ " ditto -c -k --keepParent \"$DEV_APP_PATH\" \"$RUNNER_TEMP/dev-unsigned/app.zip\"",
664
+ " - name: Upload unsigned dev app for signing (macOS)",
665
+ ` if: \${{ ${DEV_PUSH_CONDITION} && runner.os == 'macOS' }}`,
666
+ ` uses: ${UPLOAD_ARTIFACT_PIN}`,
667
+ " with:",
668
+ " name: dev-unsigned-mac-${{ github.sha }}",
669
+ " path: ${{ runner.temp }}/dev-unsigned/app.zip",
670
+ " retention-days: 1",
671
+ " if-no-files-found: error",
672
+ "",
673
+ ].join("\n"));
674
+ }
675
+ let text = parts.join("");
676
+ if (dev.mac === "sign") text += devMacSigningJob(manifest, dev, retention);
677
+ return text;
678
+ }
679
+
680
+ function devMacSigningJob(manifest, dev, retention) {
681
+ const runner = effectiveCiRunners(manifest).find((os) => MACOS_RUNNER_RE.test(os));
682
+ const entitlements = dev.macEntitlements ? ` --entitlements "$GITHUB_WORKSPACE/${dev.macEntitlements}"` : "";
683
+ const checkout = dev.macEntitlements
684
+ ? ` - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
685
+ with:
686
+ ref: \${{ github.sha }}
687
+ persist-credentials: false
688
+ `
689
+ : "";
690
+ return `
691
+ dev-mac-sign:
692
+ # Push to the default branch only. The release environment's protection
693
+ # rules (required reviewers, branch policy) apply to this job.
694
+ needs: [ci]
695
+ if: \${{ ${DEV_PUSH_CONDITION} }}
696
+ environment: ${releaseEnvironment(manifest, "signing")}
697
+ runs-on: ${runner}
698
+ permissions:
699
+ contents: read
700
+ steps:
701
+ ${checkout} - name: Download unsigned dev app
702
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
703
+ with:
704
+ name: dev-unsigned-mac-\${{ github.sha }}
705
+ path: \${{ runner.temp }}/dev-unsigned
706
+ - name: Install Apple signing identity in temporary keychain
707
+ shell: bash
708
+ env:
709
+ APPLE_CERTIFICATE_BASE64: \${{ secrets.APPLE_CERTIFICATE_BASE64 }}
710
+ APPLE_CERTIFICATE_PASSWORD: \${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
711
+ APPLE_KEYCHAIN_PASSWORD: \${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
712
+ run: |
713
+ set -euo pipefail
714
+ certificate_path="$RUNNER_TEMP/apple-signing.p12"
715
+ keychain_path="$RUNNER_TEMP/apple-signing.keychain-db"
716
+ printf '%s' "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$certificate_path"
717
+ security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$keychain_path"
718
+ security set-keychain-settings -lut 21600 "$keychain_path"
719
+ security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$keychain_path"
720
+ security import "$certificate_path" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path"
721
+ security set-key-partition-list -S apple-tool:,apple: -k "$APPLE_KEYCHAIN_PASSWORD" "$keychain_path"
722
+ security list-keychain -d user -s "$keychain_path"
723
+ - name: Developer ID sign dev app (no notarization)
724
+ shell: bash
725
+ env:
726
+ APPLE_DEVELOPER_ID: \${{ vars.APPLE_DEVELOPER_ID }}
727
+ run: |
728
+ set -euo pipefail
729
+ work="$RUNNER_TEMP/dev-sign"
730
+ rm -rf "$work" && mkdir -p "$work"
731
+ ditto -x -k "$RUNNER_TEMP/dev-unsigned/app.zip" "$work"
732
+ app="$(find "$work" -maxdepth 1 -name '*.app' -print -quit)"
733
+ test -n "$app"
734
+ codesign --force --deep --strict --options runtime --timestamp${entitlements} --sign "$APPLE_DEVELOPER_ID" "$app"
735
+ codesign --verify --deep --strict "$app"
736
+ mkdir -p "$RUNNER_TEMP/dev-signed"
737
+ ditto -c -k --keepParent "$app" "$RUNNER_TEMP/dev-signed/$(basename "$app").zip"
738
+ - name: Upload signed (not notarized) dev app
739
+ uses: ${UPLOAD_ARTIFACT_PIN}
740
+ with:
741
+ name: dev-mac-\${{ github.sha }}
742
+ path: \${{ runner.temp }}/dev-signed/*.zip
743
+ retention-days: ${retention}
744
+ if-no-files-found: error
745
+ - name: Remove Apple signing material
746
+ if: \${{ always() }}
747
+ shell: bash
748
+ run: |
749
+ security delete-keychain "$RUNNER_TEMP/apple-signing.keychain-db" 2>/dev/null || true
750
+ rm -f "$RUNNER_TEMP/apple-signing.p12"
751
+ `;
752
+ }
753
+
539
754
  function windowsSigningJob(manifest, pm, artifactPrefix) {
540
755
  if (manifest.candidate?.signWindows !== true) return "";
541
756
  const nodeVersion = String(manifest.matrix.node[0]);
@@ -1102,7 +1317,10 @@ export function buildRenderContext(manifest, repoPath, lane = "ci") {
1102
1317
  MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
1103
1318
  RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
1104
1319
  OWNERSHIP_STEP: ownershipStep(manifest, lane),
1105
- QA_EVIDENCE_STEP: qaEvidenceStep(manifest),
1320
+ QA_EVIDENCE_STEP: qaEvidenceStep(manifest) + devArtifactCi(manifest),
1321
+ CI_CACHE_WORKSPACE_CRATES: manifest.ciIncremental === true ? "true" : "false",
1322
+ CI_CARGO_INCREMENTAL: manifest.ciIncremental === true ? "1" : "0",
1323
+ CI_DERIVED_DATA_STEP: derivedDataStep(manifest),
1106
1324
  };
1107
1325
 
1108
1326
  // Every key below is populated ONLY when its source manifest section is
@@ -133,6 +133,24 @@
133
133
  "default": false,
134
134
  "description": "rust-hybrid only. When true, the ci job runs the gate on every matrix.os runner in declared order (fail-fast: false). When false or unset, the ci job uses one runner (preferring the first windows-* entry, else the first matrix.os entry)."
135
135
  },
136
+ "ciIncremental": {
137
+ "type": "boolean",
138
+ "default": false,
139
+ "description": "rust-hybrid only. When true, the ci job uses Swatinem/rust-cache with cache-workspace-crates, CARGO_INCREMENTAL=1, and caches Xcode DerivedData on macOS legs."
140
+ },
141
+ "devArtifact": {
142
+ "type": "object",
143
+ "additionalProperties": false,
144
+ "description": "rust-hybrid only. Dev builds on push to the default branch (never PRs). mac 'sign' Developer ID-signs (no notarization) in a separate job using the release environment; windows 'unsigned' uploads built files as-is.",
145
+ "properties": {
146
+ "mac": { "enum": ["sign", "none"] },
147
+ "windows": { "enum": ["unsigned", "none"] },
148
+ "retentionDays": { "type": "integer", "minimum": 1, "maximum": 90, "default": 7 },
149
+ "macAppPath": { "type": "string", "pattern": "^(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9._/ -]*\\.app$", "description": "Repository-relative path of the built .app (required when mac is 'sign')." },
150
+ "macEntitlements": { "type": "string", "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$", "description": "Optional repository-relative entitlements plist for codesign." },
151
+ "windowsGlob": { "type": "string", "pattern": "^(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9._/*?-]*$", "description": "Repository-relative glob of built files to upload (required when windows is 'unsigned')." }
152
+ }
153
+ },
136
154
  "candidate": {
137
155
  "type": "object",
138
156
  "required": ["buildScript", "checkScript", "admissionScript", "stageSummaryScript", "artifactRoot", "os"],
package/manifest.test.mjs CHANGED
@@ -418,3 +418,42 @@ test("ciAllOs is an optional boolean, rust-hybrid only", () => {
418
418
  assert.equal(validateManifest({ ...VALID_MINIMAL, ciAllOs: true }).valid, false, "node profile rejects ciAllOs true");
419
419
  assert.equal(validateManifest({ ...VALID_MINIMAL, ciAllOs: false }).valid, true, "false is accepted for any profile");
420
420
  });
421
+
422
+ test("ciIncremental is an optional boolean, rust-hybrid only", () => {
423
+ const valid = { ...VALID_MINIMAL, profile: "rust-hybrid", lanes: ["ci"], gate: { path: "scripts/gate.sh" } };
424
+ assert.equal(validateManifest({ ...valid, ciIncremental: true }).valid, true);
425
+ assert.equal(validateManifest({ ...valid, ciIncremental: false }).valid, true);
426
+ assert.equal(validateManifest({ ...valid, ciIncremental: "yes" }).valid, false);
427
+ assert.equal(validateManifest({ ...VALID_MINIMAL, ciIncremental: true }).valid, false);
428
+ });
429
+
430
+ test("devArtifact validates keys, paths, and runner coverage", () => {
431
+ const base = {
432
+ ...VALID_MINIMAL,
433
+ profile: "rust-hybrid",
434
+ lanes: ["ci"],
435
+ gate: { path: "scripts/gate.sh" },
436
+ matrix: { os: ["xcode-27", "windows-2025"], node: ["26.8.1"] },
437
+ ciAllOs: true,
438
+ };
439
+ const full = { mac: "sign", windows: "unsigned", macAppPath: "target/release/bundle/macos/Pulse.app", windowsGlob: "target/release/*.exe", retentionDays: 7 };
440
+ assert.equal(validateManifest({ ...base, devArtifact: full }).valid, true);
441
+ assert.equal(validateManifest({ ...base, devArtifact: { windows: "unsigned", windowsGlob: "dist/*.exe" } }).valid, true);
442
+
443
+ const bad = (devArtifact, manifest = base) => validateManifest({ ...manifest, devArtifact }).valid;
444
+ assert.equal(bad({ mac: "none", windows: "none" }), false, "needs at least one active target");
445
+ assert.equal(bad({ ...full, macAppPath: undefined }), false, "mac sign needs macAppPath");
446
+ assert.equal(bad({ ...full, windowsGlob: undefined }), false, "windows needs windowsGlob");
447
+ assert.equal(bad({ ...full, macAppPath: "../Pulse.app" }), false);
448
+ assert.equal(bad({ ...full, macAppPath: "target/Pulse" }), false, "must end in .app");
449
+ assert.equal(bad({ ...full, macAppPath: "a;b.app" }), false);
450
+ assert.equal(bad({ ...full, windowsGlob: "${{ x }}" }), false);
451
+ assert.equal(bad({ ...full, retentionDays: 0 }), false);
452
+ assert.equal(bad({ ...full, retentionDays: 91 }), false);
453
+ assert.equal(bad({ ...full, mac: "notarize" }), false);
454
+ assert.equal(bad({ ...full, extra: 1 }), false);
455
+ assert.equal(bad({ windows: "unsigned", windowsGlob: "a/*.exe", macAppPath: "x.app" }), false, "macAppPath requires mac sign");
456
+ assert.equal(bad(full, { ...base, ciAllOs: false }), false, "single windows runner cannot build the mac app");
457
+ assert.equal(bad(full, { ...VALID_MINIMAL, devArtifact: full }), false);
458
+ assert.equal(validateManifest({ ...VALID_MINIMAL, devArtifact: full }).valid, false, "node profile rejects devArtifact");
459
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/git",
3
- "version": "0.2.31",
3
+ "version": "0.2.32",
4
4
  "description": "Public-repo-only GitHub Actions workflow layer: templates, visibility gate, sync/drift/adopt/uninstall for right-git.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -21,8 +21,8 @@
21
21
  },
22
22
  "repository": {
23
23
  "type": "git",
24
- "url": "git+https://github.com/bogusyogi/claude.git",
25
- "directory": "tools/rightkit/packages/git"
24
+ "url": "git+https://github.com/bogusyogi/rightkit.git",
25
+ "directory": "packages/git"
26
26
  },
27
27
  "scripts": {
28
28
  "test": "node --test *.test.mjs"
package/render.test.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import fs from "node:fs";
4
+ import os from "node:os";
4
5
  import path from "node:path";
6
+ import { spawnSync } from "node:child_process";
5
7
  import { fileURLToPath } from "node:url";
6
8
  import { renderTemplate, MANAGED_MARKER } from "./render.mjs";
7
9
  import { buildRenderContext, LANE_FILENAMES, KNOWN_LANES, validateManifest } from "./manifest.mjs";
@@ -879,3 +881,90 @@ test("rust-hybrid ci uploads QA evidence after the gate only when qaEvidencePath
879
881
  const custom = renderTemplate(template, buildRenderContext({ ...structuredClone(base), qaEvidencePath: "pulse-hub-qa", qaEvidenceRetentionDays: 30 }, "/repos/widgets"));
880
882
  assert.match(custom, /retention-days: 30\n/);
881
883
  });
884
+
885
+ const INCREMENTAL_BASE = {
886
+ ...FULL_MANIFEST,
887
+ lanes: ["ci"],
888
+ profile: "rust-hybrid",
889
+ gate: { path: "scripts/gate.sh" },
890
+ matrix: { os: ["xcode-27", "windows-2025"], node: ["26.8.1"] },
891
+ ciAllOs: true,
892
+ };
893
+ const renderRustCi = (manifest) => renderTemplate(
894
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8"),
895
+ buildRenderContext(structuredClone(manifest), "/repos/pulse"),
896
+ );
897
+
898
+ test("ciIncremental unset or false renders the legacy cache settings; true opts in", () => {
899
+ const unset = renderRustCi(INCREMENTAL_BASE);
900
+ assert.match(unset, /cache-workspace-crates: false/);
901
+ assert.match(unset, /CARGO_INCREMENTAL: "0"/);
902
+ assert.doesNotMatch(unset, /DerivedData|actions\/cache@|dev-mac|dev-windows/);
903
+ assert.equal(renderRustCi({ ...INCREMENTAL_BASE, ciIncremental: false }), unset);
904
+
905
+ const on = renderRustCi({ ...INCREMENTAL_BASE, ciIncremental: true });
906
+ assert.match(on, /cache-workspace-crates: true/);
907
+ assert.match(on, /CARGO_INCREMENTAL: "1"/);
908
+ assert.match(on, /Cache Xcode DerivedData\n if: \$\{\{ runner\.os == 'macOS' \}\}\n uses: actions\/cache@[0-9a-f]{40} # v4\.2\.4/);
909
+ assert.match(on, /hashFiles\('\*\*\/Cargo\.lock', '\*\*\/Package\.resolved'/);
910
+ });
911
+
912
+ const DEV_MANIFEST = {
913
+ ...INCREMENTAL_BASE,
914
+ devArtifact: {
915
+ mac: "sign",
916
+ windows: "unsigned",
917
+ retentionDays: 5,
918
+ macAppPath: "target/release/bundle/macos/Pulse.app",
919
+ windowsGlob: "target/release/*.exe",
920
+ },
921
+ };
922
+
923
+ test("devArtifact renders push-to-default-branch dev steps and a signing job; unset adds nothing", () => {
924
+ const out = renderRustCi(DEV_MANIFEST);
925
+ assert.match(out, /name: dev-windows-\$\{\{ github\.sha \}\}\n path: target\/release\/\*\.exe\n retention-days: 5/);
926
+ assert.match(out, /DEV_APP_PATH: "target\/release\/bundle\/macos\/Pulse\.app"/);
927
+ assert.match(out, /dev-mac-sign:\n[\s\S]*needs: \[ci\]\n if: \$\{\{ github\.event_name == 'push' && github\.ref_name == github\.event\.repository\.default_branch \}\}\n environment: release\n runs-on: xcode-27/);
928
+ assert.match(out, /codesign --force --deep --strict --options runtime --timestamp --sign "\$APPLE_DEVELOPER_ID"/);
929
+ assert.match(out, /name: dev-mac-\$\{\{ github\.sha \}\}/);
930
+ assert.doesNotMatch(out, /notarytool|APPLE_API_KEY|stapler/);
931
+ // Signing material never reaches the PR-reachable ci job.
932
+ assert.doesNotMatch(out.split(" dev-mac-sign:")[0], /secrets\./);
933
+ // mac/windows only render the legs they declare.
934
+ const winOnly = renderRustCi({ ...DEV_MANIFEST, devArtifact: { windows: "unsigned", windowsGlob: "dist/*.exe" } });
935
+ assert.doesNotMatch(winOnly, /dev-mac-sign|dev-unsigned-mac/);
936
+ assert.match(winOnly, /retention-days: 7/);
937
+ assert.equal(renderRustCi({ ...INCREMENTAL_BASE, devArtifact: undefined }), renderRustCi(INCREMENTAL_BASE));
938
+ });
939
+
940
+ test("devArtifact entitlements add a checkout and --entitlements", () => {
941
+ const out = renderRustCi({ ...DEV_MANIFEST, devArtifact: { ...DEV_MANIFEST.devArtifact, macEntitlements: "build/pulse.entitlements" } });
942
+ assert.match(out, /--entitlements "\$GITHUB_WORKSPACE\/build\/pulse\.entitlements"/);
943
+ assert.match(out.split(" dev-mac-sign:")[1], /actions\/checkout@/);
944
+ });
945
+
946
+ test("rendered ciIncremental + devArtifact workflows pass actionlint", (t) => {
947
+ const bin = spawnSync("actionlint", ["-version"], { encoding: "utf8" });
948
+ if (bin.error) return t.skip("actionlint not installed");
949
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "right-git-actionlint-"));
950
+ try {
951
+ fs.mkdirSync(path.join(dir, ".github", "workflows"), { recursive: true });
952
+ fs.writeFileSync(path.join(dir, ".github", "actionlint.yaml"), "self-hosted-runner:\n labels:\n - xcode-*\n");
953
+ const samples = {
954
+ "ci-default.yml": INCREMENTAL_BASE,
955
+ "ci-incremental.yml": { ...INCREMENTAL_BASE, ciIncremental: true },
956
+ "ci-dev.yml": { ...DEV_MANIFEST, ciIncremental: true },
957
+ "ci-dev-entitlements.yml": { ...DEV_MANIFEST, devArtifact: { ...DEV_MANIFEST.devArtifact, macEntitlements: "build/pulse.entitlements" } },
958
+ };
959
+ const files = [];
960
+ for (const [name, manifest] of Object.entries(samples)) {
961
+ const file = path.join(dir, ".github", "workflows", name);
962
+ fs.writeFileSync(file, renderRustCi(manifest));
963
+ files.push(file);
964
+ }
965
+ const result = spawnSync("actionlint", ["-shellcheck=", "-pyflakes=", "-config-file", path.join(dir, ".github", "actionlint.yaml"), ...files], { encoding: "utf8" });
966
+ assert.equal(result.status, 0, result.stdout + result.stderr);
967
+ } finally {
968
+ fs.rmSync(dir, { recursive: true, force: true });
969
+ }
970
+ });
@@ -63,7 +63,7 @@ ${CI_MATRIX_OS}
63
63
  package-manager-cache: false
64
64
  ${PACKAGE_MANAGER_SETUP}
65
65
  - run: ${INSTALL_COMMAND}
66
- ${OWNERSHIP_STEP}
66
+ ${OWNERSHIP_STEP}${CI_DERIVED_DATA_STEP}
67
67
  - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0
68
68
  if: steps.rust-changes.outputs.required == 'true'
69
69
  with:
@@ -75,7 +75,7 @@ ${OWNERSHIP_STEP}
75
75
  ${RUST_CACHE_WORKSPACES}
76
76
  shared-key: ${ARTIFACT_PREFIX}-rust
77
77
  add-job-id-key: false
78
- cache-workspace-crates: false
78
+ cache-workspace-crates: ${CI_CACHE_WORKSPACE_CRATES}
79
79
  cache-on-failure: true
80
80
  save-if: ${{ github.event_name != 'pull_request' }}
81
81
  - uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
@@ -88,7 +88,7 @@ ${RUST_CACHE_WORKSPACES}
88
88
  RIGHT_GIT_RUST_REQUIRED: ${{ steps.rust-changes.outputs.required }}
89
89
  SCCACHE_GHA_ENABLED: "true"
90
90
  RUSTC_WRAPPER: ${{ steps.rust-changes.outputs.required == 'true' && 'sccache' || '' }}
91
- CARGO_INCREMENTAL: "0"
91
+ CARGO_INCREMENTAL: "${CI_CARGO_INCREMENTAL}"
92
92
  CARGO_PROFILE_DEV_DEBUG: "0"
93
93
  CARGO_PROFILE_TEST_DEBUG: "0"
94
94
  ${QA_EVIDENCE_STEP}