Add canonical release model

This commit is contained in:
vineyardbovines
2026-09-03 10:30:56 -04:00
parent 8af501de62
commit be13e2e785
4 changed files with 462 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
# Release model
The cactus release workflows use one canonical release document per native
version. This contract is intentionally independent of the existing production
workflows while the new system is being developed and tested.
## Identity
The workflow accepts one version in strict `x.y.z` format and derives all other
identifiers from it:
| Resource | Format |
| --- | --- |
| Cumulative branch | `release-x.y.z` |
| Immutable native tag | `x.y.z` |
| Release document | `RELEASE-x.y.z.md` |
| GitHub Release name | `Release x.y.z` |
| Successful OTA tag | `ota-x.y.z-N` |
Callers must not supply these derived identifiers independently.
## Prepared state
The preparation workflow creates the document before freezing the native
candidate. At this stage, only `releaseVersion` is required:
```md
---
releaseVersion: 1.131.1
---
# Release 1.131.1
<!-- public-changelog:start -->
## Initial release
- Added something
<!-- public-changelog:end -->
```
## Final state
After both native builds succeed, the workflow records the frozen source and
artifact-derived build numbers. A finalized document requires every field:
```yaml
releaseVersion: 1.131.1
sourceTag: 1.131.1
sourceSha: 0123456789abcdef0123456789abcdef01234567
iosBuildNumber: 1662
androidVersionCode: 1110
```
`sourceTag` must equal `releaseVersion`, `sourceSha` must be a full Git object
ID, and both build numbers must be positive integers.
Each successful OTA adds exactly one contiguous section (`OTA 1`, `OTA 2`, and
so on) inside the public changelog delimiters. GitHub Release text is extracted
only from those delimiters; operational frontmatter is never published.
+1
View File
@@ -56,6 +56,7 @@
"start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify",
"test": "NODE_ENV=test jest --forceExit --testTimeout=20000 --bail",
"test:release-model": "node --test scripts/release/model.test.mjs",
"test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage",
+281
View File
@@ -0,0 +1,281 @@
const VERSION_COMPONENT = '(?:0|[1-9][0-9]*)'
const VERSION_PATTERN = new RegExp(
`^${VERSION_COMPONENT}\\.${VERSION_COMPONENT}\\.${VERSION_COMPONENT}$`,
)
const RELEASE_FILENAME_PATTERN = new RegExp(
`^RELEASE-(${VERSION_COMPONENT}\\.${VERSION_COMPONENT}\\.${VERSION_COMPONENT})\\.md$`,
)
const SOURCE_SHA_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/
const FRONTMATTER_KEYS = [
'releaseVersion',
'sourceTag',
'sourceSha',
'iosBuildNumber',
'androidVersionCode',
]
const PUBLIC_CHANGELOG_START = '<!-- public-changelog:start -->'
const PUBLIC_CHANGELOG_END = '<!-- public-changelog:end -->'
export class ReleaseModelError extends Error {
constructor(message) {
super(message)
this.name = 'ReleaseModelError'
}
}
function fail(message) {
throw new ReleaseModelError(message)
}
export function assertReleaseVersion(version) {
if (typeof version !== 'string' || !VERSION_PATTERN.test(version)) {
fail(`Release version must use strict x.y.z format; found '${version}'.`)
}
return version
}
export function deriveReleaseIdentity(version) {
assertReleaseVersion(version)
return Object.freeze({
version,
branch: `release-${version}`,
tag: version,
filename: `RELEASE-${version}.md`,
githubReleaseName: `Release ${version}`,
})
}
function parseFrontmatter(markdown) {
const normalized = markdown.replace(/\r\n/g, '\n')
if (!normalized.startsWith('---\n')) {
fail('Release file must start with YAML frontmatter.')
}
const end = normalized.indexOf('\n---\n', 4)
if (end === -1) fail('Release file frontmatter is not closed.')
const metadata = {}
const lines = normalized.slice(4, end).split('\n')
for (const line of lines) {
if (!line.trim()) continue
const match = /^([A-Za-z][A-Za-z0-9]*):[ \t]*(.*)$/.exec(line)
if (!match) fail(`Unsupported frontmatter line: '${line}'.`)
const [, key, rawValue] = match
if (!FRONTMATTER_KEYS.includes(key)) {
fail(`Unknown release frontmatter field '${key}'.`)
}
if (Object.hasOwn(metadata, key)) {
fail(`Duplicate release frontmatter field '${key}'.`)
}
if (!rawValue) fail(`Release frontmatter field '${key}' cannot be empty.`)
metadata[key] = rawValue
}
const body = normalized.slice(end + 5)
if (!body.startsWith('\n')) {
fail('Release file must contain a blank line after frontmatter.')
}
return {metadata, body: body.slice(1)}
}
function findDelimitedSection(body, startMarker, endMarker) {
const start = body.indexOf(startMarker)
const end = body.indexOf(endMarker)
if (start === -1 || end === -1) {
fail('Release file must contain the public changelog delimiters.')
}
if (
start !== body.lastIndexOf(startMarker) ||
end !== body.lastIndexOf(endMarker)
) {
fail('Public changelog delimiters must occur exactly once.')
}
if (end < start)
fail('Public changelog end delimiter precedes its start delimiter.')
return {
before: body.slice(0, start + startMarker.length),
content: body.slice(start + startMarker.length, end).trim(),
after: body.slice(end),
}
}
function parseChangelogSections(publicChangelog) {
const unsupportedHeading = publicChangelog.match(
/^## (?!Initial release\s*$|OTA [1-9][0-9]*\s*$).+$/m,
)
if (unsupportedHeading) {
fail(`Unsupported public changelog section '${unsupportedHeading[0]}'.`)
}
const headings = [
...publicChangelog.matchAll(
/^## (Initial release|OTA ([1-9][0-9]*))\s*$/gm,
),
]
if (!headings.length || headings[0][1] !== 'Initial release') {
fail("Public changelog must begin with an '## Initial release' section.")
}
const prefix = publicChangelog.slice(0, headings[0].index).trim()
if (prefix)
fail(
'Public changelog cannot contain content before its initial release section.',
)
const sections = headings.map((heading, index) => {
const start = heading.index + heading[0].length
const end = headings[index + 1]?.index ?? publicChangelog.length
const content = publicChangelog.slice(start, end).trim()
if (!content) fail(`Changelog section '${heading[1]}' cannot be empty.`)
return {
type: heading[1] === 'Initial release' ? 'initial' : 'ota',
sequence: heading[2] ? Number(heading[2]) : null,
content,
}
})
sections.slice(1).forEach((section, index) => {
const expected = index + 1
if (section.type !== 'ota' || section.sequence !== expected) {
fail(
`OTA changelog sections must be contiguous; expected OTA ${expected}.`,
)
}
})
return sections
}
function validateMetadata(metadata, stage) {
assertReleaseVersion(metadata.releaseVersion)
if (metadata.sourceTag !== undefined) {
assertReleaseVersion(metadata.sourceTag)
if (metadata.sourceTag !== metadata.releaseVersion) {
fail('sourceTag must match releaseVersion.')
}
}
if (
metadata.sourceSha !== undefined &&
!SOURCE_SHA_PATTERN.test(metadata.sourceSha)
) {
fail('sourceSha must be a full lowercase hexadecimal Git object ID.')
}
for (const key of ['iosBuildNumber', 'androidVersionCode']) {
if (
metadata[key] !== undefined &&
!POSITIVE_INTEGER_PATTERN.test(metadata[key])
) {
fail(`${key} must be a positive integer.`)
}
}
if (stage === 'final') {
for (const key of FRONTMATTER_KEYS) {
if (metadata[key] === undefined) {
fail(`Finalized release file is missing '${key}'.`)
}
}
}
}
export function parseReleaseDocument(markdown, options = {}) {
if (typeof markdown !== 'string') fail('Release document must be a string.')
const stage = options.stage ?? 'prepared'
if (!['prepared', 'final'].includes(stage)) {
fail(`Unknown release validation stage '${stage}'.`)
}
const {metadata, body} = parseFrontmatter(markdown)
validateMetadata(metadata, stage)
const expectedTitle = `# Release ${metadata.releaseVersion}`
if (!body.startsWith(`${expectedTitle}\n`)) {
fail(
`Release document must begin with '${expectedTitle}' after frontmatter.`,
)
}
if (options.filename !== undefined) {
const filenameMatch = RELEASE_FILENAME_PATTERN.exec(options.filename)
if (!filenameMatch || filenameMatch[1] !== metadata.releaseVersion) {
fail(`Filename must be 'RELEASE-${metadata.releaseVersion}.md'.`)
}
}
const delimited = findDelimitedSection(
body,
PUBLIC_CHANGELOG_START,
PUBLIC_CHANGELOG_END,
)
const sections = parseChangelogSections(delimited.content)
return Object.freeze({
metadata: Object.freeze({...metadata}),
publicChangelog: delimited.content,
sections: Object.freeze(sections.map(section => Object.freeze(section))),
})
}
export function createReleaseDocument(version, initialChangelog) {
assertReleaseVersion(version)
const content = initialChangelog?.trim()
if (!content) fail('Initial release changelog cannot be empty.')
return `---
releaseVersion: ${version}
---
# Release ${version}
${PUBLIC_CHANGELOG_START}
## Initial release
${content}
${PUBLIC_CHANGELOG_END}
`
}
export function extractPublicChangelog(markdown) {
return parseReleaseDocument(markdown).publicChangelog
}
export function appendOtaChangelog(markdown, sequence, changelog) {
const parsed = parseReleaseDocument(markdown)
const expectedSequence = parsed.sections.length
if (!Number.isSafeInteger(sequence) || sequence !== expectedSequence) {
fail(`Next OTA sequence must be ${expectedSequence}; found '${sequence}'.`)
}
const content = changelog?.trim()
if (!content) fail('OTA changelog cannot be empty.')
const delimiter = `\n\n${PUBLIC_CHANGELOG_END}`
const replacement = `\n\n## OTA ${sequence}\n\n${content}${delimiter}`
const updated = markdown.replace(delimiter, replacement)
parseReleaseDocument(updated)
return updated
}
export function finalizeReleaseDocument(markdown, finalMetadata) {
const parsed = parseReleaseDocument(markdown)
const metadata = {...parsed.metadata, ...finalMetadata}
validateMetadata(metadata, 'final')
const frontmatter = FRONTMATTER_KEYS.map(
key => `${key}: ${metadata[key]}`,
).join('\n')
const updated = markdown.replace(
/^---\n[\s\S]*?\n---\n/,
`---\n${frontmatter}\n---\n`,
)
parseReleaseDocument(updated, {stage: 'final'})
return updated
}
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
ReleaseModelError,
appendOtaChangelog,
createReleaseDocument,
deriveReleaseIdentity,
extractPublicChangelog,
finalizeReleaseDocument,
parseReleaseDocument,
} from './model.mjs'
const SHA = '0123456789abcdef0123456789abcdef01234567'
test('derives every release identifier from one version', () => {
assert.deepEqual(deriveReleaseIdentity('1.131.1'), {
version: '1.131.1',
branch: 'release-1.131.1',
tag: '1.131.1',
filename: 'RELEASE-1.131.1.md',
githubReleaseName: 'Release 1.131.1',
})
})
test('rejects non-strict release versions', () => {
for (const version of [
'v1.131.1',
'1.131',
'1.131.1-beta',
' 1.131.1',
'1.0131.1',
]) {
assert.throws(() => deriveReleaseIdentity(version), ReleaseModelError)
}
})
test('creates and parses a prepared release document', () => {
const document = createReleaseDocument('1.131.1', '- Added something')
const parsed = parseReleaseDocument(document, {
filename: 'RELEASE-1.131.1.md',
})
assert.deepEqual(parsed.metadata, {releaseVersion: '1.131.1'})
assert.equal(parsed.sections.length, 1)
assert.equal(parsed.sections[0].type, 'initial')
assert.equal(
extractPublicChangelog(document),
'## Initial release\n\n- Added something',
)
})
test('finalizes a release using artifact-derived metadata', () => {
const prepared = createReleaseDocument('1.131.1', '- Added something')
const finalized = finalizeReleaseDocument(prepared, {
sourceTag: '1.131.1',
sourceSha: SHA,
iosBuildNumber: 1662,
androidVersionCode: 1110,
})
const parsed = parseReleaseDocument(finalized, {stage: 'final'})
assert.equal(parsed.metadata.sourceSha, SHA)
assert.equal(parsed.metadata.iosBuildNumber, '1662')
assert.equal(parsed.metadata.androidVersionCode, '1110')
})
test('requires all operational metadata in the final state', () => {
const prepared = createReleaseDocument('1.131.1', '- Added something')
assert.throws(
() => parseReleaseDocument(prepared, {stage: 'final'}),
/missing 'sourceTag'/,
)
})
test('appends contiguous OTA changelog sections', () => {
let document = createReleaseDocument('1.131.1', '- Initial change')
document = appendOtaChangelog(document, 1, '- First fix')
document = appendOtaChangelog(document, 2, '- Second fix')
const parsed = parseReleaseDocument(document)
assert.deepEqual(
parsed.sections.map(section => section.sequence),
[null, 1, 2],
)
assert.match(parsed.publicChangelog, /## OTA 2\n\n- Second fix$/)
})
test('rejects skipped OTA sequence numbers', () => {
const document = createReleaseDocument('1.131.1', '- Initial change')
assert.throws(() => appendOtaChangelog(document, 2, '- A fix'), /must be 1/)
})
test('rejects filename, metadata, and changelog inconsistencies', () => {
const document = createReleaseDocument('1.131.1', '- Initial change')
assert.throws(
() => parseReleaseDocument(document, {filename: 'RELEASE-1.132.0.md'}),
/Filename must be/,
)
assert.throws(
() =>
parseReleaseDocument(document.replace('releaseVersion:', 'surprise:')),
/Unknown release frontmatter field/,
)
assert.throws(
() => parseReleaseDocument(document.replace('Initial release', 'OTA 1')),
/must begin with an '## Initial release'/,
)
assert.throws(
() =>
parseReleaseDocument(
document.replace(
'- Initial change',
'- Initial change\n\n## Notes\n\nNope',
),
),
/Unsupported public changelog section/,
)
})