From f65ee563e7b8897c1b02b7ba24565c9b3de51f56 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 19 Jun 2026 09:23:12 +0200 Subject: [PATCH] feat: add release metadata helpers for ADR 0002 This includes: - validation for `release/YYYY-MM-DD.N` branch names - extraction of the release identifier - computation of `YYYY-MM-DD.N-beta.M` tags - computation of `YYYY-MM-DD.N-production` - detection of existing Production tags - detection of whether a Production tag points to the current release commit --- bin/jest.config.js | 28 +++++ bin/releaseMetadata.test.ts | 233 ++++++++++++++++++++++++++++++++++++ bin/releaseMetadata.ts | 142 ++++++++++++++++++++++ package.json | 1 + project.json | 2 +- tsconfig.bin.json | 2 +- yarn.lock | 17 +++ 7 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 bin/jest.config.js create mode 100644 bin/releaseMetadata.test.ts create mode 100644 bin/releaseMetadata.ts diff --git a/bin/jest.config.js b/bin/jest.config.js new file mode 100644 index 00000000000..71c7f3197dd --- /dev/null +++ b/bin/jest.config.js @@ -0,0 +1,28 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +module.exports = { + rootDir: '..', + testEnvironment: 'node', + testMatch: ['/bin/**/*.test.ts'], + transform: { + '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', {configFile: './apps/server/babel.config.js'}], + }, + transformIgnorePatterns: ['/node_modules/(?!(true-myth)/)'], +}; diff --git a/bin/releaseMetadata.test.ts b/bin/releaseMetadata.test.ts new file mode 100644 index 00000000000..3675c574b44 --- /dev/null +++ b/bin/releaseMetadata.test.ts @@ -0,0 +1,233 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import assert from 'node:assert'; + +import { + createNextBetaTagName, + createProductionTagName, + extractReleaseIdentifierFromBranchName, + isReleaseBranchName, + productionTagExists, + productionTagPointsToCommit, +} from './releaseMetadata'; +import type {CommitHash, ReleaseTagMetadata} from './releaseMetadata'; + +function createCommitHash(commitHash: string): CommitHash { + return commitHash as CommitHash; +} + +describe('releaseMetadata', () => { + it.each(['release/2026-06-19.1', 'release/2026-06-19.2', 'release/2026-06-19.10'])( + 'isReleaseBranchName() accepts release branch name "%s"', + branchName => { + const actualIsReleaseBranchName = isReleaseBranchName(branchName); + + expect(actualIsReleaseBranchName).toBe(true); + }, + ); + + it('isReleaseBranchName() rejects release identifiers ending in .0', () => { + const branchName = 'release/2026-06-19.0'; + + const actualIsReleaseBranchName = isReleaseBranchName(branchName); + + expect(actualIsReleaseBranchName).toBe(false); + }); + + it.each([ + 'dev', + 'master', + 'main', + 'release/foo', + 'release/2026-06-19', + 'release/2026-06-19.x', + 'release/2026-06-19.1-extra', + 'release/2026-6-19.1', + 'release/2026-06-9.1', + ])('isReleaseBranchName() rejects invalid release branch name "%s"', invalidBranchName => { + const actualIsReleaseBranchName = isReleaseBranchName(invalidBranchName); + + expect(actualIsReleaseBranchName).toBe(false); + }); + + it('extractReleaseIdentifierFromBranchName() extracts the release identifier from a valid release branch name', () => { + const branchName = 'release/2026-06-19.1'; + + const actualReleaseIdentifier = extractReleaseIdentifierFromBranchName(branchName); + + assert(actualReleaseIdentifier.isOk === true); + + expect(actualReleaseIdentifier.value).toBe('2026-06-19.1'); + }); + + it('extractReleaseIdentifierFromBranchName() rejects an invalid release branch name', () => { + const invalidBranchName = 'release/2026-06-01'; + + const actualReleaseIdentifier = extractReleaseIdentifierFromBranchName(invalidBranchName); + + assert(actualReleaseIdentifier.isErr === true); + + expect(actualReleaseIdentifier.error.message).toBe('Invalid release branch name: release/2026-06-01'); + }); + + it('createProductionTagName() creates the production tag name from the release identifier', () => { + const releaseIdentifier = '2026-06-19.1'; + + const actualProductionTagName = createProductionTagName(releaseIdentifier); + + assert(actualProductionTagName.isOk === true); + + expect(actualProductionTagName.value).toBe('2026-06-19.1-production'); + }); + + it('createProductionTagName() returns an Result Err for an invalid release identifier', () => { + const invalidReleaseIdentifier = '2026-06-19'; + + const actualProductionTagName = createProductionTagName(invalidReleaseIdentifier); + + assert(actualProductionTagName.isErr === true); + + expect(actualProductionTagName.error.message).toBe('Invalid release identifier: 2026-06-19'); + }); + + it('createNextBetaTagName() increments the latest beta tag for the release identifier', () => { + const releaseIdentifier = '2026-06-19.1'; + const existingTagNames = ['2026-06-19.1-beta.1', '2026-06-19.1-beta.2']; + + const actualNextBetaTagName = createNextBetaTagName(releaseIdentifier, existingTagNames); + + assert(actualNextBetaTagName.isOk === true); + + expect(actualNextBetaTagName.value).toBe('2026-06-19.1-beta.3'); + }); + + it('createNextBetaTagName() ignores unrelated tags', () => { + const releaseIdentifier = '2026-06-19.1'; + const existingTagNames = [ + '2026-06-18.1-beta.9', + '2026-06-19.1-beta.1', + '2026-06-19.1-production', + '2026-06-19.1-beta.extra', + 'q2-2025', + ]; + + const actualNextBetaTagName = createNextBetaTagName(releaseIdentifier, existingTagNames); + + assert(actualNextBetaTagName.isOk === true); + + expect(actualNextBetaTagName.value).toBe('2026-06-19.1-beta.2'); + }); + + it('createNextBetaTagName() starts at beta.1 when no beta tag exists for the release identifier', () => { + const releaseIdentifier = '2026-06-19.1'; + const existingTagNames = ['2026-06-18.1-beta.1', '2026-06-19.1-production']; + + const actualNextBetaTagName = createNextBetaTagName(releaseIdentifier, existingTagNames); + + assert(actualNextBetaTagName.isOk === true); + + expect(actualNextBetaTagName.value).toBe('2026-06-19.1-beta.1'); + }); + + it('createNextBetaTagName() returns an Result Err for an invalid release identifier', () => { + const invalidReleaseIdentifier = '2026-06-19'; + + const actualNextBetaTagName = createNextBetaTagName(invalidReleaseIdentifier, []); + + assert(actualNextBetaTagName.isErr === true); + + expect(actualNextBetaTagName.error.message).toBe('Invalid release identifier: 2026-06-19'); + }); + + it('productionTagExists() detects that the production tag exists', () => { + const releaseIdentifier = '2026-06-19.1'; + const existingTagNames = ['2026-06-19.1-beta.1', '2026-06-19.1-production']; + + const actualProductionTagExists = productionTagExists(releaseIdentifier, existingTagNames); + + assert(actualProductionTagExists.isOk === true); + + expect(actualProductionTagExists.value).toBe(true); + }); + + it('productionTagExists() detects that the production tag does not exist', () => { + const releaseIdentifier = '2026-06-19.1'; + const existingTagNames = ['2026-06-19.1-beta.1', '2026-06-18.1-production']; + + const actualProductionTagExists = productionTagExists(releaseIdentifier, existingTagNames); + + assert(actualProductionTagExists.isOk === true); + + expect(actualProductionTagExists.value).toBe(false); + }); + + it('productionTagPointsToCommit() detects that the production tag points to the current commit', () => { + const releaseIdentifier = '2026-06-19.1'; + const currentCommitHash = createCommitHash('1234567890abcdef'); + const releaseTagMetadata: ReleaseTagMetadata[] = [ + {tagName: '2026-06-19.1-beta.1', commitHash: createCommitHash('aaaaaaaaaaaaaaaa')}, + {tagName: '2026-06-19.1-production', commitHash: currentCommitHash}, + ]; + + const actualProductionTagPointsToCommit = productionTagPointsToCommit({ + currentCommitHash, + releaseIdentifier, + releaseTagMetadata, + }); + + assert(actualProductionTagPointsToCommit.isOk === true); + + expect(actualProductionTagPointsToCommit.value).toBe(true); + }); + + it('productionTagPointsToCommit() detects that the production tag points to a different commit', () => { + const releaseIdentifier = '2026-06-19.1'; + const currentCommitHash = createCommitHash('1234567890abcdef'); + const releaseTagMetadata: ReleaseTagMetadata[] = [ + {tagName: '2026-06-19.1-production', commitHash: createCommitHash('fedcba0987654321')}, + ]; + + const actualProductionTagPointsToCommit = productionTagPointsToCommit({ + currentCommitHash, + releaseIdentifier, + releaseTagMetadata, + }); + + assert(actualProductionTagPointsToCommit.isOk === true); + + expect(actualProductionTagPointsToCommit.value).toBe(false); + }); + + it('productionTagPointsToCommit() detects that the production tag is absent', () => { + const releaseIdentifier = '2026-06-19.1'; + const currentCommitHash = createCommitHash('1234567890abcdef'); + const releaseTagMetadata: ReleaseTagMetadata[] = [{tagName: '2026-06-19.1-beta.1', commitHash: currentCommitHash}]; + + const actualProductionTagPointsToCommit = productionTagPointsToCommit({ + currentCommitHash, + releaseIdentifier, + releaseTagMetadata, + }); + + assert(actualProductionTagPointsToCommit.isOk === true); + + expect(actualProductionTagPointsToCommit.value).toBe(false); + }); +}); diff --git a/bin/releaseMetadata.ts b/bin/releaseMetadata.ts new file mode 100644 index 00000000000..4046515241a --- /dev/null +++ b/bin/releaseMetadata.ts @@ -0,0 +1,142 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {maybe, Result} from 'true-myth'; +import type {NonEmptyString} from 'type-fest'; + +declare const commitHashBrand: unique symbol; + +type NonZeroDecimalDigit = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; + +export type ReleaseIdentifier = + NonEmptyString<`${number}${number}${number}${number}-${number}${number}-${number}${number}.${NonZeroDecimalDigit}${string}`>; +export type BetaTagName = NonEmptyString<`${ReleaseIdentifier}-beta.${number}`>; +export type ProductionTagName = NonEmptyString<`${ReleaseIdentifier}-production`>; +export type ReleaseTagName = BetaTagName | ProductionTagName; +export type CommitHash = string & {readonly [commitHashBrand]: 'CommitHash'}; + +export type ReleaseTagMetadata = { + readonly commitHash: CommitHash; + readonly tagName: ReleaseTagName; +}; + +export type ProductionTagPointsToCommitParameters = { + readonly currentCommitHash: CommitHash; + readonly releaseIdentifier: ReleaseIdentifier; + readonly releaseTagMetadata: readonly ReleaseTagMetadata[]; +}; + +const releaseIdentifierPattern = String.raw`\d{4}-\d{2}-\d{2}\.[1-9]\d*`; +const releaseBranchNamePattern = new RegExp(`^release/(${releaseIdentifierPattern})$`); + +function escapeRegularExpression(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); +} + +function validateReleaseIdentifier(releaseIdentifier: string): Result { + const releaseIdentifierMatches = new RegExp(`^${releaseIdentifierPattern}$`).test(releaseIdentifier); + + if (!releaseIdentifierMatches) { + return Result.err(new Error(`Invalid release identifier: ${releaseIdentifier}`)); + } + + return Result.ok(releaseIdentifier as ReleaseIdentifier); +} + +export function isReleaseBranchName(branchName: string): boolean { + return releaseBranchNamePattern.test(branchName); +} + +export function extractReleaseIdentifierFromBranchName(branchName: string): Result { + const branchNameMatch = releaseBranchNamePattern.exec(branchName); + + if (branchNameMatch === null) { + return Result.err(new Error(`Invalid release branch name: ${branchName}`)); + } + + return Result.ok(branchNameMatch[1] as ReleaseIdentifier); +} + +export function createProductionTagName(releaseIdentifier: string): Result { + const releaseIdentifierResult = validateReleaseIdentifier(releaseIdentifier); + + if (releaseIdentifierResult.isErr) { + return Result.err(releaseIdentifierResult.error); + } + + return Result.ok(`${releaseIdentifierResult.value}-production` as ProductionTagName); +} + +export function createNextBetaTagName( + releaseIdentifier: string, + existingTagNames: readonly string[], +): Result { + const releaseIdentifierResult = validateReleaseIdentifier(releaseIdentifier); + + if (releaseIdentifierResult.isErr) { + return Result.err(releaseIdentifierResult.error); + } + + const escapedReleaseIdentifier = escapeRegularExpression(releaseIdentifierResult.value); + const betaTagNamePattern = new RegExp(String.raw`^${escapedReleaseIdentifier}-beta\.(\d+)$`); + const existingBetaTagNumbers = existingTagNames.flatMap(existingTagName => { + const existingTagNameMatch = betaTagNamePattern.exec(existingTagName); + + if (existingTagNameMatch === null) { + return []; + } + + return [Number(existingTagNameMatch[1])]; + }); + const latestBetaTagNumber = existingBetaTagNumbers.length > 0 ? Math.max(...existingBetaTagNumbers) : 0; + const nextBetaTagNumber = latestBetaTagNumber + 1; + + return Result.ok(`${releaseIdentifierResult.value}-beta.${nextBetaTagNumber}` as BetaTagName); +} + +export function productionTagExists( + releaseIdentifier: string, + existingTagNames: readonly string[], +): Result { + const productionTagNameResult = createProductionTagName(releaseIdentifier); + + if (productionTagNameResult.isErr) { + return Result.err(productionTagNameResult.error); + } + + return Result.ok(existingTagNames.includes(productionTagNameResult.value)); +} + +export function productionTagPointsToCommit(parameters: ProductionTagPointsToCommitParameters): Result { + const productionTagNameResult = createProductionTagName(parameters.releaseIdentifier); + + if (productionTagNameResult.isErr) { + return Result.err(productionTagNameResult.error); + } + + const productionTagMetadata = maybe.find(tagMetadata => { + return tagMetadata.tagName === productionTagNameResult.value; + }, parameters.releaseTagMetadata); + + if (productionTagMetadata.isNothing) { + return Result.ok(false); + } + + return Result.ok(productionTagMetadata.value.commitHash === parameters.currentCommitHash); +} diff --git a/package.json b/package.json index bad3635be03..e2cf85f424c 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "stylelint-config-idiomatic-order": "10.0.0", "ts-node": "10.9.2", "tsc-watch": "7.2.0", + "type-fest": "5.7.0", "typescript": "6.0.3" }, "engines": { diff --git a/project.json b/project.json index b3d7bdf6bd2..bdbc0565e1b 100644 --- a/project.json +++ b/project.json @@ -54,7 +54,7 @@ "executor": "nx:run-commands", "dependsOn": [], "options": { - "command": "echo \"workspace-tools has no tests\"" + "command": "jest --config ./bin/jest.config.js" } }, "build": { diff --git a/tsconfig.bin.json b/tsconfig.bin.json index 3496883b69a..d672e1553f6 100644 --- a/tsconfig.bin.json +++ b/tsconfig.bin.json @@ -8,5 +8,5 @@ "rootDir": "." }, "include": ["bin/**/*.ts"], - "exclude": ["node_modules", "dist", "tmp"] + "exclude": ["node_modules", "dist", "tmp", "bin/**/*.test.ts", "bin/jest.config.js"] } diff --git a/yarn.lock b/yarn.lock index 867bb91863d..280b1c98104 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29489,6 +29489,13 @@ __metadata: languageName: node linkType: hard +"tagged-tag@npm:^1.0.0": + version: 1.0.0 + resolution: "tagged-tag@npm:1.0.0" + checksum: 10/e37653df3e495daa7ea7790cb161b810b00075bba2e4d6c93fb06a709e747e3ae9da11a120d0489833203926511b39e038a2affbd9d279cfb7a2f3fcccd30b5d + languageName: node + linkType: hard + "tapable@npm:^2.0.0, tapable@npm:^2.2.0, tapable@npm:^2.2.1, tapable@npm:^2.3.0": version: 2.3.0 resolution: "tapable@npm:2.3.0" @@ -30269,6 +30276,15 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:5.7.0": + version: 5.7.0 + resolution: "type-fest@npm:5.7.0" + dependencies: + tagged-tag: "npm:^1.0.0" + checksum: 10/4867626aa489968df98e09ecdefbc45dfbb191ae5fb8924b3bd45da9cd940879b387086226366dce028570983a3fbe80adc53ad105a169bbbd27621c496bd6f0 + languageName: node + linkType: hard + "type-fest@npm:^0.16.0": version: 0.16.0 resolution: "type-fest@npm:0.16.0" @@ -32048,6 +32064,7 @@ __metadata: ts-pattern: "npm:5.9.0" tsc-watch: "npm:7.2.0" tslib: "npm:2.8.1" + type-fest: "npm:5.7.0" typescript: "npm:6.0.3" uuid: "npm:14.0.0" zod: "npm:3.25.76"