github-settings-as-code 3.1.0 → 4.0.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.
Files changed (170) hide show
  1. package/README.md +61 -39
  2. package/dist/cmd/repo-settings.mjs +17 -2
  3. package/dist/cmd/repo-settings.mjs.map +1 -1
  4. package/dist/github/actions-settings/apply.d.mts.map +1 -1
  5. package/dist/github/actions-settings/apply.mjs +0 -4
  6. package/dist/github/actions-settings/apply.mjs.map +1 -1
  7. package/dist/github/actions-settings/backup.d.mts +1 -1
  8. package/dist/github/actions-settings/backup.d.mts.map +1 -1
  9. package/dist/github/actions-settings/backup.mjs +4 -4
  10. package/dist/github/actions-settings/backup.mjs.map +1 -1
  11. package/dist/github/actions-settings/constants.d.mts +1 -1
  12. package/dist/github/clear-json-files.d.mts +17 -0
  13. package/dist/github/clear-json-files.d.mts.map +1 -0
  14. package/dist/github/clear-json-files.mjs +29 -0
  15. package/dist/github/clear-json-files.mjs.map +1 -0
  16. package/dist/github/constants.d.mts +3 -0
  17. package/dist/github/constants.d.mts.map +1 -1
  18. package/dist/github/constants.mjs +3 -0
  19. package/dist/github/constants.mjs.map +1 -1
  20. package/dist/github/environments/api/get-environments.d.mts +16 -0
  21. package/dist/github/environments/api/get-environments.d.mts.map +1 -0
  22. package/dist/github/environments/api/get-environments.mjs +98 -0
  23. package/dist/github/environments/api/get-environments.mjs.map +1 -0
  24. package/dist/github/environments/api/index.d.mts +3 -0
  25. package/dist/github/environments/api/index.d.mts.map +1 -0
  26. package/dist/github/environments/api/index.mjs +3 -0
  27. package/dist/github/environments/api/index.mjs.map +1 -0
  28. package/dist/github/environments/api/set-environment.d.mts +10 -0
  29. package/dist/github/environments/api/set-environment.d.mts.map +1 -0
  30. package/dist/github/environments/api/set-environment.mjs +73 -0
  31. package/dist/github/environments/api/set-environment.mjs.map +1 -0
  32. package/dist/github/environments/apply.d.mts +11 -0
  33. package/dist/github/environments/apply.d.mts.map +1 -0
  34. package/dist/github/environments/apply.mjs +63 -0
  35. package/dist/github/environments/apply.mjs.map +1 -0
  36. package/dist/github/environments/backup.d.mts +10 -0
  37. package/dist/github/environments/backup.d.mts.map +1 -0
  38. package/dist/github/environments/backup.mjs +30 -0
  39. package/dist/github/environments/backup.mjs.map +1 -0
  40. package/dist/github/environments/constants.d.mts +354 -0
  41. package/dist/github/environments/constants.d.mts.map +1 -0
  42. package/dist/github/environments/constants.mjs +62 -0
  43. package/dist/github/environments/constants.mjs.map +1 -0
  44. package/dist/github/environments/index.d.mts +6 -0
  45. package/dist/github/environments/index.d.mts.map +1 -0
  46. package/dist/github/environments/index.mjs +6 -0
  47. package/dist/github/environments/index.mjs.map +1 -0
  48. package/dist/github/environments/read-environment-contents.d.mts +3 -0
  49. package/dist/github/environments/read-environment-contents.d.mts.map +1 -0
  50. package/dist/github/environments/read-environment-contents.mjs +31 -0
  51. package/dist/github/environments/read-environment-contents.mjs.map +1 -0
  52. package/dist/github/index.d.mts +4 -0
  53. package/dist/github/index.d.mts.map +1 -1
  54. package/dist/github/index.mjs +4 -0
  55. package/dist/github/index.mjs.map +1 -1
  56. package/dist/github/pages/apply.d.mts.map +1 -1
  57. package/dist/github/pages/apply.mjs +0 -2
  58. package/dist/github/pages/apply.mjs.map +1 -1
  59. package/dist/github/pages/backup.d.mts +11 -1
  60. package/dist/github/pages/backup.d.mts.map +1 -1
  61. package/dist/github/pages/backup.mjs +18 -5
  62. package/dist/github/pages/backup.mjs.map +1 -1
  63. package/dist/github/repository/apply.d.mts.map +1 -1
  64. package/dist/github/repository/apply.mjs +0 -4
  65. package/dist/github/repository/apply.mjs.map +1 -1
  66. package/dist/github/repository/backup.d.mts.map +1 -1
  67. package/dist/github/repository/backup.mjs +4 -4
  68. package/dist/github/repository/backup.mjs.map +1 -1
  69. package/dist/github/ruleset/apply.d.mts.map +1 -1
  70. package/dist/github/ruleset/apply.mjs +9 -9
  71. package/dist/github/ruleset/apply.mjs.map +1 -1
  72. package/dist/github/ruleset/backup.d.mts +7 -0
  73. package/dist/github/ruleset/backup.d.mts.map +1 -1
  74. package/dist/github/ruleset/backup.mjs +20 -7
  75. package/dist/github/ruleset/backup.mjs.map +1 -1
  76. package/dist/github/ruleset/read-rule-set-contents.d.mts +0 -1
  77. package/dist/github/ruleset/read-rule-set-contents.d.mts.map +1 -1
  78. package/dist/github/ruleset/read-rule-set-contents.mjs +0 -1
  79. package/dist/github/ruleset/read-rule-set-contents.mjs.map +1 -1
  80. package/dist/github/settings-file-path.d.mts +14 -0
  81. package/dist/github/settings-file-path.d.mts.map +1 -0
  82. package/dist/github/settings-file-path.mjs +41 -0
  83. package/dist/github/settings-file-path.mjs.map +1 -0
  84. package/dist/github/variables/api/list-repo-variables.d.mts.map +1 -1
  85. package/dist/github/variables/api/list-repo-variables.mjs +3 -1
  86. package/dist/github/variables/api/list-repo-variables.mjs.map +1 -1
  87. package/dist/github/variables/apply.d.mts +9 -0
  88. package/dist/github/variables/apply.d.mts.map +1 -1
  89. package/dist/github/variables/apply.mjs +31 -17
  90. package/dist/github/variables/apply.mjs.map +1 -1
  91. package/dist/github/variables/backup.d.mts +20 -0
  92. package/dist/github/variables/backup.d.mts.map +1 -0
  93. package/dist/github/variables/backup.mjs +35 -0
  94. package/dist/github/variables/backup.mjs.map +1 -0
  95. package/dist/github/variables/constants.d.mts +65 -0
  96. package/dist/github/variables/constants.d.mts.map +1 -0
  97. package/dist/github/variables/constants.mjs +48 -0
  98. package/dist/github/variables/constants.mjs.map +1 -0
  99. package/dist/github/variables/index.d.mts +2 -0
  100. package/dist/github/variables/index.d.mts.map +1 -1
  101. package/dist/github/variables/index.mjs +2 -0
  102. package/dist/github/variables/index.mjs.map +1 -1
  103. package/dist/github/vulnerability-alerts/api/get-vulnerability-alerts.d.mts +10 -0
  104. package/dist/github/vulnerability-alerts/api/get-vulnerability-alerts.d.mts.map +1 -0
  105. package/dist/github/vulnerability-alerts/api/get-vulnerability-alerts.mjs +25 -0
  106. package/dist/github/vulnerability-alerts/api/get-vulnerability-alerts.mjs.map +1 -0
  107. package/dist/github/vulnerability-alerts/api/index.d.mts +3 -0
  108. package/dist/github/vulnerability-alerts/api/index.d.mts.map +1 -0
  109. package/dist/github/vulnerability-alerts/api/index.mjs +3 -0
  110. package/dist/github/vulnerability-alerts/api/index.mjs.map +1 -0
  111. package/dist/github/vulnerability-alerts/api/set-vulnerability-alerts.d.mts +10 -0
  112. package/dist/github/vulnerability-alerts/api/set-vulnerability-alerts.d.mts.map +1 -0
  113. package/dist/github/vulnerability-alerts/api/set-vulnerability-alerts.mjs +19 -0
  114. package/dist/github/vulnerability-alerts/api/set-vulnerability-alerts.mjs.map +1 -0
  115. package/dist/github/vulnerability-alerts/apply.d.mts +11 -0
  116. package/dist/github/vulnerability-alerts/apply.d.mts.map +1 -0
  117. package/dist/github/vulnerability-alerts/apply.mjs +45 -0
  118. package/dist/github/vulnerability-alerts/apply.mjs.map +1 -0
  119. package/dist/github/vulnerability-alerts/backup.d.mts +5 -0
  120. package/dist/github/vulnerability-alerts/backup.d.mts.map +1 -0
  121. package/dist/github/vulnerability-alerts/backup.mjs +22 -0
  122. package/dist/github/vulnerability-alerts/backup.mjs.map +1 -0
  123. package/dist/github/vulnerability-alerts/constants.d.mts +72 -0
  124. package/dist/github/vulnerability-alerts/constants.d.mts.map +1 -0
  125. package/dist/github/vulnerability-alerts/constants.mjs +21 -0
  126. package/dist/github/vulnerability-alerts/constants.mjs.map +1 -0
  127. package/dist/github/vulnerability-alerts/index.d.mts +5 -0
  128. package/dist/github/vulnerability-alerts/index.d.mts.map +1 -0
  129. package/dist/github/vulnerability-alerts/index.mjs +5 -0
  130. package/dist/github/vulnerability-alerts/index.mjs.map +1 -0
  131. package/package.json +29 -28
  132. package/src/cmd/repo-settings.mts +22 -2
  133. package/src/github/actions-settings/apply.mts +0 -9
  134. package/src/github/actions-settings/backup.mts +6 -11
  135. package/src/github/actions-settings/constants.mts +1 -1
  136. package/src/github/clear-json-files.mts +31 -0
  137. package/src/github/clear-json-files.test.mts +78 -0
  138. package/src/github/constants.mts +9 -0
  139. package/src/github/environments/api/get-environments.mts +155 -0
  140. package/src/github/environments/api/index.mts +2 -0
  141. package/src/github/environments/api/set-environment.mts +106 -0
  142. package/src/github/environments/apply.mts +82 -0
  143. package/src/github/environments/backup.mts +39 -0
  144. package/src/github/environments/constants.mts +78 -0
  145. package/src/github/environments/index.mts +5 -0
  146. package/src/github/environments/read-environment-contents.mts +49 -0
  147. package/src/github/index.mts +4 -0
  148. package/src/github/pages/apply.mts +0 -6
  149. package/src/github/pages/backup.mts +21 -12
  150. package/src/github/repository/apply.mts +0 -9
  151. package/src/github/repository/backup.mts +5 -10
  152. package/src/github/ruleset/apply.mts +10 -18
  153. package/src/github/ruleset/backup.mts +23 -13
  154. package/src/github/ruleset/read-rule-set-contents.mts +0 -4
  155. package/src/github/ruleset/schema.test.mts +0 -13
  156. package/src/github/settings-file-path.mts +51 -0
  157. package/src/github/settings-file-path.test.mts +70 -0
  158. package/src/github/variables/api/list-repo-variables.mts +3 -1
  159. package/src/github/variables/apply.mts +54 -27
  160. package/src/github/variables/backup.mts +47 -0
  161. package/src/github/variables/constants.mts +61 -0
  162. package/src/github/variables/constants.test.mts +110 -0
  163. package/src/github/variables/index.mts +2 -0
  164. package/src/github/vulnerability-alerts/api/get-vulnerability-alerts.mts +31 -0
  165. package/src/github/vulnerability-alerts/api/index.mts +2 -0
  166. package/src/github/vulnerability-alerts/api/set-vulnerability-alerts.mts +31 -0
  167. package/src/github/vulnerability-alerts/apply.mts +71 -0
  168. package/src/github/vulnerability-alerts/backup.mts +31 -0
  169. package/src/github/vulnerability-alerts/constants.mts +25 -0
  170. package/src/github/vulnerability-alerts/index.mts +4 -0
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import * as fs from 'node:fs/promises';
4
+ import { Arr } from 'ts-data-forge';
5
+ import { formatUncommittedFiles, isDirectlyExecuted } from 'ts-repo-utils';
6
+ import { environmentsDir } from '../constants.mjs';
7
+ import { settingsFilePath } from '../settings-file-path.mjs';
8
+ import { getAllEnvironments, setEnvironment } from './api/index.mjs';
9
+ import { type EnvironmentSettings } from './constants.mjs';
10
+ import { readEnvironmentFiles } from './read-environment-contents.mjs';
11
+
12
+ /**
13
+ * `repo-settings/environments/*.json` を Settings > Environments に反映する。
14
+ *
15
+ * ruleset と同じく、宣言に無い環境は消さない。 GUI で作られたものは drift
16
+ * 検査が報せるのであって、 apply が黙って片付けてしまうと「いつ誰が何を
17
+ * 作ったか」を見る機会がなくなる。
18
+ */
19
+ export const applyEnvironments = async (): Promise<void> => {
20
+ const environments = await readEnvironmentFiles();
21
+
22
+ for (const environment of environments) {
23
+ assertConsistent(environment);
24
+ }
25
+
26
+ for (const environment of environments) {
27
+ await setEnvironment(environment);
28
+ }
29
+
30
+ // 反映後の実際の値でローカルのファイルを更新する
31
+ {
32
+ const applied = await getAllEnvironments();
33
+
34
+ for (const environment of applied) {
35
+ const str = JSON.stringify(environment, undefined, 2);
36
+
37
+ // `settingsFilePath` が `environmentsDir` の直下であることを確かめている。
38
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
39
+ await fs.writeFile(
40
+ settingsFilePath(environmentsDir, environment.name),
41
+ str,
42
+ );
43
+ }
44
+
45
+ await formatUncommittedFiles();
46
+ }
47
+ };
48
+
49
+ /**
50
+ * GitHub 側が黙って無視する組み合わせを、送る前に落とす。
51
+ *
52
+ * `custom_branch_policies` が立っていないのに pattern が書いてあるファイルは、
53
+ * 読む側には「この ref だけに制限されている」と読めて、実際には何の制限にも
54
+ * なっていない — 一番危ない種類の嘘なので、エラーにする。
55
+ */
56
+ const assertConsistent = (environment: EnvironmentSettings): void => {
57
+ const custom =
58
+ environment.deployment_branch_policy?.custom_branch_policies === true;
59
+
60
+ if (!custom && Arr.isNonEmpty(environment.deployment_branch_policies)) {
61
+ throw new Error(
62
+ [
63
+ `環境 "${environment.name}": deployment_branch_policies が書かれていますが、`,
64
+ 'deployment_branch_policy.custom_branch_policies が true ではありません。',
65
+ 'pattern は "Selected branches and tags" を選んだときにしか効きません。',
66
+ ].join('\n'),
67
+ );
68
+ }
69
+
70
+ // `custom` が立っている時点で TypeScript は `deployment_branch_policy` を
71
+ // record 側に絞り込んでいる。両方 true は JSON としては書けてしまうので、
72
+ // この組み合わせは送る前に落とす。
73
+ if (custom && environment.deployment_branch_policy.protected_branches) {
74
+ throw new Error(
75
+ `環境 "${environment.name}": protected_branches と custom_branch_policies は同時に true にできません。`,
76
+ );
77
+ }
78
+ };
79
+
80
+ if (isDirectlyExecuted(import.meta.url)) {
81
+ await applyEnvironments();
82
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import * as fs from 'node:fs/promises';
4
+ import { formatUncommittedFiles, isDirectlyExecuted } from 'ts-repo-utils';
5
+ import { clearJsonFilesIn } from '../clear-json-files.mjs';
6
+ import { environmentsDir } from '../constants.mjs';
7
+ import { settingsFilePath } from '../settings-file-path.mjs';
8
+ import { getAllEnvironments } from './api/index.mjs';
9
+
10
+ /**
11
+ * Settings > Environments の現在値を宣言ファイルへ撮り直す。
12
+ *
13
+ * ruleset と同じく、先に直下の `*.json` を消してから書く。理由は
14
+ * {@link clearJsonFilesIn} を参照。
15
+ */
16
+ export const backupEnvironments = async (
17
+ fmt: boolean = true,
18
+ ): Promise<void> => {
19
+ const environments = await getAllEnvironments();
20
+
21
+ await clearJsonFilesIn(environmentsDir);
22
+
23
+ for (const environment of environments) {
24
+ // `settingsFilePath` が `environmentsDir` の直下であることを確かめている。
25
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
26
+ await fs.writeFile(
27
+ settingsFilePath(environmentsDir, environment.name),
28
+ JSON.stringify(environment, undefined, 2),
29
+ );
30
+ }
31
+
32
+ if (fmt) {
33
+ await formatUncommittedFiles();
34
+ }
35
+ };
36
+
37
+ if (isDirectlyExecuted(import.meta.url)) {
38
+ await backupEnvironments();
39
+ }
@@ -0,0 +1,78 @@
1
+ import * as t from 'ts-fortress';
2
+
3
+ /**
4
+ * `repo-settings/environments/*.json` の型。
5
+ *
6
+ * Settings > Environments の 1 件に対応する。ファイルは「その環境の望ましい
7
+ * 状態」を丸ごと表しており、 GitHub 側で 2 つに分かれている次の API をまとめて
8
+ * 1 ファイルで管理する:
9
+ *
10
+ * - `PUT /repos/{owner}/{repo}/environments/{environment_name}`
11
+ * - `GET|POST|DELETE /repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies`
12
+ *
13
+ * GET が返す `protection_rules` は「有効な保護ルールだけが並ぶ配列」だが、この
14
+ * 型は書き込み側( PUT の body )に合わせた平らな形にしている。往復させる側の
15
+ * 形を正とするほうが、ファイルを読んで何が起きるか分かりやすいため。
16
+ */
17
+ export const EnvironmentSettings = t.record({
18
+ name: t.string(''),
19
+
20
+ /** 「 Wait timer 」。分。 0 なら待たない。 */
21
+ wait_timer: t.number(0),
22
+
23
+ /** 「 Prevent self-review 」。 */
24
+ prevent_self_review: t.boolean(false),
25
+
26
+ /** 「 Required reviewers 」。空配列なら承認は要らない。 */
27
+ reviewers: t.array(
28
+ t.record({
29
+ type: t.enumType(['User', 'Team'], {
30
+ typeName: 'EnvironmentReviewerType',
31
+ defaultValue: 'User',
32
+ }),
33
+ id: t.number(0),
34
+ }),
35
+ { typeName: 'EnvironmentReviewers' },
36
+ ),
37
+
38
+ /**
39
+ * 「 Deployment branches and tags 」の選択。
40
+ *
41
+ * - `null` … 「 No restriction 」( どの ref からでも deploy できる )
42
+ * - `protected_branches: true` … 「 Protected branches only 」
43
+ * - `custom_branch_policies: true` … 「 Selected branches and tags 」。
44
+ * 具体的な pattern は下の `deployment_branch_policies` 。
45
+ *
46
+ * 2 つを同時に `true` にはできない。
47
+ */
48
+ deployment_branch_policy: t.union(
49
+ [
50
+ t.record({
51
+ protected_branches: t.boolean(false),
52
+ custom_branch_policies: t.boolean(true),
53
+ }),
54
+ t.nullType,
55
+ ],
56
+ { typeName: 'DeploymentBranchPolicySelection' },
57
+ ),
58
+
59
+ /**
60
+ * `custom_branch_policies` が `true` のときの pattern 一覧。
61
+ *
62
+ * それ以外のときは空配列でなければならない(`applyEnvironments` が拒否する)。
63
+ * GitHub 側は別エンドポイントだが、「どの ref から deploy できるか」を
64
+ * 決めているのは選択と pattern の組で、片方だけ見ても意味を成さない。
65
+ */
66
+ deployment_branch_policies: t.array(
67
+ t.record({
68
+ name: t.string(''),
69
+ type: t.enumType(['branch', 'tag'], {
70
+ typeName: 'DeploymentBranchPolicyType',
71
+ defaultValue: 'branch',
72
+ }),
73
+ }),
74
+ { typeName: 'DeploymentBranchPolicies' },
75
+ ),
76
+ });
77
+
78
+ export type EnvironmentSettings = t.TypeOf<typeof EnvironmentSettings>;
@@ -0,0 +1,5 @@
1
+ export * from './api/index.mjs';
2
+ export * from './apply.mjs';
3
+ export * from './backup.mjs';
4
+ export * from './constants.mjs';
5
+ export * from './read-environment-contents.mjs';
@@ -0,0 +1,49 @@
1
+ import { type Dirent } from 'node:fs';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as t from 'ts-fortress';
5
+ import { isDirectlyExecuted, Result } from 'ts-repo-utils';
6
+ import { environmentsDir } from '../constants.mjs';
7
+ import { EnvironmentSettings } from './constants.mjs';
8
+
9
+ export const readEnvironmentFiles = async (): Promise<
10
+ readonly EnvironmentSettings[]
11
+ > => readFilesIn(environmentsDir);
12
+
13
+ const readFilesIn = async (
14
+ dir: string,
15
+ ): Promise<readonly EnvironmentSettings[]> => {
16
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
17
+ const files: readonly Dirent<string>[] = await fs.readdir(dir, {
18
+ withFileTypes: true,
19
+ });
20
+
21
+ const fileContents: readonly string[] = await Promise.all(
22
+ files
23
+ .filter((d) => d.isFile())
24
+ .map((d) =>
25
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
26
+ fs.readFile(path.resolve(dir, d.name), { encoding: 'utf8' }),
27
+ ),
28
+ );
29
+
30
+ const validationResults = fileContents.map((content) =>
31
+ EnvironmentSettings.validate(JSON.parse(content)),
32
+ );
33
+
34
+ const mut_values: EnvironmentSettings[] = [];
35
+
36
+ for (const r of validationResults) {
37
+ if (Result.isErr(r)) {
38
+ throw new Error(t.validationErrorsToMessages(r.value).join('\n'));
39
+ }
40
+
41
+ mut_values.push(r.value);
42
+ }
43
+
44
+ return mut_values;
45
+ };
46
+
47
+ if (isDirectlyExecuted(import.meta.url)) {
48
+ await readEnvironmentFiles();
49
+ }
@@ -1,7 +1,11 @@
1
1
  export * from './actions-settings/index.mjs';
2
+ export * from './clear-json-files.mjs';
2
3
  export * from './constants.mjs';
4
+ export * from './environments/index.mjs';
3
5
  export * from './pages/index.mjs';
4
6
  export * from './repository/index.mjs';
5
7
  export * from './resolve-target-repo.mjs';
6
8
  export * from './ruleset/index.mjs';
9
+ export * from './settings-file-path.mjs';
7
10
  export * from './variables/index.mjs';
11
+ export * from './vulnerability-alerts/index.mjs';
@@ -43,12 +43,6 @@ export const applyPagesSettings = async (): Promise<void> => {
43
43
  // eslint-disable-next-line security/detect-non-literal-fs-filename
44
44
  await fs.writeFile(settingsPath, str);
45
45
 
46
- // eslint-disable-next-line security/detect-non-literal-fs-filename
47
- await fs.writeFile(
48
- path.resolve(pagesSettingsDir, 'bk', settingsJsonName),
49
- str,
50
- );
51
-
52
46
  await formatUncommittedFiles();
53
47
  }
54
48
  };
