@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/README.md +35 -0
- package/adopt.mjs +96 -0
- package/cli/right-git.mjs +207 -0
- package/cli.test.mjs +68 -0
- package/drift.mjs +133 -0
- package/drift.test.mjs +98 -0
- package/guard-coverage.test.mjs +220 -0
- package/install.mjs +72 -0
- package/manifest.mjs +278 -0
- package/manifest.schema.json +85 -0
- package/manifest.test.mjs +196 -0
- package/pack-smoke.test.mjs +55 -0
- package/package.json +30 -0
- package/policy/visibility-gate.mjs +168 -0
- package/render.mjs +35 -0
- package/render.test.mjs +117 -0
- package/sync.mjs +139 -0
- package/sync.test.mjs +84 -0
- package/templates/ci.yml +47 -0
- package/templates/package-smoke.yml +37 -0
- package/templates/publish-npm.yml +52 -0
- package/templates/publish-package-managers.yml +157 -0
- package/templates/qualification.yml +82 -0
- package/templates/release-candidate.yml +48 -0
- package/test-support.mjs +33 -0
- package/uninstall.mjs +36 -0
- package/uninstall.test.mjs +77 -0
- package/visibility-gate.test.mjs +141 -0
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
|
+
}
|
package/templates/ci.yml
ADDED
|
@@ -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
|