Skip to content

Commit be70768

Browse files
chore(release): define stable graduation policy
1 parent fbeaa1d commit be70768

8 files changed

Lines changed: 328 additions & 26 deletions

File tree

.beads/issues.jsonl

Lines changed: 6 additions & 1 deletion
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
### Changed
1818

19+
* **release:** define the intentional pre-1.0 release line in a structured manifest; semantic-release now derives breaking-change bumps from that policy and refuses stable-line analysis or `1.x` generation without a dated approval Bead plus platform, compatibility, application-runtime, and independent-review evidence.
1920
* **reflaxe:** align stdlib override sources with the Reflaxe `_std` layout; scoped source-tree builds now load `std/elixir/_std` before target-owned `std` APIs so PhoenixHx/examples keep using the Elixir stdlib overrides in dev mode.
2021
* **reflaxe:** move remaining upstream-colliding `std/haxe/**` replacement modules into `std/elixir/_std`, leaving plain `std/haxe/**` for target-owned support surfaces.
2122
* **reflaxe:** move the authored `haxe.Exception` override into `std/elixir/_std/haxe/Exception.hx`; Reflaxe now creates `src/haxe/Exception.cross.hx` only in built release packages, matching the Rust and OCaml target layout without changing exception semantics.

docs/06-guides/VERSIONING_AND_STABILITY.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ make it clear what is safe to depend on and what may change.
77
> Experimental features remain opt‑in and may evolve in minor releases.
88
> For now, even breaking changes are released on the `0.x` minor line and documented clearly.
99
10+
The machine-readable source of truth is [`release/manifest.json`](../../release/manifest.json).
11+
Semantic-release reads that policy when classifying commits; the breaking-change rule is not copied
12+
into release configuration by hand.
13+
1014
## Stability tiers
1115

1216
### ✅ Stable (SemVer protected, with pre-1.0 policy)
@@ -75,6 +79,23 @@ If a bug fix changes behavior in a way that could break a real app, it must be c
7579
in `CHANGELOG.md`. While pre-1.0, this can still ship as a MINOR; once `1.0.0` is reached, stable-surface
7680
breaks move to MAJOR.
7781

82+
## Stable graduation gate
83+
84+
The project cannot enter the stable release line merely by changing a version string. Before
85+
`releasePolicy.currentLine` can become `stable`, the manifest must contain all of the following:
86+
87+
- an approved, reviewed Bead that owns the graduation decision;
88+
- the approval date;
89+
- evidence for the supported platform and toolchain matrix;
90+
- compatibility evidence for documented stable surfaces;
91+
- application-runtime evidence, including representative Phoenix/OTP QA;
92+
- an independent review record.
93+
94+
The release-policy test rejects stable commit analysis without that complete record. Version
95+
generation separately rejects `1.x` while the policy is `pre1`, and rejects further `0.x` generation
96+
after the approved stable line is selected. This makes graduation an explicit reviewed event rather
97+
than an accidental semantic-release side effect.
98+
7899
## Deprecation policy
79100

80101
For Stable APIs:

