From 8defc33cdfd74df8fa205fbe746a43913c7f8dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hask=C5=8D?= <102477627+haskou@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:40:48 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(release):=20=F0=9F=90=9B=20Synchronize?= =?UTF-8?q?=20package=20version=20after=20publication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 43 +++++++++ docs/guides/release-flow.md | 4 + package.json | 2 +- scripts/tests/release-workflow.test.cjs | 123 ++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3bc13a..00d8399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,6 +220,48 @@ jobs: fi } + sync_package_version() { + local version attempt + version="$(npm view "$package_name" dist-tags.latest)" + for attempt in 1 2 3; do + git fetch origin "$base_branch" + git checkout --detach FETCH_HEAD + node - "$version" <<'NODE' + const fs = require('node:fs'); + const version = process.argv[2]; + const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const parse = (value) => { + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value)) { + throw new Error(`Unsupported release version: ${value}`); + } + return value.split('.').map(Number); + }; + const current = parse(manifest.version); + const published = parse(version); + const different = current.findIndex((part, index) => part !== published[index]); + if (different !== -1 && current[different] > published[different]) { + throw new Error(`Refusing to downgrade ${manifest.version} to ${version}`); + } + if (manifest.version !== version) { + manifest.version = version; + fs.writeFileSync('package.json', JSON.stringify(manifest, null, 2) + '\n'); + } + NODE + if git diff --quiet -- package.json; then + return 0 + fi + git add package.json + # Do not match the historical "Release v... for #..." baseline. + git commit -m "chore(release): 🔖 Sync package version to v${version}" + if git -c "http.https://github.com/.extraheader=$auth_header" push origin "HEAD:refs/heads/${base_branch}"; then + return 0 + fi + echo "Version sync push failed; retrying from the latest remote commit (${attempt}/3)." + done + echo "Could not synchronize package.json after three attempts." + return 1 + } + prs_json="$( gh pr list \ --repo "$GITHUB_REPOSITORY" \ @@ -310,6 +352,7 @@ jobs: published_version="$next_version" ensure_github_release "$pr_json" "$tag" "$next_version" "$bump" done + sync_package_version env: GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }} diff --git a/docs/guides/release-flow.md b/docs/guides/release-flow.md index 23a65c3..9490584 100644 --- a/docs/guides/release-flow.md +++ b/docs/guides/release-flow.md @@ -33,6 +33,10 @@ yarn build ## What CI publishes +After the release queue completes, CI reads npm's `latest` version and synchronizes `package.json` on the base branch with a dedicated `chore(release)` commit. It starts from the latest remote commit and retries rejected pushes without force-pushing. Re-running the workflow does not create another commit when the version already matches. Registry errors and version downgrades fail the synchronization instead of writing an unverified version. + +The synchronization commit does not create a new release or change the historical release baseline. Published tags continue to identify the original source snapshots. + The package publishes only the compiled `dist` directory. The package metadata declares: diff --git a/package.json b/package.json index 6a7d314..a961b42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@haskou/value-objects", - "version": "4.0.1", + "version": "7.0.2", "description": "TypeScript value objects for validated, immutable primitive values and small domain-safe data structures.", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/scripts/tests/release-workflow.test.cjs b/scripts/tests/release-workflow.test.cjs index b1595d2..4561802 100644 --- a/scripts/tests/release-workflow.test.cjs +++ b/scripts/tests/release-workflow.test.cjs @@ -227,3 +227,126 @@ test('legacy release commits remain recognized and PR numbers match exactly', (t ); assert.equal(absent.status, 1); }); + +test('version sync preserves newer remote commits and is idempotent', (t) => { + const f = fixture(t); + writeFileSync( + join(f.cwd, 'package.json'), + '{\n "name": "test",\n "version": "4.0.1"\n}\n', + ); + f.commit('add manifest'); + const remote = mkdtempSync(join(tmpdir(), 'release-sync-remote-')); + t.after(() => rmSync(remote, { recursive: true, force: true })); + execFileSync('git', ['clone', '--bare', f.cwd, remote], { stdio: 'pipe' }); + f.git('remote', 'add', 'origin', remote); + const newer = f.commit('unrelated newer change'); + f.git('push', 'origin', 'master'); + f.git('checkout', '--detach', 'HEAD~1'); + const sync = () => + spawnSync( + 'bash', + [ + '-c', + `set -euo pipefail\n${shellFunction('sync_package_version')}\nbase_branch=master; package_name=test; auth_header=''; npm() { echo 7.0.2; }; sync_package_version`, + ], + { cwd: f.cwd, encoding: 'utf8' }, + ); + const result = sync(); + assert.equal(result.status, 0, result.stderr); + assert.equal(f.git('rev-parse', 'HEAD^'), newer); + assert.equal( + JSON.parse(readFileSync(join(f.cwd, 'package.json'))).version, + '7.0.2', + ); + assert.equal(f.git('diff', '--name-only', newer, 'HEAD'), 'package.json'); + const first = f.git('rev-parse', 'HEAD'); + assert.equal(sync().status, 0); + assert.equal(f.git('rev-parse', 'HEAD'), first); + assert.equal( + f.git( + 'log', + '--first-parent', + '-n', + '1', + '--format=%H', + '--extended-regexp', + '--grep=^chore\\(release\\): .*Release v[^ ]+ for #[0-9]+$', + ), + f.baseline, + ); + + for (const npm of [ + 'npm() { return 1; }', + 'npm() { echo invalid; }', + 'npm() { echo 6.0.0; }', + ]) { + const failed = spawnSync( + 'bash', + [ + '-c', + `set -euo pipefail\n${shellFunction('sync_package_version')}\nbase_branch=master; package_name=test; auth_header=''; ${npm}; sync_package_version`, + ], + { cwd: f.cwd, encoding: 'utf8' }, + ); + assert.notEqual(failed.status, 0); + assert.equal(f.git('rev-parse', 'HEAD'), first); + assert.equal( + JSON.parse(readFileSync(join(f.cwd, 'package.json'))).version, + '7.0.2', + ); + } +}); + +test('version sync retries a concurrent push without losing its changes', (t) => { + const f = fixture(t); + writeFileSync( + join(f.cwd, 'package.json'), + '{"name":"test","version":"7.0.2"}\n', + ); + f.commit('add manifest'); + const root = mkdtempSync(join(tmpdir(), 'release-sync-race-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const remote = join(root, 'remote.git'); + const competitor = join(root, 'competitor'); + execFileSync('git', ['clone', '--bare', f.cwd, remote], { stdio: 'pipe' }); + execFileSync('git', ['clone', remote, competitor], { stdio: 'pipe' }); + f.git('remote', 'add', 'origin', remote); + const result = spawnSync( + 'bash', + [ + '-c', + ` +set -euo pipefail +${shellFunction('sync_package_version')} +base_branch=master; package_name=test; auth_header='' +npm() { echo 7.0.3; } +competed=false +git() { + if [[ "$*" == *"push origin"* ]] && [ "$competed" = false ]; then + competed=true + echo concurrent > "$COMPETITOR/concurrent" + command git -C "$COMPETITOR" add concurrent + command git -C "$COMPETITOR" -c user.name=Test -c user.email=test@example.invalid commit -m concurrent + command git -C "$COMPETITOR" push origin master + fi + command git "$@" +} +sync_package_version +`, + ], + { + cwd: f.cwd, + encoding: 'utf8', + env: { ...process.env, COMPETITOR: competitor }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /retrying/); + assert.equal(readFileSync(join(f.cwd, 'concurrent'), 'utf8'), 'concurrent\n'); + assert.equal( + JSON.parse(readFileSync(join(f.cwd, 'package.json'))).version, + '7.0.3', + ); + assert.equal(f.git('log', '-1', '--format=%s', 'HEAD^'), 'concurrent'); + assert.equal(f.git('rev-parse', 'HEAD'), f.git('rev-parse', 'origin/master')); +}); From edabcf644af800cd13234beffbb03ad42ffed6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hask=C5=8D?= <102477627+haskou@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:49:04 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(release):=20=F0=9F=90=9B=20Guard=20sync?= =?UTF-8?q?hronization=20against=20stale=20registry=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 6 ++- docs/guides/release-flow.md | 2 + scripts/tests/release-workflow.test.cjs | 60 ++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00d8399..188c040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,13 +223,17 @@ jobs: sync_package_version() { local version attempt version="$(npm view "$package_name" dist-tags.latest)" + version="$(max_version "$published_version" "$version")" for attempt in 1 2 3; do git fetch origin "$base_branch" git checkout --detach FETCH_HEAD - node - "$version" <<'NODE' + node - "$version" "$package_name" <<'NODE' const fs = require('node:fs'); const version = process.argv[2]; const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8')); + if (manifest.name !== process.argv[3]) { + throw new Error(`Package name changed from ${process.argv[3]} to ${manifest.name}; refusing version synchronization`); + } const parse = (value) => { if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value)) { throw new Error(`Unsupported release version: ${value}`); diff --git a/docs/guides/release-flow.md b/docs/guides/release-flow.md index 9490584..0b5f55f 100644 --- a/docs/guides/release-flow.md +++ b/docs/guides/release-flow.md @@ -37,6 +37,8 @@ After the release queue completes, CI reads npm's `latest` version and synchroni The synchronization commit does not create a new release or change the historical release baseline. Published tags continue to identify the original source snapshots. +Synchronization never falls below the version successfully published by the current run, even if npm temporarily returns an older `latest`. It stops if the package name changes on the remote branch, rather than copying a version from another package. + The package publishes only the compiled `dist` directory. The package metadata declares: diff --git a/scripts/tests/release-workflow.test.cjs b/scripts/tests/release-workflow.test.cjs index 4561802..293ecd8 100644 --- a/scripts/tests/release-workflow.test.cjs +++ b/scripts/tests/release-workflow.test.cjs @@ -14,7 +14,7 @@ const workflow = readFileSync( function shellFunction(name) { const start = workflow.indexOf(`${name}() {`); assert.notEqual(start, -1, `Missing workflow function ${name}`); - const end = workflow.indexOf('\n}', start); + const end = workflow.indexOf('\n}\n', start); assert.notEqual(end, -1); return workflow.slice(start, end + 2); } @@ -247,7 +247,7 @@ test('version sync preserves newer remote commits and is idempotent', (t) => { 'bash', [ '-c', - `set -euo pipefail\n${shellFunction('sync_package_version')}\nbase_branch=master; package_name=test; auth_header=''; npm() { echo 7.0.2; }; sync_package_version`, + `set -euo pipefail\n${shellFunction('max_version')}\n${shellFunction('sync_package_version')}\npublished_version=0.0.0; base_branch=master; package_name=test; auth_header=''; npm() { echo 7.0.2; }; sync_package_version`, ], { cwd: f.cwd, encoding: 'utf8' }, ); @@ -284,7 +284,7 @@ test('version sync preserves newer remote commits and is idempotent', (t) => { 'bash', [ '-c', - `set -euo pipefail\n${shellFunction('sync_package_version')}\nbase_branch=master; package_name=test; auth_header=''; ${npm}; sync_package_version`, + `set -euo pipefail\n${shellFunction('max_version')}\n${shellFunction('sync_package_version')}\npublished_version=0.0.0; base_branch=master; package_name=test; auth_header=''; ${npm}; sync_package_version`, ], { cwd: f.cwd, encoding: 'utf8' }, ); @@ -317,8 +317,8 @@ test('version sync retries a concurrent push without losing its changes', (t) => '-c', ` set -euo pipefail -${shellFunction('sync_package_version')} -base_branch=master; package_name=test; auth_header='' +${shellFunction('max_version')}\n${shellFunction('sync_package_version')} +published_version=0.0.0; base_branch=master; package_name=test; auth_header='' npm() { echo 7.0.3; } competed=false git() { @@ -350,3 +350,53 @@ sync_package_version assert.equal(f.git('log', '-1', '--format=%s', 'HEAD^'), 'concurrent'); assert.equal(f.git('rev-parse', 'HEAD'), f.git('rev-parse', 'origin/master')); }); + +for (const renamed of [false, true]) { + test( + renamed + ? 'version sync rejects a renamed remote package' + : 'version sync retains the just-published version when npm latest is stale', + (t) => { + const f = fixture(t); + writeFileSync( + join(f.cwd, 'package.json'), + JSON.stringify({ + name: renamed ? 'renamed' : 'test', + version: '7.0.2', + }) + '\n', + ); + const original = f.commit('manifest'); + const remote = mkdtempSync(join(tmpdir(), 'release-sync-registry-')); + t.after(() => rmSync(remote, { recursive: true, force: true })); + execFileSync('git', ['clone', '--bare', f.cwd, remote], { + stdio: 'pipe', + }); + f.git('remote', 'add', 'origin', remote); + const result = spawnSync( + 'bash', + [ + '-c', + `set -euo pipefail +${shellFunction('max_version')} +${shellFunction('sync_package_version')} +published_version=7.0.3; base_branch=master; package_name=test; auth_header='' +npm() { echo 7.0.2; } +sync_package_version +`, + ], + { cwd: f.cwd, encoding: 'utf8' }, + ); + if (renamed) { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Package name changed/); + assert.equal(f.git('rev-parse', 'HEAD'), original); + } else { + assert.equal(result.status, 0, result.stderr); + assert.equal( + JSON.parse(readFileSync(join(f.cwd, 'package.json'))).version, + '7.0.3', + ); + } + }, + ); +}