diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3bc13a..188c040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,6 +220,52 @@ jobs: fi } + 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" "$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}`); + } + 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 +356,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..0b5f55f 100644 --- a/docs/guides/release-flow.md +++ b/docs/guides/release-flow.md @@ -33,6 +33,12 @@ 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. + +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/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..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); } @@ -227,3 +227,176 @@ 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('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' }, + ); + 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('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' }, + ); + 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('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() { + 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')); +}); + +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', + ); + } + }, + ); +}