package.json

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"test:haxe5": "bash scripts/test-haxe5-smoke.sh",
4545
"test:haxelib-package": "bash scripts/ci/haxelib-package-smoke.sh",
4646
"test:qa-logpeek": "bash scripts/ci/qa-logpeek-smoke.sh",
47+
"test:release-policy": "node scripts/release/test-release-policy.js",
4748
"package:haxelib": "bash scripts/release/package-haxelib.sh",
4849
"test:core": "scripts/test-runner.sh --category core",
4950
"test:stdlib": "scripts/test-runner.sh --category stdlib",
@@ -87,7 +88,7 @@
8788
"guard:stdlib-parity": "bash scripts/ci/check-stdlib-parity-report.sh",
8889
"guard:upstream-unitstd": "python3 scripts/ci/check-upstream-unitstd-manifest.py",
8990
"guard:hx-format": "bash scripts/lint/hx_format_guard.sh",
90-
"ci:guards": "npm run guard:hx-format && npm run guard:names && npm run guard:numeric && npm run guard:sloppy-pattern-numeric && npm run guard:no-elixir-in-app && npm run guard:no-dynamic && npm run guard:no-disabled-debug && npm run guard:examples-hxx-mode && npm run guard:hxx-raw-heex && npm run guard:examples-qa && npm run guard:filesize && npm run guard:stdlib-layout && npm run guard:stdlib-parity && npm run guard:upstream-unitstd && npm run guard:docs-links && npm run test:qa-logpeek && echo 'Guards passed'",
91+
"ci:guards": "npm run guard:hx-format && npm run guard:names && npm run guard:numeric && npm run guard:sloppy-pattern-numeric && npm run guard:no-elixir-in-app && npm run guard:no-dynamic && npm run guard:no-disabled-debug && npm run guard:examples-hxx-mode && npm run guard:hxx-raw-heex && npm run guard:examples-qa && npm run guard:filesize && npm run guard:stdlib-layout && npm run guard:stdlib-parity && npm run guard:upstream-unitstd && npm run guard:docs-links && npm run test:qa-logpeek && npm run test:release-policy && echo 'Guards passed'",
9192
"ci:dead-code-audit": "bash -lc 'mkdir -p tmp && scripts/repo-dead-code-audit.sh --scope src --limit 200 --json > tmp/dead-code-audit.json && echo \"Wrote tmp/dead-code-audit.json\"'",
9293
"ci:acceptance": "bash scripts/ci/acceptance_gate.sh",
9394
"ci:budgets": "bash scripts/ci/perf-determinism-budgets.sh",
@@ -106,31 +107,9 @@
106107
],
107108
"plugins": [
108109
[
109-
"@semantic-release/commit-analyzer",
110+
"./scripts/release/analyze-commits.js",
110111
{
111-
"preset": "conventionalcommits",
112-
"releaseRules": [
113-
{
114-
"breaking": true,
115-
"release": "minor"
116-
},
117-
{
118-
"type": "feat",
119-
"release": "minor"
120-
},
121-
{
122-
"type": "fix",
123-
"release": "patch"
124-
},
125-
{
126-
"type": "perf",
127-
"release": "patch"
128-
},
129-
{
130-
"type": "revert",
131-
"release": "patch"
132-
}
133-
]
112+
"manifestPath": "release/manifest.json"
134113
}
135114
],
136115
"@semantic-release/release-notes-generator",

release/manifest.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"schemaVersion": 1,
3+
"package": {
4+
"name": "reflaxe.elixir",
5+
"version": "0.14.22"
6+
},
7+
"releasePolicy": {
8+
"currentLine": "pre1",
9+
"lines": {
10+
"pre1": {
11+
"major": 0,
12+
"breakingRelease": "minor"
13+
},
14+
"stable": {
15+
"minimumMajor": 1,
16+
"breakingRelease": "major"
17+
}
18+
},
19+
"graduation": {
20+
"approved": false,
21+
"approvalBead": null,
22+
"approvedAt": null,
23+
"evidence": {
24+
"platformToolchain": null,
25+
"compatibility": null,
26+
"applicationRuntime": null,
27+
"independentReview": null
28+
}
29+
}
30+
}
31+
}