@@ -2,35 +2,44 @@
2
2
  import 'dotenv/config';
3
3
  import * as fs from 'node:fs/promises';
4
4
  import * as path from 'node:path';
5
- import {
6
- formatUncommittedFiles,
7
- isDirectlyExecuted,
8
- makeEmptyDir,
9
- } from 'ts-repo-utils';
5
+ import { formatUncommittedFiles, isDirectlyExecuted } from 'ts-repo-utils';
6
+ import { clearJsonFilesIn } from '../clear-json-files.mjs';
10
7
  import { pagesSettingsDir, settingsJsonName } from '../constants.mjs';
11
8
  import { getPagesSettings } from './api/index.mjs';
12
9
 
13
- const backupDir = path.resolve(pagesSettingsDir, './bk');
14
-
15
- /** Settings > Pages の現在値を `bk/` に保存する。 */
10
+ /**
11
+ * Settings > Pages の現在値を宣言ファイルへ撮り直す。
12
+ *
13
+ * **Pages が無効なら宣言も消える。** 他のターゲットと同じ規則にしてある — 撮り
14
+ * 直したあとに在るものが live から取れたものそのもの、という性質を、ここだけ
15
+ * 崩さない。無効になったことが `git status` に削除として出る。
16
+ *
17
+ * 無効化と読み取り失敗を区別できないという弱点は残るが、`getPagesSettings` が
18
+ * `undefined` を返すのは 404 のときだけで、それ以外の失敗は例外になる。消えた
19
+ * 宣言はコミットしなければ失われない。
20
+ */
16
21
  export const backupPagesSettings = async (
17
22
  fmt: boolean = true,
18
23
  ): Promise<void> => {
19
- await makeEmptyDir(backupDir);
20
-
21
24
  const settings = await getPagesSettings();
22
25
 
26
+ await clearJsonFilesIn(pagesSettingsDir);
27
+
23
28
  if (settings === undefined) {
24
29
  console.warn(
25
- 'GitHub Pages is not enabled for this repository; skipping backup.',
30
+ 'GitHub Pages is not enabled for this repository; the declaration was removed.',
26
31
  );
27
32
 
33
+ if (fmt) {
34
+ await formatUncommittedFiles();
35
+ }
36
+
28
37
  return;
29
38
  }
30
39
 
31
40
  // eslint-disable-next-line security/detect-non-literal-fs-filename
32
41
  await fs.writeFile(
33
- path.resolve(backupDir, settingsJsonName),
42
+ path.resolve(pagesSettingsDir, settingsJsonName),
34
43
  JSON.stringify(settings, undefined, 2),
35
44
  );
36
45
 
@@ -13,7 +13,6 @@ import {
13
13
  import { type StrictPick } from 'ts-type-forge';
14
14
  import { repositorySettingsDir, settingsJsonName } from '../constants.mjs';
15
15
  import { getRepositorySettings, updateRepository } from './api/index.mjs';
16
- import { backupRepositorySettings } from './backup.mjs';
17
16
  import {
18
17
  type RepositoryKeysToPick,
19
18
  repositoryKeysToPick,
@@ -21,8 +20,6 @@ import {
21
20
  } from './constants.mjs';
22
21
 
23
22
  export const applyRepositorySettings = async (): Promise<void> => {
24
- await backupRepositorySettings(false);
25
-
26
23
  const settings = await readSettings();
27
24
 
28
25
  await updateRepository({
@@ -72,12 +69,6 @@ export const applyRepositorySettings = async (): Promise<void> => {
72
69
  str,
73
70
  );
74
71
 
75
- // eslint-disable-next-line security/detect-non-literal-fs-filename
76
- await fs.writeFile(
77
- path.resolve(repositorySettingsDir, 'bk', settingsJsonName),
78
- str,
79
- );
80
-
81
72
  await formatUncommittedFiles();
82
73
  }
83
74
  };
@@ -3,27 +3,22 @@ import 'dotenv/config';
3
3
  import * as fs from 'node:fs/promises';
4
4
  import * as path from 'node:path';
5
5
  import { Obj } from 'ts-data-forge';
6
- import {
7
- formatUncommittedFiles,
8
- isDirectlyExecuted,
9
- makeEmptyDir,
10
- } from 'ts-repo-utils';
6
+ import { formatUncommittedFiles, isDirectlyExecuted } from 'ts-repo-utils';
7
+ import { clearJsonFilesIn } from '../clear-json-files.mjs';
11
8
  import { repositorySettingsDir, settingsJsonName } from '../constants.mjs';
12
9
  import { getRepositorySettings } from './api/index.mjs';
13
10
  import { repositoryKeysToPick } from './constants.mjs';
14
11
 
15
- const backupDir = path.resolve(repositorySettingsDir, './bk');
16
-
17
12
  export const backupRepositorySettings = async (
18
13
  fmt: boolean = true,
19
14
  ): Promise<void> => {
20
- await makeEmptyDir(backupDir);
21
-
22
15
  const repositorySettings = await getRepositorySettings();
23
16
 
17
+ await clearJsonFilesIn(repositorySettingsDir);
18
+
24
19
  // eslint-disable-next-line security/detect-non-literal-fs-filename
25
20
  await fs.writeFile(
26
- path.resolve(backupDir, settingsJsonName),
21
+ path.resolve(repositorySettingsDir, settingsJsonName),
27
22
  JSON.stringify(
28
23
  Obj.pick(repositorySettings, repositoryKeysToPick),
29
24
  undefined,
@@ -1,35 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  import 'dotenv/config';
3
3
  import * as fs from 'node:fs/promises';
4
- import * as path from 'node:path';
5
4
  import { Obj } from 'ts-data-forge';
6
5
  import { formatUncommittedFiles, isDirectlyExecuted } from 'ts-repo-utils';
7
6
  import { rulesetsDir } from '../constants.mjs';
7
+ import { settingsFilePath } from '../settings-file-path.mjs';
8
8
  import {
9
9
  createRuleset,
10
10
  getAllRulesets,
11
11
  getRuleset,
12
12
  updateRuleset,
13
13
  } from './api/index.mjs';
14
- import { backupRulesets } from './backup.mjs';
15
14
  import { rulesetKeysToPick } from './constants.mjs';
16
- import {
17
- readRulesetBackupFiles,
18
- readRulesetFiles,
19
- } from './read-rule-set-contents.mjs';
15
+ import { readRulesetFiles } from './read-rule-set-contents.mjs';
20
16
 
21
17
  export const applyRulesets = async (): Promise<void> => {
22
- await backupRulesets(false);
23
-
24
18
  const rulesets = await readRulesetFiles();
25
19
 
26
- const backupIds: ReadonlySet<number> = await readRulesetBackupFiles().then(
20
+ // 既にあるかどうかは live に訊く。以前はこの直前に走らせた backup の出力を
21
+ // 読んでいたが、それは live の写しでしかなく、写した時点と送る時点のあいだに
22
+ // 消えた ruleset を「更新できる」と誤らせる余地があった。
23
+ const existingIds: ReadonlySet<number> = await getAllRulesets().then(
27
24
  (rs) => new Set(rs.map((r) => r.id)),
28
25
  );
29
26
 
30
- const rulesetsToUpdate = rulesets.filter((r) => backupIds.has(r.id));
27
+ const rulesetsToUpdate = rulesets.filter((r) => existingIds.has(r.id));
31
28
 
32
- const rulesetsToCreate = rulesets.filter((r) => !backupIds.has(r.id));
29
+ const rulesetsToCreate = rulesets.filter((r) => !existingIds.has(r.id));
33
30
 
34
31
  for (const ruleset of rulesetsToUpdate) {
35
32
  await updateRuleset({
@@ -71,14 +68,9 @@ export const applyRulesets = async (): Promise<void> => {
71
68
  2,
72
69
  );
73
70
 
71
+ // `settingsFilePath` が `rulesetsDir` の直下であることを確かめている。
74
72
  // eslint-disable-next-line security/detect-non-literal-fs-filename
75
- await fs.writeFile(path.resolve(rulesetsDir, `${rule.name}.json`), str);
76
-
77
- // eslint-disable-next-line security/detect-non-literal-fs-filename
78
- await fs.writeFile(
79
- path.resolve(rulesetsDir, `./bk/${rule.name}.json`),
80
- str,
81
- );
73
+ await fs.writeFile(settingsFilePath(rulesetsDir, rule.name), str);
82
74
  }
83
75
 
84
76
  await formatUncommittedFiles();
@@ -1,30 +1,40 @@
1
1
  #!/usr/bin/env node
2
2
  import 'dotenv/config';
3
3
  import * as fs from 'node:fs/promises';
4
- import * as path from 'node:path';
5
4
  import { Obj } from 'ts-data-forge';
6
- import {
7
- formatUncommittedFiles,
8
- isDirectlyExecuted,
9
- makeEmptyDir,
10
- } from 'ts-repo-utils';
5
+ import { formatUncommittedFiles, isDirectlyExecuted } from 'ts-repo-utils';
6
+ import { clearJsonFilesIn } from '../clear-json-files.mjs';
11
7
  import { rulesetsDir } from '../constants.mjs';
8
+ import { settingsFilePath } from '../settings-file-path.mjs';
12
9
  import { getAllRulesets, getRuleset } from './api/index.mjs';
13
10
  import { rulesetKeysToPick } from './constants.mjs';
14
11
 
15
- const backupDir = path.resolve(rulesetsDir, './bk');
16
-
12
+ /**
13
+ * ruleset の現在値を宣言ファイルへ撮り直す。
14
+ *
15
+ * 先に直下の `*.json` を消してから書くので、撮り直したあとにそこに在るものが
16
+ * live から取れたものそのものになる。取れなかった ruleset は `git status` に
17
+ * 削除として現れる。理由は {@link clearJsonFilesIn} を参照。
18
+ */
17
19
  export const backupRulesets = async (fmt: boolean = true): Promise<void> => {
18
- await makeEmptyDir(backupDir);
19
-
20
20
  const rulesetsResult = await getAllRulesets();
21
21
 
22
- for (const rule of rulesetsResult) {
23
- const content = await getRuleset(rule.id);
22
+ // 取得がすべて終わってから消す。途中で失敗したときに、宣言だけ消えて何も
23
+ // 書かれていない状態にしないため。
24
+ const contents = await Promise.all(
25
+ rulesetsResult.map(async (rule) => ({
26
+ name: rule.name,
27
+ content: await getRuleset(rule.id),
28
+ })),
29
+ );
30
+
31
+ await clearJsonFilesIn(rulesetsDir);
24
32
 
33
+ for (const { name, content } of contents) {
34
+ // `settingsFilePath` が `rulesetsDir` の直下であることを確かめている。
25
35
  // eslint-disable-next-line security/detect-non-literal-fs-filename
26
36
  await fs.writeFile(
27
- path.resolve(backupDir, `${rule.name}.json`),
37
+ settingsFilePath(rulesetsDir, name),
28
38
  JSON.stringify(Obj.pick(content, rulesetKeysToPick), undefined, 2),
29
39
  );
30
40
  }
@@ -9,10 +9,6 @@ import { RulesetPicked } from './constants.mjs';
9
9
  export const readRulesetFiles = async (): Promise<readonly RulesetPicked[]> =>
10
10
  readFilesIn(rulesetsDir);
11
11
 
12
- export const readRulesetBackupFiles = async (): Promise<
13
- readonly RulesetPicked[]
14
- > => readFilesIn(path.resolve(rulesetsDir, './bk'));
15
-
16
12
  const readFilesIn = async (dir: string): Promise<readonly RulesetPicked[]> => {
17
13
  // eslint-disable-next-line security/detect-non-literal-fs-filename
18
14
  const files: readonly Dirent<string>[] = await fs.readdir(dir, {
@@ -77,19 +77,6 @@ describe('the ruleset schema', () => {
77
77
  }
78
78
  });
79
79
 
80
- test('accepts every backed-up ruleset', async () => {
81
- const files = await readJsonFilesIn(path.resolve(rulesetsDir, './bk'));
82
-
83
- assert.isTrue(Arr.isNonEmpty(files));
84
-
85
- for (const [name, content] of files) {
86
- assert.deepStrictEqual(
87
- outcomeOf(name, RulesetPicked.validate(content)),
88
- name,
89
- );
90
- }
91
- });
92
-
93
80
  test('accepts the fields GitHub adds to what apply sends back', async () => {
94
81
  const files = await readJsonFilesIn(rulesetsDir);
95
82
 
@@ -0,0 +1,51 @@
1
+ import * as path from 'node:path';
2
+
3
+ /**
4
+ * `dir` の直下の `<name>.json` を指すパスを返す。ファイル名にできない名前は
5
+ * 拒否する。
6
+ *
7
+ * environment 名と ruleset 名は GitHub API が返す素の文字列で、パス片として
8
+ * 使えることは何も保証していない。区切りを含む名前をそのまま結合すると、
9
+ * 書き込み先が `dir` の外 — GitHub の現在値と突き合わせる宣言ファイルが
10
+ * 置いてある場所 — へ出てしまう。
11
+ *
12
+ * 正規化ではなく拒否にしてあるのは、そういう名前が来たこと自体が異常の証拠だ
13
+ * からで、黙って書き換えると何が起きていたのかが分からなくなる。
14
+ */
15
+ export const settingsFilePath = (dir: string, name: string): string => {
16
+ if (!isUsableAsFileName(name)) {
17
+ throw new Error(
18
+ [
19
+ `ファイル名にできない名前です: ${JSON.stringify(name)}。`,
20
+ `${dir} の外へ書き込もうとしている可能性があります。`,
21
+ ].join('\n'),
22
+ );
23
+ }
24
+
25
+ const resolvedDir = path.resolve(dir);
26
+
27
+ const resolved = path.resolve(resolvedDir, `${name}.json`);
28
+
29
+ // 二重の歯止め。上の判定が漏れても、`dir` の外には出さない。
30
+ if (!resolved.startsWith(resolvedDir + path.sep)) {
31
+ throw new Error(
32
+ `書き込み先が ${resolvedDir} の外を指しています: ${resolved}`,
33
+ );
34
+ }
35
+
36
+ return resolved;
37
+ };
38
+
39
+ /**
40
+ * パス片ひとつとして読める名前か。
41
+ *
42
+ * `path.sep` ではなく両方の区切りを見るのは、Windows で書かれた名前が POSIX
43
+ * 上で素通りしないようにするため。
44
+ */
45
+ const isUsableAsFileName = (name: string): boolean =>
46
+ name !== '' &&
47
+ name !== '.' &&
48
+ name !== '..' &&
49
+ !name.includes('/') &&
50
+ !name.includes('\\') &&
51
+ !name.includes('\u{0}');
@@ -0,0 +1,70 @@
1
+ import * as path from 'node:path';
2
+ import { Result } from 'ts-data-forge';
3
+ import { settingsFilePath } from './settings-file-path.mjs';
4
+
5
+ const dir = path.resolve('/tmp/repo-settings/environments');
6
+
7
+ /** `'rejected'` when the name was refused, the resolved path when it was not. */
8
+ const outcomeOf = (name: string): string => {
9
+ const result = Result.fromThrowable(() => settingsFilePath(dir, name));
10
+
11
+ return Result.isErr(result) ? 'rejected' : result.value;
12
+ };
13
+
14
+ describe(settingsFilePath, () => {
15
+ test('names a file directly under the directory it was given', () => {
16
+ assert.deepStrictEqual(
17
+ settingsFilePath(dir, 'release'),
18
+ path.join(dir, 'release.json'),
19
+ );
20
+ });
21
+
22
+ test('keeps the characters a name is allowed to carry', () => {
23
+ assert.deepStrictEqual(
24
+ settingsFilePath(dir, 'main-no-bypass'),
25
+ path.join(dir, 'main-no-bypass.json'),
26
+ );
27
+
28
+ assert.deepStrictEqual(
29
+ settingsFilePath(dir, 'Production (US East)'),
30
+ path.join(dir, 'Production (US East).json'),
31
+ );
32
+ });
33
+
34
+ test('rejects a name that would resolve above the directory', () => {
35
+ assert.deepStrictEqual(outcomeOf('../release'), 'rejected');
36
+
37
+ assert.deepStrictEqual(outcomeOf('../../../../tmp/elsewhere'), 'rejected');
38
+ });
39
+
40
+ test('rejects a name carrying a separator, rather than nesting it', () => {
41
+ assert.deepStrictEqual(outcomeOf('production/us-east'), 'rejected');
42
+
43
+ assert.deepStrictEqual(
44
+ outcomeOf(String.raw`production\us-east`),
45
+ 'rejected',
46
+ );
47
+ });
48
+
49
+ test('rejects the names that are not file names at all', () => {
50
+ assert.deepStrictEqual(outcomeOf(''), 'rejected');
51
+
52
+ assert.deepStrictEqual(outcomeOf('.'), 'rejected');
53
+
54
+ assert.deepStrictEqual(outcomeOf('..'), 'rejected');
55
+
56
+ assert.deepStrictEqual(outcomeOf('release\u{0}'), 'rejected');
57
+ });
58
+
59
+ test('says which name it refused', () => {
60
+ const result = Result.fromThrowable(() =>
61
+ settingsFilePath(dir, '../release'),
62
+ );
63
+
64
+ assert.isTrue(Result.isErr(result));
65
+
66
+ if (Result.isErr(result)) {
67
+ assert.isTrue(result.value.message.includes('../release'));
68
+ }
69
+ });
70
+ });