diff --git a/src/noldova.tools.pipelines/README.md b/src/noldova.tools.pipelines/README.md index b2c2070..40b8bb0 100644 --- a/src/noldova.tools.pipelines/README.md +++ b/src/noldova.tools.pipelines/README.md @@ -75,7 +75,7 @@ It succeeds only when: - the version is a version, the commit is its full id, and the scope is `@` and a lowercase name, such as `@noldova`; - the checkout is at the commit; - the folder holds one archive for each package at the version, named as `npm pack` names it, such as `noldova-tools.pipelines-0.0.4.tgz`, and nothing else; -- each archive's `package/package.json` has the package's name, inside the scope, the version, and the commit as its `gitHead`, isn't `"private": true`, has a `license`, and lists any other expected package under `dependencies` at exactly the version; +- each archive's `package/package.json` has the package's name, inside the scope, the version, and the commit as its `gitHead`, isn't `"private": true`, has a `license`, lists any other expected package under `dependencies` at exactly the version, and lists any under `peerDependencies` in the range from the version to the next minor one, such as `>=0.0.9 <0.1.0`, as a package that takes its siblings as peers names them; - each archive holds `package/LICENSE`, and no file of a kind that holds credentials: `.npmrc`, `.netrc`, `.env` or `.env.`, `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519`, or a name ending in `.pem`, `.key`, `.p12` or `.pfx`, such as `.key` itself, in any folder and whatever its case; - each archive holds every file the build copies into its package, as a file with the same bytes as its source, and neither the copy nor its source is a symbolic link or under one. @@ -102,7 +102,7 @@ A wrong value, a checkout at another commit or a missing folder fails with that Otherwise the step fails with one line for each problem it finds with the archives, such as `archives has no noldova-tools.pipelines-0.0.4.tgz.` or `noldova-tools.pipelines-0.0.4.tgz: its version is 0.0.3, not 0.0.4.` It reads each manifest and lists each archive's files with the `tar` program, which must be on the `PATH`; an archive `tar` can't read or list is one of those lines, with `tar`'s exit code and message. When the archives pass, the step checks the copies, extracting each archive with `tar` into a new temporary folder that it removes after, and adds one line, such as `Each of the 2 files the build copies in matches its source.`; it fails with npm's exit code and message when the build can't list its copies, and when its output doesn't end with `end of copies`; otherwise with one line for each copy the build lists that isn't three fields, has a path that isn't inside its folder or a package without an archive, a source that is a symbolic link or under one or isn't a file, an archive `tar` can't extract, and a copy an archive lacks, holds as a symbolic link, under one or as a folder, or holds with other bytes. -A `package.json`, in an archive or under `src/`, makes the step throw `JsonException` if it isn't a JSON object, lacks a `name` or `version` text where the step needs one, has a `gitHead` or a `license` that isn't a text, or has a `private` that isn't `true` or `false` or `dependencies` that aren't an object of texts. +A `package.json`, in an archive or under `src/`, makes the step throw `JsonException` if it isn't a JSON object, lacks a `name` or `version` text where the step needs one, has a `gitHead` or a `license` that isn't a text, or has a `private` that isn't `true` or `false` or `dependencies` or `peerDependencies` that aren't an object of texts. ### The publish step diff --git a/src/noldova.tools.pipelines/src/api/index.d.ts b/src/noldova.tools.pipelines/src/api/index.d.ts index 01be51c..96e924a 100644 --- a/src/noldova.tools.pipelines/src/api/index.d.ts +++ b/src/noldova.tools.pipelines/src/api/index.d.ts @@ -262,6 +262,22 @@ export declare class Version { */ public equals(other: Version): boolean; + /** + * Gives the first release of the next minor version, the upper bound of a + * sibling package's peer range. + * + * @returns The version with the minor part one higher, the patch part 0 + * and no nightly date. + * @example + * ```ts + * import { Version } from "@noldova/tools.pipelines"; + * + * Version.parse("0.0.9").nextMinor().toString(); // "0.1.0" + * Version.parse("1.4.1-nightly.20261101").nextMinor().toString(); // "1.5.0" + * ``` + */ + public nextMinor(): Version; + /** * Writes the version as its text. * @@ -1943,8 +1959,10 @@ export declare class SourceStep implements IStep { * the commit, and the folder holds exactly one archive for each package at * the version, named as `npm pack` names it, whose `package/package.json` * has the package's name, inside the scope, the version, and the commit as - * its `gitHead`, is not `"private": true`, has a `license`, and lists any expected package under `dependencies` at - * exactly the version. Each archive must also hold `package/LICENSE`, and no + * its `gitHead`, is not `"private": true`, has a `license`, lists any + * expected package under `dependencies` at exactly the version, and lists any + * under `peerDependencies` in the range from the version to the next minor + * one, such as `>=0.0.9 <0.1.0`. Each archive must also hold `package/LICENSE`, and no * file of a kind that holds credentials: `.npmrc`, `.netrc`, `.env` or * `.env.`, `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519`, or a name * ending in `.pem`, `.key`, `.p12` or `.pfx`, such as `.key` itself, in any @@ -2002,8 +2020,9 @@ export declare class ArchiveStep implements IStep { * `tar` cannot read the manifest of, with `tar`'s exit code and message, * and a manifest with another name, a name outside the scope, another * version, no `gitHead` or another one, `"private": true`, no `license` - * or an empty one, or an expected package under `dependencies` - * at anything but exactly the version, an archive `tar` cannot list the + * or an empty one, an expected package under `dependencies` + * at anything but exactly the version, or one under `peerDependencies` in + * any range but `>= `, an archive `tar` cannot list the * files of, with `tar`'s exit code and message, a file of a kind that holds * credentials, and no `package/LICENSE`. When the archives pass, it asks * the build for its copies and adds a line, such as diff --git a/src/noldova.tools.pipelines/src/models/version.ts b/src/noldova.tools.pipelines/src/models/version.ts index 4ed7d3f..4a92a3d 100644 --- a/src/noldova.tools.pipelines/src/models/version.ts +++ b/src/noldova.tools.pipelines/src/models/version.ts @@ -61,6 +61,10 @@ export class Version { return this.compareTo(other) === 0; } + public nextMinor(): Version { + return new Version(this.major, this.minor + 1, 0); + } + public toString(): string { const release = [this.major, this.minor, this.patch].join(Resources.versionPartSeparator); diff --git a/src/noldova.tools.pipelines/src/resources.ts b/src/noldova.tools.pipelines/src/resources.ts index 7e292bc..d94e7db 100644 --- a/src/noldova.tools.pipelines/src/resources.ts +++ b/src/noldova.tools.pipelines/src/resources.ts @@ -596,6 +596,14 @@ export class Resources { return `${archiveName}: its dependency ${dependency} is ${found}, not exactly ${version}.`; } + public static formatSiblingPeerRange(version: string, nextMinor: string): string { + return `>=${version} <${nextMinor}`; + } + + public static formatPeerRangeDiffers(archiveName: string, peer: string, found: string, expected: string): string { + return `${archiveName}: its peer ${peer} is ${found}, not ${expected}.`; + } + public static formatArchiveChecked(archiveName: string, packageName: string, version: string, commit: string): string { return `${archiveName}: ${packageName}@${version} from commit ${commit}`; } diff --git a/src/noldova.tools.pipelines/src/services/archive-step.ts b/src/noldova.tools.pipelines/src/services/archive-step.ts index e9f9487..92df7c8 100644 --- a/src/noldova.tools.pipelines/src/services/archive-step.ts +++ b/src/noldova.tools.pipelines/src/services/archive-step.ts @@ -147,6 +147,15 @@ export class ArchiveStep implements IStep { findings.push(Resources.formatDependencyDiffers(archiveName, dependency, range, version)); } } + if (manifest.hasField(Resources.peerDependenciesField)) { + const peers = manifest.readObject(Resources.peerDependenciesField); + const expected = Resources.formatSiblingPeerRange(version, Version.parse(version).nextMinor().toString()); + for (const peer of packageNames.filter(t => peers.hasField(t))) { + const range = peers.readString(peer); + if (range !== expected) + findings.push(Resources.formatPeerRangeDiffers(archiveName, peer, range, expected)); + } + } findings.push(...await this.checkFilesAsync(archivePath)); diff --git a/src/noldova.tools.pipelines/tests/models/version.test.ts b/src/noldova.tools.pipelines/tests/models/version.test.ts index 1b989f0..cbd3bde 100644 --- a/src/noldova.tools.pipelines/tests/models/version.test.ts +++ b/src/noldova.tools.pipelines/tests/models/version.test.ts @@ -26,6 +26,14 @@ export class VersionTests { Assert.areEqual(text, version.toString()); } + @TestData("0.0.9", "0.1.0") + @TestData("1.4.1", "1.5.0") + @TestData("1.4.1-nightly.20261101", "1.5.0") + @TestMethod + public givesTheFirstReleaseOfTheNextMinorVersion(text: string, nextMinor: string): void { + Assert.areEqual(nextMinor, Version.parse(text).nextMinor().toString()); + } + @TestData("1.4.1-nightly.20261101", 1, 4, 1, "20261101") @TestData("0.0.0-nightly.10000101", 0, 0, 0, "10000101") @TestData("0.1.1-nightly.20280229", 0, 1, 1, "20280229") diff --git a/src/noldova.tools.pipelines/tests/services/archive-step.test.ts b/src/noldova.tools.pipelines/tests/services/archive-step.test.ts index ad39179..bad93ca 100644 --- a/src/noldova.tools.pipelines/tests/services/archive-step.test.ts +++ b/src/noldova.tools.pipelines/tests/services/archive-step.test.ts @@ -455,6 +455,45 @@ export class ArchiveStepTests { result.message); } + @TestMethod + public async succeedsForSiblingPeersInTheirRangeAsync(): Promise { + using repository = GitRepositoryFixture.create(); + const commit = repository.commit(ArchiveStepTests.PACKAGES); + const folder = ArchiveStepTests.packArchives(repository, ArchiveStepTests.MANIFEST_A, ArchiveStepTests.peerManifestB(">=0.0.4 <0.1.0")); + + const result = await ArchiveStepTests.createStep(repository.directory).runAsync([folder, "0.0.4", commit, "@noldova"]); + + Assert.isTrue(result.isSuccessful, result.message); + } + + @TestData(">=0.0.3 <0.1.0") + @TestData(">=0.0.4 <0.0.5") + @TestData(">=0.0.4 <1.0.0") + @TestData("0.0.4") + @TestData("^0.0.4") + @TestMethod + public async failsForASiblingPeerInAnotherRangeAsync(range: string): Promise { + using repository = GitRepositoryFixture.create(); + const commit = repository.commit(ArchiveStepTests.PACKAGES); + const folder = ArchiveStepTests.packArchives(repository, ArchiveStepTests.MANIFEST_A, ArchiveStepTests.peerManifestB(range)); + + const result = await ArchiveStepTests.createStep(repository.directory).runAsync([folder, "0.0.4", commit, "@noldova"]); + + Assert.isFalse(result.isSuccessful); + Assert.areEqual(`noldova-b-0.0.4.tgz: its peer @noldova/a is ${range}, not >=0.0.4 <0.1.0.`, result.message); + } + + @TestMethod + public async rejectsPeerDependenciesThatAreNotAnObjectAsync(): Promise { + using repository = GitRepositoryFixture.create(); + const commit = repository.commit(ArchiveStepTests.PACKAGES); + const folder = ArchiveStepTests.packArchives(repository, ArchiveStepTests.MANIFEST_A, ArchiveStepTests.MANIFEST_B.replace("\"dependencies\"", "\"peerDependencies\": [], \"dependencies\"")); + + const exception = await Assert.throwsAsync(() => ArchiveStepTests.createStep(repository.directory).runAsync([folder, "0.0.4", commit, "@noldova"]), JsonException); + + Assert.isTrue(exception.path.startsWith("noldova-b-0.0.4.tgz/package/package.json"), exception.path); + } + @TestMethod public async failsForAnArchiveFromAnotherCommitAsync(): Promise { using repository = GitRepositoryFixture.create(); @@ -700,6 +739,17 @@ export class ArchiveStepTests { return new ArchiveStep(new Git(processRunner, directory), new Tar(processRunner), new ListingNpm(directory, listed)); } + private static peerManifestB(range: string): string { + return JSON.stringify({ + name: "@noldova/b", + version: "0.0.4", + gitHead: "__COMMIT__", + license: "MIT", + private: false, + peerDependencies: { "@noldova/a": range, "@example/d": "^1.0.0" } + }); + } + private static packArchives(repository: GitRepositoryFixture, manifestA: string, manifestB: string, filesA: Readonly> = {}): string { const commit = repository.readHead(); repository.pack("archives/noldova-a-0.0.4.tgz", { "package/package.json": ArchiveStepTests.withCommit(manifestA, commit), ...ArchiveStepTests.LICENSE, ...filesA });