scripts/release/analyze-commits.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const { analyzeCommits: analyzeConventionalCommits } = require('@semantic-release/commit-analyzer')
2+
const {
3+
DEFAULT_MANIFEST_PATH,
4+
loadReleaseManifest,
5+
releaseRulesForManifest,
6+
} = require('./release-manifest')
7+
8+
async function analyzeCommits(pluginConfig, context) {
9+
const manifestPath = pluginConfig.manifestPath || DEFAULT_MANIFEST_PATH
10+
const manifest = loadReleaseManifest(manifestPath, context.cwd)
11+
const releaseRules = releaseRulesForManifest(manifest)
12+
13+
context.logger.log(
14+
`Using ${manifest.releasePolicy.currentLine} release policy from ${manifestPath}`
15+
)
16+
17+
return analyzeConventionalCommits(
18+
{
19+
preset: 'conventionalcommits',
20+
releaseRules,
21+
},
22+
context
23+
)
24+
}
25+
26+
module.exports = { analyzeCommits }
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
4+
const DEFAULT_MANIFEST_PATH = 'release/manifest.json'
5+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
6+
const APPROVAL_BEAD_PATTERN = /^haxe\.elixir(?:\.codex)?-[0-9A-Za-z.]+$/
7+
const APPROVAL_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
8+
const EVIDENCE_KEYS = [
9+
'platformToolchain',
10+
'compatibility',
11+
'applicationRuntime',
12+
'independentReview',
13+
]
14+
15+
function fail(message) {
16+
throw new Error(`Invalid release manifest: ${message}`)
17+
}
18+
19+
function isObject(value) {
20+
return value !== null && typeof value === 'object' && !Array.isArray(value)
21+
}
22+
23+
function parseSemver(version, label = 'version') {
24+
const match = typeof version === 'string' ? SEMVER_PATTERN.exec(version) : null
25+
if (!match) fail(`${label} must be a valid semantic version`)
26+
return {
27+
major: Number(match[1]),
28+
minor: Number(match[2]),
29+
patch: Number(match[3]),
30+
}
31+
}
32+
33+
function validateReleaseManifest(manifest) {
34+
if (!isObject(manifest)) fail('root must be an object')
35+
if (manifest.schemaVersion !== 1) fail('schemaVersion must be 1')
36+
37+
if (!isObject(manifest.package)) fail('package must be an object')
38+
if (manifest.package.name !== 'reflaxe.elixir') {
39+
fail('package.name must be reflaxe.elixir')
40+
}
41+
parseSemver(manifest.package.version, 'package.version')
42+
43+
const policy = manifest.releasePolicy
44+
if (!isObject(policy)) fail('releasePolicy must be an object')
45+
if (policy.currentLine !== 'pre1' && policy.currentLine !== 'stable') {
46+
fail('releasePolicy.currentLine must be pre1 or stable')
47+
}
48+
49+
if (!isObject(policy.lines)) fail('releasePolicy.lines must be an object')
50+
const pre1 = policy.lines.pre1
51+
const stable = policy.lines.stable
52+
if (!isObject(pre1) || pre1.major !== 0 || pre1.breakingRelease !== 'minor') {
53+
fail('pre1 line must use major 0 and minor breaking releases')
54+
}
55+
if (!isObject(stable) || stable.minimumMajor !== 1 || stable.breakingRelease !== 'major') {
56+
fail('stable line must start at major 1 and use major breaking releases')
57+
}
58+
59+
const graduation = policy.graduation
60+
if (!isObject(graduation) || typeof graduation.approved !== 'boolean') {
61+
fail('releasePolicy.graduation must contain an approved boolean')
62+
}
63+
if (!isObject(graduation.evidence)) {
64+
fail('releasePolicy.graduation.evidence must be an object')
65+
}
66+
for (const key of EVIDENCE_KEYS) {
67+
if (!(key in graduation.evidence)) {
68+
fail(`releasePolicy.graduation.evidence.${key} is required`)
69+
}
70+
}
71+
72+
return manifest
73+
}
74+
75+
function loadReleaseManifest(manifestPath = DEFAULT_MANIFEST_PATH, cwd = process.cwd()) {
76+
const absolutePath = path.resolve(cwd, manifestPath)
77+
let parsed
78+
try {
79+
parsed = JSON.parse(fs.readFileSync(absolutePath, 'utf8'))
80+
} catch (error) {
81+
throw new Error(`Unable to read release manifest ${absolutePath}: ${error.message}`)
82+
}
83+
return validateReleaseManifest(parsed)
84+
}
85+
86+
function assertGraduationApproved(manifest) {
87+
validateReleaseManifest(manifest)
88+
const graduation = manifest.releasePolicy.graduation
89+
const missing = []
90+
91+
if (graduation.approved !== true) missing.push('approved=true')
92+
if (!APPROVAL_BEAD_PATTERN.test(graduation.approvalBead || '')) {
93+
missing.push('a reviewed approvalBead')
94+
}
95+
if (!APPROVAL_DATE_PATTERN.test(graduation.approvedAt || '')) {
96+
missing.push('approvedAt in YYYY-MM-DD form')
97+
}
98+
for (const key of EVIDENCE_KEYS) {
99+
const value = graduation.evidence[key]
100+
if (typeof value !== 'string' || value.trim() === '') {
101+
missing.push(`${key} evidence`)
102+
}
103+
}
104+
105+
if (missing.length > 0) {
106+
throw new Error(`Stable graduation is not approved: missing ${missing.join(', ')}`)
107+
}
108+
}
109+
110+
function assertVersionAllowed(manifest, version) {
111+
validateReleaseManifest(manifest)
112+
const { major } = parseSemver(version, 'requested version')
113+
const line = manifest.releasePolicy.currentLine
114+
115+
if (major === 0 && line !== 'pre1') {
116+
throw new Error('Stable release policy cannot generate a 0.x version')
117+
}
118+
if (major >= 1) {
119+
if (line !== 'stable') {
120+
throw new Error('A 1.x release requires releasePolicy.currentLine=stable')
121+
}
122+
assertGraduationApproved(manifest)
123+
}
124+
}
125+
126+
function releaseRulesForManifest(manifest) {
127+
validateReleaseManifest(manifest)
128+
const line = manifest.releasePolicy.currentLine
129+
if (line === 'stable') assertGraduationApproved(manifest)
130+
131+
return [
132+
{
133+
breaking: true,
134+
release: manifest.releasePolicy.lines[line].breakingRelease,
135+
},
136+
{ type: 'feat', release: 'minor' },
137+
{ type: 'fix', release: 'patch' },
138+
{ type: 'perf', release: 'patch' },
139+
{ type: 'revert', release: 'patch' },
140+
]
141+
}
142+
143+
module.exports = {
144+
DEFAULT_MANIFEST_PATH,
145+
assertGraduationApproved,
146+
assertVersionAllowed,
147+
loadReleaseManifest,
148+
parseSemver,
149+
releaseRulesForManifest,
150+
validateReleaseManifest,
151+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env node
2+
const assert = require('assert')
3+
const fs = require('fs')
4+
const os = require('os')
5+
const path = require('path')
6+
const { analyzeCommits } = require('./analyze-commits')
7+
const {
8+
assertGraduationApproved,
9+
assertVersionAllowed,
10+
loadReleaseManifest,
11+
releaseRulesForManifest,
12+
} = require('./release-manifest')
13+
14+
const root = path.resolve(__dirname, '../..')
15+
const manifest = loadReleaseManifest('release/manifest.json', root)
16+
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
17+
18+
function approvedStableManifest() {
19+
const stable = JSON.parse(JSON.stringify(manifest))
20+
stable.releasePolicy.currentLine = 'stable'
21+
stable.releasePolicy.graduation = {
22+
approved: true,
23+
approvalBead: 'haxe.elixir.codex-stable.1',
24+
approvedAt: '2026-07-09',
25+
evidence: {
26+
platformToolchain: 'CI matrix evidence URL',
27+
compatibility: 'compatibility report URL',
28+
applicationRuntime: 'application QA report URL',
29+
independentReview: 'independent review URL',
30+
},
31+
}
32+
return stable
33+
}
34+
35+
async function analyze(manifestFixture, message) {
36+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reflaxe-release-policy-'))
37+
const manifestPath = path.join(tempDir, 'manifest.json')
38+
fs.writeFileSync(manifestPath, `${JSON.stringify(manifestFixture, null, 2)}\n`)
39+
try {
40+
return await analyzeCommits(
41+
{ manifestPath },
42+
{
43+
cwd: root,
44+
commits: [{ message }],
45+
logger: { log() {} },
46+
}
47+
)
48+
} finally {
49+
fs.rmSync(tempDir, { recursive: true, force: true })
50+
}
51+
}
52+
53+
async function main() {
54+
assert.strictEqual(manifest.package.version, packageJson.version)
55+
assert.strictEqual(manifest.releasePolicy.currentLine, 'pre1')
56+
const analyzerPlugin = packageJson.release.plugins[0]
57+
assert.deepStrictEqual(analyzerPlugin, [
58+
'./scripts/release/analyze-commits.js',
59+
{ manifestPath: 'release/manifest.json' },
60+
])
61+
assert.strictEqual(releaseRulesForManifest(manifest)[0].release, 'minor')
62+
assert.strictEqual(await analyze(manifest, 'feat!: change stable behavior'), 'minor')
63+
assert.strictEqual(await analyze(manifest, 'fix: preserve behavior'), 'patch')
64+
assert.doesNotThrow(() => assertVersionAllowed(manifest, '0.15.0'))
65+
assert.throws(() => assertVersionAllowed(manifest, '1.0.0'), /requires.*stable/)
66+
67+
const stable = approvedStableManifest()
68+
assert.doesNotThrow(() => assertGraduationApproved(stable))
69+
assert.strictEqual(releaseRulesForManifest(stable)[0].release, 'major')
70+
assert.strictEqual(await analyze(stable, 'feat!: change stable behavior'), 'major')
71+
assert.doesNotThrow(() => assertVersionAllowed(stable, '1.0.0'))
72+
assert.throws(() => assertVersionAllowed(stable, '0.15.0'), /cannot generate a 0\.x/)
73+
74+
const unapprovedStable = approvedStableManifest()
75+
unapprovedStable.releasePolicy.graduation.approved = false
76+
unapprovedStable.releasePolicy.graduation.evidence.independentReview = null
77+
assert.throws(
78+
() => releaseRulesForManifest(unapprovedStable),
79+
/Stable graduation is not approved.*approved=true.*independentReview/
80+
)
81+
82+
console.log('[release-policy] OK: pre-1.0 and stable graduation contracts')
83+
}
84+
85+
main().catch((error) => {
86+
console.error(error.stack || error.message)
87+
process.exit(1)
88+
})

0 commit comments

Comments
 (0)