github-settings-as-code 3.2.0 → 4.0.1

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 (123) hide show
  1. package/README.md +17 -15
  2. package/dist/cmd/repo-settings.mjs +4 -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 +1 -0
  17. package/dist/github/constants.d.mts.map +1 -1
  18. package/dist/github/constants.mjs +1 -0
  19. package/dist/github/constants.mjs.map +1 -1
  20. package/dist/github/environments/api/get-environments.d.mts +1 -1
  21. package/dist/github/environments/apply.d.mts +3 -3
  22. package/dist/github/environments/apply.d.mts.map +1 -1
  23. package/dist/github/environments/apply.mjs +5 -9
  24. package/dist/github/environments/apply.mjs.map +1 -1
  25. package/dist/github/environments/backup.d.mts +6 -1
  26. package/dist/github/environments/backup.d.mts.map +1 -1
  27. package/dist/github/environments/backup.mjs +11 -5
  28. package/dist/github/environments/backup.mjs.map +1 -1
  29. package/dist/github/environments/constants.d.mts +1 -1
  30. package/dist/github/environments/read-environment-contents.d.mts +0 -1
  31. package/dist/github/environments/read-environment-contents.d.mts.map +1 -1
  32. package/dist/github/environments/read-environment-contents.mjs +0 -1
  33. package/dist/github/environments/read-environment-contents.mjs.map +1 -1
  34. package/dist/github/index.d.mts +2 -0
  35. package/dist/github/index.d.mts.map +1 -1
  36. package/dist/github/index.mjs +2 -0
  37. package/dist/github/index.mjs.map +1 -1
  38. package/dist/github/pages/apply.d.mts.map +1 -1
  39. package/dist/github/pages/apply.mjs +0 -2
  40. package/dist/github/pages/apply.mjs.map +1 -1
  41. package/dist/github/pages/backup.d.mts +11 -1
  42. package/dist/github/pages/backup.d.mts.map +1 -1
  43. package/dist/github/pages/backup.mjs +18 -5
  44. package/dist/github/pages/backup.mjs.map +1 -1
  45. package/dist/github/repository/apply.d.mts.map +1 -1
  46. package/dist/github/repository/apply.mjs +0 -4
  47. package/dist/github/repository/apply.mjs.map +1 -1
  48. package/dist/github/repository/backup.d.mts.map +1 -1
  49. package/dist/github/repository/backup.mjs +4 -4
  50. package/dist/github/repository/backup.mjs.map +1 -1
  51. package/dist/github/ruleset/apply.d.mts.map +1 -1
  52. package/dist/github/ruleset/apply.mjs +9 -9
  53. package/dist/github/ruleset/apply.mjs.map +1 -1
  54. package/dist/github/ruleset/backup.d.mts +7 -0
  55. package/dist/github/ruleset/backup.d.mts.map +1 -1
  56. package/dist/github/ruleset/backup.mjs +20 -7
  57. package/dist/github/ruleset/backup.mjs.map +1 -1
  58. package/dist/github/ruleset/read-rule-set-contents.d.mts +0 -1
  59. package/dist/github/ruleset/read-rule-set-contents.d.mts.map +1 -1
  60. package/dist/github/ruleset/read-rule-set-contents.mjs +0 -1
  61. package/dist/github/ruleset/read-rule-set-contents.mjs.map +1 -1
  62. package/dist/github/settings-file-path.d.mts +14 -0
  63. package/dist/github/settings-file-path.d.mts.map +1 -0
  64. package/dist/github/settings-file-path.mjs +41 -0
  65. package/dist/github/settings-file-path.mjs.map +1 -0
  66. package/dist/github/variables/api/list-repo-variables.d.mts.map +1 -1
  67. package/dist/github/variables/api/list-repo-variables.mjs +3 -1
  68. package/dist/github/variables/api/list-repo-variables.mjs.map +1 -1
  69. package/dist/github/variables/apply.d.mts +9 -0
  70. package/dist/github/variables/apply.d.mts.map +1 -1
  71. package/dist/github/variables/apply.mjs +31 -17
  72. package/dist/github/variables/apply.mjs.map +1 -1
  73. package/dist/github/variables/backup.d.mts +20 -0
  74. package/dist/github/variables/backup.d.mts.map +1 -0
  75. package/dist/github/variables/backup.mjs +35 -0
  76. package/dist/github/variables/backup.mjs.map +1 -0
  77. package/dist/github/variables/constants.d.mts +65 -0
  78. package/dist/github/variables/constants.d.mts.map +1 -0
  79. package/dist/github/variables/constants.mjs +48 -0
  80. package/dist/github/variables/constants.mjs.map +1 -0
  81. package/dist/github/variables/index.d.mts +2 -0
  82. package/dist/github/variables/index.d.mts.map +1 -1
  83. package/dist/github/variables/index.mjs +2 -0
  84. package/dist/github/variables/index.mjs.map +1 -1
  85. package/dist/github/vulnerability-alerts/apply.d.mts.map +1 -1
  86. package/dist/github/vulnerability-alerts/apply.mjs +0 -2
  87. package/dist/github/vulnerability-alerts/apply.mjs.map +1 -1
  88. package/dist/github/vulnerability-alerts/backup.d.mts +1 -1
  89. package/dist/github/vulnerability-alerts/backup.d.mts.map +1 -1
  90. package/dist/github/vulnerability-alerts/backup.mjs +4 -4
  91. package/dist/github/vulnerability-alerts/backup.mjs.map +1 -1
  92. package/package.json +35 -34
  93. package/src/cmd/repo-settings.mts +5 -2
  94. package/src/github/actions-settings/apply.mts +0 -9
  95. package/src/github/actions-settings/backup.mts +6 -11
  96. package/src/github/actions-settings/constants.mts +1 -1
  97. package/src/github/clear-json-files.mts +31 -0
  98. package/src/github/clear-json-files.test.mts +78 -0
  99. package/src/github/constants.mts +2 -0
  100. package/src/github/environments/api/get-environments.mts +1 -1
  101. package/src/github/environments/apply.mts +8 -13
  102. package/src/github/environments/backup.mts +13 -12
  103. package/src/github/environments/constants.mts +1 -1
  104. package/src/github/environments/read-environment-contents.mts +0 -4
  105. package/src/github/index.mts +2 -0
  106. package/src/github/pages/apply.mts +0 -6
  107. package/src/github/pages/backup.mts +21 -12
  108. package/src/github/repository/apply.mts +0 -9
  109. package/src/github/repository/backup.mts +5 -10
  110. package/src/github/ruleset/apply.mts +10 -18
  111. package/src/github/ruleset/backup.mts +23 -13
  112. package/src/github/ruleset/read-rule-set-contents.mts +0 -4
  113. package/src/github/ruleset/schema.test.mts +13 -24
  114. package/src/github/settings-file-path.mts +51 -0
  115. package/src/github/settings-file-path.test.mts +70 -0
  116. package/src/github/variables/api/list-repo-variables.mts +3 -1
  117. package/src/github/variables/apply.mts +54 -27
  118. package/src/github/variables/backup.mts +47 -0
  119. package/src/github/variables/constants.mts +61 -0
  120. package/src/github/variables/constants.test.mts +112 -0
  121. package/src/github/variables/index.mts +2 -0
  122. package/src/github/vulnerability-alerts/apply.mts +0 -6
  123. package/src/github/vulnerability-alerts/backup.mts +6 -11
