@rightkit/git 0.1.0

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/sync.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // Render templates for a target repo's manifest and install them into
2
+ // <repo>/.github/workflows/, showing a diff before applying (DESIGN.md §4).
3
+ // Refuses private/internal/unknown-visibility repos — no override (I1).
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { assertPublic } from "./policy/visibility-gate.mjs";
9
+ import { loadManifest, LANE_FILENAMES, buildRenderContext } from "./manifest.mjs";
10
+ import { renderTemplate, MANAGED_MARKER } from "./render.mjs";
11
+
12
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
13
+ const TEMPLATES_DIR = path.join(packageRoot, "templates");
14
+ const WORKFLOWS_SUBDIR = path.join(".github", "workflows");
15
+
16
+ /** Render every lane declared in the manifest. Returns { lane, filename, content }[]. */
17
+ export function renderLanes(manifest, repoPath) {
18
+ const context = buildRenderContext(manifest, repoPath);
19
+ return manifest.lanes.map((lane) => {
20
+ const filename = LANE_FILENAMES[lane];
21
+ const templatePath = path.join(TEMPLATES_DIR, filename);
22
+ const templateText = fs.readFileSync(templatePath, "utf8");
23
+ return { lane, filename, content: renderTemplate(templateText, context) };
24
+ });
25
+ }
26
+
27
+ /** List files in <repo>/.github/workflows that carry the right-git managed marker. */
28
+ export function listManagedFiles(repoPath) {
29
+ const dir = path.join(repoPath, WORKFLOWS_SUBDIR);
30
+ let entries;
31
+ try {
32
+ entries = fs.readdirSync(dir);
33
+ } catch {
34
+ return [];
35
+ }
36
+ return entries.filter((name) => {
37
+ if (!name.endsWith(".yml") && !name.endsWith(".yaml")) return false;
38
+ try {
39
+ const content = fs.readFileSync(path.join(dir, name), "utf8");
40
+ return content.startsWith(MANAGED_MARKER);
41
+ } catch {
42
+ return false;
43
+ }
44
+ });
45
+ }
46
+
47
+ /** Minimal line-based diff: common prefix/suffix trimmed, middle shown as -/+ . */
48
+ export function lineDiff(oldText, newText) {
49
+ const oldLines = oldText == null ? [] : oldText.split("\n");
50
+ const newLines = newText.split("\n");
51
+ let start = 0;
52
+ while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) start++;
53
+ let oldEnd = oldLines.length;
54
+ let newEnd = newLines.length;
55
+ while (oldEnd > start && newEnd > start && oldLines[oldEnd - 1] === newLines[newEnd - 1]) {
56
+ oldEnd--;
57
+ newEnd--;
58
+ }
59
+ const out = [];
60
+ for (let i = start; i < oldEnd; i++) out.push(`-${oldLines[i]}`);
61
+ for (let i = start; i < newEnd; i++) out.push(`+${newLines[i]}`);
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Compute the sync plan without writing anything: visibility check, manifest
67
+ * load, per-lane render, diff against what's on disk, plus removal of any
68
+ * managed file whose lane fell out of the manifest.
69
+ * Returns a typed result — never throws.
70
+ */
71
+ export async function planSync(repoPath, deps = {}) {
72
+ const visibility = await assertPublic(repoPath, deps);
73
+ if (!visibility.allowed) {
74
+ return { ok: false, stage: "visibility", errors: [visibility.reason], changes: [] };
75
+ }
76
+
77
+ const loaded = loadManifest(repoPath);
78
+ if (!loaded.ok) {
79
+ return { ok: false, stage: "manifest", errors: loaded.errors, changes: [] };
80
+ }
81
+
82
+ const rendered = renderLanes(loaded.manifest, repoPath);
83
+ const wantedFilenames = new Set(rendered.map((r) => r.filename));
84
+ const changes = [];
85
+
86
+ for (const { lane, filename, content } of rendered) {
87
+ const target = path.join(repoPath, WORKFLOWS_SUBDIR, filename);
88
+ let existing = null;
89
+ try {
90
+ existing = fs.readFileSync(target, "utf8");
91
+ } catch {
92
+ // not installed yet
93
+ }
94
+ if (existing === content) {
95
+ changes.push({ lane, filename, action: "unchanged", diff: [] });
96
+ } else {
97
+ changes.push({ lane, filename, action: existing === null ? "create" : "update", diff: lineDiff(existing, content), content });
98
+ }
99
+ }
100
+
101
+ for (const managedFilename of listManagedFiles(repoPath)) {
102
+ if (!wantedFilenames.has(managedFilename)) {
103
+ changes.push({ lane: null, filename: managedFilename, action: "remove", diff: [] });
104
+ }
105
+ }
106
+
107
+ return { ok: true, stage: "planned", errors: [], changes };
108
+ }
109
+
110
+ /** Apply a plan produced by planSync: write/update/remove files on disk. */
111
+ export function applyPlan(repoPath, plan) {
112
+ const dir = path.join(repoPath, WORKFLOWS_SUBDIR);
113
+ fs.mkdirSync(dir, { recursive: true });
114
+ for (const change of plan.changes) {
115
+ const target = path.join(dir, change.filename);
116
+ if (change.action === "create" || change.action === "update") {
117
+ fs.writeFileSync(target, change.content, "utf8");
118
+ } else if (change.action === "remove") {
119
+ fs.rmSync(target, { force: true });
120
+ }
121
+ }
122
+ }
123
+
124
+ /** Full sync: plan, print diff, apply unless dryRun. Returns the plan. */
125
+ export async function syncCommand(repoPath, { dryRun = false, deps = {} } = {}) {
126
+ const plan = await planSync(repoPath, deps);
127
+ if (!plan.ok) return plan;
128
+
129
+ for (const change of plan.changes) {
130
+ if (change.action === "unchanged") continue;
131
+ console.log(`\n${change.action.toUpperCase()} ${change.filename}`);
132
+ for (const line of change.diff) console.log(line);
133
+ }
134
+
135
+ if (!dryRun) {
136
+ applyPlan(repoPath, plan);
137
+ }
138
+ return plan;
139
+ }
package/sync.test.mjs ADDED
@@ -0,0 +1,84 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { syncCommand } from "./sync.mjs";
6
+ import { makeTempRepo, PUBLIC_DEPS, PRIVATE_DEPS, SAMPLE_MANIFEST } from "./test-support.mjs";
7
+
8
+ test("sync refuses a private repo and writes nothing", async () => {
9
+ const dir = makeTempRepo(SAMPLE_MANIFEST);
10
+ const result = await syncCommand(dir, { deps: PRIVATE_DEPS });
11
+ assert.equal(result.ok, false);
12
+ assert.equal(result.stage, "visibility");
13
+ assert.equal(fs.existsSync(path.join(dir, ".github", "workflows")), false);
14
+ });
15
+
16
+ test("sync writes one rendered file per lane for a public repo", async () => {
17
+ const dir = makeTempRepo(SAMPLE_MANIFEST);
18
+ const result = await syncCommand(dir, { deps: PUBLIC_DEPS });
19
+ assert.equal(result.ok, true);
20
+ for (const filename of ["ci.yml", "package-smoke.yml"]) {
21
+ assert.ok(fs.existsSync(path.join(dir, ".github", "workflows", filename)), `${filename} was not written`);
22
+ }
23
+ });
24
+
25
+ test("sync --dry-run plans changes without writing files", async () => {
26
+ const dir = makeTempRepo(SAMPLE_MANIFEST);
27
+ const result = await syncCommand(dir, { deps: PUBLIC_DEPS, dryRun: true });
28
+ assert.equal(result.ok, true);
29
+ assert.ok(result.changes.some((c) => c.action === "create"));
30
+ assert.equal(fs.existsSync(path.join(dir, ".github", "workflows")), false);
31
+ });
32
+
33
+ test("sync is idempotent: a second run reports no changes", async () => {
34
+ const dir = makeTempRepo(SAMPLE_MANIFEST);
35
+ await syncCommand(dir, { deps: PUBLIC_DEPS });
36
+ const second = await syncCommand(dir, { deps: PUBLIC_DEPS });
37
+ assert.ok(second.changes.every((c) => c.action === "unchanged"));
38
+ });
39
+
40
+ test("sync removes a workflow file for a lane dropped from the manifest", async () => {
41
+ const dir = makeTempRepo(SAMPLE_MANIFEST);
42
+ await syncCommand(dir, { deps: PUBLIC_DEPS });
43
+ fs.writeFileSync(path.join(dir, ".rightgit.json"), `${JSON.stringify({ ...SAMPLE_MANIFEST, lanes: ["ci"] }, null, 2)}\n`, "utf8");
44
+ const result = await syncCommand(dir, { deps: PUBLIC_DEPS });
45
+ assert.equal(result.ok, true);
46
+ assert.equal(fs.existsSync(path.join(dir, ".github", "workflows", "package-smoke.yml")), false);
47
+ assert.ok(fs.existsSync(path.join(dir, ".github", "workflows", "ci.yml")));
48
+ });
49
+
50
+ test("sync reports manifest errors instead of writing files", async () => {
51
+ const dir = makeTempRepo(null);
52
+ const result = await syncCommand(dir, { deps: PUBLIC_DEPS });
53
+ assert.equal(result.ok, false);
54
+ assert.equal(result.stage, "manifest");
55
+ });
56
+
57
+ // F15 end-to-end: a hostile `.rightgit.json` on disk — the real attack
58
+ // surface, not just the in-memory validateManifest() unit — must be refused
59
+ // at the manifest stage, before renderLanes() (and therefore render.mjs's
60
+ // unescaped `${TOKEN}` substitution) ever sees the payload. This is the
61
+ // concrete proof that "none survive into rendered output": nothing is
62
+ // rendered or written at all when the manifest is hostile. Covers a family
63
+ // of payloads across the fields that flow into the credential-bearing
64
+ // publish-package-managers lane.
65
+ const HOSTILE_MANIFESTS = [
66
+ { tap: "acme/tap", formulaName: "${{ secrets.APPLE_CERTIFICATE }}" },
67
+ { tap: "acme/tap", formulaName: "`whoami`" },
68
+ { tap: "acme/tap", formulaName: "widgets\nrun: exit 1" },
69
+ { tap: "acme/${{ secrets.X }}", formulaName: "widgets" },
70
+ ];
71
+
72
+ for (const [i, homebrew] of HOSTILE_MANIFESTS.entries()) {
73
+ test(`F15: sync refuses a hostile manifest and writes nothing (payload ${i})`, async () => {
74
+ const dir = makeTempRepo({
75
+ ...SAMPLE_MANIFEST,
76
+ lanes: ["publish-package-managers"],
77
+ publish: { homebrew, winget: { packageIdentifier: "Acme.Widgets" } },
78
+ });
79
+ const result = await syncCommand(dir, { deps: PUBLIC_DEPS });
80
+ assert.equal(result.ok, false);
81
+ assert.equal(result.stage, "manifest");
82
+ assert.equal(fs.existsSync(path.join(dir, ".github", "workflows")), false, "no workflow file may be written for a hostile manifest");
83
+ });
84
+ }
@@ -0,0 +1,47 @@
1
+ # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
+ # Template: ci.yml | Template version: ${TEMPLATE_VERSION}
3
+ name: ci
4
+ on:
5
+ pull_request:
6
+ push:
7
+ branches: [main]
8
+ permissions:
9
+ contents: read
10
+ # F5 fix: `private-repo-guard` carries no job-level `if`, so it always runs.
11
+ # Its one step's own `if` fires only when the repo is private, and `exit 1`
12
+ # then fails the JOB (loud, visible, non-zero) rather than the previous
13
+ # design of gating the whole job with `if: !private`, which made the guard
14
+ # step unreachable and left the run merely SKIPPED — indistinguishable from
15
+ # "nothing to do" instead of a failure nobody can miss. Every other job below
16
+ # depends on this one, so a skipped-by-failure guard blocks them too
17
+ # (GitHub's default `needs` semantics require the needed job to succeed).
18
+ jobs:
19
+ private-repo-guard:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Refuse private repository
23
+ if: ${{ github.event.repository.private }}
24
+ run: |
25
+ echo "::error::right-git: repository is private; managed public-only workflows must not run here."
26
+ exit 1
27
+ test:
28
+ needs: [private-repo-guard]
29
+ strategy:
30
+ fail-fast: false
31
+ matrix:
32
+ os: ${OS_MATRIX}
33
+ node: ${NODE_MATRIX}
34
+ runs-on: ${{ matrix.os }}
35
+ steps:
36
+ - uses: actions/checkout@v5
37
+ - uses: actions/setup-node@v5
38
+ with:
39
+ node-version: ${{ matrix.node }}
40
+ cache: ${PM_CACHE}
41
+ - run: corepack enable
42
+ - run: ${INSTALL_COMMAND}
43
+ - run: ${PM_RUN} test
44
+ - run: ${PM_RUN} test:random
45
+ - run: ${PM_RUN} test:all
46
+ - run: node scripts/ci/check-generated.mjs
47
+ - run: node scripts/ci/check-network-boundary.mjs
@@ -0,0 +1,37 @@
1
+ # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
+ # Template: package-smoke.yml | Template version: ${TEMPLATE_VERSION}
3
+ # Adapted from Cortex's package.yml (DESIGN.md §6).
4
+ name: package-smoke
5
+ on:
6
+ pull_request:
7
+ push:
8
+ branches: [main]
9
+ permissions:
10
+ contents: read
11
+ # F5 fix: see ci.yml for why the guard is its own always-runs job rather
12
+ # than a job-level `if: !private` (which only skipped instead of failing).
13
+ jobs:
14
+ private-repo-guard:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - name: Refuse private repository
18
+ if: ${{ github.event.repository.private }}
19
+ run: |
20
+ echo "::error::right-git: repository is private; managed public-only workflows must not run here."
21
+ exit 1
22
+ clean-tarball:
23
+ needs: [private-repo-guard]
24
+ strategy:
25
+ fail-fast: false
26
+ matrix:
27
+ os: ${OS_MATRIX}
28
+ runs-on: ${{ matrix.os }}
29
+ steps:
30
+ - uses: actions/checkout@v5
31
+ - uses: actions/setup-node@v5
32
+ with:
33
+ node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
34
+ cache: ${PM_CACHE}
35
+ - run: corepack enable
36
+ - run: ${INSTALL_COMMAND}
37
+ - run: node scripts/test-package.mjs
@@ -0,0 +1,52 @@
1
+ # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
+ # Template: publish-npm.yml | Template version: ${TEMPLATE_VERSION}
3
+ # Extracted from Cortex's immutable-release.yml publish-npm job only
4
+ # (DESIGN.md §6). OIDC trusted publishing is the sole publish credential —
5
+ # no long-lived npm token, no signing material (invariant I2). Runs after
6
+ # right-release has already built/signed/notarized locally and attached
7
+ # artifacts to the GitHub Release (DESIGN.md §7); this job publishes the
8
+ # source npm package itself.
9
+ name: publish-npm
10
+ on:
11
+ release:
12
+ types: [published]
13
+ permissions:
14
+ contents: read
15
+ # F5 fix: see ci.yml for why the guard is its own always-runs job rather
16
+ # than a job-level `if: !private` (which only skipped instead of failing).
17
+ jobs:
18
+ private-repo-guard:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - name: Refuse private repository
22
+ if: ${{ github.event.repository.private }}
23
+ run: |
24
+ echo "::error::right-git: repository is private; managed public-only workflows must not run here."
25
+ exit 1
26
+ publish-npm:
27
+ needs: [private-repo-guard]
28
+ runs-on: ubuntu-latest
29
+ environment: release
30
+ permissions:
31
+ contents: read
32
+ id-token: write
33
+ steps:
34
+ - uses: actions/checkout@v5
35
+ with:
36
+ ref: ${{ github.event.release.tag_name }}
37
+ - uses: actions/setup-node@v5
38
+ with:
39
+ node-version: ${NODE_PUBLISH_VERSION}
40
+ cache: ${PM_CACHE}
41
+ registry-url: https://registry.npmjs.org
42
+ - run: corepack enable
43
+ - run: ${INSTALL_COMMAND}
44
+ - run: node scripts/release/build-candidate.mjs --version "${{ github.event.release.tag_name }}" --platform current --out "release/candidates/publish"
45
+ - run: node scripts/release/check-release.mjs release/candidates/publish
46
+ - id: tarball
47
+ shell: bash
48
+ run: |
49
+ TARBALL="release/candidates/publish/$(node -e 'const c=require("./release/candidates/publish/compatibility.json");process.stdout.write(c.packageName.replace(/^@/, "").replace("/", "-")+"-"+c.version+".tgz")')"
50
+ test -f "$TARBALL"
51
+ echo "path=$TARBALL" >> "$GITHUB_OUTPUT"
52
+ - run: npm publish "${{ steps.tarball.outputs.path }}"${NPM_PROVENANCE_FLAG} --access public
@@ -0,0 +1,157 @@
1
+ # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
+ # Template: publish-package-managers.yml | Template version: ${TEMPLATE_VERSION}
3
+ # Adapted from Cortex's publish-package-managers.yml (DESIGN.md §6). Consumes
4
+ # artifacts already signed locally by right-release and attached to the
5
+ # GitHub Release — never signs anything here. Invariant I2 ("no signing
6
+ # credential is ever configured in Actions") is about *signing* material —
7
+ # Apple/Azure/update-key credentials — and holds here: nothing in this file
8
+ # signs, notarizes, or handles code-signing key material.
9
+ #
10
+ # CREDENTIAL BOUNDARY (read before enabling this lane): this is the one lane
11
+ # in right-git that configures long-lived repository secrets, and it is
12
+ # opt-in only — it is rendered only when a repo's .rightgit.json explicitly
13
+ # lists the 'publish-package-managers' lane, and `right-git install`/
14
+ # `validateManifest` (manifest.mjs) refuse that lane unless both
15
+ # publish.homebrew and publish.winget are supplied (--homebrew-tap,
16
+ # --homebrew-formula, --winget-id). No repo gets these secrets by default.
17
+ # - HOMEBREW_TAP_TOKEN: a GitHub PAT with write access to the Homebrew tap
18
+ # repo (${HOMEBREW_TAP}), used only to check out that repo and open a
19
+ # formula-update pull request. Scoped to one tap repo, not the org; the
20
+ # repo owner creates and stores it, right-git never generates it.
21
+ # - WINGET_CREATE_GITHUB_TOKEN: a classic PAT with `public_repo` scope,
22
+ # passed to `wingetcreate` only via env, used only to submit a manifest
23
+ # PR to microsoft/winget-pkgs when the manual `submit: true` dispatch
24
+ # input is set.
25
+ # Both are package-manager PR-submission tokens, not signing credentials —
26
+ # narrower than an npm publish token, but still long-lived secrets, which is
27
+ # why this lane (unlike every other right-git lane) requires explicit,
28
+ # per-target configuration rather than being available by just naming it.
29
+ #
30
+ # Trust-posture note (do not overstate): this lane's `secrets.*` posture
31
+ # differs from publish-npm.yml's, which is OIDC-only and stores no
32
+ # long-lived token at all. HOMEBREW_TAP_TOKEN and WINGET_CREATE_GITHUB_TOKEN
33
+ # ARE real, owner-provisioned, long-lived GitHub tokens sitting in this
34
+ # repo's Actions secrets for as long as the lane stays enabled. Invariant I1
35
+ # (right-git installs into public repos only) makes that exposure
36
+ # public-repo-scoped, never private-repo-scoped — but "public-repo-scoped"
37
+ # is a narrower blast radius, not zero, and is a materially different trust
38
+ # posture from "no credential in secrets.* at all."
39
+ #
40
+ # WinGet stays fail-closed on a missing token and defaults to validate-only;
41
+ # a real microsoft/winget-pkgs PR requires the manual `submit: true` dispatch
42
+ # input (OD-4 in DESIGN.md is left open — this preserves Cortex's existing
43
+ # reviewed-by-default behavior rather than picking a side).
44
+ name: publish-package-managers
45
+ on:
46
+ workflow_dispatch:
47
+ inputs:
48
+ submit:
49
+ description: 'Submit WinGet PR to microsoft/winget-pkgs (true) or validate/output-only (false)'
50
+ required: false
51
+ default: 'false'
52
+ release:
53
+ types: [published]
54
+ permissions:
55
+ contents: read
56
+ # F5 fix: see ci.yml for why the guard is its own always-runs job rather
57
+ # than a job-level `if: !private` (which only skipped instead of failing).
58
+ # Both root jobs below (`homebrew`, `winget-token`) depend on it, so
59
+ # `winget` (which itself only depends on `winget-token`) is guarded
60
+ # transitively too.
61
+ jobs:
62
+ private-repo-guard:
63
+ runs-on: ubuntu-latest
64
+ steps:
65
+ - name: Refuse private repository
66
+ if: ${{ github.event.repository.private }}
67
+ run: |
68
+ echo "::error::right-git: repository is private; managed public-only workflows must not run here."
69
+ exit 1
70
+ homebrew:
71
+ needs: [private-repo-guard]
72
+ runs-on: ubuntu-latest
73
+ environment: release
74
+ steps:
75
+ - uses: actions/checkout@v5
76
+ - uses: actions/setup-node@v5
77
+ with:
78
+ node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
79
+ cache: ${PM_CACHE}
80
+ - run: corepack enable
81
+ - run: ${INSTALL_COMMAND}
82
+ - run: ruby -c release/homebrew/${HOMEBREW_FORMULA_NAME}.rb
83
+ - name: Checkout Homebrew tap
84
+ uses: actions/checkout@v5
85
+ with:
86
+ repository: ${HOMEBREW_TAP}
87
+ token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
88
+ path: homebrew-tap
89
+ - run: |
90
+ mkdir -p homebrew-tap/Formula
91
+ cp release/homebrew/${HOMEBREW_FORMULA_NAME}.rb homebrew-tap/Formula/${HOMEBREW_FORMULA_NAME}.rb
92
+ # Downstream PR only — never rewrites an existing release.
93
+ - name: Open Homebrew tap PR
94
+ uses: peter-evans/create-pull-request@v6
95
+ with:
96
+ token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
97
+ path: homebrew-tap
98
+ branch: ${HOMEBREW_FORMULA_NAME}-${{ github.ref_name }}
99
+ title: "${HOMEBREW_FORMULA_NAME} ${{ github.ref_name }}"
100
+ delete-branch: true
101
+
102
+ # WinGet is fail-closed: no token, no job. The token is a classic PAT with
103
+ # public_repo scope, created by the repo owner as WINGET_CREATE_GITHUB_TOKEN,
104
+ # and is only ever passed to wingetcreate via env.
105
+ #
106
+ # F5 fix: this job depends on `private-repo-guard` rather than relying on
107
+ # `winget` (below) alone. It has no other `needs`, so without this
108
+ # dependency it would run unconditionally regardless of repository
109
+ # visibility — the probe step always exits 0, so a repo flipped private
110
+ # after install could still satisfy `winget`'s `needs.winget-token` success
111
+ # requirement and let `winget` run its real work in a private repo.
112
+ winget-token:
113
+ needs: [private-repo-guard]
114
+ runs-on: ubuntu-latest
115
+ environment: release
116
+ outputs:
117
+ available: ${{ steps.probe.outputs.available }}
118
+ steps:
119
+ - id: probe
120
+ env:
121
+ TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }}
122
+ run: echo "available=${TOKEN:+true}" >> "$GITHUB_OUTPUT"
123
+
124
+ winget:
125
+ needs: [winget-token]
126
+ runs-on: windows-latest
127
+ environment: release
128
+ if: ${{ needs.winget-token.outputs.available == 'true' }}
129
+ steps:
130
+ - uses: actions/checkout@v5
131
+ - name: Install wingetcreate
132
+ run: dotnet tool install --global Microsoft.WingetCreate
133
+ - name: Validate WinGet manifests (output-only)
134
+ shell: pwsh
135
+ run: |
136
+ $catalog = Get-Content -Raw release/catalog.json | ConvertFrom-Json
137
+ $installer = $catalog.artifacts | Where-Object { $_.name -like '*-Setup.exe' } | Select-Object -First 1
138
+ if (-not $installer) { throw 'release/catalog.json missing a *-Setup.exe installer artifact' }
139
+ $version = [string]$catalog.version
140
+ if ($version -notmatch '^\d+\.\d+\.\d+') { throw "unresolved catalog version: $version" }
141
+ $url = "https://github.com/${{ github.repository }}/releases/download/v$version/$($installer.name)"
142
+ $out = "${{ runner.temp }}\winget"
143
+ wingetcreate update ${WINGET_PACKAGE_IDENTIFIER} --version $version --urls $url --output $out
144
+ Get-ChildItem $out -Filter '${WINGET_PACKAGE_IDENTIFIER}.*' | ForEach-Object { wingetcreate validate $_.FullName }
145
+ - name: Submit WinGet PR to microsoft/winget-pkgs
146
+ if: ${{ inputs.submit == 'true' }}
147
+ shell: pwsh
148
+ env:
149
+ WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }}
150
+ run: |
151
+ $catalog = Get-Content -Raw release/catalog.json | ConvertFrom-Json
152
+ $installer = $catalog.artifacts | Where-Object { $_.name -like '*-Setup.exe' } | Select-Object -First 1
153
+ if (-not $installer) { throw 'release/catalog.json missing a *-Setup.exe installer artifact' }
154
+ $version = [string]$catalog.version
155
+ if ($version -notmatch '^\d+\.\d+\.\d+') { throw "unresolved catalog version: $version" }
156
+ $url = "https://github.com/${{ github.repository }}/releases/download/v$version/$($installer.name)"
157
+ wingetcreate update ${WINGET_PACKAGE_IDENTIFIER} --version $version --urls $url --submit --token $env:WINGET_CREATE_GITHUB_TOKEN
@@ -0,0 +1,82 @@
1
+ # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
+ # Template: qualification.yml | Template version: ${TEMPLATE_VERSION}
3
+ name: qualification
4
+ on:
5
+ pull_request:
6
+ push:
7
+ branches: [main]
8
+ permissions:
9
+ contents: read
10
+ # F5 fix: see ci.yml for why the guard is its own always-runs job rather
11
+ # than a job-level `if: !private` (which only skipped instead of failing).
12
+ jobs:
13
+ private-repo-guard:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Refuse private repository
17
+ if: ${{ github.event.repository.private }}
18
+ run: |
19
+ echo "::error::right-git: repository is private; managed public-only workflows must not run here."
20
+ exit 1
21
+ qualification:
22
+ needs: [private-repo-guard]
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v5
26
+ - uses: actions/setup-node@v5
27
+ with:
28
+ node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
29
+ cache: ${PM_CACHE}
30
+ - run: corepack enable
31
+ - run: ${INSTALL_COMMAND}
32
+ - run: ${PM_RUN} test:all
33
+ schema:
34
+ needs: [qualification]
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@v5
38
+ - uses: actions/setup-node@v5
39
+ with:
40
+ node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
41
+ cache: ${PM_CACHE}
42
+ - run: corepack enable
43
+ - run: ${INSTALL_COMMAND}
44
+ - run: node scripts/ci/check-schema.mjs || true
45
+ security:
46
+ needs: [qualification]
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v5
50
+ - uses: actions/setup-node@v5
51
+ with:
52
+ node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
53
+ cache: ${PM_CACHE}
54
+ - run: corepack enable
55
+ - run: ${INSTALL_COMMAND}
56
+ - run: node scripts/ci/check-network-boundary.mjs
57
+ - name: Hostile-repository and secret-egress suite
58
+ run: ${PM_RUN} test:security
59
+ hardening:
60
+ needs: [qualification]
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - uses: actions/checkout@v5
64
+ - uses: actions/setup-node@v5
65
+ with:
66
+ node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
67
+ cache: ${PM_CACHE}
68
+ - run: corepack enable
69
+ - run: ${INSTALL_COMMAND}
70
+ - run: ${PM_RUN} test:hardening
71
+ conformance:
72
+ needs: [qualification]
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - uses: actions/checkout@v5
76
+ - uses: actions/setup-node@v5
77
+ with:
78
+ node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
79
+ cache: ${PM_CACHE}
80
+ - run: corepack enable
81
+ - run: ${INSTALL_COMMAND}
82
+ - run: ${PM_RUN} test:conformance
@@ -0,0 +1,48 @@
1
+ # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
+ # Template: release-candidate.yml | Template version: ${TEMPLATE_VERSION}
3
+ # Adapted from Cortex's release-candidate.yml (DESIGN.md §6). No signing
4
+ # credentials of any kind (invariant I2) — unsigned candidate build only.
5
+ name: release-candidate
6
+ on:
7
+ workflow_dispatch:
8
+ push:
9
+ tags: ['v*']
10
+ permissions:
11
+ contents: read
12
+ # F5 fix: see ci.yml for why the guard is its own always-runs job rather
13
+ # than a job-level `if: !private` (which only skipped instead of failing).
14
+ jobs:
15
+ private-repo-guard:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - name: Refuse private repository
19
+ if: ${{ github.event.repository.private }}
20
+ run: |
21
+ echo "::error::right-git: repository is private; managed public-only workflows must not run here."
22
+ exit 1
23
+ candidate:
24
+ needs: [private-repo-guard]
25
+ strategy:
26
+ fail-fast: false
27
+ matrix:
28
+ os: ${OS_MATRIX}
29
+ runs-on: ${{ matrix.os }}
30
+ steps:
31
+ - uses: actions/checkout@v5
32
+ - uses: actions/setup-node@v5
33
+ with:
34
+ node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
35
+ cache: ${PM_CACHE}
36
+ - run: corepack enable
37
+ - run: ${INSTALL_COMMAND}
38
+ - run: node --test tests/release-candidate.test.mjs
39
+ - run: node scripts/release/build-candidate.mjs --platform current --out "${{ runner.temp }}/${ARTIFACT_PREFIX}-rc"
40
+ - run: node scripts/release/check-release.mjs "${{ runner.temp }}/${ARTIFACT_PREFIX}-rc"
41
+ - uses: actions/upload-artifact@v4
42
+ with:
43
+ name: ${ARTIFACT_PREFIX}-rc-${{ matrix.os }}
44
+ path: |
45
+ ${{ runner.temp }}/${ARTIFACT_PREFIX}-rc/**
46
+ ${{ runner.temp }}/${ARTIFACT_PREFIX}-*.tar.gz
47
+ ${{ runner.temp }}/${ARTIFACT_PREFIX}-*.zip
48
+ ${{ runner.temp }}/${ARTIFACT_PREFIX}-*.sha256