@rightkit/git 0.2.25 → 0.2.27
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 +41 -25
- package/guard-coverage.test.mjs +1 -2
- package/manifest.mjs +55 -4
- package/manifest.schema.json +12 -0
- package/manifest.test.mjs +14 -0
- package/package.json +1 -1
- package/render.test.mjs +44 -14
- package/rightkit-ownership.json +15 -0
- package/sync.mjs +1 -1
- package/templates/profiles/rust-hybrid/ci.yml +2 -1
- package/templates/publish-npm.yml +23 -3
package/README.md
CHANGED
|
@@ -1,43 +1,59 @@
|
|
|
1
1
|
# @rightkit/git
|
|
2
2
|
|
|
3
|
-
Public-
|
|
3
|
+
Public-repository-only GitHub Actions workflow layer for Right Suite products. The CLI `right-git` renders and installs CI lanes into `.github/workflows/` from a `.rightgit.json` manifest.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
| Item | Value |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Where it runs | Build and release tooling (repository CI setup) |
|
|
8
|
+
| Rust pair | None |
|
|
9
|
+
| Version | see INDEX.md |
|
|
10
|
+
| License | MIT OR Apache-2.0 |
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
## Commands
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
- `right-git install <repo> --lanes ci,...` writes `.rightgit.json` and renders workflows.
|
|
15
|
+
- `right-git sync` re-renders installed workflows from the manifest.
|
|
16
|
+
- `right-git drift --all` checks installed workflows against templates and re-checks visibility.
|
|
17
|
+
- `right-git adopt` imports existing workflows into the template library.
|
|
18
|
+
- `right-git uninstall` removes every managed file.
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
`install` and `sync` refuse private, internal and unconfirmed-visibility repositories. There is no override flag.
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
- **`right-git drift`** — detect divergence between installed workflows and templates, and orphaned managed workflows.
|
|
17
|
-
- **`right-git adopt`** — import existing workflows into the template library.
|
|
18
|
-
- **`right-git uninstall`** — remove every managed file, leaving zero behind.
|
|
22
|
+
## Optional QA evidence upload (rust-hybrid)
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
Set `qaEvidencePath` in `.rightgit.json` to upload the gate's QA output as a workflow artifact. The path is relative to `$RUNNER_TEMP`; the rendered step uses `${{ runner.temp }}/<path>`. Absolute paths and expression syntax are rejected.
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"profile": "rust-hybrid",
|
|
29
|
+
"gate": { "path": "scripts/gate.sh" },
|
|
30
|
+
"qaEvidencePath": "pulse-hub-qa",
|
|
31
|
+
"qaEvidenceRetentionDays": 14
|
|
32
|
+
}
|
|
33
|
+
```
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
- The step runs after the gate with `if: always()` and `if-no-files-found: ignore`.
|
|
36
|
+
- Artifact name: `qa-evidence-${{ matrix.os || runner.os }}-${{ github.run_attempt }}`.
|
|
37
|
+
- `qaEvidenceRetentionDays` is an integer from 1 to 90 and defaults to 7. It requires `qaEvidencePath`.
|
|
38
|
+
- Omit the key and the rendered `ci.yml` is unchanged.
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
- **Exception, disclosed:** the optional, opt-in `publish-package-managers` lane *does* require two owner-provisioned, long-lived tokens in GitHub Actions `secrets.*` (`HOMEBREW_TAP_TOKEN`, `WINGET_CREATE_GITHUB_TOKEN`) to open tap/manifest PRs. These are real bearer tokens, public-repo-scoped by the visibility gate but not zero-exposure. The "no signing credential in CI" invariant is specifically about code-signing material; it does not mean "no credential of any kind."
|
|
40
|
+
## Entry points
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
No importable API. The package has no `main` and no `exports` map. The supported surface is the `right-git` bin.
|
|
30
43
|
|
|
31
|
-
##
|
|
44
|
+
## Peer dependencies
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
npm install -g @rightkit/git # or: npx @rightkit/git --help
|
|
35
|
-
```
|
|
46
|
+
None. No runtime dependencies (Node builtins only).
|
|
36
47
|
|
|
37
|
-
|
|
48
|
+
## Example
|
|
38
49
|
|
|
39
|
-
|
|
50
|
+
```sh
|
|
51
|
+
pnpm add -D @rightkit/git
|
|
52
|
+
pnpm exec right-git install . --lanes ci
|
|
53
|
+
pnpm exec right-git drift --all
|
|
54
|
+
```
|
|
40
55
|
|
|
41
|
-
|
|
56
|
+
## Known issues
|
|
42
57
|
|
|
43
|
-
|
|
58
|
+
- The optional `publish-package-managers` lane needs two long-lived tokens in GitHub Actions secrets. Their names and scope are in the package templates, and they are not zero-exposure. Read `docs/rules/github-access.md` in the workspace before enabling it.
|
|
59
|
+
- Manifest fields are charset-checked before they reach workflow YAML.
|
package/guard-coverage.test.mjs
CHANGED
|
@@ -166,12 +166,11 @@ function isGuarded(jobs, jobName, visiting = new Set()) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
function renderAllLanes() {
|
|
169
|
-
const context = buildRenderContext(FULL_MANIFEST, "/repos/widgets");
|
|
170
169
|
const rendered = {};
|
|
171
170
|
for (const lane of FULL_MANIFEST.lanes) {
|
|
172
171
|
const templatePath = path.join(templatesDir, LANE_FILENAMES[lane]);
|
|
173
172
|
const templateText = fs.readFileSync(templatePath, "utf8");
|
|
174
|
-
rendered[lane] = renderTemplate(templateText,
|
|
173
|
+
rendered[lane] = renderTemplate(templateText, buildRenderContext(FULL_MANIFEST, "/repos/widgets", lane));
|
|
175
174
|
}
|
|
176
175
|
return rendered;
|
|
177
176
|
}
|
package/manifest.mjs
CHANGED
|
@@ -59,9 +59,17 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
59
59
|
"profile",
|
|
60
60
|
"gate",
|
|
61
61
|
"rustCache",
|
|
62
|
+
"qaEvidencePath",
|
|
63
|
+
"qaEvidenceRetentionDays",
|
|
62
64
|
"candidate",
|
|
63
65
|
"ownership",
|
|
64
66
|
]);
|
|
67
|
+
// Optional QA evidence upload (rust-hybrid ci only). qaEvidencePath is a safe
|
|
68
|
+
// relative path under $RUNNER_TEMP; it renders as `${{ runner.temp }}/<path>`.
|
|
69
|
+
const QA_EVIDENCE_DEFAULT_RETENTION_DAYS = 7;
|
|
70
|
+
const QA_EVIDENCE_MAX_RETENTION_DAYS = 90;
|
|
71
|
+
// Pinned to the same upload-artifact release the rust-hybrid release-candidate template uses.
|
|
72
|
+
const UPLOAD_ARTIFACT_PIN = "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4";
|
|
65
73
|
const GATE_PATH_RE = /^(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
66
74
|
const RUST_WORKSPACE_PATH_RE = /^(?:\.|(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*)$/;
|
|
67
75
|
const PACKAGE_SCRIPT_RE = /^[A-Za-z0-9][A-Za-z0-9:_-]*$/;
|
|
@@ -160,6 +168,21 @@ export function validateManifest(manifest) {
|
|
|
160
168
|
errors.push("gate is only valid for profile 'rust-hybrid'");
|
|
161
169
|
}
|
|
162
170
|
|
|
171
|
+
if (manifest.qaEvidencePath !== undefined) {
|
|
172
|
+
if (profile !== "rust-hybrid") {
|
|
173
|
+
errors.push("qaEvidencePath is only valid for profile 'rust-hybrid'");
|
|
174
|
+
} else if (typeof manifest.qaEvidencePath !== "string" || !GATE_PATH_RE.test(manifest.qaEvidencePath)) {
|
|
175
|
+
errors.push("qaEvidencePath must be a safe relative path under $RUNNER_TEMP (no '..', whitespace, leading '/', or expression syntax)");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (manifest.qaEvidenceRetentionDays !== undefined) {
|
|
179
|
+
if (manifest.qaEvidencePath === undefined) {
|
|
180
|
+
errors.push("qaEvidenceRetentionDays requires qaEvidencePath");
|
|
181
|
+
} else if (!Number.isInteger(manifest.qaEvidenceRetentionDays) || manifest.qaEvidenceRetentionDays < 1 || manifest.qaEvidenceRetentionDays > QA_EVIDENCE_MAX_RETENTION_DAYS) {
|
|
182
|
+
errors.push(`qaEvidenceRetentionDays must be an integer from 1 to ${QA_EVIDENCE_MAX_RETENTION_DAYS}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
163
186
|
const rustCache = manifest.rustCache;
|
|
164
187
|
if (rustCache !== undefined) {
|
|
165
188
|
if (profile !== "rust-hybrid") {
|
|
@@ -990,15 +1013,42 @@ export function deriveProjectName(repoPath) {
|
|
|
990
1013
|
* validated manifest plus the target repo's derived project name.
|
|
991
1014
|
*/
|
|
992
1015
|
/** Every managed public workflow runs ownership scanning; repository metadata cannot opt it out. */
|
|
993
|
-
export function ownershipStep(manifest) {
|
|
1016
|
+
export function ownershipStep(manifest, lane = "ci") {
|
|
994
1017
|
const { name } = parsePackageManager(manifest.packageManager);
|
|
995
1018
|
const command = name === "pnpm" ? "pnpm exec right-release ownership --app ."
|
|
996
1019
|
: name === "npm" ? "npm exec -- right-release ownership --app ."
|
|
997
1020
|
: "yarn exec right-release ownership --app .";
|
|
998
|
-
|
|
1021
|
+
// Only release-candidate dispatches declare source_revision. Referencing
|
|
1022
|
+
// inputs.source_revision from other lanes makes actionlint reject their
|
|
1023
|
+
// generated workflows, even when guarded by an event-name expression.
|
|
1024
|
+
const sourceRevision = lane === "release-candidate"
|
|
1025
|
+
? "\${{ inputs.source_revision }}"
|
|
1026
|
+
: "\${{ github.sha }}";
|
|
1027
|
+
return `\n - name: RightKit ownership (protected registry roots)\n shell: bash\n run: test "$(git rev-parse HEAD)" = "${sourceRevision}" && ${command}`;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Optional QA evidence upload, rendered after the rust-hybrid gate step.
|
|
1032
|
+
* Returns "" when qaEvidencePath is unset, so the rendered workflow stays
|
|
1033
|
+
* byte-identical to a manifest without the key.
|
|
1034
|
+
*/
|
|
1035
|
+
export function qaEvidenceStep(manifest) {
|
|
1036
|
+
if (manifest.qaEvidencePath === undefined) return "";
|
|
1037
|
+
const retentionDays = manifest.qaEvidenceRetentionDays ?? QA_EVIDENCE_DEFAULT_RETENTION_DAYS;
|
|
1038
|
+
return [
|
|
1039
|
+
" - name: Upload QA evidence",
|
|
1040
|
+
" if: always()",
|
|
1041
|
+
` uses: ${UPLOAD_ARTIFACT_PIN}`,
|
|
1042
|
+
" with:",
|
|
1043
|
+
" name: qa-evidence-${{ matrix.os || runner.os }}-${{ github.run_attempt }}",
|
|
1044
|
+
` path: \${{ runner.temp }}/${manifest.qaEvidencePath}`,
|
|
1045
|
+
` retention-days: ${retentionDays}`,
|
|
1046
|
+
" if-no-files-found: ignore",
|
|
1047
|
+
"",
|
|
1048
|
+
].join("\n");
|
|
999
1049
|
}
|
|
1000
1050
|
|
|
1001
|
-
export function buildRenderContext(manifest, repoPath) {
|
|
1051
|
+
export function buildRenderContext(manifest, repoPath, lane = "ci") {
|
|
1002
1052
|
const pm = parsePackageManager(manifest.packageManager);
|
|
1003
1053
|
const projectName = deriveProjectName(repoPath);
|
|
1004
1054
|
const artifactPrefix = projectName.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
|
|
@@ -1024,7 +1074,8 @@ export function buildRenderContext(manifest, repoPath) {
|
|
|
1024
1074
|
WINDOWS_SIGNING_JOB: windowsSigningJob(manifest, pm, artifactPrefix),
|
|
1025
1075
|
MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
|
|
1026
1076
|
RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
|
|
1027
|
-
OWNERSHIP_STEP: ownershipStep(manifest),
|
|
1077
|
+
OWNERSHIP_STEP: ownershipStep(manifest, lane),
|
|
1078
|
+
QA_EVIDENCE_STEP: qaEvidenceStep(manifest),
|
|
1028
1079
|
};
|
|
1029
1080
|
|
|
1030
1081
|
// Every key below is populated ONLY when its source manifest section is
|
package/manifest.schema.json
CHANGED
|
@@ -116,6 +116,18 @@
|
|
|
116
116
|
"type": "object", "required": ["path"], "additionalProperties": false,
|
|
117
117
|
"properties": { "path": { "type": "string", "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$" } }
|
|
118
118
|
},
|
|
119
|
+
"qaEvidencePath": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"description": "rust-hybrid only. Safe relative path under $RUNNER_TEMP where the gate writes QA evidence; rendered as ${{ runner.temp }}/<path> and uploaded with if: always().",
|
|
122
|
+
"pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$"
|
|
123
|
+
},
|
|
124
|
+
"qaEvidenceRetentionDays": {
|
|
125
|
+
"type": "integer",
|
|
126
|
+
"minimum": 1,
|
|
127
|
+
"maximum": 90,
|
|
128
|
+
"default": 7,
|
|
129
|
+
"description": "rust-hybrid only, requires qaEvidencePath. Artifact retention in days."
|
|
130
|
+
},
|
|
119
131
|
"candidate": {
|
|
120
132
|
"type": "object",
|
|
121
133
|
"required": ["buildScript", "checkScript", "admissionScript", "stageSummaryScript", "artifactRoot", "os"],
|
package/manifest.test.mjs
CHANGED
|
@@ -389,3 +389,17 @@ test("F15 non-regression: realistic homebrew/winget/matrix values still validate
|
|
|
389
389
|
});
|
|
390
390
|
assert.equal(result.valid, true, JSON.stringify(result.errors));
|
|
391
391
|
});
|
|
392
|
+
|
|
393
|
+
test("qaEvidencePath and qaEvidenceRetentionDays are optional, rust-hybrid only, and charset-checked", () => {
|
|
394
|
+
const valid = { ...VALID_MINIMAL, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
|
|
395
|
+
assert.equal(validateManifest({ ...valid, qaEvidencePath: "pulse-hub-qa" }).valid, true);
|
|
396
|
+
assert.equal(validateManifest({ ...valid, qaEvidencePath: "pulse-hub-qa", qaEvidenceRetentionDays: 30 }).valid, true);
|
|
397
|
+
for (const qaEvidencePath of ["/abs", "../up", "pulse hub", "${{ runner.temp }}/x", "a;b", "", null]) {
|
|
398
|
+
assert.equal(validateManifest({ ...valid, qaEvidencePath }).valid, false, String(qaEvidencePath));
|
|
399
|
+
}
|
|
400
|
+
for (const qaEvidenceRetentionDays of [0, 91, 1.5, "7"]) {
|
|
401
|
+
assert.equal(validateManifest({ ...valid, qaEvidencePath: "pulse-hub-qa", qaEvidenceRetentionDays }).valid, false, String(qaEvidenceRetentionDays));
|
|
402
|
+
}
|
|
403
|
+
assert.equal(validateManifest({ ...valid, qaEvidenceRetentionDays: 7 }).valid, false, "retention requires a path");
|
|
404
|
+
assert.equal(validateManifest({ ...VALID_MINIMAL, qaEvidencePath: "pulse-hub-qa" }).valid, false, "node profile rejects qaEvidencePath");
|
|
405
|
+
});
|
package/package.json
CHANGED
package/render.test.mjs
CHANGED
|
@@ -24,12 +24,11 @@ const FULL_MANIFEST = {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
function renderAllLanes(manifest, repoPath) {
|
|
27
|
-
const context = buildRenderContext(manifest, repoPath);
|
|
28
27
|
const rendered = {};
|
|
29
28
|
for (const lane of manifest.lanes) {
|
|
30
29
|
const templatePath = path.join(templatesDir, LANE_FILENAMES[lane]);
|
|
31
30
|
const templateText = fs.readFileSync(templatePath, "utf8");
|
|
32
|
-
rendered[lane] = renderTemplate(templateText,
|
|
31
|
+
rendered[lane] = renderTemplate(templateText, buildRenderContext(manifest, repoPath, lane));
|
|
33
32
|
}
|
|
34
33
|
return rendered;
|
|
35
34
|
}
|
|
@@ -60,6 +59,7 @@ test("rust-hybrid ci renders its pinned package manager and gate", () => {
|
|
|
60
59
|
assert.match(content, /changed=true[\s\S]*\^0\{40\}\$[\s\S]*git cat-file -e[\s\S]*git diff --name-only --diff-filter=ACDMRTUXB/);
|
|
61
60
|
assert.match(content, /--diff-filter=ACDMRTUXB/, "deleted Rust inputs must trigger Rust setup");
|
|
62
61
|
assert.match(content, /\*\.rs\|Cargo\.toml[\s\S]*scripts\/gate\.sh/);
|
|
62
|
+
assert.doesNotMatch(content, /\|build\.rs\||\|\*\/build\.rs\|/);
|
|
63
63
|
assert.match(content, /\.rightgit\.json\|\.github\/workflows\/\*\.yml\|\.github\/workflows\/\*\.yaml/);
|
|
64
64
|
assert.equal((content.match(/if: steps\.rust-changes\.outputs\.required == 'true'/g) ?? []).length, 3);
|
|
65
65
|
assert.match(content, /RIGHT_GIT_RUST_CHANGED: \$\{\{ steps\.rust-changes\.outputs\.changed \}\}/);
|
|
@@ -94,6 +94,15 @@ test("ordinary CI templates cancel superseded runs on the same ref", () => {
|
|
|
94
94
|
assert.match(generic, /cancel-in-progress: true/);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
test("ownership revision expression matches each lane trigger contract", () => {
|
|
98
|
+
const rendered = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
|
|
99
|
+
assert.match(rendered.ci, /git rev-parse HEAD\)" = "\$\{\{ github\.sha \}\}/);
|
|
100
|
+
assert.doesNotMatch(rendered.ci, /inputs\.source_revision/);
|
|
101
|
+
assert.match(rendered["publish-package-managers"], /git rev-parse HEAD\)" = "\$\{\{ github\.sha \}\}/);
|
|
102
|
+
assert.doesNotMatch(rendered["publish-package-managers"], /inputs\.source_revision/);
|
|
103
|
+
assert.match(rendered["release-candidate"], /git rev-parse HEAD\)" = "\$\{\{ inputs\.source_revision \}\}/);
|
|
104
|
+
});
|
|
105
|
+
|
|
97
106
|
test("rust-hybrid ci preserves a declared macOS-only lane", () => {
|
|
98
107
|
const manifest = {
|
|
99
108
|
...FULL_MANIFEST,
|
|
@@ -195,7 +204,7 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
|
|
|
195
204
|
};
|
|
196
205
|
const content = renderTemplate(
|
|
197
206
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
198
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
207
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
199
208
|
);
|
|
200
209
|
assert.match(content, /environment: release/);
|
|
201
210
|
// Tag-last contract: signing is gated on the admitted, same-run
|
|
@@ -212,7 +221,7 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
|
|
|
212
221
|
// out the exact admitted revision by SHA, never by resolving HEAD after a
|
|
213
222
|
// ref-triggered checkout.
|
|
214
223
|
assert.match(content, /windows-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/);
|
|
215
|
-
assert.match(content, /git rev-parse HEAD.*inputs\.source_revision
|
|
224
|
+
assert.match(content, /git rev-parse HEAD.*inputs\.source_revision/);
|
|
216
225
|
assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
|
|
217
226
|
assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
|
|
218
227
|
assert.doesNotMatch(content, /secrets\.|client-secret|private.key/i);
|
|
@@ -240,7 +249,7 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
|
|
|
240
249
|
};
|
|
241
250
|
const content = renderTemplate(
|
|
242
251
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
243
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
252
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
244
253
|
);
|
|
245
254
|
assert.match(content, /macos-sign:/);
|
|
246
255
|
assert.match(content, /macos-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
|
|
@@ -251,7 +260,7 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
|
|
|
251
260
|
assert.match(content, /right-release build --platform mac --skip-checks/);
|
|
252
261
|
assert.match(content, /secrets\.APPLE_API_KEY_BASE64/);
|
|
253
262
|
assert.match(content, /macos-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/);
|
|
254
|
-
assert.match(content, /git rev-parse HEAD.*inputs\.source_revision
|
|
263
|
+
assert.match(content, /git rev-parse HEAD.*inputs\.source_revision/);
|
|
255
264
|
assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
|
|
256
265
|
assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
|
|
257
266
|
assert.match(content, /security delete-keychain/);
|
|
@@ -287,7 +296,7 @@ test("rust-hybrid multi-architecture candidates bind every native target to exac
|
|
|
287
296
|
};
|
|
288
297
|
const content = renderTemplate(
|
|
289
298
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
290
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
299
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
291
300
|
);
|
|
292
301
|
for (const [runner, triple] of [["windows-2025", "x86_64-pc-windows-msvc"], ["windows-11-arm", "aarch64-pc-windows-msvc"], ["macos-15", "aarch64-apple-darwin"], ["macos-15-intel", "x86_64-apple-darwin"]]) {
|
|
293
302
|
assert.match(content, new RegExp(`os: ${JSON.stringify(runner).replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`));
|
|
@@ -338,7 +347,7 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
|
|
|
338
347
|
};
|
|
339
348
|
const content = renderTemplate(
|
|
340
349
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
341
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
350
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
342
351
|
);
|
|
343
352
|
assert.match(content, /windows-sign:[\s\S]*pnpm run release:finalize:win/);
|
|
344
353
|
assert.match(content, /macos-sign:[\s\S]*pnpm run release:finalize:mac/);
|
|
@@ -372,7 +381,7 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
|
|
|
372
381
|
assert.match(content, /CLOUDFLARE_API_TOKEN: \$\{\{ secrets\.CLOUDFLARE_API_TOKEN \}\}/);
|
|
373
382
|
// Ownership scan binds to the source revision input before admission emits
|
|
374
383
|
// that same revision for every downstream job.
|
|
375
|
-
assert.match(content, /git rev-parse HEAD.*inputs\.source_revision
|
|
384
|
+
assert.match(content, /git rev-parse HEAD.*inputs\.source_revision/);
|
|
376
385
|
assert.doesNotMatch(content, /RIGHT_GIT_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
|
|
377
386
|
// candidate, windows-sign, macos-sign, installed-qualification and publish all
|
|
378
387
|
// check out the admitted revision with full history; admission checks out
|
|
@@ -430,7 +439,7 @@ test("every 'run <script>' in a full release-chain workflow names a script the m
|
|
|
430
439
|
};
|
|
431
440
|
const content = renderTemplate(
|
|
432
441
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
433
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
442
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
434
443
|
);
|
|
435
444
|
const declaredScripts = new Set([
|
|
436
445
|
manifest.candidate.buildScript,
|
|
@@ -491,7 +500,7 @@ test("release chain: installed-qualification has a stage summary and publish dow
|
|
|
491
500
|
};
|
|
492
501
|
const content = renderTemplate(
|
|
493
502
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
494
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
503
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
495
504
|
);
|
|
496
505
|
const installedQualificationJob = content.slice(
|
|
497
506
|
content.search(/^ {2}installed-qualification:\n {4}needs:/m),
|
|
@@ -689,7 +698,7 @@ test("every job checking out an admitted revision fetches full history", () => {
|
|
|
689
698
|
};
|
|
690
699
|
const content = renderTemplate(
|
|
691
700
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
692
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
701
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
693
702
|
);
|
|
694
703
|
const checkouts = content
|
|
695
704
|
.split(/^ {6}- uses: actions\/checkout@/m)
|
|
@@ -732,7 +741,7 @@ test("release-candidate template exposes a dry_run input threaded through admiss
|
|
|
732
741
|
};
|
|
733
742
|
const content = renderTemplate(
|
|
734
743
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
735
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
744
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
736
745
|
);
|
|
737
746
|
assert.match(content, /^ dry_run:\n description: .*\n required: false\n type: boolean\n default: false$/m);
|
|
738
747
|
assert.match(content, /dry_run: \$\{\{ steps\.admit\.outputs\.dry_run \}\}/);
|
|
@@ -785,7 +794,7 @@ function environmentManifest(environments) {
|
|
|
785
794
|
function renderRelease(manifest) {
|
|
786
795
|
return renderTemplate(
|
|
787
796
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
788
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
797
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
789
798
|
);
|
|
790
799
|
}
|
|
791
800
|
|
|
@@ -820,3 +829,24 @@ test("ci.yml ownership check cannot be removed by deleting optional repo metadat
|
|
|
820
829
|
const without = renderTemplate(template, buildRenderContext(structuredClone(FULL_MANIFEST), "/repos/widgets"));
|
|
821
830
|
assert.match(without, /RightKit ownership \(protected registry roots\)/);
|
|
822
831
|
});
|
|
832
|
+
|
|
833
|
+
test("rust-hybrid ci uploads QA evidence after the gate only when qaEvidencePath is set", () => {
|
|
834
|
+
const base = { ...FULL_MANIFEST, lanes: ["ci"], profile: "rust-hybrid", gate: { path: "scripts/gate.sh" }, templateVersion: "1.1.0" };
|
|
835
|
+
const template = fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8");
|
|
836
|
+
|
|
837
|
+
const unset = renderTemplate(template, buildRenderContext(structuredClone(base), "/repos/widgets"));
|
|
838
|
+
assert.doesNotMatch(unset, /upload-artifact|QA evidence|runner\.temp/);
|
|
839
|
+
assert.match(unset, /CARGO_PROFILE_TEST_DEBUG: "0"\n$/);
|
|
840
|
+
|
|
841
|
+
const set = renderTemplate(template, buildRenderContext({ ...structuredClone(base), qaEvidencePath: "pulse-hub-qa" }, "/repos/widgets"));
|
|
842
|
+
assert.ok(set.indexOf("bash ./scripts/gate.sh") < set.indexOf("- name: Upload QA evidence"), "upload must follow the gate step");
|
|
843
|
+
assert.match(
|
|
844
|
+
set,
|
|
845
|
+
/ - name: Upload QA evidence\n if: always\(\)\n uses: actions\/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4\n with:\n name: qa-evidence-\$\{\{ matrix\.os \|\| runner\.os \}\}-\$\{\{ github\.run_attempt \}\}\n path: \$\{\{ runner\.temp \}\}\/pulse-hub-qa\n retention-days: 7\n if-no-files-found: ignore\n$/,
|
|
846
|
+
);
|
|
847
|
+
// The upload step is the only difference between the two renders.
|
|
848
|
+
assert.equal(set.replace(/ - name: Upload QA evidence[\s\S]*$/, ""), unset);
|
|
849
|
+
|
|
850
|
+
const custom = renderTemplate(template, buildRenderContext({ ...structuredClone(base), qaEvidencePath: "pulse-hub-qa", qaEvidenceRetentionDays: 30 }, "/repos/widgets"));
|
|
851
|
+
assert.match(custom, /retention-days: 30\n/);
|
|
852
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"package": "@rightkit/git",
|
|
3
|
+
"capabilities": [
|
|
4
|
+
{
|
|
5
|
+
"capability": "github-workflows",
|
|
6
|
+
"summary": "Public-repo-only GitHub Actions workflow layer: templates, visibility gate, sync/drift/adopt/uninstall for right-git.; declaration-only detection (no precise implementation fingerprint)",
|
|
7
|
+
"fingerprints": [],
|
|
8
|
+
"detection": "declaration-only",
|
|
9
|
+
"policyNotes": [
|
|
10
|
+
"Package contract retained; dependency declarations do not verify integration.",
|
|
11
|
+
"No precise implementation fingerprint; declaration-only inventory, never enforced ownership or verified integration."
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
package/sync.mjs
CHANGED
|
@@ -31,8 +31,8 @@ function releaseChainDependencyError(repoPath, manifest) {
|
|
|
31
31
|
|
|
32
32
|
/** Render every lane declared in the manifest. Returns { lane, filename, content }[]. */
|
|
33
33
|
export function renderLanes(manifest, repoPath) {
|
|
34
|
-
const context = buildRenderContext(manifest, repoPath);
|
|
35
34
|
return manifest.lanes.map((lane) => {
|
|
35
|
+
const context = buildRenderContext(manifest, repoPath, lane);
|
|
36
36
|
const filename = LANE_FILENAMES[lane];
|
|
37
37
|
const templatePath = manifest.profile === "rust-hybrid"
|
|
38
38
|
? path.join(TEMPLATES_DIR, "profiles", "rust-hybrid", filename)
|
|
@@ -45,7 +45,7 @@ jobs:
|
|
|
45
45
|
changed=false
|
|
46
46
|
while IFS= read -r changed_path; do
|
|
47
47
|
case "$changed_path" in
|
|
48
|
-
*.rs|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|
|
|
48
|
+
*.rs|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|rust-toolchain|*/rust-toolchain|rust-toolchain.toml|*/rust-toolchain.toml|.cargo/*|*/.cargo/*|.rightgit.json|.github/workflows/*.yml|.github/workflows/*.yaml|${GATE_PATH})
|
|
49
49
|
changed=true
|
|
50
50
|
break
|
|
51
51
|
;;
|
|
@@ -93,3 +93,4 @@ ${RUST_CACHE_WORKSPACES}
|
|
|
93
93
|
CARGO_INCREMENTAL: "0"
|
|
94
94
|
CARGO_PROFILE_DEV_DEBUG: "0"
|
|
95
95
|
CARGO_PROFILE_TEST_DEBUG: "0"
|
|
96
|
+
${QA_EVIDENCE_STEP}
|
|
@@ -43,10 +43,30 @@ ${PACKAGE_MANAGER_SETUP}
|
|
|
43
43
|
${OWNERSHIP_STEP}
|
|
44
44
|
- run: node scripts/release/build-candidate.mjs --version "${{ github.event.release.tag_name }}" --platform current --out "release/candidates/publish"
|
|
45
45
|
- run: node scripts/release/check-release.mjs release/candidates/publish
|
|
46
|
+
# check-release must supply publication-evidence.json with artifactSha256.
|
|
47
|
+
# Capture gated bytes before publication; never hash the source path during readback.
|
|
46
48
|
- id: tarball
|
|
47
49
|
shell: bash
|
|
48
50
|
run: |
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
node --input-type=module <<'NODE'
|
|
52
|
+
import fs from 'node:fs';
|
|
53
|
+
import { prepareNpmPublication } from '@rightkit/release/publication-gate.mjs';
|
|
54
|
+
const root = 'release/candidates/publish';
|
|
55
|
+
const c = JSON.parse(fs.readFileSync(`${root}/compatibility.json`));
|
|
56
|
+
const tarball = `${root}/${c.packageName.replace(/^@/, '').replace('/', '-')}-${c.version}.tgz`;
|
|
57
|
+
const proof = prepareNpmPublication({ name: c.packageName, version: c.version, tarball, evidencePath: `${root}/publication-evidence.json` });
|
|
58
|
+
// Only safe single-line metadata may enter GitHub outputs.
|
|
59
|
+
for (const value of [tarball, proof.name, proof.version]) if (/[\r\n]/.test(value)) throw new Error('unsafe npm publication identity');
|
|
60
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `path=${tarball}\nname=${proof.name}\nversion=${proof.version}\nsha1=${proof.sha1}\n`);
|
|
61
|
+
NODE
|
|
52
62
|
- run: npm publish "${{ steps.tarball.outputs.path }}"${NPM_PROVENANCE_FLAG} --access public
|
|
63
|
+
- name: Require registry byte equality
|
|
64
|
+
env:
|
|
65
|
+
NPM_PUBLICATION_NAME: ${{ steps.tarball.outputs.name }}
|
|
66
|
+
NPM_PUBLICATION_VERSION: ${{ steps.tarball.outputs.version }}
|
|
67
|
+
NPM_PUBLICATION_SHA1: ${{ steps.tarball.outputs.sha1 }}
|
|
68
|
+
run: |
|
|
69
|
+
node --input-type=module <<'NODE'
|
|
70
|
+
import { verifyNpmRegistryBytes } from '@rightkit/release/publication-gate.mjs';
|
|
71
|
+
verifyNpmRegistryBytes({ name: process.env.NPM_PUBLICATION_NAME, version: process.env.NPM_PUBLICATION_VERSION, sha1: process.env.NPM_PUBLICATION_SHA1 });
|
|
72
|
+
NODE
|