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
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,52 @@ jobs:
fi
}

sync_package_version() {
local version attempt
version="$(npm view "$package_name" dist-tags.latest)"
Comment thread
haskou marked this conversation as resolved.
Resolved
Comment thread
haskou marked this conversation as resolved.
Resolved
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" \
Expand Down Expand Up @@ -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 }}
6 changes: 6 additions & 0 deletions docs/guides/release-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
175 changes: 174 additions & 1 deletion scripts/tests/release-workflow.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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',
);
}
},
);
}
Loading