@rtorcato/repo-tooling 3.20.0 → 3.21.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/dist/base/fixers.js +21 -1
- package/dist/base/github-settings.js +132 -6
- package/package.json +1 -1
package/dist/base/fixers.js
CHANGED
|
@@ -26,7 +26,7 @@ import { classifyCopiedAssets } from '../cli/utils/copied-assets.js';
|
|
|
26
26
|
import { copyPreset } from '../cli/utils/copy-preset.js';
|
|
27
27
|
import { detectLanguage } from '../cli/utils/detect-language.js';
|
|
28
28
|
import { resolveLanguageModule } from '../languages/registry.js';
|
|
29
|
-
import { applyGithubSettings } from './github-settings.js';
|
|
29
|
+
import { applyGithubSettings, applyReleaseEnvironment, RELEASE_ENV_CHECK, RELEASE_GATE_CHECK, } from './github-settings.js';
|
|
30
30
|
import { applyLoopLabels } from './labels.js';
|
|
31
31
|
import { closeCompletedMilestones } from './milestones.js';
|
|
32
32
|
/**
|
|
@@ -210,6 +210,26 @@ export const BASE_FIXERS = [
|
|
|
210
210
|
return { filesWritten: await applyGithubSettings(targetDir) };
|
|
211
211
|
},
|
|
212
212
|
},
|
|
213
|
+
{
|
|
214
|
+
target: 'release-environment',
|
|
215
|
+
description: 'Create the `release` environment (authenticated user as required reviewer) and wire `environment: release` into the publishing job — the gate between merging and publishing (#429)',
|
|
216
|
+
appliesTo: [RELEASE_GATE_CHECK, RELEASE_ENV_CHECK],
|
|
217
|
+
outputs: [
|
|
218
|
+
'GitHub `release` environment (remote, via gh api)',
|
|
219
|
+
'.github/workflows/<the publishing workflow>',
|
|
220
|
+
],
|
|
221
|
+
// safe-add for the same shadow-run reason as github-settings, and
|
|
222
|
+
// explicitOnly because arming the gate changes what a merge *does*: the
|
|
223
|
+
// next release run sits `waiting` for an approval instead of publishing.
|
|
224
|
+
// That is the point, but it must be a chosen rollout per repo, not a side
|
|
225
|
+
// effect of a bare `fix`.
|
|
226
|
+
riskLevel: 'safe-add',
|
|
227
|
+
explicitOnly: true,
|
|
228
|
+
canFixDrift: true,
|
|
229
|
+
async run({ targetDir }) {
|
|
230
|
+
return { filesWritten: await applyReleaseEnvironment(targetDir) };
|
|
231
|
+
},
|
|
232
|
+
},
|
|
213
233
|
{
|
|
214
234
|
target: 'milestones',
|
|
215
235
|
description: 'Close 100%-complete open milestones on GitHub via gh api (mutates the remote repo, not files). Never deletes or creates one',
|
|
@@ -51,8 +51,8 @@ export const GITHUB_STANDARD = {
|
|
|
51
51
|
requiredContexts: ['lint', 'typecheck', 'build', 'test'],
|
|
52
52
|
};
|
|
53
53
|
const CODE_SCANNING_CHECK = 'Code-scanning gate';
|
|
54
|
-
const RELEASE_GATE_CHECK = 'Release gate';
|
|
55
|
-
const RELEASE_ENV_CHECK = 'Release environment';
|
|
54
|
+
export const RELEASE_GATE_CHECK = 'Release gate';
|
|
55
|
+
export const RELEASE_ENV_CHECK = 'Release environment';
|
|
56
56
|
const CHECK_NAMES = [
|
|
57
57
|
'Branch protection',
|
|
58
58
|
'Merge settings',
|
|
@@ -438,7 +438,7 @@ const RELEASE_ENVIRONMENT = 'release';
|
|
|
438
438
|
* `semantic-release` counts on its own — the shipped preset publishes with it.
|
|
439
439
|
*/
|
|
440
440
|
const PUBLISH_COMMAND = /semantic-release|changesets\/action|(?:npm|pnpm|yarn)\s+publish/;
|
|
441
|
-
const GATE_HINT = '
|
|
441
|
+
const GATE_HINT = 'Run `npx @rtorcato/repo-tooling fix release-environment` to create the `release` environment with required reviewers (you) and add `environment: release` to the publishing job — a merge to the default branch then leaves the run `waiting` instead of publishing';
|
|
442
442
|
const ENV_HINT = 'Add `environment: release` to the publishing job, or delete the environment — whichever was meant. An environment nothing references still lists under Settings → Environments as though it gates something';
|
|
443
443
|
const unquote = (s) => s.replace(/^['"]|['"]$/g, '');
|
|
444
444
|
/**
|
|
@@ -604,9 +604,9 @@ async function readEnvironments(gh, nwo) {
|
|
|
604
604
|
* environment" is the gate check's; "no `environment:` but a `release`
|
|
605
605
|
* environment exists" is the environment check's, and the gate check defers.
|
|
606
606
|
*
|
|
607
|
-
* A repo that publishes nothing is `ok` — not applicable, not drift.
|
|
608
|
-
*
|
|
609
|
-
*
|
|
607
|
+
* A repo that publishes nothing is `ok` — not applicable, not drift. The fixer
|
|
608
|
+
* is `fix release-environment` (`applyReleaseEnvironment` below), which uses
|
|
609
|
+
* the authenticated user as the required reviewer.
|
|
610
610
|
*/
|
|
611
611
|
async function checkReleaseGate(gh, nwo, dir) {
|
|
612
612
|
const publish = (await isPrivatePackage(dir)) ? null : await findPublishJob(dir);
|
|
@@ -836,3 +836,129 @@ export async function applyGithubSettings(dir, exec) {
|
|
|
836
836
|
console.error(chalk.gray(' already configured — nothing to apply'));
|
|
837
837
|
return applied;
|
|
838
838
|
}
|
|
839
|
+
// --- Release environment scaffolding (#429) --------------------------------
|
|
840
|
+
/**
|
|
841
|
+
* Insert `environment: <env>` as the first key of job `jobId`. Line-based, same
|
|
842
|
+
* reasoning as `workflowJobs`: this package ships no YAML dependency, and jobs
|
|
843
|
+
* sit one indent level under `jobs:` with their keys one level below. Returns
|
|
844
|
+
* null when the job cannot be found. The caller only reaches this when
|
|
845
|
+
* `jobEnvironment()` was null, so it never doubles an existing key.
|
|
846
|
+
*/
|
|
847
|
+
export function addJobEnvironment(yaml, jobId, env) {
|
|
848
|
+
const lines = yaml.split('\n');
|
|
849
|
+
const start = lines.findIndex((l) => /^jobs:\s*$/.test(l));
|
|
850
|
+
if (start === -1)
|
|
851
|
+
return null;
|
|
852
|
+
const indentOf = (l) => l.length - l.trimStart().length;
|
|
853
|
+
// The job headers' indent — from the first real line after `jobs:`, exactly
|
|
854
|
+
// as workflowJobs derives it, so the two agree on what counts as a header.
|
|
855
|
+
let jobIndent = -1;
|
|
856
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
857
|
+
const line = lines[i] ?? '';
|
|
858
|
+
if (line.trim() === '' || line.trimStart().startsWith('#'))
|
|
859
|
+
continue;
|
|
860
|
+
if (indentOf(line) === 0)
|
|
861
|
+
return null;
|
|
862
|
+
jobIndent = indentOf(line);
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
if (jobIndent === -1)
|
|
866
|
+
return null;
|
|
867
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
868
|
+
const line = lines[i] ?? '';
|
|
869
|
+
if (line.trim() === '' || line.trimStart().startsWith('#'))
|
|
870
|
+
continue;
|
|
871
|
+
if (indentOf(line) === 0)
|
|
872
|
+
return null; // left the jobs block
|
|
873
|
+
if (indentOf(line) !== jobIndent || !line.trim().startsWith(`${jobId}:`))
|
|
874
|
+
continue;
|
|
875
|
+
// Key indent = the job's first real line, so the insert matches whatever
|
|
876
|
+
// indentation the file already uses.
|
|
877
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
878
|
+
const next = lines[j] ?? '';
|
|
879
|
+
if (next.trim() === '' || next.trimStart().startsWith('#'))
|
|
880
|
+
continue;
|
|
881
|
+
lines.splice(j, 0, `${' '.repeat(indentOf(next))}environment: ${env}`);
|
|
882
|
+
return lines.join('\n');
|
|
883
|
+
}
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Scaffold the release environment gate — the fixer half of #429 (the checks
|
|
890
|
+
* shipped with #449). Two writes, each skipped when already in place:
|
|
891
|
+
*
|
|
892
|
+
* 1. `PUT /repos/{nwo}/environments/release` with the **authenticated user** as
|
|
893
|
+
* the required reviewer. On a solo-maintained repo they are the only human
|
|
894
|
+
* there is; add or swap reviewers afterwards under Settings → Environments.
|
|
895
|
+
* An environment that already carries a `required_reviewers` rule is never
|
|
896
|
+
* touched — whoever set it up made a richer decision than this default.
|
|
897
|
+
* 2. `environment: release` on the publishing job, so the merge leaves the run
|
|
898
|
+
* `waiting` instead of publishing.
|
|
899
|
+
*
|
|
900
|
+
* A job already behind some *other* environment is a warning, not a rename —
|
|
901
|
+
* this fixer scaffolds the standard, it does not migrate a custom setup.
|
|
902
|
+
*/
|
|
903
|
+
export async function applyReleaseEnvironment(dir, exec) {
|
|
904
|
+
if (!(await fs.pathExists(path.join(dir, '.git')))) {
|
|
905
|
+
console.error(chalk.gray(' skipped — not a git repository'));
|
|
906
|
+
return [];
|
|
907
|
+
}
|
|
908
|
+
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
909
|
+
const probe = await probeRepo(gh);
|
|
910
|
+
if ('skip' in probe) {
|
|
911
|
+
console.error(chalk.gray(` skipped — ${probe.skip}`));
|
|
912
|
+
return [];
|
|
913
|
+
}
|
|
914
|
+
const nwo = probe.info.nwo;
|
|
915
|
+
const publish = (await isPrivatePackage(dir)) ? null : await findPublishJob(dir);
|
|
916
|
+
if (publish === 'skip') {
|
|
917
|
+
console.error(chalk.yellow(' skipped — could not read .github/workflows'));
|
|
918
|
+
return [];
|
|
919
|
+
}
|
|
920
|
+
if (!publish) {
|
|
921
|
+
console.error(chalk.gray(' skipped — no workflow job publishes to a registry'));
|
|
922
|
+
return [];
|
|
923
|
+
}
|
|
924
|
+
if (publish.environment !== null && publish.environment !== RELEASE_ENVIRONMENT) {
|
|
925
|
+
console.error(chalk.yellow(` skipped — ${publish.file} \`${publish.job}\` already runs behind \`${publish.environment}\`; not renaming it`));
|
|
926
|
+
return [];
|
|
927
|
+
}
|
|
928
|
+
const envs = await readEnvironments(gh, nwo);
|
|
929
|
+
if (envs === 'skip') {
|
|
930
|
+
console.error(chalk.yellow(' skipped — could not read environments'));
|
|
931
|
+
return [];
|
|
932
|
+
}
|
|
933
|
+
const applied = [];
|
|
934
|
+
if (envs.get(RELEASE_ENVIRONMENT) !== true) {
|
|
935
|
+
const user = await gh(['api', 'user', '--jq', '.id']);
|
|
936
|
+
const id = user.ok ? Number.parseInt(user.stdout.trim(), 10) : Number.NaN;
|
|
937
|
+
if (Number.isNaN(id)) {
|
|
938
|
+
console.error(chalk.yellow(` could not resolve the authenticated user for required_reviewers: ${user.stderr.trim() || 'gh error'}`));
|
|
939
|
+
return applied;
|
|
940
|
+
}
|
|
941
|
+
const label = `\`${RELEASE_ENVIRONMENT}\` environment with the authenticated user as required reviewer`;
|
|
942
|
+
const r = await gh(['api', '-X', 'PUT', `repos/${nwo}/environments/${RELEASE_ENVIRONMENT}`, '--input', '-'], JSON.stringify({ reviewers: [{ type: 'User', id }] }));
|
|
943
|
+
if (r.ok)
|
|
944
|
+
applied.push(label);
|
|
945
|
+
else {
|
|
946
|
+
console.error(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
|
|
947
|
+
return applied;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (publish.environment === null) {
|
|
951
|
+
const file = path.join(dir, '.github', 'workflows', publish.file);
|
|
952
|
+
const updated = addJobEnvironment(await fs.readFile(file, 'utf-8'), publish.job, RELEASE_ENVIRONMENT);
|
|
953
|
+
if (updated === null) {
|
|
954
|
+
console.error(chalk.yellow(` could not add \`environment:\` to ${publish.file} \`${publish.job}\` — add it by hand`));
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
await fs.writeFile(file, updated);
|
|
958
|
+
applied.push(`environment: ${RELEASE_ENVIRONMENT} on ${publish.file} \`${publish.job}\``);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (applied.length === 0)
|
|
962
|
+
console.error(chalk.gray(' already configured — nothing to apply'));
|
|
963
|
+
return applied;
|
|
964
|
+
}
|
package/package.json
CHANGED