@sous-io/sous 0.2.11 → 0.2.13
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/docs/markdown/commands.md +2 -1
- package/docs/markdown/repositories-authoring.md +12 -5
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/repo/release.ts +16 -14
- package/src/commands/repo/submit.ts +0 -2
- package/src/lib/repos/release/git-state.ts +53 -0
- package/src/lib/repos/release/submit-service.ts +48 -53
- package/src/lib/repos/scaffold/templates.ts +4 -4
|
@@ -207,7 +207,8 @@ and `git config user.email`), because it commits and cuts annotated tags. Takes
|
|
|
207
207
|
- `--no-bump`: raise nothing; a changed recipe that was never raised is then an error.
|
|
208
208
|
- `--include-unchanged`: release every recipe in scope, changed or not.
|
|
209
209
|
- `--tag`, `--push`: tag even on a non-default branch, and push the commit and this run's tags.
|
|
210
|
-
- `--check`: only validate
|
|
210
|
+
- `--check`: only validate. It fails on a problem the release would refuse, and reports, without failing, how
|
|
211
|
+
merging would rewrite the committed index.
|
|
211
212
|
- `--ci`: the merge preset. Never bump, never ask, and fail on anything unbumped. It still needs `--yes` to
|
|
212
213
|
accept the plan it prints, so a merge job runs `sous repo release --ci --yes --push`.
|
|
213
214
|
|
|
@@ -195,8 +195,9 @@ third case is what a merge looks like to continuous integration: the bump is don
|
|
|
195
195
|
|
|
196
196
|
`--namespace <ns>` and `--recipe <ns/name>` narrow the run and both repeat; `--bump <level>` is `patch` (the
|
|
197
197
|
default), `minor`, `major` or `prerelease`; `--no-bump` raises nothing; `--include-unchanged` releases every
|
|
198
|
-
recipe in scope, changed or not; `--check` only reads, validating
|
|
199
|
-
and
|
|
198
|
+
recipe in scope, changed or not; `--check` only reads, validating, failing on any problem the release would
|
|
199
|
+
refuse, and reporting how merging would rewrite the committed index without failing on it, since that index is
|
|
200
|
+
the release's output; and `--ci` is the merge preset: never bump, accept the plan, never ask, fail on anything unbumped. Every flag
|
|
200
201
|
this command takes, `--tag`, `--push` and `--non-interactive` among them, is in the
|
|
201
202
|
[command reference](commands.md#sous-repo-release).
|
|
202
203
|
|
|
@@ -244,7 +245,8 @@ give that identity to the account a continuous integration job runs as (the scaf
|
|
|
244
245
|
`sous repo init` writes `.github/workflows/sous-release.yml`, which runs the same command in its two presets,
|
|
245
246
|
calling the CLI from npm so nothing is installed into the repository. On a **pull request** it runs
|
|
246
247
|
`npx --yes @sous-io/sous repo release --check`, which only reads, so it is safe on an untrusted branch and
|
|
247
|
-
fails the pull request when a manifest is wrong or
|
|
248
|
+
fails the pull request when a manifest is wrong or a published version changed, and notes how the merge will
|
|
249
|
+
rewrite the index. On a **push to `main`** it
|
|
248
250
|
runs `npx --yes @sous-io/sous repo release --ci --push --yes`; edit the workflow's `branches` list if your
|
|
249
251
|
default branch has another name. `--ci` accepts the plan on its own, the redundant `--yes` keeping the file
|
|
250
252
|
working with an older published sous. Both jobs check out with `fetch-depth: 0`, so existing tags are visible
|
|
@@ -295,8 +297,13 @@ printing each step:
|
|
|
295
297
|
|
|
296
298
|
1. **Preflight.** An `origin` remote exists, sous recognizes its provider, that provider's command line tool
|
|
297
299
|
(`gh` or `glab`) is installed and signed in, and everything is committed.
|
|
298
|
-
2. **Validation.** The repository validates and
|
|
299
|
-
maintainer's own checks and wastes their review.
|
|
300
|
+
2. **Validation.** The repository validates, and your change leaves `sous.index.json` as it found it, so a
|
|
301
|
+
proposal never fails the maintainer's own checks and wastes their review. The index is written by the
|
|
302
|
+
repository's own release after a merge; whether it agrees with the release tags is checked there, by
|
|
303
|
+
`sous repo release --check` on a full clone, not by `submit`. That is what lets `submit` run from the shallow
|
|
304
|
+
checkout `sous repo link` makes, which holds almost none of the tags. The comparison is made against the
|
|
305
|
+
copy of the default branch your checkout holds (`origin/main`, for example); when it holds none, `submit`
|
|
306
|
+
says the check was skipped.
|
|
300
307
|
3. **Delegation.** Sous asks the provider whether you can push to the repository itself, forks it onto your
|
|
301
308
|
account when you cannot, pushes the branch, and asks the provider to open the proposal. A change sitting on
|
|
302
309
|
the default branch is moved to `sous/submit-<YYYYMMDD>-<HHMM>`.
|
package/package.json
CHANGED
|
@@ -40,12 +40,15 @@ import {
|
|
|
40
40
|
type ValidationProblem,
|
|
41
41
|
} from "../../lib/repos/release/index.js";
|
|
42
42
|
import {
|
|
43
|
+
BULLET,
|
|
43
44
|
blankLine,
|
|
44
45
|
displayErrorBlock,
|
|
45
46
|
dryRunNotice,
|
|
46
47
|
footer,
|
|
47
48
|
header,
|
|
48
49
|
log,
|
|
50
|
+
note,
|
|
51
|
+
paragraph,
|
|
49
52
|
section,
|
|
50
53
|
showCommandVars,
|
|
51
54
|
showVariables,
|
|
@@ -373,23 +376,22 @@ export default class RepoRelease extends Command {
|
|
|
373
376
|
reportProblems(result.problems);
|
|
374
377
|
if (hasErrors(result.problems)) return this.stopForErrors(result.problems);
|
|
375
378
|
|
|
376
|
-
|
|
379
|
+
// The index is the release's output, not the author's: the release that
|
|
380
|
+
// runs after a merge rewrites it before tagging. So drift here is reported
|
|
381
|
+
// as what merging will change, never as a failure; only the problems the
|
|
382
|
+
// index build raises above stop a check.
|
|
383
|
+
if (result.stale) {
|
|
384
|
+
note(`Merging rewrites the committed ${INDEX_FILENAME}, because:`);
|
|
385
|
+
for (const line of describeIndexDrift(existing, result.index)) {
|
|
386
|
+
paragraph(`${BULLET} ${line}`, { indent: 4, hangingIndent: 2 });
|
|
387
|
+
}
|
|
388
|
+
blankLine();
|
|
389
|
+
} else {
|
|
377
390
|
log(` The committed ${INDEX_FILENAME} is current, and so are the dependencies it`);
|
|
378
391
|
log(" records for every version it publishes.");
|
|
379
|
-
reportPending(result, "These versions have no tag yet; they publish when this merges:");
|
|
380
|
-
footer();
|
|
381
|
-
return;
|
|
382
392
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
`The committed ${INDEX_FILENAME} is out of date:`,
|
|
386
|
-
"",
|
|
387
|
-
...describeIndexDrift(existing, result.index).map((line) => ` ${line}`),
|
|
388
|
-
"",
|
|
389
|
-
"Run 'sous repo release' to regenerate it, and commit what it writes.",
|
|
390
|
-
];
|
|
391
|
-
displayErrorBlock(lines.join("\n"));
|
|
392
|
-
this.exit(1);
|
|
393
|
+
reportPending(result, "These versions have no tag yet; they publish when this merges:");
|
|
394
|
+
footer();
|
|
393
395
|
}
|
|
394
396
|
|
|
395
397
|
// --- Output ---------------------------------------------------------------------------------
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Command, Flags } from "@oclif/core";
|
|
2
|
-
import { SOUS_VERSION } from "../../lib/settings.js";
|
|
3
2
|
import { findRepoRoot, submitRepo } from "../../lib/repos/release/index.js";
|
|
4
3
|
import { reportCommandError } from "../../utils/command-errors.js";
|
|
5
4
|
import { nonInteractiveFlag } from "../../utils/flags.js";
|
|
@@ -90,7 +89,6 @@ export default class RepoSubmit extends Command {
|
|
|
90
89
|
body: flags.body,
|
|
91
90
|
draft: flags.draft,
|
|
92
91
|
dryRun,
|
|
93
|
-
sousVersion: SOUS_VERSION,
|
|
94
92
|
onStep: (message) => log(` ${message}`),
|
|
95
93
|
onNotice: (message) => (dryRun ? dryRunNotice(message) : log(` ${message}`)),
|
|
96
94
|
});
|
|
@@ -105,6 +105,59 @@ export async function defaultBranch(
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The commit where this checkout's own work starts: the point HEAD shares with
|
|
110
|
+
* the remote's copy of a branch. Returns undefined when this checkout holds no
|
|
111
|
+
* copy of that branch, or when git cannot find a commit the two share.
|
|
112
|
+
*
|
|
113
|
+
* A shallow clone answers this as long as HEAD was built on the branch it was
|
|
114
|
+
* cloned from, which is why a submission can rely on it where it cannot rely on
|
|
115
|
+
* tags.
|
|
116
|
+
*
|
|
117
|
+
* @param rootDir - The repository's root directory.
|
|
118
|
+
* @param remote - The remote whose branch is compared, normally `origin`.
|
|
119
|
+
* @param branch - The branch on that remote, normally the default one.
|
|
120
|
+
* @param options - The command runner to use.
|
|
121
|
+
*/
|
|
122
|
+
export async function forkPoint(
|
|
123
|
+
rootDir: string,
|
|
124
|
+
remote: string,
|
|
125
|
+
branch: string,
|
|
126
|
+
options: RunOptions = {}
|
|
127
|
+
): Promise<string | undefined> {
|
|
128
|
+
try {
|
|
129
|
+
const commit = await runGit(["merge-base", `refs/remotes/${remote}/${branch}`, "HEAD"], {
|
|
130
|
+
cwd: rootDir,
|
|
131
|
+
run: options.run,
|
|
132
|
+
});
|
|
133
|
+
return commit.length > 0 ? commit : undefined;
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True when the commits since `since` changed a path, comparing that commit
|
|
141
|
+
* with HEAD.
|
|
142
|
+
*
|
|
143
|
+
* @param rootDir - The repository's root directory.
|
|
144
|
+
* @param since - The commit to compare HEAD with.
|
|
145
|
+
* @param relativePath - The path to check, relative to the repository root.
|
|
146
|
+
* @param options - The command runner to use.
|
|
147
|
+
*/
|
|
148
|
+
export async function pathChangedSince(
|
|
149
|
+
rootDir: string,
|
|
150
|
+
since: string,
|
|
151
|
+
relativePath: string,
|
|
152
|
+
options: RunOptions = {}
|
|
153
|
+
): Promise<boolean> {
|
|
154
|
+
const changed = await runGit(["diff", "--name-only", since, "HEAD", "--", relativePath], {
|
|
155
|
+
cwd: rootDir,
|
|
156
|
+
run: options.run,
|
|
157
|
+
});
|
|
158
|
+
return changed.length > 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
108
161
|
/**
|
|
109
162
|
* The URL of a remote, or undefined when the repository has no such remote.
|
|
110
163
|
*
|
|
@@ -15,8 +15,12 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Two rules shape everything here:
|
|
17
17
|
*
|
|
18
|
-
* - Nothing is sent until the repository validates and
|
|
19
|
-
* proposal that fails the maintainer's own checks wastes
|
|
18
|
+
* - Nothing is sent until the repository validates and the contributor has left
|
|
19
|
+
* its index alone. A proposal that fails the maintainer's own checks wastes
|
|
20
|
+
* their review. Whether the index agrees with the tags is the maintainer's
|
|
21
|
+
* check, not the contributor's: `sous repo release --check` makes it on a full
|
|
22
|
+
* clone, while a contributor usually works in the shallow checkout that
|
|
23
|
+
* `sous repo link` makes, which holds almost none of the tags.
|
|
20
24
|
* - Every step announces itself BEFORE it runs, and a failure says exactly which
|
|
21
25
|
* steps completed. A half-finished submission (a branch pushed, no proposal
|
|
22
26
|
* opened) is a normal outcome of a network failure, and the contributor has to
|
|
@@ -36,17 +40,16 @@ import {
|
|
|
36
40
|
type RepoProvider,
|
|
37
41
|
type SubmitCapableProvider,
|
|
38
42
|
} from "../providers/provider.js";
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
readIndexFile,
|
|
43
|
-
type IndexBuildResult,
|
|
44
|
-
} from "./index-builder.js";
|
|
43
|
+
import { INDEX_FILENAME } from "../formats/common.js";
|
|
44
|
+
import type { IndexFile } from "../formats/index-file.js";
|
|
45
|
+
import { readIndexFile } from "./index-builder.js";
|
|
45
46
|
import {
|
|
46
47
|
currentBranch,
|
|
47
48
|
createBranch,
|
|
48
49
|
defaultBranch,
|
|
50
|
+
forkPoint,
|
|
49
51
|
lastCommitSubject,
|
|
52
|
+
pathChangedSince,
|
|
50
53
|
pushBranch,
|
|
51
54
|
remoteUrl,
|
|
52
55
|
submitBranchName,
|
|
@@ -81,8 +84,6 @@ export type SubmitOptions = {
|
|
|
81
84
|
draft?: boolean;
|
|
82
85
|
/** When true, everything is checked and reported and nothing is sent. */
|
|
83
86
|
dryRun?: boolean;
|
|
84
|
-
/** The version of sous, recorded when the index is regenerated for the check. */
|
|
85
|
-
sousVersion: string;
|
|
86
87
|
/** When the submission is happening; decides the branch name. Defaults to now. */
|
|
87
88
|
now?: Date;
|
|
88
89
|
/** How subprocesses are run. Defaults to spawning a real process. */
|
|
@@ -127,7 +128,6 @@ export type SubmitResult = {
|
|
|
127
128
|
export async function submitRepo(options: SubmitOptions): Promise<SubmitResult> {
|
|
128
129
|
const {
|
|
129
130
|
rootDir,
|
|
130
|
-
sousVersion,
|
|
131
131
|
draft = false,
|
|
132
132
|
dryRun = false,
|
|
133
133
|
now = new Date(),
|
|
@@ -204,19 +204,27 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
|
|
|
204
204
|
assertRepoValidates(validation);
|
|
205
205
|
completed.push("Checked that every recipe describes itself correctly");
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
207
|
+
const baseBranch = (await defaultBranch(rootDir, { run })) ?? "main";
|
|
208
|
+
|
|
209
|
+
step(`Checking that ${INDEX_FILENAME} was left alone`);
|
|
210
|
+
const since = await forkPoint(rootDir, UPSTREAM_REMOTE, baseBranch, { run });
|
|
211
|
+
if (since === undefined) {
|
|
212
|
+
notice(
|
|
213
|
+
`This checkout holds no copy of '${UPSTREAM_REMOTE}/${baseBranch}', so sous could not ` +
|
|
214
|
+
`check whether ${INDEX_FILENAME} was changed.`
|
|
215
|
+
);
|
|
216
|
+
} else if (await pathChangedSince(rootDir, since, INDEX_FILENAME, { run })) {
|
|
217
|
+
throw new ConfigError(
|
|
218
|
+
`This change edits ${INDEX_FILENAME}. The index is written by the repository's own ` +
|
|
219
|
+
`release, after a change is merged, so a proposal leaves it as it found it.\n` +
|
|
220
|
+
` Restore it with 'git checkout ${since.slice(0, 12)} -- ${INDEX_FILENAME}', ` +
|
|
221
|
+
`commit that, then run the command again.`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
completed.push(`Checked that ${INDEX_FILENAME} was left alone`);
|
|
216
225
|
|
|
217
226
|
// --- The branch the change lives on ---------------------------------------
|
|
218
227
|
|
|
219
|
-
const baseBranch = (await defaultBranch(rootDir, { run })) ?? "main";
|
|
220
228
|
const checkedOut = await currentBranch(rootDir, { run });
|
|
221
229
|
let branch = checkedOut;
|
|
222
230
|
|
|
@@ -233,7 +241,7 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
|
|
|
233
241
|
|
|
234
242
|
const title =
|
|
235
243
|
options.title ?? (await lastCommitSubject(rootDir, { run })) ?? defaultTitle(validation);
|
|
236
|
-
const body = options.body ?? defaultBody(
|
|
244
|
+
const body = options.body ?? defaultBody(validation, readIndexFile(rootDir));
|
|
237
245
|
|
|
238
246
|
// --- Fork, push, propose --------------------------------------------------
|
|
239
247
|
|
|
@@ -332,34 +340,6 @@ function assertRepoValidates(validation: RepoValidation): void {
|
|
|
332
340
|
);
|
|
333
341
|
}
|
|
334
342
|
|
|
335
|
-
/** Refuses to submit while the index disagrees with what the repository publishes. */
|
|
336
|
-
function assertIndexReady(
|
|
337
|
-
built: IndexBuildResult,
|
|
338
|
-
existing: ReturnType<typeof readIndexFile>
|
|
339
|
-
): void {
|
|
340
|
-
if (hasErrors(built.problems)) {
|
|
341
|
-
throw new ConfigError(
|
|
342
|
-
"This repository's index and its tags do not agree, so there is nothing worth " +
|
|
343
|
-
"proposing yet:\n\n" +
|
|
344
|
-
renderProblems(errorsIn(built.problems)) +
|
|
345
|
-
"\n\n Fix these, then run the command again."
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (built.stale) {
|
|
350
|
-
const drift = describeIndexDrift(existing, built.index)
|
|
351
|
-
.map((line) => ` ${line}`)
|
|
352
|
-
.join("\n");
|
|
353
|
-
throw new ConfigError(
|
|
354
|
-
"The committed index is out of date, and a maintainer's own checks would reject " +
|
|
355
|
-
"the proposal:\n\n" +
|
|
356
|
-
`${drift}\n\n` +
|
|
357
|
-
" Run 'sous repo release', commit the regenerated index, then run this command " +
|
|
358
|
-
"again."
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
343
|
/** Renders a list of problems as an indented block. */
|
|
364
344
|
function renderProblems(problems: ReadonlyArray<ValidationProblem>): string {
|
|
365
345
|
return problems.map((problem) => ` ${problem.where}: ${problem.message}`).join("\n");
|
|
@@ -450,8 +430,22 @@ function defaultTitle(validation: RepoValidation): string {
|
|
|
450
430
|
return `Update the ${validation.manifest.name} recipes`;
|
|
451
431
|
}
|
|
452
432
|
|
|
433
|
+
/**
|
|
434
|
+
* The versions merging this change would publish: every recipe whose manifest
|
|
435
|
+
* declares a version the committed index does not list yet. Worked out from
|
|
436
|
+
* the manifests and the index alone, so it needs none of the tags.
|
|
437
|
+
*/
|
|
438
|
+
function versionsToPublish(
|
|
439
|
+
validation: RepoValidation,
|
|
440
|
+
index: IndexFile | undefined
|
|
441
|
+
): Array<{ key: string; version: string }> {
|
|
442
|
+
return validation.recipes
|
|
443
|
+
.filter((recipe) => index?.recipes[recipe.key]?.versions[recipe.manifest.version] === undefined)
|
|
444
|
+
.map((recipe) => ({ key: recipe.key, version: recipe.manifest.version }));
|
|
445
|
+
}
|
|
446
|
+
|
|
453
447
|
/** The body sous writes when the contributor did not supply one. */
|
|
454
|
-
function defaultBody(
|
|
448
|
+
function defaultBody(validation: RepoValidation, index: IndexFile | undefined): string {
|
|
455
449
|
const lines = [
|
|
456
450
|
`Proposed with 'sous repo submit' from the ${validation.manifest.name} repository.`,
|
|
457
451
|
"",
|
|
@@ -460,10 +454,11 @@ function defaultBody(built: IndexBuildResult, validation: RepoValidation): strin
|
|
|
460
454
|
for (const recipe of validation.recipes) {
|
|
461
455
|
lines.push(`- ${recipe.key} at version ${recipe.manifest.version}`);
|
|
462
456
|
}
|
|
463
|
-
|
|
457
|
+
const toPublish = versionsToPublish(validation, index);
|
|
458
|
+
if (toPublish.length > 0) {
|
|
464
459
|
lines.push("");
|
|
465
460
|
lines.push("Versions this proposal would publish once it is merged and tagged:");
|
|
466
|
-
for (const entry of
|
|
461
|
+
for (const entry of toPublish) {
|
|
467
462
|
lines.push(`- ${entry.key} ${entry.version}`);
|
|
468
463
|
}
|
|
469
464
|
}
|
|
@@ -327,10 +327,10 @@ export function buildReleaseWorkflow(): string {
|
|
|
327
327
|
# installed into this repository:
|
|
328
328
|
#
|
|
329
329
|
# 'sous repo release --check' validates every manifest, confirms each recipe
|
|
330
|
-
# folder matches what the repo manifest lists, and
|
|
331
|
-
# index
|
|
332
|
-
#
|
|
333
|
-
#
|
|
330
|
+
# folder matches what the repo manifest lists, and reports how merging would
|
|
331
|
+
# rewrite the committed index, which the release after a merge regenerates.
|
|
332
|
+
# It only reads; it never writes, commits or tags. That makes it the right
|
|
333
|
+
# thing to run on a pull request.
|
|
334
334
|
#
|
|
335
335
|
# 'sous repo release --ci --push --yes' does the same validation and then
|
|
336
336
|
# publishes. '--ci' raises no versions, accepts the plan it prints, and asks
|