@multipliers-dev/renovate-workflow 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +172 -2
- package/dist/cli.js +35 -0
- package/dist/lib/agent-plugins-spec.js +100 -0
- package/dist/lib/bootstrap-tarball.js +175 -0
- package/dist/lib/derive-stop-causes.js +85 -0
- package/dist/lib/renovate-freshness-poll.js +344 -0
- package/dist/lib/renovate-guardrails.js +416 -0
- package/dist/lib/renovate-investigation-eligibility.js +42 -0
- package/dist/lib/renovate-policy-facts.js +113 -0
- package/dist/renovate-freshness-poll.js +166 -0
- package/package.json +54 -4
- package/scripts/lib/agent-plugins-spec.ts +150 -0
- package/scripts/lib/bootstrap-tarball.ts +230 -0
- package/scripts/lib/derive-stop-causes.ts +121 -0
- package/scripts/lib/renovate-freshness-poll.ts +624 -0
- package/scripts/lib/renovate-guardrails.ts +636 -0
- package/scripts/lib/renovate-investigation-eligibility.ts +65 -0
- package/scripts/lib/renovate-policy-facts.ts +189 -0
- package/scripts/renovate-freshness-poll.ts +210 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Truong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,173 @@
|
|
|
1
|
-
#
|
|
1
|
+
# renovate-workflow
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Governed Renovate merge ladder with an Agent Plugins 1.0 portable core and Cursor-specific integration: classify dependency PRs, investigate high-risk changes, and merge only when repo-local policy and human gates allow.
|
|
4
|
+
|
|
5
|
+
Renovate opens many dependency PRs. This product turns that queue into a repeatable **classify → route → (investigate) → maintainer** path with explicit policy, instead of ad-hoc merges.
|
|
6
|
+
|
|
7
|
+
**Background:** [Upgrades don't have to be a blind trust exercise](https://dev.to/michaeltruong/upgrades-dont-have-to-be-a-blind-trust-exercise-13mj) (DEV Community) — the problem framing, four-step evidence checklist, and review path that led to this repository.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
classify (/renovate-classifier)
|
|
11
|
+
→ route by packet
|
|
12
|
+
→ low risk → maintainer (auto path)
|
|
13
|
+
→ high-touch / unlisted → investigator → human audit → maintainer --approved
|
|
14
|
+
→ optional loop (/renovate-loop) for repeated cycles
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Ownership split**
|
|
18
|
+
|
|
19
|
+
| Layer | Where it lives |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Skills, agents, runbook, portable rubric | This repo (Cursor plugin + [Agent Plugins 1.0](https://agent-plugins.org/specification) `skills/`) |
|
|
22
|
+
| Executable scripts (freshness poll, guardrails) | This repo (npm/git devDependency) |
|
|
23
|
+
| Policy facts, Renovate bot config, CI workflow | Your repo |
|
|
24
|
+
|
|
25
|
+
Portable vs Cursor manifest layers: [docs/adopt.md#portable-vs-cursor-layers-this-repo](docs/adopt.md#portable-vs-cursor-layers-this-repo).
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Install (Cursor plugin)
|
|
30
|
+
|
|
31
|
+
Cursor imports this GitHub repository as a **local marketplace**, then installs the listed plugin.
|
|
32
|
+
|
|
33
|
+
**Step 1 — import marketplace**
|
|
34
|
+
|
|
35
|
+
Customize → **Plugins** → **+ Add** → **From GitHub Repository** → `https://github.com/multipliers-dev/renovate-workflow`
|
|
36
|
+
|
|
37
|
+
Or in Agent chat:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
/add-plugin https://github.com/multipliers-dev/renovate-workflow
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Step 2 — install plugin**
|
|
44
|
+
|
|
45
|
+
Customize → **Plugins** → open the imported **renovate-workflow** marketplace → install the **renovate-workflow** plugin.
|
|
46
|
+
|
|
47
|
+
Full adoption guide: [docs/adopt.md](docs/adopt.md)
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Quick start (classifier only)
|
|
52
|
+
|
|
53
|
+
Minimum path to evaluate the ladder — **no `package.json` changes required**.
|
|
54
|
+
|
|
55
|
+
1. Import marketplace (step 1 above)
|
|
56
|
+
2. Install the **renovate-workflow** plugin (step 2 above)
|
|
57
|
+
3. Confirm `/renovate-classifier` appears under Customize → Plugins
|
|
58
|
+
4. Copy [.agents/renovate-policy.template.yml](.agents/renovate-policy.template.yml) to `.agents/renovate-policy.yml` in your repo and customize package lists, checks, and paths — see [docs/policy-setup.md](docs/policy-setup.md) and [examples/example-repo/](examples/example-repo/)
|
|
59
|
+
5. Ensure GitHub access (`gh` CLI or GitHub MCP)
|
|
60
|
+
6. Run `/renovate-classifier`
|
|
61
|
+
|
|
62
|
+
Success is `queue_empty` or a classification packet. The classifier **never merges**.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Enable loop / babysit helpers
|
|
67
|
+
|
|
68
|
+
Required for `/renovate-loop --babysit` and the freshness poll CLI. Not required to try the classifier.
|
|
69
|
+
|
|
70
|
+
Add a git devDependency and script in your consumer `package.json`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"renovate-workflow": "github:multipliers-dev/renovate-workflow",
|
|
76
|
+
"tsx": "^4.23.12"
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"renovate:freshness-poll": "tsx node_modules/renovate-workflow/scripts/renovate-freshness-poll.ts"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The `scripts/` tree in the installable package is a **temporary legacy surface** for git consumers running `tsx` on TypeScript sources. The stable npm API is the compiled `renovate-workflow` CLI (`renovate-workflow freshness-poll`); see [`.cursor/plans/2026-10-07-npm-package-distribution.plan.md`](.cursor/plans/2026-10-07-npm-package-distribution.plan.md).
|
|
85
|
+
|
|
86
|
+
See [examples/adopt-stub/package.json](examples/adopt-stub/package.json) and [docs/adopt.md](docs/adopt.md).
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## What this does not do
|
|
91
|
+
|
|
92
|
+
- Fully autonomous merging — human gates remain for high-risk and investigation paths
|
|
93
|
+
- Scheduled or webhook-driven automation — manual invocation only
|
|
94
|
+
- Classifier or investigator merge authority — only maintainer may merge, and only with a valid packet
|
|
95
|
+
- Loop with `--approved` — investigation-approved merges require a separate maintainer step
|
|
96
|
+
- Replace the Renovate bot — you still configure `renovate.json` and a Renovate workflow in your repo
|
|
97
|
+
- Cursor public marketplace or org Team Marketplace catalog — install via GitHub import of this repo
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Documentation
|
|
102
|
+
|
|
103
|
+
Primary adopter path:
|
|
104
|
+
|
|
105
|
+
1. [docs/adopt.md](docs/adopt.md) — plugin install + npm helpers + consumer layout
|
|
106
|
+
2. [docs/policy-setup.md](docs/policy-setup.md) — consumer policy sync model
|
|
107
|
+
3. [docs/renovate-workflow.md](docs/renovate-workflow.md) — operator runbook
|
|
108
|
+
|
|
109
|
+
Also:
|
|
110
|
+
|
|
111
|
+
- [Upgrades don't have to be a blind trust exercise](https://dev.to/michaeltruong/upgrades-dont-have-to-be-a-blind-trust-exercise-13mj) — background essay (DEV Community)
|
|
112
|
+
- [docs/versioning.md](docs/versioning.md) — version alignment and git dependency pinning
|
|
113
|
+
- [docs/distribution-discovery.md](docs/distribution-discovery.md) — architecture rationale (maintainer deep-dive)
|
|
114
|
+
- [AGENTS.md](AGENTS.md) — agent command reference
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Publishing (maintainers)
|
|
119
|
+
|
|
120
|
+
This repository uses a **two-step release** — version bump in a reviewed PR, then manual publish. Merging to `main` never publishes.
|
|
121
|
+
|
|
122
|
+
| Step | Who | What |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| **A — Version bump** | Reviewed PR | Bump aligned manifests (`package.json`, `plugin.json`, `.cursor-plugin/plugin.json`). For `0.3.0`, also scope-rename to `@multipliers-dev/renovate-workflow` and remove `private`. |
|
|
125
|
+
| **B — Publish** | Maintainer | Dispatch [`.github/workflows/release.yml`](.github/workflows/release.yml) on `main` with confirmation `publish X.Y.Z`. |
|
|
126
|
+
|
|
127
|
+
**Before first release:**
|
|
128
|
+
|
|
129
|
+
1. `version-bump-0.3.0` merged on `main` (scoped name, `0.3.0`, not private).
|
|
130
|
+
2. **npm CLI preflight** — `sh scripts/npm-stage-cli-preflight.sh` (staged publishing requires npm **≥ 11.15.0**; Node version alone does not guarantee `npm stage` exists).
|
|
131
|
+
3. **One-time registry bootstrap** — validate `0.3.0` in the checkout (`npm ci`, `npm test`, `npm run typecheck`, `npm run build`, `npm pack`), then `npm run bootstrap:tarball` to build/preflight a **disposable `0.0.1` tarball** (npm pack in an isolated temp dir — not manual `tar -czf`; canonical checkout stays at `0.3.0`), then `npm stage publish` that tarball. **Do not** stage `0.3.0` and leave it pending. See [docs/versioning.md § Trusted Publishing bootstrap](docs/versioning.md#trusted-publishing-bootstrap).
|
|
132
|
+
4. Configure npm **Trusted Publisher** for workflow `release.yml` (`multipliers-dev/renovate-workflow`). See [docs/versioning.md § npm Trusted Publisher settings](docs/versioning.md#npm-trusted-publisher-settings).
|
|
133
|
+
5. Dispatch **Release** within **48 hours** of creating the Trusted Publisher configuration; after successful OIDC `0.3.0`, reject the pending bootstrap stage with `npm stage reject <stage-id>` (2FA).
|
|
134
|
+
|
|
135
|
+
Publishing uses **OIDC** (no `NPM_TOKEN`). Provenance is automatic for public packages from this public repo.
|
|
136
|
+
|
|
137
|
+
The release workflow reads **name and version from the checked-out `package.json` only** (`npm publish --access public` with no CLI coordinate override). It does not bump versions or commit to `main`. npm and plugin always share version *V* at the same commit. Only one release dispatch runs at a time (`concurrency: release`).
|
|
138
|
+
|
|
139
|
+
If `npm publish` succeeds but tag/GitHub Release creation fails, **do not republish** — verify the registry version matches the intended commit, then create the missing tag and GitHub Release manually. See [docs/versioning.md § Partial release recovery](docs/versioning.md#partial-release-recovery).
|
|
140
|
+
|
|
141
|
+
Full policy: [docs/versioning.md](docs/versioning.md)
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npm install
|
|
149
|
+
npm test
|
|
150
|
+
npm run typecheck
|
|
151
|
+
npm run build
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Layout
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
plugin.json Agent Plugins 1.0 portable manifest (metadata only)
|
|
160
|
+
skills/ Five renovate skills + verification assets (fixed discovery path)
|
|
161
|
+
.cursor-plugin/ Cursor marketplace + extension manifest (agents, skill paths)
|
|
162
|
+
.agents/ Agent prompts, rubric base, policy template, report templates
|
|
163
|
+
.cursor/ Repo-local Cursor dev (hooks, Cloud VM) — not plugin-distributed
|
|
164
|
+
docs/ Adoption guides and runbook
|
|
165
|
+
scripts/ Guardrails, freshness poll, tests/fixtures
|
|
166
|
+
examples/example-repo/ Synthetic consumer stubs for policy schema tests
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
[MIT](LICENSE)
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runRenovateFreshnessPollCli } from "./renovate-freshness-poll.js";
|
|
3
|
+
function printRootUsage() {
|
|
4
|
+
console.error(`Usage: renovate-workflow <command>
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
freshness-poll Run the Renovate post-update babysit helper
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
-h, --help Show this help
|
|
11
|
+
|
|
12
|
+
Run \`renovate-workflow freshness-poll --help\` for freshness-poll options.
|
|
13
|
+
`);
|
|
14
|
+
}
|
|
15
|
+
async function main() {
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
18
|
+
printRootUsage();
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
const [command, ...rest] = args;
|
|
22
|
+
switch (command) {
|
|
23
|
+
case "freshness-poll":
|
|
24
|
+
await runRenovateFreshnessPollCli(rest);
|
|
25
|
+
return;
|
|
26
|
+
default:
|
|
27
|
+
console.error(`unknown command: ${command}`);
|
|
28
|
+
printRootUsage();
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
main().catch((error) => {
|
|
33
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export const AGENT_PLUGINS_SCHEMA_BASE = "https://agent-plugins.org/schemas";
|
|
2
|
+
export const PUBLISHED_SPEC_MD_URL = "https://agent-plugins.org/specification.md";
|
|
3
|
+
const DECLARED_SCHEMA_PATTERN = /^https:\/\/agent-plugins\.org\/schemas\/(\d+\.\d+\.\d+)\/plugin\.schema\.json$/;
|
|
4
|
+
const PUBLISHED_SPEC_VERSION_PATTERN = /^\*\*Spec Version:\s*(\d+\.\d+\.\d+)\s*\*\*$/m;
|
|
5
|
+
const PUBLISHED_STATUS_PATTERN = /^\*\*Status:\s*Published\s*\*\*$/m;
|
|
6
|
+
export function buildPublishedSchemaUrl(version) {
|
|
7
|
+
return `${AGENT_PLUGINS_SCHEMA_BASE}/${version}/plugin.schema.json`;
|
|
8
|
+
}
|
|
9
|
+
export function parseAgentPluginsSchemaUrl(schemaUrl) {
|
|
10
|
+
const match = DECLARED_SCHEMA_PATTERN.exec(schemaUrl);
|
|
11
|
+
if (!match) {
|
|
12
|
+
throw new Error(`malformed Agent Plugins schema URL: expected https://agent-plugins.org/schemas/X.Y.Z/plugin.schema.json`);
|
|
13
|
+
}
|
|
14
|
+
return match[1];
|
|
15
|
+
}
|
|
16
|
+
export function parseDeclaredSpecVersion($schema) {
|
|
17
|
+
try {
|
|
18
|
+
return parseAgentPluginsSchemaUrl($schema);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error(`malformed $schema URL: expected https://agent-plugins.org/schemas/X.Y.Z/plugin.schema.json`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function parsePublishedSchemaDocumentVersion(schemaDocument) {
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(schemaDocument);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error("published schema document is not valid JSON");
|
|
31
|
+
}
|
|
32
|
+
if (typeof parsed !== "object" || parsed === null || !("$id" in parsed)) {
|
|
33
|
+
throw new Error("published schema document is missing $id");
|
|
34
|
+
}
|
|
35
|
+
const schemaId = parsed.$id;
|
|
36
|
+
if (typeof schemaId !== "string") {
|
|
37
|
+
throw new Error("published schema document has invalid $id");
|
|
38
|
+
}
|
|
39
|
+
return parseAgentPluginsSchemaUrl(schemaId);
|
|
40
|
+
}
|
|
41
|
+
export function parsePublishedSpecVersion(specMarkdown) {
|
|
42
|
+
if (!PUBLISHED_STATUS_PATTERN.test(specMarkdown)) {
|
|
43
|
+
throw new Error("upstream specification.md is missing Status: Published");
|
|
44
|
+
}
|
|
45
|
+
const versionMatch = PUBLISHED_SPEC_VERSION_PATTERN.exec(specMarkdown);
|
|
46
|
+
if (!versionMatch) {
|
|
47
|
+
throw new Error("upstream specification.md is missing Spec Version");
|
|
48
|
+
}
|
|
49
|
+
return versionMatch[1];
|
|
50
|
+
}
|
|
51
|
+
export function compareSemver(a, b) {
|
|
52
|
+
const aParts = a.split(".").map((part) => Number(part));
|
|
53
|
+
const bParts = b.split(".").map((part) => Number(part));
|
|
54
|
+
if (aParts.length !== 3 || bParts.length !== 3) {
|
|
55
|
+
throw new Error(`invalid semver: ${a} or ${b}`);
|
|
56
|
+
}
|
|
57
|
+
for (let index = 0; index < 3; index += 1) {
|
|
58
|
+
const aPart = aParts[index];
|
|
59
|
+
const bPart = bParts[index];
|
|
60
|
+
if (!Number.isInteger(aPart) || !Number.isInteger(bPart)) {
|
|
61
|
+
throw new Error(`invalid semver: ${a} or ${b}`);
|
|
62
|
+
}
|
|
63
|
+
if (aPart < bPart) {
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
if (aPart > bPart) {
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
export function confirmPublishedUpstreamVersion(signals) {
|
|
73
|
+
const { markdownVersion, schemaVersion, schemaHttpStatus } = signals;
|
|
74
|
+
if (schemaHttpStatus !== 200) {
|
|
75
|
+
throw new Error(`published schema URL returned HTTP ${schemaHttpStatus} for version ${schemaVersion}`);
|
|
76
|
+
}
|
|
77
|
+
if (markdownVersion !== schemaVersion) {
|
|
78
|
+
throw new Error(`published signal mismatch: specification.md reports ${markdownVersion} but published schema document $id reports ${schemaVersion}`);
|
|
79
|
+
}
|
|
80
|
+
return markdownVersion;
|
|
81
|
+
}
|
|
82
|
+
export function evaluateSpecRelationship(options) {
|
|
83
|
+
const comparison = compareSemver(options.declared, options.published);
|
|
84
|
+
let relationship;
|
|
85
|
+
if (comparison < 0) {
|
|
86
|
+
relationship = "behind";
|
|
87
|
+
}
|
|
88
|
+
else if (comparison > 0) {
|
|
89
|
+
relationship = "ahead";
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
relationship = "current";
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
declaredVersion: options.declared,
|
|
96
|
+
latestPublishedVersion: options.published,
|
|
97
|
+
relationship,
|
|
98
|
+
driftDetected: relationship === "behind",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
export const BOOTSTRAP_VERSION = "0.0.1";
|
|
6
|
+
const FORBIDDEN_ENTRY_PATTERNS = [
|
|
7
|
+
{
|
|
8
|
+
label: "directory entry package/ (trailing slash)",
|
|
9
|
+
test: (entry) => entry === "package/" || entry === "package",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
label: "AppleDouble (._*)",
|
|
13
|
+
test: (entry) => /(^|\/)\._[^/]+/.test(entry),
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
label: ".git",
|
|
17
|
+
test: (entry) => entry.includes(".git"),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
label: "test files",
|
|
21
|
+
test: (entry) => entry.endsWith(".test.ts") || entry.endsWith(".test.js"),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
label: "fixtures",
|
|
25
|
+
test: (entry) => entry.includes("/fixtures/"),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
label: "credentials",
|
|
29
|
+
test: (entry) => /(^|\/)\.env($|\.)/.test(entry) ||
|
|
30
|
+
entry.includes("credentials.json") ||
|
|
31
|
+
entry.includes("id_rsa"),
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
export function listTarballEntries(tarballPath) {
|
|
35
|
+
const listing = execSync(`tar -tzf "${tarballPath}"`, { encoding: "utf8" });
|
|
36
|
+
return listing
|
|
37
|
+
.trim()
|
|
38
|
+
.split("\n")
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
export function readPackedManifest(tarballPath) {
|
|
42
|
+
const raw = execSync(`tar -xOf "${tarballPath}" package/package.json`, { encoding: "utf8" });
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
export function parsePackTarballName(packOutput) {
|
|
46
|
+
const tarballName = packOutput
|
|
47
|
+
.split(/\r?\n/)
|
|
48
|
+
.map((line) => line.trim())
|
|
49
|
+
.filter((line) => line.endsWith(".tgz"))
|
|
50
|
+
.at(-1);
|
|
51
|
+
if (!tarballName) {
|
|
52
|
+
throw new Error(`npm pack did not report a .tgz filename:\n${packOutput}`);
|
|
53
|
+
}
|
|
54
|
+
return tarballName;
|
|
55
|
+
}
|
|
56
|
+
export function assertBootstrapTarballEntries(entries) {
|
|
57
|
+
const violations = [];
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
for (const pattern of FORBIDDEN_ENTRY_PATTERNS) {
|
|
60
|
+
if (pattern.test(entry)) {
|
|
61
|
+
violations.push(`${entry} (${pattern.label})`);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (violations.length > 0) {
|
|
67
|
+
throw new Error(`bootstrap tarball failed preflight:\n${violations.map((v) => ` - ${v}`).join("\n")}`);
|
|
68
|
+
}
|
|
69
|
+
const normalized = entries.map((entry) => entry.replace(/^package\//, ""));
|
|
70
|
+
if (!normalized.some((entry) => entry.startsWith("dist/"))) {
|
|
71
|
+
throw new Error("bootstrap tarball missing dist/ publish surface");
|
|
72
|
+
}
|
|
73
|
+
if (!normalized.includes("package.json")) {
|
|
74
|
+
throw new Error("bootstrap tarball missing package/package.json");
|
|
75
|
+
}
|
|
76
|
+
if (!normalized.includes("README.md")) {
|
|
77
|
+
throw new Error("bootstrap tarball missing package/README.md");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Lifecycle hooks that reference dev-only paths excluded from the packed `files` list. */
|
|
81
|
+
const ISOLATED_PACK_SCRIPT_KEYS = ["prepare", "prepack"];
|
|
82
|
+
function stripIsolatedPackLifecycleScripts(packageDir) {
|
|
83
|
+
const packageJsonPath = join(packageDir, "package.json");
|
|
84
|
+
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
85
|
+
if (!manifest.scripts) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const key of ISOLATED_PACK_SCRIPT_KEYS) {
|
|
89
|
+
delete manifest.scripts[key];
|
|
90
|
+
}
|
|
91
|
+
writeFileSync(packageJsonPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
92
|
+
}
|
|
93
|
+
function packDirectoryToDestination(packageDir, packDestination) {
|
|
94
|
+
mkdirSync(packDestination, { recursive: true });
|
|
95
|
+
const packOutput = execSync(`npm pack --ignore-scripts --loglevel error --pack-destination "${packDestination}"`, {
|
|
96
|
+
cwd: packageDir,
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
99
|
+
});
|
|
100
|
+
const packedName = parsePackTarballName(packOutput);
|
|
101
|
+
return join(packDestination, packedName);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Build a disposable bootstrap tarball from an already-validated `npm pack` artifact.
|
|
105
|
+
*
|
|
106
|
+
* Uses `npm pack --ignore-scripts` in an isolated temp directory so the tarball
|
|
107
|
+
* matches libnpmpack layout (file entries only under package/..., no bare package/
|
|
108
|
+
* directory entry). Manual `tar -czf` is rejected by the npm registry (E415 invalid
|
|
109
|
+
* path: package/) because BSD tar emits a trailing-slash directory entry.
|
|
110
|
+
*/
|
|
111
|
+
export function createBootstrapTarballFromValidatedPack(validatedTarballPath, options = {}) {
|
|
112
|
+
const absoluteValidated = resolve(validatedTarballPath);
|
|
113
|
+
if (!existsSync(absoluteValidated)) {
|
|
114
|
+
throw new Error(`validated pack tarball not found: ${absoluteValidated}`);
|
|
115
|
+
}
|
|
116
|
+
const workRoot = mkdtempSync(join(tmpdir(), "renovate-workflow-bootstrap-work-"));
|
|
117
|
+
const extractDir = join(workRoot, "extract");
|
|
118
|
+
const isolatedPackageDir = join(workRoot, "isolated-package");
|
|
119
|
+
// Default: durable artifact dir outside workRoot so the returned .tgz survives work cleanup.
|
|
120
|
+
const artifactDir = options.outputDir
|
|
121
|
+
? resolve(options.outputDir)
|
|
122
|
+
: mkdtempSync(join(tmpdir(), "renovate-workflow-bootstrap-artifact-"));
|
|
123
|
+
try {
|
|
124
|
+
mkdirSync(extractDir, { recursive: true });
|
|
125
|
+
cpSync(absoluteValidated, join(workRoot, "validated.tgz"));
|
|
126
|
+
execSync(`tar -xzf "${join(workRoot, "validated.tgz")}" -C "${extractDir}"`, {
|
|
127
|
+
stdio: "pipe",
|
|
128
|
+
});
|
|
129
|
+
const extractedPackageDir = join(extractDir, "package");
|
|
130
|
+
if (!existsSync(join(extractedPackageDir, "package.json"))) {
|
|
131
|
+
throw new Error("validated pack tarball missing package/package.json");
|
|
132
|
+
}
|
|
133
|
+
cpSync(extractedPackageDir, isolatedPackageDir, { recursive: true });
|
|
134
|
+
execSync(`npm pkg set version=${BOOTSTRAP_VERSION}`, {
|
|
135
|
+
cwd: isolatedPackageDir,
|
|
136
|
+
stdio: "pipe",
|
|
137
|
+
});
|
|
138
|
+
// Packed artifact omits scripts/*.sh; npm pack may still run `prepare` unless removed.
|
|
139
|
+
stripIsolatedPackLifecycleScripts(isolatedPackageDir);
|
|
140
|
+
const packDir = join(workRoot, "packed");
|
|
141
|
+
const packedPath = packDirectoryToDestination(isolatedPackageDir, packDir);
|
|
142
|
+
const packedName = basename(packedPath);
|
|
143
|
+
const finalPath = join(artifactDir, packedName);
|
|
144
|
+
copyFileSync(packedPath, finalPath);
|
|
145
|
+
const entries = listTarballEntries(finalPath);
|
|
146
|
+
assertBootstrapTarballEntries(entries);
|
|
147
|
+
const manifest = readPackedManifest(finalPath);
|
|
148
|
+
if (manifest.version !== BOOTSTRAP_VERSION) {
|
|
149
|
+
throw new Error(`bootstrap tarball version must be ${BOOTSTRAP_VERSION}, got ${manifest.version}`);
|
|
150
|
+
}
|
|
151
|
+
return { tarballPath: finalPath, entries, manifest };
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (!options.keepWorkdirs) {
|
|
155
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function packValidatedReleaseArtifact(repoRoot) {
|
|
160
|
+
const packDir = mkdtempSync(join(tmpdir(), "renovate-workflow-validated-pack-"));
|
|
161
|
+
return packDirectoryToDestination(repoRoot, packDir);
|
|
162
|
+
}
|
|
163
|
+
export function removePackedArtifactTree(tarballPath) {
|
|
164
|
+
rmSync(dirname(tarballPath), { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
/** Remove the temp directory that owns a default-path bootstrap tarball. */
|
|
167
|
+
export function removeBootstrapArtifactTree(tarballPath) {
|
|
168
|
+
rmSync(dirname(tarballPath), { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
export function assertCheckoutVersion(repoRoot, expectedVersion) {
|
|
171
|
+
const manifest = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
|
|
172
|
+
if (manifest.version !== expectedVersion) {
|
|
173
|
+
throw new Error(`Expected package version ${expectedVersion}, got ${manifest.version}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single derivation table for classifier emission and guardrails evaluation.
|
|
3
|
+
* Keys must match the reference table in the renovate investigation lane plan.
|
|
4
|
+
*/
|
|
5
|
+
export const TRIGGER_WATCH_KEYS = [
|
|
6
|
+
"implementation_changes_required",
|
|
7
|
+
"lockfile_threshold_exceeded",
|
|
8
|
+
"ci_failure_unexplained",
|
|
9
|
+
"runtime_behavior_affected",
|
|
10
|
+
];
|
|
11
|
+
export const KNOWN_STOP_CAUSES = [
|
|
12
|
+
"decision_human_required",
|
|
13
|
+
"stop_flag_expected_human_required",
|
|
14
|
+
"triggered_implementation_changes_required",
|
|
15
|
+
"triggered_lockfile_threshold_exceeded",
|
|
16
|
+
"triggered_ci_failure_unexplained",
|
|
17
|
+
"triggered_runtime_behavior_affected",
|
|
18
|
+
"triggered_runtime_behavior_affected_sole",
|
|
19
|
+
"triggered_human_required_unmapped",
|
|
20
|
+
"decision_defer",
|
|
21
|
+
];
|
|
22
|
+
const KNOWN_STOP_CAUSE_SET = new Set(KNOWN_STOP_CAUSES);
|
|
23
|
+
const TRIGGER_WATCH_KEY_SET = new Set(TRIGGER_WATCH_KEYS);
|
|
24
|
+
export function isKnownStopCause(value) {
|
|
25
|
+
return KNOWN_STOP_CAUSE_SET.has(value);
|
|
26
|
+
}
|
|
27
|
+
function deriveTriggerStopCauses(triggered) {
|
|
28
|
+
const causes = [];
|
|
29
|
+
if (triggered.some((key) => !TRIGGER_WATCH_KEY_SET.has(key))) {
|
|
30
|
+
causes.push("triggered_human_required_unmapped");
|
|
31
|
+
}
|
|
32
|
+
if (triggered.includes("implementation_changes_required")) {
|
|
33
|
+
causes.push("triggered_implementation_changes_required");
|
|
34
|
+
}
|
|
35
|
+
if (triggered.includes("lockfile_threshold_exceeded")) {
|
|
36
|
+
causes.push("triggered_lockfile_threshold_exceeded");
|
|
37
|
+
}
|
|
38
|
+
if (triggered.includes("ci_failure_unexplained")) {
|
|
39
|
+
causes.push("triggered_ci_failure_unexplained");
|
|
40
|
+
}
|
|
41
|
+
if (triggered.includes("runtime_behavior_affected")) {
|
|
42
|
+
if (triggered.length === 1) {
|
|
43
|
+
causes.push("triggered_runtime_behavior_affected_sole");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
causes.push("triggered_runtime_behavior_affected");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return causes;
|
|
50
|
+
}
|
|
51
|
+
export function deriveStopCauses(packet) {
|
|
52
|
+
const causes = [];
|
|
53
|
+
const decision = packet.classification?.decision;
|
|
54
|
+
const triggered = packet.triggered_human_required ?? [];
|
|
55
|
+
if (decision === "defer") {
|
|
56
|
+
causes.push("decision_defer");
|
|
57
|
+
}
|
|
58
|
+
if (decision === "human_required") {
|
|
59
|
+
causes.push("decision_human_required");
|
|
60
|
+
}
|
|
61
|
+
if (packet.stop === true && decision === "human_required") {
|
|
62
|
+
causes.push("stop_flag_expected_human_required");
|
|
63
|
+
}
|
|
64
|
+
causes.push(...deriveTriggerStopCauses(triggered));
|
|
65
|
+
return causes;
|
|
66
|
+
}
|
|
67
|
+
export function stopCausesMatchDerivation(packet) {
|
|
68
|
+
const expectedCauses = deriveStopCauses(packet);
|
|
69
|
+
const packetCauses = packet.stop_causes ?? [];
|
|
70
|
+
if (packetCauses.length !== expectedCauses.length) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const sortedPacketCauses = [...packetCauses].sort();
|
|
74
|
+
const sortedExpectedCauses = [...expectedCauses].sort();
|
|
75
|
+
return sortedPacketCauses.every((cause, index) => cause === sortedExpectedCauses[index]);
|
|
76
|
+
}
|
|
77
|
+
export function validateStopCausesDerivation(packet) {
|
|
78
|
+
if (packet.stop !== true) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!stopCausesMatchDerivation(packet)) {
|
|
82
|
+
return "packet stop_causes do not match deriveStopCauses derivation";
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|