@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.
@@ -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 = 'Create a `release` environment with required reviewers (Settings → Environments) and add `environment: release` to the publishing job — a merge to the default branch then leaves the run `waiting` instead of publishing';
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. There is
608
- * no fixer: creating the environment needs a `required_reviewers` list only a
609
- * human can supply, so both hints describe the manual step.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.20.0",
3
+ "version": "3.21.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [