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: 12 additions & 0 deletions .github/release/binary-packages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"crates/windows-placement-probe": {
"package": "windows-placement-probe",
"component": "placement-probe",
"workflow": "release-placement-probe.yml"
},
"crates/windows-platform-probes": {
"package": "windows-platform-probes",
"component": "windows-platform-probes",
"workflow": "release-platform-probes.yml"
}
}
26 changes: 26 additions & 0 deletions .github/release/calver.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Mike Grier.
'use strict';

function nextCalendarVersion(previous, date = new Date()) {
if (!(date instanceof Date) || !Number.isFinite(date.valueOf())) throw new Error('Invalid release date');
const match = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(previous);
if (!match) throw new Error('Calendar version must have three numeric components');
const [year, day, counter] = match.slice(1).map(Number);
if (![year, day, counter].every(Number.isSafeInteger)) throw new Error('Version exceeds safe integer range');
if (year !== 0) {
const month = Math.floor(day / 100);
const monthDay = day % 100;
const parsed = new Date(Date.UTC(year, month - 1, monthDay));
if (year < 1000 || month < 1 || month > 12 || parsed.getUTCFullYear() !== year || parsed.getUTCMonth() + 1 !== month || parsed.getUTCDate() !== monthDay) {
throw new Error('Invalid calendar date in previous version');
}
}
const todayYear = date.getUTCFullYear();
const todayDay = (date.getUTCMonth() + 1) * 100 + date.getUTCDate();
if (todayYear < 1000) throw new Error('Release year must be at least 1000');
if (todayYear > year || (todayYear === year && todayDay > day)) return `${todayYear}.${todayDay}.0`;
if (!Number.isSafeInteger(counter + 1)) throw new Error('Calendar counter exhausted');
return `${year}.${day}.${counter + 1}`;
}

module.exports = { nextCalendarVersion };
29 changes: 29 additions & 0 deletions .github/release/calver.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Mike Grier.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { nextCalendarVersion } = require('./calver.cjs');

test('calendar releases roll over UTC dates and increment within a day', () => {
for (const [previous, now, expected] of [
['2026.902.0', '2026-09-19T00:00:00Z', '2026.919.0'],
['2026.919.0', '2026-09-19T23:59:59Z', '2026.919.1'],
['2026.919.8', '2026-09-19T00:00:00Z', '2026.919.9'],
['2026.1231.3', '2027-01-01T00:00:00Z', '2027.101.0'],
['2026.131.0', '2026-02-01T00:00:00Z', '2026.201.0'],
['2028.228.0', '2028-02-29T00:00:00Z', '2028.229.0'],
['2028.229.0', '2028-03-01T00:00:00Z', '2028.301.0'],
['2026.920.0', '2026-09-19T23:59:59Z', '2026.920.1'],
['2027.101.0', '2026-09-19T00:00:00Z', '2027.101.1'],
['0.0.1', '2026-09-19T00:00:00Z', '2026.919.0'],
['2026.919.0', '2026-09-19T22:00:00-07:00', '2026.920.0'],
]) assert.equal(nextCalendarVersion(previous, new Date(now)), expected);
});

test('invalid dates, prereleases and numeric overflow fail before publication', () => {
for (const previous of ['1.2', '2026.0902.0', '2026.229.0', '2026.230.0', '2026.1301.0', '2026.900.0', '2026.902.-1', '2026.902.0-beta', '2026.902.9007199254740992']) {
assert.throws(() => nextCalendarVersion(previous, new Date('2026-09-19T00:00:00Z')));
}
assert.throws(() => nextCalendarVersion('2026.919.9007199254740991', new Date('2026-09-19T00:00:00Z')));
assert.throws(() => nextCalendarVersion('2026.902.0', new Date('invalid')));
});
74 changes: 74 additions & 0 deletions .github/release/check-publication.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) Mike Grier.
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const yaml = require('yaml');
const toml = require('smol-toml');
const packages = require('./binary-packages.json');

function load(root) {
const read = name => fs.readFileSync(path.join(root, name), 'utf8');
return {
config: JSON.parse(read('release-please-config.json')),
versions: JSON.parse(read('.release-please-manifest.json')),
releasePlease: yaml.parse(read('.github/workflows/release-please.yml')),
registry: yaml.parse(read('.github/workflows/publish-crate.yml')),
probes: Object.entries(packages).map(([directory, spec]) => ({ directory, spec,
manifest: toml.parse(read(`${directory}/Cargo.toml`)),
workflow: yaml.parse(read(`.github/workflows/${spec.workflow}`)),
})),
};
}

