@natjswenson/shipflow 0.2.3 → 0.2.4
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/bin/shipflow.js +5 -1
- package/lib/detect.mjs +18 -9
- package/lib/gh.mjs +17 -0
- package/package.json +1 -1
package/bin/shipflow.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
dispatchReleaseWorkflow,
|
|
15
15
|
renameDefaultBranch,
|
|
16
16
|
} from '../lib/apply.mjs';
|
|
17
|
+
import { readFileCapped } from '../lib/gh.mjs';
|
|
17
18
|
|
|
18
19
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
20
|
const TEMPLATE_PATH = join(PACKAGE_ROOT, 'templates', 'dev-to-main-automerge.yml.tmpl');
|
|
@@ -24,7 +25,10 @@ function readPackageVersion() {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function readConfig(path) {
|
|
27
|
-
|
|
28
|
+
// .github/shipflow.json lives in whatever repo --repo points at — a
|
|
29
|
+
// repo-write-editable file, not admin-only — so cap its size before
|
|
30
|
+
// parsing (see lib/gh.mjs's readFileCapped for why).
|
|
31
|
+
return JSON.parse(readFileCapped(path));
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
function defaultConfigPath(repoPath) {
|
package/lib/detect.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { spawnArgs, ghApiJson, git, sha256 } from './gh.mjs';
|
|
3
|
+
import { spawnArgs, ghApiJson, git, sha256, readFileCapped } from './gh.mjs';
|
|
4
4
|
|
|
5
5
|
const TEMPLATE_RELATIVE_PATH = '.github/workflows/dev-to-main-automerge.yml';
|
|
6
6
|
const CONFIG_RELATIVE_PATH = '.github/shipflow.json';
|
|
@@ -14,11 +14,20 @@ const SETTINGS_AS_CODE_NAME_RE = /repo-settings\.(sh|js|mjs|py)$/i;
|
|
|
14
14
|
const SETTINGS_AS_CODE_CONTENT_RE =
|
|
15
15
|
/github_branch_protection|github_repository_ruleset|branches\/[\w-]+\/protection/;
|
|
16
16
|
|
|
17
|
+
// A segment made of only dots ("." / ".." / "...") matches [\w.-]+ but is
|
|
18
|
+
// never a real GitHub owner or repo name — reject it rather than build an
|
|
19
|
+
// ownerRepo string that could normalize away the intended repos/<owner>/<repo>
|
|
20
|
+
// prefix once interpolated into gh api paths downstream.
|
|
21
|
+
const ALL_DOTS_RE = /^\.+$/;
|
|
22
|
+
|
|
17
23
|
export function resolveOwnerRepo(repoPath) {
|
|
18
24
|
const r = git(['remote', 'get-url', 'origin'], { cwd: repoPath });
|
|
19
25
|
if (r.status !== 0) return null;
|
|
20
26
|
const m = r.stdout.match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(\.git)?$/);
|
|
21
|
-
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
const [, owner, repo] = m;
|
|
29
|
+
if (ALL_DOTS_RE.test(owner) || ALL_DOTS_RE.test(repo)) return null;
|
|
30
|
+
return `${owner}/${repo}`;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export function listTrackedFiles(repoPath) {
|
|
@@ -42,7 +51,7 @@ export function findSettingsAsCodeArtifact(repoPath, trackedFiles) {
|
|
|
42
51
|
if (!existsSync(full)) continue;
|
|
43
52
|
let content;
|
|
44
53
|
try {
|
|
45
|
-
content =
|
|
54
|
+
content = readFileCapped(full);
|
|
46
55
|
} catch {
|
|
47
56
|
continue;
|
|
48
57
|
}
|
|
@@ -66,7 +75,7 @@ export function listWorkflowJobNames(repoPath, trackedFiles) {
|
|
|
66
75
|
if (!existsSync(full)) continue;
|
|
67
76
|
let content;
|
|
68
77
|
try {
|
|
69
|
-
content =
|
|
78
|
+
content = readFileCapped(full);
|
|
70
79
|
} catch {
|
|
71
80
|
continue;
|
|
72
81
|
}
|
|
@@ -87,7 +96,7 @@ export function listWorkflowJobNames(repoPath, trackedFiles) {
|
|
|
87
96
|
export function readTemplateFileHash(repoPath) {
|
|
88
97
|
const full = join(repoPath, TEMPLATE_RELATIVE_PATH);
|
|
89
98
|
if (!existsSync(full)) return { exists: false, sha256: null };
|
|
90
|
-
const content =
|
|
99
|
+
const content = readFileCapped(full);
|
|
91
100
|
return { exists: true, sha256: sha256(content) };
|
|
92
101
|
}
|
|
93
102
|
|
|
@@ -95,14 +104,14 @@ export function readExistingConfig(repoPath) {
|
|
|
95
104
|
const full = join(repoPath, CONFIG_RELATIVE_PATH);
|
|
96
105
|
if (!existsSync(full)) return null;
|
|
97
106
|
try {
|
|
98
|
-
return JSON.parse(
|
|
107
|
+
return JSON.parse(readFileCapped(full));
|
|
99
108
|
} catch {
|
|
100
109
|
return null;
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
export function fetchBranchProtection(ownerRepo, branch) {
|
|
105
|
-
const r = ghApiJson(`repos/${ownerRepo}/branches/${branch}/protection`);
|
|
114
|
+
const r = ghApiJson(`repos/${ownerRepo}/branches/${encodeURIComponent(branch)}/protection`);
|
|
106
115
|
if (!r.ok) return null;
|
|
107
116
|
const checks = r.data?.required_status_checks?.contexts ?? [];
|
|
108
117
|
return { requiredChecks: checks, raw: r.data };
|
|
@@ -133,7 +142,7 @@ export function fetchRulesetRequiredChecks(ownerRepo, rulesetId) {
|
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
export function checkSecretPresent(ownerRepo, secretName) {
|
|
136
|
-
const r = ghApiJson(`repos/${ownerRepo}/actions/secrets/${secretName}`);
|
|
145
|
+
const r = ghApiJson(`repos/${ownerRepo}/actions/secrets/${encodeURIComponent(secretName)}`);
|
|
137
146
|
return r.ok;
|
|
138
147
|
}
|
|
139
148
|
|
package/lib/gh.mjs
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { statSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
|
|
5
|
+
// Every file shipflow reads under a target repo (.github/shipflow.json,
|
|
6
|
+
// candidate settings-as-code artifacts, workflow YAML, the rendered
|
|
7
|
+
// template on disk) is repo-write-controlled, not admin-only — a
|
|
8
|
+
// maliciously huge or pathologically nested file could exhaust memory on
|
|
9
|
+
// an unbounded readFileSync/JSON.parse. 1 MB is generous for any
|
|
10
|
+
// legitimate config/workflow/IaC file shipflow actually needs to read.
|
|
11
|
+
const MAX_READ_BYTES = 1_000_000;
|
|
12
|
+
|
|
13
|
+
export function readFileCapped(path, encoding = 'utf8') {
|
|
14
|
+
const size = statSync(path).size;
|
|
15
|
+
if (size > MAX_READ_BYTES) {
|
|
16
|
+
throw new Error(`refusing to read ${path}: ${size} bytes exceeds the ${MAX_READ_BYTES}-byte safety cap`);
|
|
17
|
+
}
|
|
18
|
+
return readFileSync(path, encoding);
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
// argv-style invocation; no shell, so user-supplied args cannot inject.
|
|
5
22
|
// Returns { status, stdout, stderr } so callers can distinguish failure modes
|
|
6
23
|
// (e.g. a 404 from gh vs. a network error) instead of collapsing to a boolean.
|
package/package.json
CHANGED