From df32b99a3f6731251b8956d90e47e8cb3ac54ee0 Mon Sep 17 00:00:00 2001 From: Reaan Date: Thu, 17 Sep 2026 18:39:07 -0500 Subject: [PATCH 1/2] fix(release): skip unchanged package versions --- .github/workflows/tag-release.yml | 12 +++- odd/tasks/issue-10-release-detection.md | 95 +++++++++++++++++++++++++ scripts/detect-release.mjs | 38 ++++++++++ tests/detect-release.test.mjs | 50 +++++++++++++ 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 odd/tasks/issue-10-release-detection.md create mode 100644 scripts/detect-release.mjs create mode 100644 tests/detect-release.test.mjs diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 9d43630..247bf71 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -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 diff --git a/odd/tasks/issue-10-release-detection.md b/odd/tasks/issue-10-release-detection.md new file mode 100644 index 0000000..4312b2c --- /dev/null +++ b/odd/tasks/issue-10-release-detection.md @@ -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. +- [ ] Create one Conventional Commit containing the detector, workflow integration, and tests. +- [ ] 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: staged diff contains 191 authored insertions/deletions across four intended files and passes `git diff --cached --check`. +- Commit: pending. + +## Next step + +Implementation and verification are complete; the parent should create the work-unit commit. diff --git a/scripts/detect-release.mjs b/scripts/detect-release.mjs new file mode 100644 index 0000000..dbd7df4 --- /dev/null +++ b/scripts/detect-release.mjs @@ -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(); +} diff --git a/tests/detect-release.test.mjs b/tests/detect-release.test.mjs new file mode 100644 index 0000000..acfac03 --- /dev/null +++ b/tests/detect-release.test.mjs @@ -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/); +}); From ae809c03a3458e9eb7aa97fc2b013d4c74739f5b Mon Sep 17 00:00:00 2001 From: Reaan Date: Thu, 17 Sep 2026 18:40:14 -0500 Subject: [PATCH 2/2] chore(odd): record issue 10 completion --- odd/tasks/issue-10-release-detection.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/odd/tasks/issue-10-release-detection.md b/odd/tasks/issue-10-release-detection.md index 4312b2c..dabdc5b 100644 --- a/odd/tasks/issue-10-release-detection.md +++ b/odd/tasks/issue-10-release-detection.md @@ -76,8 +76,8 @@ Do not alter merged-PR provenance, ancestry checks, annotated-tag creation, tag ### T5 — Close the work unit - [x] Review the diff and authored line count. -- [ ] Create one Conventional Commit containing the detector, workflow integration, and tests. -- [ ] Record the commit identity here. +- [x] Create one Conventional Commit containing the detector, workflow integration, and tests. +- [x] Record the commit identity here. ## Progress and evidence @@ -87,9 +87,9 @@ Do not alter merged-PR provenance, ancestry checks, annotated-tag creation, tag - 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: staged diff contains 191 authored insertions/deletions across four intended files and passes `git diff --cached --check`. -- Commit: pending. +- 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 and verification are complete; the parent should create the work-unit commit. +Implementation, verification, and the work-unit commit are complete. The branch is ready for the user-owned PR decision.