diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4430b65..e8623ec 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -200,3 +200,33 @@ The GitHub Actions workflow requires: **NEVER CANCEL operations - they complete very quickly. Set timeouts of 60+ seconds minimum for safety.** + +## Adding New Subcommands + +When adding a new subcommand to the CLI, you MUST also update the following +files: + +1. **action.yml**: Add the new subcommand to the `inputs.command.options` list + - This makes the command available in the GitHub Action + - Keep the list alphabetically sorted for consistency + +2. **.github/workflows/checks.yml**: Add an integration test step in the + `docker-action` job + - Add a step that tests the new subcommand using the action + - Follow the naming pattern: `- name: ` + - Provide appropriate test values via the `with:` section + - This ensures the command works correctly in the Docker action context + +3. **README.md**: Update the usage documentation + - Add the new command to the commands list + - Provide usage examples + - Document any command-specific options or flags + +4. **src/commands/mod.ts**: Export the new command +5. **main.ts**: Register the new command with yargs + +**Example**: When adding the `sort` subcommand: + +- Added `- sort` to action.yml command options +- Added a "Sort" test step in docker-action job with test values +- Updated README with sort command documentation and examples diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 77d1aef..a5d245c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -141,6 +141,11 @@ jobs: command: lte value: 1.2.3 compare-to: 1.2.3 + - name: Sort + uses: ./ + with: + command: sort + value: 2.0.0 1.0.0 3.0.0 docker: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 393dee2..3a6f70e 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,17 @@ deno task install semver Commands: - semver get Get the version - semver set Set the version - semver inc Increment the version - semver parse [value] Parse the version and print - semver cmp Compare v1 to v2 and return -1/0/1 - semver gt Return 0 if v1 is greater than v2, else 1 - semver gte Return 0 if v1 is greater than or equal to v2, else 1 - semver lt Return 0 if v1 is less than v2, else 1 - semver lte Return 0 if v1 is less than or equal to v2, else 1 - semver eq Return 0 if v1 is equal to v2, else 1 + semver get Get the version + semver set Set the version + semver inc Increment the version + semver parse [value] Parse the version and print + semver cmp Compare v1 to v2 and return -1/0/1 + semver gt Return 0 if v1 is greater than v2, else 1 + semver gte Return 0 if v1 is greater than or equal to v2, else 1 + semver lt Return 0 if v1 is less than v2, else 1 + semver lte Return 0 if v1 is less than or equal to v2, else 1 + semver eq Return 0 if v1 is equal to v2, else 1 + semver sort [versions..] Sort semantic versions Options: --help Show help [boolean] @@ -74,6 +75,10 @@ command will create the `VERSION` file if it doesn't already exist. The `parse` command accepts a version string as input and parses and prints that version as output if it is valid. +The `sort` command accepts one or more version strings and outputs them in +sorted order (descending by default, one version per line). Use the `-a` flag +for ascending order, or read versions from stdin using `--`. + #### examples ```sh @@ -97,6 +102,28 @@ semver get # 1.2.3 semver parse 1.0.0 # {"major":1,"minor":1,"patch":0,"prerelease":[],"build":[]} ``` +```sh +# sort versions in descending order (default) +semver sort 2.0.0 1.0.0 3.0.0 +# 3.0.0 +# 2.0.0 +# 1.0.0 +``` + +```sh +# sort versions in ascending order +semver sort -a 2.0.0 1.0.0 3.0.0 +# 1.0.0 +# 2.0.0 +# 3.0.0 +``` + +```sh +# sort versions from stdin +cat versions.txt | semver sort -- +# (sorted output) +``` + ### Incrementing When calling the command `inc` the `VERSION` file will be updated based on the diff --git a/action.yml b/action.yml index 3442530..155c5ca 100644 --- a/action.yml +++ b/action.yml @@ -20,6 +20,7 @@ inputs: - gte - lt - lte + - sort sub-command: type: choice description: "The kind of increment (major|minor|patch|none) for (get|inc) commands" @@ -39,7 +40,7 @@ inputs: required: false value: type: string - description: The Version (for set, parse, eq, cmp, gt, gte, lt, lte commands) + description: The Version (for set, parse, eq, cmp, gt, gte, lt, lte commands) or space-separated versions (for sort command) required: false compare-to: type: string diff --git a/deno.json b/deno.json index 1a052a8..1734d69 100644 --- a/deno.json +++ b/deno.json @@ -5,7 +5,7 @@ "@std/fmt": "jsr:@std/fmt@^1.0.8", "json5": "npm:json5@^2.2.3", "jsonc-parser": "npm:jsonc-parser@^3.2.1", - "semver": "jsr:@std/semver@^1.0.3", + "semver": "jsr:@std/semver@^1.0.6", "path": "jsr:@std/path@^1.0.6", "assert": "jsr:@std/assert@^1.0.6", "testing/bdd": "jsr:@std/testing@^1.0.3/bdd", diff --git a/deno.lock b/deno.lock index 0770e57..348d48e 100644 --- a/deno.lock +++ b/deno.lock @@ -1,16 +1,26 @@ { "version": "5", "specifiers": { + "jsr:@std/assert@^1.0.15": "1.0.15", + "jsr:@std/assert@^1.0.6": "1.0.15", "jsr:@std/fmt@^1.0.8": "1.0.8", "jsr:@std/internal@^1.0.10": "1.0.12", + "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/path@^1.0.6": "1.1.2", - "jsr:@std/semver@^1.0.3": "1.0.6", + "jsr:@std/semver@^1.0.6": "1.0.6", + "jsr:@std/testing@^1.0.3": "1.0.16", "jsr:@std/yaml@^1.0.5": "1.0.10", "npm:json5@^2.2.3": "2.2.3", "npm:jsonc-parser@^3.2.1": "3.3.1", "npm:yargs@^17.7.2": "17.7.2" }, "jsr": { + "@std/assert@1.0.15": { + "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/fmt@1.0.8": { "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" }, @@ -20,12 +30,19 @@ "@std/path@1.1.2": { "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.10" ] }, "@std/semver@1.0.6": { "integrity": "b7c98ae2843547cf3f7ac37f3995889e6e4cee0a97b57b57f17f62722843303c" }, + "@std/testing@1.0.16": { + "integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092", + "dependencies": [ + "jsr:@std/assert@^1.0.15", + "jsr:@std/internal@^1.0.12" + ] + }, "@std/yaml@1.0.10": { "integrity": "245706ea3511cc50c8c6d00339c23ea2ffa27bd2c7ea5445338f8feff31fa58e" } @@ -126,7 +143,7 @@ "jsr:@std/assert@^1.0.6", "jsr:@std/fmt@^1.0.8", "jsr:@std/path@^1.0.6", - "jsr:@std/semver@^1.0.3", + "jsr:@std/semver@^1.0.6", "jsr:@std/testing@^1.0.3", "jsr:@std/yaml@^1.0.5", "npm:json5@^2.2.3", diff --git a/main.ts b/main.ts index d8fa84f..6be4081 100644 --- a/main.ts +++ b/main.ts @@ -12,6 +12,7 @@ import { lte, parse, set, + sort, } from "./src/commands/mod.ts"; import { getContext } from "./src/context.ts"; import { ApplicationError } from "./src/errors/application.error.ts"; @@ -34,6 +35,7 @@ try { .command(lt) .command(lte) .command(eq) + .command(sort) .strictOptions() .strictCommands() .demandCommand(1) diff --git a/src/commands/mod.ts b/src/commands/mod.ts index e1ac22f..4336d95 100644 --- a/src/commands/mod.ts +++ b/src/commands/mod.ts @@ -8,3 +8,4 @@ export * from "./gte.ts"; export * from "./lt.ts"; export * from "./lte.ts"; export * from "./eq.ts"; +export * from "./sort.ts"; diff --git a/src/commands/sort.test.ts b/src/commands/sort.test.ts new file mode 100644 index 0000000..b691637 --- /dev/null +++ b/src/commands/sort.test.ts @@ -0,0 +1,217 @@ +import { describe, it } from "testing/bdd"; +import { assertSpyCall, assertSpyCalls, stub } from "testing/mock"; +import { Arguments } from "yargs"; +import { sort } from "./sort.ts"; +import { testContext } from "../util/testContext.ts"; +import { IContext } from "../context.ts"; + +describe("sort", () => { + const ctx = testContext({ + consoleLog: () => stub(console, "log"), + }); + + it("SORT00 - sorts versions in descending order by default", async () => { + await sort.handler( + { + _: [], + versions: ["2.0.0", "1.0.0", "3.0.0"], + asc: false, + } as unknown as Arguments & IContext, + ); + assertSpyCall(ctx.consoleLog, 0, { + args: ["3.0.0"], + }); + assertSpyCall(ctx.consoleLog, 1, { + args: ["2.0.0"], + }); + assertSpyCall(ctx.consoleLog, 2, { + args: ["1.0.0"], + }); + assertSpyCalls(ctx.consoleLog, 3); + }); + + it("SORT01 - sorts versions in ascending order with -a flag", async () => { + await sort.handler( + { + _: [], + versions: ["2.0.0", "1.0.0", "3.0.0"], + asc: true, + } as unknown as Arguments & IContext, + ); + assertSpyCall(ctx.consoleLog, 0, { + args: ["1.0.0"], + }); + assertSpyCall(ctx.consoleLog, 1, { + args: ["2.0.0"], + }); + assertSpyCall(ctx.consoleLog, 2, { + args: ["3.0.0"], + }); + assertSpyCalls(ctx.consoleLog, 3); + }); + + it("SORT02 - sorts versions in descending order by default", async () => { + await sort.handler( + { + _: [], + versions: ["1.0.0", "3.0.0", "2.0.0"], + asc: false, + } as unknown as Arguments & IContext, + ); + assertSpyCall(ctx.consoleLog, 0, { + args: ["3.0.0"], + }); + assertSpyCall(ctx.consoleLog, 1, { + args: ["2.0.0"], + }); + assertSpyCall(ctx.consoleLog, 2, { + args: ["1.0.0"], + }); + assertSpyCalls(ctx.consoleLog, 3); + }); + + it("SORT03 - handles prerelease versions correctly", async () => { + await sort.handler( + { + _: [], + versions: ["1.0.0", "1.0.0-alpha", "1.0.0-beta"], + asc: true, + } as unknown as Arguments & IContext, + ); + assertSpyCall(ctx.consoleLog, 0, { + args: ["1.0.0-alpha"], + }); + assertSpyCall(ctx.consoleLog, 1, { + args: ["1.0.0-beta"], + }); + assertSpyCall(ctx.consoleLog, 2, { + args: ["1.0.0"], + }); + assertSpyCalls(ctx.consoleLog, 3); + }); + + it("SORT04 - handles build metadata correctly", async () => { + await sort.handler( + { + _: [], + versions: ["1.0.0+build1", "1.0.0+build2", "1.0.0"], + asc: true, + } as unknown as Arguments & IContext, + ); + // Build metadata doesn't affect version precedence (semver spec section 10) + // All three versions are considered equal, so sort order is not guaranteed + // Just verify all three are output + assertSpyCalls(ctx.consoleLog, 3); + const calls = [ + ctx.consoleLog.calls[0].args[0], + ctx.consoleLog.calls[1].args[0], + ctx.consoleLog.calls[2].args[0], + ]; + // Verify all three versions are present (order doesn't matter) + if ( + !calls.includes("1.0.0") || + !calls.includes("1.0.0+build1") || + !calls.includes("1.0.0+build2") + ) { + throw new Error( + `Expected all three versions to be output, got: ${calls.join(", ")}`, + ); + } + }); + + it("SORT05 - sorts complex semver versions", async () => { + await sort.handler( + { + _: [], + versions: ["1.0.0", "2.1.0", "2.0.0", "1.1.0", "1.0.1"], + asc: true, + } as unknown as Arguments & IContext, + ); + assertSpyCall(ctx.consoleLog, 0, { + args: ["1.0.0"], + }); + assertSpyCall(ctx.consoleLog, 1, { + args: ["1.0.1"], + }); + assertSpyCall(ctx.consoleLog, 2, { + args: ["1.1.0"], + }); + assertSpyCall(ctx.consoleLog, 3, { + args: ["2.0.0"], + }); + assertSpyCall(ctx.consoleLog, 4, { + args: ["2.1.0"], + }); + assertSpyCalls(ctx.consoleLog, 5); + }); + + it("SORT06 - handles single version", async () => { + await sort.handler( + { + _: [], + versions: ["1.0.0"], + asc: false, + } as unknown as Arguments & IContext, + ); + assertSpyCall(ctx.consoleLog, 0, { + args: ["1.0.0"], + }); + assertSpyCalls(ctx.consoleLog, 1); + }); + + it("SORT07 - handles empty versions array gracefully", async () => { + await sort.handler( + { + _: [], + versions: [], + asc: false, + } as unknown as Arguments & IContext, + ); + // Should not output anything and not throw + assertSpyCalls(ctx.consoleLog, 0); + }); + + it("SORT08 - handles null versions gracefully", async () => { + await sort.handler( + { + _: [], + versions: null, + asc: false, + } as unknown as Arguments & IContext, + ); + // Should not output anything and not throw + assertSpyCalls(ctx.consoleLog, 0); + }); + + it("SORT09 - handles undefined versions gracefully", async () => { + await sort.handler( + { + _: [], + versions: undefined, + asc: false, + } as unknown as Arguments & IContext, + ); + // Should not output anything and not throw + assertSpyCalls(ctx.consoleLog, 0); + }); + + it("SORT10 - handles space-separated versions in single string", async () => { + await sort.handler( + { + _: [], + versions: ["2.0.0 1.0.0", "3.0.0"], + asc: true, + } as unknown as Arguments & IContext, + ); + assertSpyCall(ctx.consoleLog, 0, { + args: ["1.0.0"], + }); + assertSpyCall(ctx.consoleLog, 1, { + args: ["2.0.0"], + }); + assertSpyCall(ctx.consoleLog, 2, { + args: ["3.0.0"], + }); + assertSpyCalls(ctx.consoleLog, 3); + }); +}); diff --git a/src/commands/sort.ts b/src/commands/sort.ts new file mode 100644 index 0000000..05e6d3e --- /dev/null +++ b/src/commands/sort.ts @@ -0,0 +1,88 @@ +import { Arguments, YargsInstance } from "yargs"; +import { compare, parse } from "semver"; +import { InvalidVersionError } from "../errors/mod.ts"; +import { IContext } from "../context.ts"; + +export const sort = { + command: "sort [versions..]", + describe: "Sort semantic versions", + builder(yargs: YargsInstance) { + return yargs + .positional("versions", { + describe: "Versions to sort", + type: "string", + array: true, + }) + .option("asc", { + alias: "a", + type: "boolean", + description: "Sort in ascending order", + default: false, + }); + }, + async handler(args: Arguments & IContext) { + const { versions, asc } = args; + let versionList: string[] = []; + + // Check if we should read from stdin + // The main.ts filters out "--" from args, so we check if "--" was in the original Deno.args + const hasStdinFlag = Deno.args.includes("--"); + + if (hasStdinFlag) { + // Read from stdin + const decoder = new TextDecoder(); + const data = await Deno.stdin.readable; + const reader = data.getReader(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + } + } finally { + reader.releaseLock(); + } + + versionList = buffer + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } else if (versions === null || versions === undefined) { + versionList = []; + } else if (Array.isArray(versions)) { + // Split each version string by whitespace to handle cases like "2.0.0 1.0.0" + versionList = versions.flatMap((v) => v.split(/\s+/)).filter((v) => + v.length > 0 + ); + } else { + throw new Error("Invalid versions argument"); + } + + // If no versions provided, exit early with success + if (versionList.length === 0) { + return; + } + + // Parse and validate all versions + const parsedVersions = versionList.map((v) => { + const parsed = parse(v); + if (!parsed) { + throw new InvalidVersionError(v); + } + return { original: v, parsed }; + }); + + // Sort versions using semver compare + parsedVersions.sort((a, b) => compare(a.parsed, b.parsed)); + + // Default is descending unless -a/--asc is explicitly set + if (!asc) { + parsedVersions.reverse(); + } + + // Output sorted versions, one per line + parsedVersions.forEach((v) => console.log(v.original)); + }, +}; diff --git a/src/util/increment.test.ts b/src/util/increment.test.ts index b47c52a..e5de21a 100644 --- a/src/util/increment.test.ts +++ b/src/util/increment.test.ts @@ -99,16 +99,40 @@ const testCases: (IncrementOptions & { expected: string })[] = [ prerelease: "rc", expected: "1.0.0-rc.0", }, + { + kind: IncrementKind.None, + version: parse("1.0.0"), + build: "build.123", + expected: "1.0.0+build.123", + }, + { + kind: IncrementKind.None, + version: parse("1.0.0+build.456"), + build: "build.789", + expected: "1.0.0+build.789", + }, + { + kind: IncrementKind.None, + version: parse("1.0.0-alpha.1"), + build: "build.123", + expected: "1.0.0-alpha.1+build.123", + }, + { + kind: IncrementKind.None, + version: parse("1.0.0-alpha.1+build.456"), + build: "build.789", + expected: "1.0.0-alpha.1+build.789", + }, ]; testCases.forEach((testCases, i) => { - const { kind, version, prerelease, expected } = testCases; + const { kind, version, prerelease, build, expected } = testCases; Deno.test({ name: `INC${i.toLocaleString(undefined, { minimumIntegerDigits: 2 })} - ${ format(version) - }:${kind}:${prerelease} -> ${expected}`, + }:${kind}:${prerelease}${build ? `:build=${build}` : ""} -> ${expected}`, fn: () => { - const result = increment({ kind, version, prerelease }); + const result = increment({ kind, version, prerelease, build }); assertEquals(format(result.current), expected); }, }); diff --git a/src/util/increment.ts b/src/util/increment.ts index ec641f8..b0519ae 100644 --- a/src/util/increment.ts +++ b/src/util/increment.ts @@ -17,9 +17,6 @@ export interface IncrementOptions { export function increment(options: IncrementOptions) { const { kind, version, prerelease, build } = options; - const pre = bumpPrerelease(version.prerelease, prerelease); - // console.log({ version, pre}) - return { previous: version, current: (() => { @@ -37,82 +34,14 @@ export function increment(options: IncrementOptions) { ? inc(version, "prepatch", { prerelease, build }) : inc(version, "patch", { build }); case IncrementKind.None: - return prerelease - ? { - ...inc(version, "pre", { prerelease, build }), - prerelease: pre, - } - : { - ...version, - prerelease: version.prerelease ?? [], - build: build ? build.split(".") : version.build ?? [], - }; + return prerelease ? inc(version, "pre", { prerelease, build }) : { + ...version, + prerelease: version.prerelease ?? [], + build: build ? build.split(".") : version.build ?? [], + }; default: throw new Error(`Unknown increment kind: ${kind}`); } })(), }; } - -const NUMERIC_IDENTIFIER = "0|[1-9]\\d*"; -const NUMERIC_IDENTIFIER_REGEXP = new RegExp(`^${NUMERIC_IDENTIFIER}$`); -export function isValidNumber(value: unknown): value is number { - return ( - typeof value === "number" && - !Number.isNaN(value) && - (!Number.isFinite(value) || - (0 <= value && value <= Number.MAX_SAFE_INTEGER)) - ); -} - -export function parsePrerelease(prerelease: string) { - return prerelease - .split(".") - .filter(Boolean) - .map((id: string) => { - if (NUMERIC_IDENTIFIER_REGEXP.test(id)) { - const number = Number(id); - if (isValidNumber(number)) return number; - } - return id; - }); -} - -function bumpPrereleaseNumber(prerelease: ReadonlyArray = []) { - const values = [...prerelease]; - - let index = values.length; - while (index >= 0) { - const value = values[index]; - if (typeof value === "number") { - values[index] = value + 1; - break; - } - index -= 1; - } - // if no number was bumped - if (index === -1) values.push(0); - - return values; -} - -function bumpPrerelease( - prerelease: ReadonlyArray = [], - identifier: string | undefined, -) { - const prereleaseValues = bumpPrereleaseNumber(prerelease); - if (!identifier) return prereleaseValues; - - const identifierValues = parsePrerelease(identifier); - // 1.2.0-beta.1 bumps to 1.2.0-beta.2, - // 1.2.0-beta.foobar or 1.2.0-beta bumps to 1.2.0-beta.0 - if ( - prereleaseValues[0] !== identifierValues[0] || - isNaN(prereleaseValues[1] as number) || - !isNaN(identifierValues[1] as number) - ) { - return [identifierValues[0], identifierValues[1] ?? 0]; - } else { - return prereleaseValues; - } -}