@@ -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
 
@@ -99,18 +86,20 @@ describe('the ruleset schema', () => {
99
86
 
100
87
  assert.deepStrictEqual(outcomeOf(files[0][0], declared), files[0][0]);
101
88
 
102
- if (Result.isOk(declared)) {
103
- // `getRuleset` hands the whole API response to this type, and a response
104
- // carries more than the seven fields that go back out again.
105
- const result = RulesetPicked.validate({
106
- ...declared.value,
107
- source: 'noshiro-pf/mono',
108
- source_type: 'Repository',
109
- node_id: 'RRS_lACqUmVwb3NpdG9yeQ',
110
- });
111
-
112
- assert.isTrue(Result.isOk(result));
89
+ if (Result.isErr(declared)) {
90
+ return;
113
91
  }
92
+
93
+ // `getRuleset` hands the whole API response to this type, and a response
94
+ // carries more than the seven fields that go back out again.
95
+ const result = RulesetPicked.validate({
96
+ ...declared.value,
97
+ source: 'noshiro-pf/mono',
98
+ source_type: 'Repository',
99
+ node_id: 'RRS_lACqUmVwb3NpdG9yeQ',
100
+ });
101
+
102
+ assert.isTrue(Result.isOk(result));
114
103
  });
115
104
 
116
105
  test('accepts a ruleset built from values GitHub does use', () => {
@@ -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
+ });
@@ -23,5 +23,7 @@ export const listRepoVariables = async (): Promise<
23
23
  },
24
24
  );
25
25
 
26
- return result.data.variables;
26
+ // `getAllEnvironments` と同じく名前順に整えて返す。宣言ファイルの中身が API
27
+ // の返す順に左右されると、値が変わっていない日にも diff が出る。
28
+ return result.data.variables.toSorted((a, b) => a.name.localeCompare(b.name));
27
29
  };
@@ -1,43 +1,70 @@
1
1
  #!/usr/bin/env node
2
-
3
- // cspell:ignore lilpi
4
2
  import 'dotenv/config';
