diff --git a/docs/release-model.md b/docs/release-model.md new file mode 100644 index 0000000000..5c38384e3b --- /dev/null +++ b/docs/release-model.md @@ -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 + + + +## Initial release + +- Added something + + +``` + +## 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. diff --git a/package.json b/package.json index 45fc1102cc..80d6329f40 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/release/model.mjs b/scripts/release/model.mjs new file mode 100644 index 0000000000..96bf0af599 --- /dev/null +++ b/scripts/release/model.mjs @@ -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 = '' +const 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 +} diff --git a/scripts/release/model.test.mjs b/scripts/release/model.test.mjs new file mode 100644 index 0000000000..6a83d30f37 --- /dev/null +++ b/scripts/release/model.test.mjs @@ -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/, + ) +})