Add canonical release model
This commit is contained in:
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user