function check(data) {
const steps = data.releasePlease.jobs['release-please'].steps;
const wrapper = steps.find(step => step.run === 'node .github/release/release.cjs');
assert(wrapper, 'release-please must load the calendar extension');
assert.equal(wrapper.env.RELEASE_PLEASE_TOKEN, '${{ secrets.RELEASE_PLEASE_TOKEN }}', 'release tags need the PAT to trigger binary workflows');
assert(steps.some(step => step.run === 'npm ci --prefix .github/release'), 'release tooling must use the lockfile');
assert.deepEqual(data.releasePlease.on.push.branches, ['main']);
for (const { directory, spec, manifest, workflow } of data.probes) {
const config = data.config.packages[directory];
assert(config, `${directory} missing from release-please`);
assert.equal(config.component, spec.component);
assert.equal(config['package-name'], manifest.package.name);
assert.equal(config.versioning, 'probe-calver');
assert.equal(data.versions[directory], manifest.package.version, 'manifest/package baseline mismatch');
assert.equal(manifest.package.publish, false, 'probe packages must stay off crates.io');
assert(workflow.on.push.tags.includes(`${spec.component}-v*`), 'missing probe tag trigger');
assert(!data.registry.on.push.tags.includes(`${spec.component}-v*`), 'binary tag must not publish to crates.io');
assert.deepEqual(workflow.jobs.build.strategy.matrix.target, ['x86_64-pc-windows-msvc', 'aarch64-pc-windows-msvc']);
assert.equal(workflow.permissions.contents, 'read');
assert.equal(workflow.jobs.release.permissions.contents, 'write');
assert.equal(workflow.jobs.release.permissions['id-token'], 'write');
assert.equal(workflow.jobs.release.permissions.attestations, 'write');
const guard = `github.event_name == 'push' && startsWith(github.ref, 'refs/tags/${spec.component}-v')`;
assert.equal(workflow.jobs.release.if, guard, 'release must exclude PR and manual tag dispatch');
const uploads = workflow.jobs.build.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'));
assert(uploads.length, 'missing binary artifact transfer');
for (const upload of uploads) {
assert.equal(upload.if, guard, 'PR/manual builds must not distribute stamped artifacts');
assert.equal(upload.with['if-no-files-found'], 'error');
}
const releaseSteps = workflow.jobs.release.steps;
const attest = releaseSteps.findIndex(step => step.uses?.startsWith('actions/attest-build-provenance@'));
const publish = releaseSteps.findIndex(step => step.run?.includes('gh release upload'));
assert(attest >= 0 && publish > attest, 'attest before publishing bytes');
assert(!releaseSteps.some(step => step.run?.includes('gh release edit')), 'do not overwrite release-please changelogs');
if (spec.package === 'windows-platform-probes') {
const build = workflow.jobs.build.steps.find(step => step.name === 'Build and verify archive');
assert(build?.run.includes('package-probes.cjs'), 'use metadata-derived packaging');
assert.equal(build.env.IS_RELEASE, `\${{ ${guard} }}`);
assert(!build.run.includes('--all-features'), 'test-only renderer oracle must remain disabled');
}
}
}

module.exports = { load, check };
if (require.main === module) {
let result;
try { check(load(process.argv[2] || path.resolve(__dirname, '../..'))); result = { status: 'success', message: 'Binary release routes and publication guards verified' }; }
catch (error) { process.exitCode = 1; result = { status: 'error', error: error.message }; }
process.stdout.write(JSON.stringify(result) + '\n');
}
30 changes: 30 additions & 0 deletions .github/release/check-publication.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Mike Grier.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { load, check } = require('./check-publication.cjs');

test('actual manifests and workflows have executable binary release routes', () => check(load(path.resolve(__dirname, '../..'))));

test('publication guards reject missing routes, unsafe triggers and oracle builds', () => {
for (let defect = 0; defect < 12; defect++) {
const data = load(path.resolve(__dirname, '../..'));
const probe = data.probes[defect % 2];
switch (defect) {
case 0: delete data.config.packages[probe.directory]; break;
case 1: probe.workflow.on.push.tags = []; break;
case 2: probe.workflow.jobs.release.if = 'true'; break;
case 3: delete probe.workflow.jobs.build.steps.find(step => step.uses?.startsWith('actions/upload-artifact@')).if; break;
case 4: probe.manifest.package.publish = true; break;
case 5: data.registry.on.push.tags.push(`${probe.spec.component}-v*`); break;
case 6: probe.workflow.jobs.release.steps.reverse(); break;
case 7: probe.workflow.jobs.build.steps.find(step => step.name === 'Build and verify archive').run += '\ncargo build --all-features'; break;
case 8: data.versions[probe.directory] = '0.0.0'; break;
case 9: probe.workflow.jobs.build.strategy.matrix.target.pop(); break;
case 10: data.releasePlease.jobs['release-please'].steps.find(step => step.id === 'release').env.RELEASE_PLEASE_TOKEN = '${{ github.token }}'; break;
case 11: data.config.packages[probe.directory].versioning = 'default'; break;
}
assert.throws(() => check(data), `defect ${defect} survived`);
}
});
Loading
Loading