5
- import { isDirectlyExecuted } from 'ts-repo-utils';
3
+ import * as fs from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import * as t from 'ts-fortress';
6
+ import { isDirectlyExecuted, Result } from 'ts-repo-utils';
7
+ import { settingsJsonName, variablesDir } from '../constants.mjs';
6
8
  import {
7
9
  createRepoVariable,
8
10
  listRepoVariables,
9
11
  updateRepoVariable,
10
12
  } from './api/index.mjs';
13
+ import {
14
+ assertVariableNamesAreValid,
15
+ RepositoryVariables,
16
+ } from './constants.mjs';
11
17
 
18
+ /**
19
+ * `repo-settings/variables/settings.json` を Settings > Secrets and variables >
20
+ * Actions > Variables に反映する。
21
+ *
22
+ * ruleset や environment と同じく、**宣言に無い変数は消さない。** 消したい
23
+ * ときは宣言から外したうえで GitHub 側でも消す。 apply が片付けてしまうと、
24
+ * 誰かが GUI で足したものが「いつの間にか無くなる」ことになり、drift 検査が
25
+ * 報せるという見え方が働かなくなる。
26
+ */
12
27
  export const applyVariables = async (): Promise<void> => {
13
- const variables = [
14
- {
15
- // noshiro-repo-settings-bot (App ID: 4476573)
16
- // Administration を含む強い権限を持つ。設定の backup / apply 専用。
17
- name: 'REPO_SETTINGS_BOT_CLIENT_ID',
18
- value: 'Iv23ctr4Lq9H3wOYRQrD',
19
- },
20
- {
21
- // noshiro-repo-automation-bot (App ID: 4478721)
22
- // Contents / Pull requests / Issues 権限。PR を作る自動化( pnpm-update )と
23
- // release で共用する。実際に使う権限は各ワークフローの permission-* 入力で
24
- // 絞る。
25
- name: 'REPO_AUTOMATION_BOT_CLIENT_ID',
26
- value: 'Iv23lilpiDh0twq2PErv',
27
- },
28
- ] as const;
29
-
30
- const variableListSaved = await listRepoVariables();
31
-
32
- const variablesSaved: ReadonlySet<string> = new Set(
33
- variableListSaved.map((v) => v.name),
28
+ const variables = await readSettings();
29
+
30
+ // 1 本目を送る前に、全部の名前を見る。
31
+ assertVariableNamesAreValid(variables);
32
+
33
+ const existing: ReadonlySet<string> = await listRepoVariables().then(
34
+ (vs) => new Set(vs.map((v) => v.name)),
35
+ );
36
+
37
+ for (const [name, value] of Object.entries(variables)) {
38
+ await (existing.has(name)
39
+ ? updateRepoVariable({ name, value })
40
+ : createRepoVariable({ name, value }));
41
+ }
42
+
43
+ // ここで live を読み直して書き戻すことはしない。 backup は live をそのまま
44
+ // 宣言に写す操作で、 live は宣言の上位集合になりうる — apply の締めに呼ぶと、
45
+ // 宣言していない変数まで黙って宣言に入る。送った値は宣言そのものなので、
46
+ // 書き戻して得られるものも無い。現在値を取り込みたいときは backup を明示的に
47
+ // 実行する。
48
+ };
49
+
50
+ const readSettings = async (): Promise<RepositoryVariables> => {
51
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
52
+ const settingsText = await fs.readFile(
53
+ path.resolve(variablesDir, settingsJsonName),
54
+ { encoding: 'utf8' },
34
55
  );
35
56
 
36
- for (const variable of variables) {
37
- const found = variablesSaved.has(variable.name);
57
+ const validationResult = RepositoryVariables.validate(
58
+ JSON.parse(settingsText),
59
+ );
38
60
 
39
- await (found ? updateRepoVariable(variable) : createRepoVariable(variable));
61
+ if (Result.isErr(validationResult)) {
62
+ throw new Error(
63
+ t.validationErrorsToMessages(validationResult.value).join('\n'),
64
+ );
40
65
  }
66
+
67
+ return validationResult.value;
41
68
  };
42
69
 
43
70
  if (isDirectlyExecuted(import.meta.url)) {
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import * as fs from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import { formatUncommittedFiles, isDirectlyExecuted } from 'ts-repo-utils';
6
+ import { clearJsonFilesIn } from '../clear-json-files.mjs';
7
+ import { settingsJsonName, variablesDir } from '../constants.mjs';
8
+ import { listRepoVariables } from './api/index.mjs';
9
+ import { type RepositoryVariables } from './constants.mjs';
10
+
11
+ /**
12
+ * repository variable の現在値を宣言ファイルへ撮り直す。
13
+ *
14
+ * 宣言していないものも含めて live にあるものを全部書く。 backup は「いま何が
15
+ * あるか」を写す操作なので、 GUI で足された変数もここで宣言に入る。要らない
16
+ * ものは撮り直したあとに宣言から外し、 GitHub 側でも消す。
17
+ */
18
+ export const backupVariables = async (fmt: boolean = true): Promise<void> => {
19
+ const variables = await listRepoVariables();
20
+
21
+ await clearJsonFilesIn(variablesDir);
22
+
23
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
24
+ await fs.writeFile(
25
+ path.resolve(variablesDir, settingsJsonName),
26
+ JSON.stringify(toDeclaration(variables), undefined, 2),
27
+ );
28
+
29
+ if (fmt) {
30
+ await formatUncommittedFiles();
31
+ }
32
+ };
33
+
34
+ /**
35
+ * API の配列を `settings.json` と同じ record に畳む。
36
+ *
37
+ * `created_at` / `updated_at` はここで落ちる。値が変わっていなくても動くので、
38
+ * 残すと宣言ファイルが毎日変わり、ドリフト検査が本当の変更を報せなくなる。
39
+ */
40
+ export const toDeclaration = (
41
+ variables: Awaited<ReturnType<typeof listRepoVariables>>,
42
+ ): RepositoryVariables =>
43
+ Object.fromEntries(variables.map(({ name, value }) => [name, value]));
44
+
45
+ if (isDirectlyExecuted(import.meta.url)) {
46
+ await backupVariables();
47
+ }
@@ -0,0 +1,61 @@
1
+ import { Arr } from 'ts-data-forge';
2
+ import * as t from 'ts-fortress';
3
+
4
+ /**
5
+ * `repo-settings/variables/settings.json` の型。
6
+ *
7
+ * Settings > Secrets and variables > Actions > Variables に対応する
8
+ * (`GET|POST /repos/{owner}/{repo}/actions/variables`,
9
+ * `PATCH /repos/{owner}/{repo}/actions/variables/{name}`)。
10
+ *
11
+ * 変数名をキー、値を値にした素の record にしてある。 API が返す配列
12
+ * (`{ name, value, created_at, updated_at }[]`) をそのまま写さないのは、
13
+ * `created_at` / `updated_at` が値の変化なしに動くためで、宣言に入れると
14
+ * 毎日ドリフトとして出てしまう。ここで持つのは宣言できるものだけ。
15
+ *
16
+ * **ここに秘密を書かないこと。** repository variable は API でも Actions の
17
+ * ログでも平文で読める。秘密は secret 側で、そちらはこのツールの管理外にある。
18
+ */
19
+ export const RepositoryVariables = t.keyValueRecord(t.string(), t.string(), {
20
+ typeName: 'RepositoryVariables',
21
+ });
22
+
23
+ export type RepositoryVariables = t.TypeOf<typeof RepositoryVariables>;
24
+
25
+ /**
26
+ * GitHub が受け付けない変数名を、送る前にすべて挙げて落とす。
27
+ *
28
+ * 名前は `PATCH .../actions/variables/{name}` のパスに入る。 octokit が URL
29
+ * エンコードするので注入にはならないが、弾かれる名前は 422 になり、それが
30
+ * 起きるのは**何本か書き込んだ後**である。 environments の `assertConsistent`
31
+ * と同じ理由でここに置いてある — 1 本目を送る前に全部の名前を見ておけば、
32
+ * この種の間違いで中途半端に適用された状態にはならない。
33
+ *
34
+ * 規則は GitHub の documentation のとおり: 英数字と `_` のみ、先頭に数字は
35
+ * 不可、`GITHUB_` 始まりは予約。名前の大文字小文字は区別されないので、
36
+ * 予約接頭辞の判定も区別しない。
37
+ */
38
+ export const assertVariableNamesAreValid = (
39
+ variables: RepositoryVariables,
40
+ ): void => {
41
+ const offenders = Object.keys(variables).filter(
42
+ (name) => !isValidVariableName(name),
43
+ );
44
+
45
+ if (Arr.isNonEmpty(offenders)) {
46
+ throw new Error(
47
+ [
48
+ `repository variable の名前として使えないものがあります: ${offenders
49
+ .map((name) => JSON.stringify(name))
50
+ .join(', ')}。`,
51
+ '英数字と "_" のみが使え、先頭に数字は置けず、"GITHUB_" 始まりは予約されています。',
52
+ ].join('\n'),
53
+ );
54
+ }
55
+ };
56
+
57
+ /** 先頭は英字か `_`、以降は英数字か `_`。入れ子の量化子は無いので線形。 */
58
+ const variableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/u;
59
+
60
+ const isValidVariableName = (name: string): boolean =>
61
+ variableNamePattern.test(name) && !name.toUpperCase().startsWith('GITHUB_');