Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/tag-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,15 @@ jobs:
echo "Prerelease version $version is not taggable."
exit 0
fi
node scripts/validate-package.mjs --release-transition "$base_version" "$version"

printf 'should_tag=true\nversion=%s\n' "$version" >> "$GITHUB_OUTPUT"
if ! release_output="$(node scripts/detect-release.mjs "$base_version" "$version")"; then
echo "Release detection failed; refusing to tag." >&2
exit 1
fi
if [[ "$release_output" == "should_tag=false" ]]; then
echo "Package version is unchanged; this is an ordinary merge."
exit 0
fi
printf '%s\n' "$release_output" >> "$GITHUB_OUTPUT"

tag:
name: Create release tag
Expand Down
95 changes: 95 additions & 0 deletions odd/tasks/issue-10-release-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Issue #10: Unchanged Package Versions Are Ordinary Merges

## Objective

Make the release workflow distinguish a package metadata change from a package version change before attempting strict release-transition validation.

## Problem and rationale

The workflow currently routes every `package.json` diff through `assertReleaseTransition`. A metadata-only change with the same version therefore fails as `0.1.0 -> 0.1.0`, even though it is an ordinary merge and must not create a tag.

## Authorized scope

- `.github/workflows/tag-release.yml`
- `scripts/detect-release.mjs`
- `tests/detect-release.test.mjs`
- `odd/tasks/issue-10-release-detection.md`

Do not alter merged-PR provenance, ancestry checks, annotated-tag creation, tag idempotency/conflict protection, npm publication, or unrelated package validation.

## Constraints and decisions

- Generated technical artifacts remain in English.
- Keep `assertReleaseTransition` as the strict validator for an actual release transition.
- Compare base/current version values before attempting release validation.
- An identical valid version returns `should_tag=false` and creates no tag.
- A valid stable increase returns `should_tag=true` with the current version.
- Invalid, lower, prerelease, or build-metadata transitions remain rejected when release validation is attempted.
- Preserve the existing explicit no-tag behavior for prerelease package versions in the workflow.
- Keep current package validation before release detection so an unchanged invalid package version cannot bypass package validation.
- Keep the detector pure and testable outside workflow YAML.
- Use package-relative repository paths and Node's built-in test runner.
- Delivery strategy: `ask-on-risk`; this fix is expected to stay below the 400-line review heuristic.
- TDD mode: no explicit project TDD configuration was found; use ordinary checks and record observed results.

## Acceptance criteria

- [x] A metadata-only `package.json` change with an unchanged valid version is classified as an ordinary merge and produces no tag.
- [x] A valid stable version increase follows the existing validated tag path.
- [x] Equal, lower, invalid, prerelease, and build-metadata release transitions are rejected when strict release validation is invoked.
- [x] Merged-PR provenance checks remain unchanged.
- [x] Annotated-tag idempotency and conflict protection remain unchanged.
- [x] Release detection has automated tests outside workflow YAML.
- [x] `npm test` succeeds.
- [x] `node --test tests/detect-release.test.mjs` succeeds.

## Task checklist

### T1 — Extract release detection

- [x] Add a pure `detectReleaseTransition(baseVersion, currentVersion)` helper.
- [x] Return an ordinary-merge result for identical valid versions.
- [x] Delegate actual release validation to the existing strict transition helper.
- [x] Add a CLI output contract suitable for `$GITHUB_OUTPUT`.

### T2 — Integrate the workflow

- [x] Replace the inline strict-transition call with the detector command.
- [x] Preserve provenance, ancestry, validation, prerelease, and tag-job behavior.
- [x] Emit `should_tag=false` for unchanged versions and no `version` tag output.
- [x] Emit `should_tag=true` and the version only for valid stable increases.

### T3 — Add regression tests

- [x] Test unchanged valid versions as ordinary merges.
- [x] Test valid stable increases as taggable.
- [x] Test equal/lower/invalid/prerelease/build-metadata strict rejection.

### T4 — Verify and record evidence

- [x] Run `npm test`.
- [x] Run `node --test tests/detect-release.test.mjs`.
- [x] Run `git diff --check`.
- [x] Confirm provenance and tag-creation workflow sections are unchanged except for release detection integration.
- [x] Update this document with observed results.

### T5 — Close the work unit

- [x] Review the diff and authored line count.
- [x] Create one Conventional Commit containing the detector, workflow integration, and tests.
- [x] Record the commit identity here.

## Progress and evidence

- Branch: `fix/issue-10-release-detection`
- Base: `main` at `9561127`
- Maintainer issue: #10 has `status:approved` and `type:chore`.
- Reproduction on base: `node scripts/validate-package.mjs --release-transition 0.1.0 0.1.0` exits 1 with the strict-increase error.
- Verification: `npm test` passed (`Package validation passed.`); `node --test tests/detect-release.test.mjs` passed (7 tests); `git diff --check` passed. The workflow diff changes only release-detection integration; provenance and tag-job sections remain unchanged.
- Parent readback: unchanged versions retain the workflow's default `should_tag=false` output; only valid releases append detector outputs, avoiding duplicate output keys.
- Diff review: implementation diff contained 192 authored insertions/deletions across four intended files and passed `git diff --cached --check`.
- Commit: `df32b99` (`fix(release): skip unchanged package versions`).

## Next step

Implementation, verification, and the work-unit commit are complete. The branch is ready for the user-owned PR decision.
38 changes: 38 additions & 0 deletions scripts/detect-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env node

import { resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { assertReleaseTransition, parseSemVer } from "./validate-package.mjs";

export function detectReleaseTransition(baseVersion, currentVersion) {
const base = parseSemVer(baseVersion);
const current = parseSemVer(currentVersion);
if (!base) throw new Error(`Base version is not valid SemVer: ${JSON.stringify(baseVersion)}.`);
if (!current) throw new Error(`Release version is not valid SemVer: ${JSON.stringify(currentVersion)}.`);

if (baseVersion === currentVersion) return { shouldTag: false };

assertReleaseTransition(baseVersion, currentVersion);
return { shouldTag: true, version: currentVersion };
}

function main() {
try {
const result = detectReleaseTransition(process.argv[2], process.argv[3]);
if (!result.shouldTag) {
console.log("should_tag=false");
return 0;
}
console.log("should_tag=true");
console.log(`version=${result.version}`);
return 0;
} catch (error) {
console.error(`Invalid release transition: ${error.message}`);
return 1;
}
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exitCode = main();
}
50 changes: 50 additions & 0 deletions tests/detect-release.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { strict as assert } from "node:assert";
import { spawnSync } from "node:child_process";
import test from "node:test";

import { assertReleaseTransition } from "../scripts/validate-package.mjs";
import { detectReleaseTransition } from "../scripts/detect-release.mjs";

const detector = new URL("../scripts/detect-release.mjs", import.meta.url);

test("unchanged valid versions are ordinary merges", () => {
assert.deepEqual(detectReleaseTransition("0.1.0", "0.1.0"), { shouldTag: false });
});

test("stable increases are taggable", () => {
assert.deepEqual(detectReleaseTransition("0.1.0", "0.2.0"), {
shouldTag: true,
version: "0.2.0",
});
});

test("strict validation rejects equal and lower transitions", () => {
assert.throws(() => assertReleaseTransition("0.1.0", "0.1.0"), /strictly increase/);
assert.throws(() => detectReleaseTransition("0.2.0", "0.1.0"), /strictly increase/);
});

test("invalid versions are rejected", () => {
assert.throws(() => detectReleaseTransition("not-semver", "0.2.0"), /Base version is not valid SemVer/);
assert.throws(() => detectReleaseTransition("0.1.0", "1.0"), /Release version is not valid SemVer/);
});

test("prerelease and build metadata transitions are rejected", () => {
assert.throws(() => detectReleaseTransition("0.1.0", "0.2.0-rc.1"), /must be stable/);
assert.throws(() => detectReleaseTransition("0.1.0", "0.2.0+build.1"), /must not contain build metadata/);
});

test("CLI emits GitHub output for unchanged and taggable versions", () => {
const unchanged = spawnSync(process.execPath, [detector.pathname, "0.1.0", "0.1.0"], { encoding: "utf8" });
assert.equal(unchanged.status, 0);
assert.equal(unchanged.stdout, "should_tag=false\n");

const increased = spawnSync(process.execPath, [detector.pathname, "0.1.0", "0.2.0"], { encoding: "utf8" });
assert.equal(increased.status, 0);
assert.equal(increased.stdout, "should_tag=true\nversion=0.2.0\n");
});

test("CLI reports invalid transitions and exits nonzero", () => {
const result = spawnSync(process.execPath, [detector.pathname, "0.2.0", "0.1.0"], { encoding: "utf8" });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Invalid release transition: .*strictly increase/);
});
Loading