diff --git a/.craft.yml b/.craft.yml index 10759f3a..cc9ffee4 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,5 +1,6 @@ minVersion: '2.14.0' -changelog: auto +changelog: + policy: auto preReleaseCommand: >- node -p " const {execSync} = require('child_process'); @@ -42,4 +43,6 @@ targets: target: getsentry/craft targetFormat: '{{{target}}}:latest' - name: github + floatingTags: + - 'v{major}' - name: gh-pages diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5d5e5cf1..7cf107b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: - master - release/** pull_request: + workflow_call: concurrency: group: ${{ github.ref_name || github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc23a8f9..4a8d49c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,11 +10,16 @@ on: force: description: Force a release even when there are release-blockers (optional) required: false - craft_version: - description: Craft version to use for the release jobs: + build: + name: Build + uses: ./.github/workflows/build.yml + permissions: + contents: read + release: + needs: build runs-on: ubuntu-latest name: 'Release a new version' permissions: @@ -32,10 +37,9 @@ jobs: token: ${{ steps.token.outputs.token }} fetch-depth: 0 - name: Prepare release - uses: getsentry/action-prepare-release@c8e1c2009ab08259029170132c384f03c1064c0e # v1 + uses: ./ env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: version: ${{ github.event.inputs.version }} force: ${{ github.event.inputs.force }} - craft_version: ${{ github.event.inputs.craft_version }} diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..31ebe3f0 --- /dev/null +++ b/action.yml @@ -0,0 +1,242 @@ +name: "Craft Prepare Release" +description: "Prepare a new release using Craft" + +inputs: + version: + description: > + Version to release. Can be a semver string (e.g., "1.2.3"), + a bump type ("major", "minor", "patch"), or "auto" for automatic detection. + required: false + merge_target: + description: Target branch to merge into. Uses the default branch as a fallback. + required: false + force: + description: Force a release even when there are release-blockers + required: false + default: "false" + blocker_label: + description: Label that blocks releases + required: false + default: "release-blocker" + publish_repo: + description: Repository for publish issues (owner/repo format) + required: false + git_user_name: + description: Git committer name + required: false + git_user_email: + description: Git committer email + required: false + path: + description: The path that Craft will run inside + required: false + default: "." + craft_config_from_merge_target: + description: Use the craft config from the merge target branch + required: false + default: "false" + +outputs: + version: + description: The resolved version being released + value: ${{ steps.craft.outputs.version }} + branch: + description: The release branch name + value: ${{ steps.craft.outputs.branch }} + sha: + description: The commit SHA on the release branch + value: ${{ steps.craft.outputs.sha }} + previous_tag: + description: The tag before this release (for diff links) + value: ${{ steps.craft.outputs.previous_tag }} + changelog: + description: The changelog for this release + value: ${{ steps.craft.outputs.changelog }} + +runs: + using: "composite" + steps: + - id: killswitch + name: Check release blockers + shell: bash + run: | + if [[ '${{ inputs.force }}' != 'true' ]] && gh issue list -l '${{ inputs.blocker_label }}' -s open | grep -q '^[0-9]\+[[:space:]]'; then + echo "::error::Open release-blocking issues found (label: ${{ inputs.blocker_label }}), cancelling release..." + gh api -X POST repos/:owner/:repo/actions/runs/$GITHUB_RUN_ID/cancel + fi + + - name: Set git user + shell: bash + run: | + # Use provided values or fall back to triggering actor + GIT_USER_NAME='${{ inputs.git_user_name }}' + GIT_USER_EMAIL='${{ inputs.git_user_email }}' + + if [[ -z "$GIT_USER_NAME" ]]; then + GIT_USER_NAME="${GITHUB_ACTOR}" + fi + if [[ -z "$GIT_USER_EMAIL" ]]; then + GIT_USER_EMAIL="${GITHUB_ACTOR_ID}+${GITHUB_ACTOR}@users.noreply.github.com" + fi + + echo "GIT_COMMITTER_NAME=${GIT_USER_NAME}" >> $GITHUB_ENV + echo "GIT_AUTHOR_NAME=${GIT_USER_NAME}" >> $GITHUB_ENV + echo "EMAIL=${GIT_USER_EMAIL}" >> $GITHUB_ENV + + - name: Download Craft from build artifact + id: artifact + if: github.repository == 'getsentry/craft' + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 + continue-on-error: true + with: + name: ${{ github.sha }} + path: /tmp/craft-artifact + + - name: Install Craft from artifact + if: steps.artifact.outcome == 'success' + shell: bash + run: | + echo "Installing Craft from build artifact..." + sudo install -m 755 /tmp/craft-artifact/dist/craft /usr/local/bin/craft + + - name: Install Craft from release + if: steps.artifact.outcome != 'success' + shell: bash + run: | + # Try action ref first (e.g., v2, 2.15.0) + ACTION_REF="${{ github.action_ref }}" + CRAFT_URL="https://github.com/getsentry/craft/releases/download/${ACTION_REF}/craft" + + echo "Trying to download Craft from: ${CRAFT_URL}" + + # Fallback to latest if ref doesn't have a release + if ! curl -sfI "$CRAFT_URL" >/dev/null 2>&1; then + echo "Release not found for ref '${ACTION_REF}', falling back to latest..." + CRAFT_URL=$(curl -s "https://api.github.com/repos/getsentry/craft/releases/latest" \ + | jq -r '.assets[] | select(.name == "craft") | .browser_download_url') + fi + + echo "Installing Craft from: ${CRAFT_URL}" + sudo curl -sL -o /usr/local/bin/craft "$CRAFT_URL" + sudo chmod +x /usr/local/bin/craft + + - name: Craft Prepare + id: craft + shell: bash + env: + CRAFT_LOG_LEVEL: Debug + working-directory: ${{ inputs.path }} + run: | + # Ensure we have origin/HEAD set + git remote set-head origin --auto + + # Build command with optional flags + CRAFT_ARGS="" + if [[ '${{ inputs.craft_config_from_merge_target }}' == 'true' && -n '${{ inputs.merge_target }}' ]]; then + CRAFT_ARGS="--config-from ${{ inputs.merge_target }}" + fi + + # Version is optional - if not provided, Craft uses versioning.policy from config + VERSION_ARG="" + if [[ -n '${{ inputs.version }}' ]]; then + VERSION_ARG="${{ inputs.version }}" + fi + + craft prepare $VERSION_ARG $CRAFT_ARGS + + - name: Read Craft Targets + id: craft-targets + shell: bash + working-directory: ${{ inputs.path }} + env: + CRAFT_LOG_LEVEL: Warn + run: | + targets=$(craft targets | jq -r '.[]|" - [ ] \(.)"') + + # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings + echo "targets<> "$GITHUB_OUTPUT" + echo "$targets" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Request publish + shell: bash + run: | + if [[ '${{ inputs.path }}' == '.' ]]; then + subdirectory='' + else + subdirectory='/${{ inputs.path }}' + fi + + if [[ -n '${{ inputs.merge_target }}' ]]; then + merge_target='${{ inputs.merge_target }}' + else + merge_target='(default)' + fi + + # Use resolved version from Craft output + RESOLVED_VERSION="${{ steps.craft.outputs.version }}" + if [[ -z "$RESOLVED_VERSION" ]]; then + echo "::error::Craft did not output a version. This is unexpected." + exit 1 + fi + + title="publish: ${GITHUB_REPOSITORY}${subdirectory}@${RESOLVED_VERSION}" + + # Determine publish repo + PUBLISH_REPO='${{ inputs.publish_repo }}' + if [[ -z "$PUBLISH_REPO" ]]; then + PUBLISH_REPO="${GITHUB_REPOSITORY_OWNER}/publish" + fi + + # Check if issue already exists + # GitHub only allows search with the "in" operator and this issue search can + # return non-exact matches. We extract the titles and check with grep -xF + if gh -R "$PUBLISH_REPO" issue list -S "'$title' in:title" --json title -q '.[] | .title' | grep -qxF -- "$title"; then + echo "There's already an open publish request, skipped issue creation." + exit 0 + fi + + # Use Craft outputs for git info + RELEASE_BRANCH="${{ steps.craft.outputs.branch }}" + RELEASE_SHA="${{ steps.craft.outputs.sha }}" + PREVIOUS_TAG="${{ steps.craft.outputs.previous_tag }}" + + # Fall back to HEAD if no previous tag + if [[ -z "$PREVIOUS_TAG" ]]; then + PREVIOUS_TAG="HEAD" + fi + + # Build changelog section if available + CHANGELOG='${{ steps.craft.outputs.changelog }}' + if [[ -n "$CHANGELOG" ]]; then + CHANGELOG_SECTION=" + --- + +
+ 📋 Changelog + + ${CHANGELOG} + +
" + else + CHANGELOG_SECTION="" + fi + + body="Requested by: @${GITHUB_ACTOR} + + Merge target: ${merge_target} + + Quick links: + - [View changes](https://github.com/${GITHUB_REPOSITORY}/compare/${PREVIOUS_TAG}...${RELEASE_BRANCH}) + - [View check runs](https://github.com/${GITHUB_REPOSITORY}/commit/${RELEASE_SHA}/checks/) + + Assign the **accepted** label to this issue to approve the release. + To retract the release, the person requesting it must leave a comment containing \`#retract\` on a line by itself under this issue. + + ### Targets + + ${{ steps.craft-targets.outputs.targets }} + + Checked targets will be skipped (either already published or user-requested skip). Uncheck to retry a target. + ${CHANGELOG_SECTION}" + gh issue create -R "$PUBLISH_REPO" --title "$title" --body "$body" diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 12451551..d2ed758f 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -9,9 +9,13 @@ import { DEFAULT_RELEASE_BRANCH_NAME, getGlobalGitHubConfig, requiresMinVersion, + loadConfigurationFromString, + CONFIG_FILE_NAME, + getVersioningPolicy, } from '../config'; import { logger } from '../logger'; -import { ChangelogPolicy } from '../schemas/project_config'; +import { ChangelogPolicy, VersioningPolicy } from '../schemas/project_config'; +import { calculateCalVer, DEFAULT_CALVER_CONFIG } from '../utils/calver'; import { sleep } from '../utils/async'; import { DEFAULT_CHANGELOG_PATH, @@ -60,8 +64,9 @@ export const builder: CommandBuilder = (yargs: Argv) => .positional('NEW-VERSION', { description: 'The new version to release. Can be: a semver string (e.g., "1.2.3"), ' + - 'a bump type ("major", "minor", or "patch"), or "auto" to determine automatically ' + - 'from conventional commits. Bump types and "auto" require minVersion >= 2.14.0 in .craft.yml', + 'a bump type ("major", "minor", or "patch"), "auto" to determine automatically ' + + 'from conventional commits, or "calver" for calendar versioning. ' + + 'If omitted, uses the versioning.policy from .craft.yml', type: 'string', }) .option('rev', { @@ -95,12 +100,20 @@ export const builder: CommandBuilder = (yargs: Argv) => description: 'The git remote to use when pushing', type: 'string', }) + .option('config-from', { + description: 'Load .craft.yml from the specified remote branch instead of local file', + type: 'string', + }) + .option('calver-offset', { + description: 'Days to go back for CalVer date calculation (overrides config)', + type: 'number', + }) .check(checkVersionOrPart); /** Command line options. */ interface PrepareOptions { - /** The new version to release */ - newVersion: string; + /** The new version to release (optional if versioning.policy is configured) */ + newVersion?: string; /** The base revision to release */ rev: string; /** The git remote to use when pushing */ @@ -113,6 +126,10 @@ interface PrepareOptions { noPush: boolean; /** Run publish right after */ publish: boolean; + /** Load config from specified remote branch */ + configFrom?: string; + /** Override CalVer offset (days to go back) */ + calverOffset?: number; } /** @@ -125,8 +142,9 @@ const SLEEP_BEFORE_PUBLISH_SECONDS = 30; * Checks the provided version argument for validity * * We check that the argument is either a valid version string, 'auto' for - * automatic version detection, a version bump type (major/minor/patch), or - * a valid semantic version. + * automatic version detection, 'calver' for calendar versioning, a version + * bump type (major/minor/patch), or a valid semantic version. + * Empty/undefined is also allowed (will use versioning.policy from config). * * @param argv Parsed yargs arguments * @param _opt A list of options and aliases @@ -134,11 +152,21 @@ const SLEEP_BEFORE_PUBLISH_SECONDS = 30; export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { const version = argv.newVersion; + // Allow empty version (will use versioning.policy from config) + if (!version) { + return true; + } + // Allow 'auto' for automatic version detection if (version === 'auto') { return true; } + // Allow 'calver' for calendar versioning + if (version === 'calver') { + return true; + } + // Allow version bump types (major, minor, patch) if (isBumpType(version)) { return true; @@ -377,6 +405,7 @@ async function execPublish(remote: string, newVersion: string): Promise { * @param newVersion The new version we are releasing * @param changelogPolicy One of the changelog policies, such as "none", "simple", etc. * @param changelogPath Path to the changelog file + * @returns The changelog body for this version, or undefined if no changelog */ async function prepareChangelog( git: SimpleGit, @@ -384,12 +413,12 @@ async function prepareChangelog( newVersion: string, changelogPolicy: ChangelogPolicy = ChangelogPolicy.None, changelogPath: string = DEFAULT_CHANGELOG_PATH -): Promise { +): Promise { if (changelogPolicy === ChangelogPolicy.None) { logger.debug( `Changelog policy is set to "${changelogPolicy}", nothing to do.` ); - return; + return undefined; } if ( @@ -466,6 +495,7 @@ async function prepareChangelog( logger.debug('Changelog entry found:', changeset.name); logger.trace(changeset.body); + return changeset?.body; } /** @@ -490,37 +520,81 @@ async function switchToDefaultBranch( } } +interface ResolveVersionOptions { + /** The raw version input from CLI (may be undefined, 'auto', 'calver', bump type, or semver) */ + versionArg?: string; + /** Override for CalVer offset (days to go back) */ + calverOffset?: number; +} + /** - * Body of 'prepare' command + * Resolves the final semver version string from various input types. * - * @param argv Command-line arguments + * Handles: + * - No input: uses versioning.policy from config + * - 'calver': calculates calendar version + * - 'auto': analyzes commits to determine bump type + * - 'major'/'minor'/'patch': applies bump to latest tag + * - Explicit semver: returns as-is + * + * @param git Local git client + * @param options Version resolution options + * @returns The resolved semver version string */ -export async function prepareMain(argv: PrepareOptions): Promise { - // Get repo configuration +async function resolveVersion( + git: SimpleGit, + options: ResolveVersionOptions +): Promise { const config = getConfiguration(); - const githubConfig = await getGlobalGitHubConfig(); - let newVersion = argv.newVersion; + let version = options.versionArg; - const git = await getGitClient(); + // If no version specified, use the versioning policy from config + if (!version) { + const policy = getVersioningPolicy(); + logger.debug(`No version specified, using versioning policy: ${policy}`); - const defaultBranch = await getDefaultBranch(git, argv.remote); - logger.debug(`Default branch for the repo:`, defaultBranch); - const repoStatus = await git.status(); - const rev = argv.rev || repoStatus.current || defaultBranch; + if (policy === VersioningPolicy.Manual) { + throw new ConfigurationError( + 'Version is required. Either specify a version argument or set ' + + 'versioning.policy to "auto" or "calver" in .craft.yml' + ); + } - if (argv.noGitChecks) { - logger.info('Not checking the status of the local repository'); - } else { - // Check that we're in an acceptable state for the release - checkGitStatus(repoStatus, rev); + // Use the policy as the version type + version = policy; } - // Handle automatic version detection or version bump types - const isVersionBumpType = isBumpType(newVersion); + // Handle CalVer versioning + if (version === 'calver') { + if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { + throw new ConfigurationError( + `CalVer versioning requires minVersion >= ${AUTO_VERSION_MIN_VERSION} in .craft.yml. ` + + 'Please update your configuration or specify the version explicitly.' + ); + } - if (newVersion === 'auto' || isVersionBumpType) { + // Build CalVer config with overrides + const calverOffset = + options.calverOffset ?? + (process.env.CRAFT_CALVER_OFFSET + ? parseInt(process.env.CRAFT_CALVER_OFFSET, 10) + : undefined) ?? + config.versioning?.calver?.offset ?? + DEFAULT_CALVER_CONFIG.offset; + + const calverFormat = + config.versioning?.calver?.format ?? DEFAULT_CALVER_CONFIG.format; + + return calculateCalVer(git, { + offset: calverOffset, + format: calverFormat, + }); + } + + // Handle automatic version detection or version bump types + if (version === 'auto' || isBumpType(version)) { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { - const featureName = isVersionBumpType + const featureName = isBumpType(version) ? 'Version bump types' : 'Auto-versioning'; throw new ConfigurationError( @@ -532,15 +606,13 @@ export async function prepareMain(argv: PrepareOptions): Promise { const latestTag = await getLatestTag(git); // Determine bump type - either from arg or from commit analysis - // Note: generateChangesetFromGit is memoized, so calling getChangelogWithBumpType - // here and later in prepareChangelog won't result in duplicate GitHub API calls let bumpType: BumpType; - if (newVersion === 'auto') { + if (version === 'auto') { const changelogResult = await getChangelogWithBumpType(git, latestTag); - validateBumpType(changelogResult); // Throws if no valid bump type + validateBumpType(changelogResult); bumpType = changelogResult.bumpType; } else { - bumpType = newVersion as BumpType; + bumpType = version as BumpType; } // Calculate new version from latest tag @@ -549,10 +621,63 @@ export async function prepareMain(argv: PrepareOptions): Promise { ? latestTag.replace(/^v/, '') : '0.0.0'; - newVersion = calculateNextVersion(currentVersion, bumpType); - logger.info(`Version bump: ${currentVersion} -> ${newVersion} (${bumpType} bump)`); + const newVersion = calculateNextVersion(currentVersion, bumpType); + logger.info( + `Version bump: ${currentVersion} -> ${newVersion} (${bumpType} bump)` + ); + return newVersion; + } + + // Explicit semver version - return as-is + return version; +} + +/** + * Body of 'prepare' command + * + * @param argv Command-line arguments + */ +export async function prepareMain(argv: PrepareOptions): Promise { + const git = await getGitClient(); + + // Handle --config-from: load config from remote branch + if (argv.configFrom) { + logger.info(`Loading configuration from remote branch: ${argv.configFrom}`); + try { + await git.fetch([argv.remote, argv.configFrom]); + const configContent = await git.show([ + `${argv.remote}/${argv.configFrom}:${CONFIG_FILE_NAME}`, + ]); + loadConfigurationFromString(configContent); + } catch (error: any) { + throw new ConfigurationError( + `Failed to load ${CONFIG_FILE_NAME} from branch "${argv.configFrom}": ${error.message}` + ); + } + } + + // Get repo configuration + const config = getConfiguration(); + const githubConfig = await getGlobalGitHubConfig(); + + const defaultBranch = await getDefaultBranch(git, argv.remote); + logger.debug(`Default branch for the repo:`, defaultBranch); + const repoStatus = await git.status(); + const rev = argv.rev || repoStatus.current || defaultBranch; + + if (argv.noGitChecks) { + logger.info('Not checking the status of the local repository'); + } else { + // Check that we're in an acceptable state for the release + checkGitStatus(repoStatus, rev); } + // Resolve version from input, policy, or automatic detection + const newVersion = await resolveVersion(git, { + versionArg: argv.newVersion, + calverOffset: argv.calverOffset, + }); + // Emit resolved version for GitHub Actions setGitHubActionsOutput('version', newVersion); @@ -592,7 +717,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { ? config.changelog.policy : config.changelogPolicy ) as ChangelogPolicy | undefined; - await prepareChangelog( + const changelogBody = await prepareChangelog( git, oldVersion, newVersion, @@ -617,6 +742,15 @@ export async function prepareMain(argv: PrepareOptions): Promise { // Push the release branch await pushReleaseBranch(git, branchName, argv.remote, !argv.noPush); + // Emit GitHub Actions outputs for downstream steps + const releaseSha = await git.revparse(['HEAD']); + setGitHubActionsOutput('branch', branchName); + setGitHubActionsOutput('sha', releaseSha); + setGitHubActionsOutput('previous_tag', oldVersion || ''); + if (changelogBody) { + setGitHubActionsOutput('changelog', changelogBody); + } + logger.info( `View diff at: https://github.com/${githubConfig.owner}/${githubConfig.repo}/compare/${branchName}` ); diff --git a/src/config.ts b/src/config.ts index 0e64e504..890cc659 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,7 @@ import { StatusProviderName, TargetConfig, ChangelogPolicy, + VersioningPolicy, } from './schemas/project_config'; import { ConfigurationError } from './utils/errors'; import { @@ -162,6 +163,21 @@ export function getConfiguration(clearCache = false): CraftProjectConfig { return _configCache; } +/** + * Loads and caches configuration from a YAML string. + * + * This is used by --config-from to load config from a remote branch. + * + * @param configContent The raw YAML configuration content + */ +export function loadConfigurationFromString(configContent: string): CraftProjectConfig { + logger.debug('Loading configuration from provided content...'); + const rawConfig = load(configContent) as Record; + _configCache = validateConfiguration(rawConfig); + checkMinimalConfigVersion(_configCache); + return _configCache; +} + /** * Checks that the current "craft" version is compatible with the configuration * @@ -230,6 +246,38 @@ export function requiresMinVersion(requiredVersion: string): boolean { return versionGreaterOrEqualThan(configuredMinVersion, required); } +/** Minimum craft version required for auto-versioning and CalVer */ +const AUTO_VERSION_MIN_VERSION = '2.14.0'; + +/** + * Returns the effective versioning policy for the project. + * + * The policy determines how versions are resolved when no explicit version + * is provided to `craft prepare`: + * - 'auto': Analyze commits to determine the bump type + * - 'manual': Require an explicit version argument + * - 'calver': Use calendar versioning + * + * If not explicitly configured, defaults to: + * - 'auto' if minVersion >= 2.14.0 + * - 'manual' otherwise (for backward compatibility) + * + * @returns The versioning policy + */ +export function getVersioningPolicy(): VersioningPolicy { + const config = getConfiguration(); + + // Use explicitly configured policy if available + if (config.versioning?.policy) { + return config.versioning.policy; + } + + // Default based on minVersion + return requiresMinVersion(AUTO_VERSION_MIN_VERSION) + ? VersioningPolicy.Auto + : VersioningPolicy.Manual; +} + /** * Return the parsed global GitHub configuration */ diff --git a/src/schemas/projectConfig.schema.ts b/src/schemas/projectConfig.schema.ts index 0cc38f1e..8d433141 100644 --- a/src/schemas/projectConfig.schema.ts +++ b/src/schemas/projectConfig.schema.ts @@ -106,6 +106,43 @@ const projectConfigJsonSchema = { additionalProperties: false, required: ['name'], }, + versioning: { + title: 'VersioningConfig', + description: 'Version resolution configuration', + type: 'object', + properties: { + policy: { + title: 'VersioningPolicy', + description: + 'Default versioning policy when no version argument is provided. ' + + 'auto: analyze commits to determine bump type, ' + + 'manual: require explicit version, ' + + 'calver: use calendar versioning', + type: 'string', + enum: ['auto', 'manual', 'calver'], + tsEnumNames: ['Auto', 'Manual', 'CalVer'], + }, + calver: { + title: 'CalVerConfig', + description: 'Calendar versioning configuration', + type: 'object', + properties: { + offset: { + type: 'number', + description: 'Days to go back for date calculation (default: 14)', + }, + format: { + type: 'string', + description: + 'strftime-like format for date part (default: %y.%-m). ' + + 'Supports: %y (2-digit year), %m (zero-padded month), %-m (month without padding)', + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, }, additionalProperties: false, diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts index b22e58a3..8785c7c2 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -25,6 +25,7 @@ export interface CraftProjectConfig { requireNames?: string[]; statusProvider?: BaseStatusProvider; artifactProvider?: BaseArtifactProvider; + versioning?: VersioningConfig; } /** * Global (non-target!) GitHub configuration for the project @@ -62,6 +63,26 @@ export interface BaseArtifactProvider { [k: string]: any; }; } +/** + * Version resolution configuration + */ +export interface VersioningConfig { + policy?: VersioningPolicy; + calver?: CalVerConfig; +} +/** + * Calendar versioning configuration + */ +export interface CalVerConfig { + /** + * Days to go back for date calculation (default: 14) + */ + offset?: number; + /** + * strftime-like format for date part (default: %y.%-m). Supports: %y (2-digit year), %m (zero-padded month), %-m (month without padding) + */ + format?: string; +} /** * DEPRECATED: Use changelog.policy instead. Different policies for changelog management @@ -85,3 +106,11 @@ export const enum ArtifactProviderName { GitHub = 'github', None = 'none', } +/** + * Default versioning policy when no version argument is provided. auto: analyze commits to determine bump type, manual: require explicit version, calver: use calendar versioning + */ +export const enum VersioningPolicy { + Auto = 'auto', + Manual = 'manual', + CalVer = 'calver', +} diff --git a/src/targets/github.ts b/src/targets/github.ts index 563f118f..d4103bc5 100644 --- a/src/targets/github.ts +++ b/src/targets/github.ts @@ -18,6 +18,7 @@ import { isDryRun } from '../utils/helpers'; import { isPreviewRelease, parseVersion, + SemVer, versionGreaterOrEqualThan, versionToTag, } from '../utils/version'; @@ -42,6 +43,12 @@ export interface GitHubTargetConfig extends GitHubGlobalConfig { previewReleases: boolean; /** Do not create a full GitHub release, only push a git tag */ tagOnly: boolean; + /** + * Floating tags to create/update when publishing a release. + * Supports placeholders: {major}, {minor}, {patch} + * Example: "v{major}" creates a "v2" tag for version "2.15.0" + */ + floatingTags: string[]; } /** @@ -96,6 +103,7 @@ export class GitHubTarget extends BaseTarget { !!this.config.previewReleases, tagPrefix: this.config.tagPrefix || '', tagOnly: !!this.config.tagOnly, + floatingTags: this.config.floatingTags || [], }; this.github = getGitHubClient(); } @@ -376,6 +384,86 @@ export class GitHubTarget extends BaseTarget { } } + /** + * Resolves a floating tag pattern by replacing placeholders with version components. + * + * @param pattern The pattern string (e.g., "v{major}") + * @param parsedVersion The parsed semantic version + * @returns The resolved tag name (e.g., "v2") + */ + protected resolveFloatingTag(pattern: string, parsedVersion: SemVer): string { + return pattern + .replace('{major}', String(parsedVersion.major)) + .replace('{minor}', String(parsedVersion.minor)) + .replace('{patch}', String(parsedVersion.patch)); + } + + /** + * Creates or updates floating tags for the release. + * + * Floating tags (like "v2") point to the latest release in a major version line. + * They are force-updated if they already exist. + * + * @param version The version being released + * @param revision Git commit SHA to point the tags to + */ + protected async updateFloatingTags( + version: string, + revision: string + ): Promise { + const floatingTags = this.githubConfig.floatingTags; + if (!floatingTags || floatingTags.length === 0) { + return; + } + + const parsedVersion = parseVersion(version); + if (!parsedVersion) { + this.logger.warn( + `Cannot parse version "${version}" for floating tags, skipping` + ); + return; + } + + for (const pattern of floatingTags) { + const tag = this.resolveFloatingTag(pattern, parsedVersion); + const tagRef = `refs/tags/${tag}`; + + if (isDryRun()) { + this.logger.info( + `[dry-run] Not updating floating tag: "${tag}" (from pattern "${pattern}")` + ); + continue; + } + + this.logger.info(`Updating floating tag: "${tag}"...`); + + try { + // Try to update existing tag + await this.github.rest.git.updateRef({ + owner: this.githubConfig.owner, + repo: this.githubConfig.repo, + ref: `tags/${tag}`, + sha: revision, + force: true, + }); + this.logger.debug(`Updated existing floating tag: "${tag}"`); + } catch (error) { + // Tag doesn't exist, create it + if (error.status === 422) { + await this.github.rest.git.createRef({ + owner: this.githubConfig.owner, + repo: this.githubConfig.repo, + ref: tagRef, + sha: revision, + }); + this.logger.debug(`Created new floating tag: "${tag}"`); + } else { + throw error; + } + } + } + } + /** * Creates a new GitHub release and publish all available artifacts. * @@ -389,7 +477,9 @@ export class GitHubTarget extends BaseTarget { this.logger.info( `Not creating a GitHub release because "tagOnly" flag was set.` ); - return this.createGitTag(version, revision); + await this.createGitTag(version, revision); + await this.updateFloatingTags(version, revision); + return; } const config = getConfiguration(); @@ -449,6 +539,9 @@ export class GitHubTarget extends BaseTarget { ); await this.publishRelease(draftRelease, { makeLatest }); + + // Update floating tags (e.g., v2 for version 2.15.0) + await this.updateFloatingTags(version, revision); } } diff --git a/src/utils/__tests__/calver.test.ts b/src/utils/__tests__/calver.test.ts new file mode 100644 index 00000000..2afb073e --- /dev/null +++ b/src/utils/__tests__/calver.test.ts @@ -0,0 +1,199 @@ +import { formatCalVerDate, calculateCalVer, DEFAULT_CALVER_CONFIG } from '../calver'; + +// Mock the config module to control tagPrefix +jest.mock('../../config', () => ({ + getGitTagPrefix: jest.fn(() => ''), +})); + +import { getGitTagPrefix } from '../../config'; + +const mockGetGitTagPrefix = getGitTagPrefix as jest.Mock; + +describe('formatCalVerDate', () => { + it('formats %y as 2-digit year', () => { + const date = new Date('2024-12-15'); + expect(formatCalVerDate(date, '%y')).toBe('24'); + }); + + it('formats %Y as 4-digit year', () => { + const date = new Date('2024-12-15'); + expect(formatCalVerDate(date, '%Y')).toBe('2024'); + }); + + it('formats %m as zero-padded month', () => { + const date = new Date('2024-01-15'); + expect(formatCalVerDate(date, '%m')).toBe('01'); + + const date2 = new Date('2024-12-15'); + expect(formatCalVerDate(date2, '%m')).toBe('12'); + }); + + it('formats %-m as month without padding', () => { + const date = new Date('2024-01-15'); + expect(formatCalVerDate(date, '%-m')).toBe('1'); + + const date2 = new Date('2024-12-15'); + expect(formatCalVerDate(date2, '%-m')).toBe('12'); + }); + + it('formats %d as zero-padded day', () => { + const date = new Date('2024-12-05'); + expect(formatCalVerDate(date, '%d')).toBe('05'); + + const date2 = new Date('2024-12-25'); + expect(formatCalVerDate(date2, '%d')).toBe('25'); + }); + + it('formats %-d as day without padding', () => { + const date = new Date('2024-12-05'); + expect(formatCalVerDate(date, '%-d')).toBe('5'); + + const date2 = new Date('2024-12-25'); + expect(formatCalVerDate(date2, '%-d')).toBe('25'); + }); + + it('handles the default format %y.%-m', () => { + const date = new Date('2024-12-15'); + expect(formatCalVerDate(date, '%y.%-m')).toBe('24.12'); + + const date2 = new Date('2024-01-15'); + expect(formatCalVerDate(date2, '%y.%-m')).toBe('24.1'); + }); + + it('handles complex format strings', () => { + const date = new Date('2024-03-05'); + expect(formatCalVerDate(date, '%Y.%m.%d')).toBe('2024.03.05'); + expect(formatCalVerDate(date, '%y.%-m.%-d')).toBe('24.3.5'); + }); +}); + +describe('calculateCalVer', () => { + const mockGit = { + tags: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetGitTagPrefix.mockReturnValue(''); + // Mock Date to return a fixed date + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-12-23')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns first patch version when no tags exist', async () => { + mockGit.tags.mockResolvedValue({ all: [] }); + + const version = await calculateCalVer(mockGit as any, { + offset: 0, + format: '%y.%-m', + }); + + expect(version).toBe('24.12.0'); + }); + + it('increments patch version when tag exists', async () => { + mockGit.tags.mockResolvedValue({ all: ['24.12.0'] }); + + const version = await calculateCalVer(mockGit as any, { + offset: 0, + format: '%y.%-m', + }); + + expect(version).toBe('24.12.1'); + }); + + it('finds the highest patch and increments', async () => { + mockGit.tags.mockResolvedValue({ all: ['24.12.0', '24.12.1', '24.12.2'] }); + + const version = await calculateCalVer(mockGit as any, { + offset: 0, + format: '%y.%-m', + }); + + expect(version).toBe('24.12.3'); + }); + + it('ignores tags from different date parts', async () => { + mockGit.tags.mockResolvedValue({ all: ['24.11.0', '24.11.1', '23.12.0'] }); + + const version = await calculateCalVer(mockGit as any, { + offset: 0, + format: '%y.%-m', + }); + + expect(version).toBe('24.12.0'); + }); + + it('applies offset correctly', async () => { + // Date is 2024-12-23, with 14 day offset should be 2024-12-09 (still December) + mockGit.tags.mockResolvedValue({ all: [] }); + + const version = await calculateCalVer(mockGit as any, { + offset: 14, + format: '%y.%-m', + }); + + expect(version).toBe('24.12.0'); + }); + + it('applies large offset that changes month', async () => { + // Date is 2024-12-23, with 30 day offset should be 2024-11-23 + mockGit.tags.mockResolvedValue({ all: [] }); + + const version = await calculateCalVer(mockGit as any, { + offset: 30, + format: '%y.%-m', + }); + + expect(version).toBe('24.11.0'); + }); + + it('handles non-numeric patch suffixes gracefully', async () => { + mockGit.tags.mockResolvedValue({ all: ['24.12.0', '24.12.beta', '24.12.1'] }); + + const version = await calculateCalVer(mockGit as any, { + offset: 0, + format: '%y.%-m', + }); + + expect(version).toBe('24.12.2'); + }); + + it('uses default config values', () => { + expect(DEFAULT_CALVER_CONFIG.offset).toBe(14); + expect(DEFAULT_CALVER_CONFIG.format).toBe('%y.%-m'); + }); + + it('accounts for git tag prefix when searching for existing tags', async () => { + // When tagPrefix is 'v', tags are like 'v24.12.0' + mockGetGitTagPrefix.mockReturnValue('v'); + mockGit.tags.mockResolvedValue({ all: ['v24.12.0', 'v24.12.1'] }); + + const version = await calculateCalVer(mockGit as any, { + offset: 0, + format: '%y.%-m', + }); + + // Should find v24.12.1 and increment to 24.12.2 + expect(version).toBe('24.12.2'); + }); + + it('ignores tags without the configured prefix', async () => { + mockGetGitTagPrefix.mockReturnValue('v'); + // Mix of prefixed and non-prefixed tags + mockGit.tags.mockResolvedValue({ all: ['24.12.5', 'v24.12.0', 'v24.12.1'] }); + + const version = await calculateCalVer(mockGit as any, { + offset: 0, + format: '%y.%-m', + }); + + // Should only find v24.12.0 and v24.12.1, increment to 24.12.2 + // The non-prefixed '24.12.5' should be ignored + expect(version).toBe('24.12.2'); + }); +}); diff --git a/src/utils/calver.ts b/src/utils/calver.ts new file mode 100644 index 00000000..4ecccd9f --- /dev/null +++ b/src/utils/calver.ts @@ -0,0 +1,101 @@ +import type { SimpleGit } from 'simple-git'; + +import { getGitTagPrefix } from '../config'; +import { logger } from '../logger'; + +/** + * Configuration for CalVer versioning + */ +export interface CalVerConfig { + /** Days to go back for date calculation */ + offset: number; + /** strftime-like format for date part */ + format: string; +} + +/** + * Default CalVer configuration + */ +export const DEFAULT_CALVER_CONFIG: CalVerConfig = { + offset: 14, + format: '%y.%-m', +}; + +/** + * Formats a date according to a strftime-like format string. + * + * Supported format specifiers: + * - %y: 2-digit year (e.g., "24" for 2024) + * - %Y: 4-digit year (e.g., "2024") + * - %m: Zero-padded month (e.g., "01" for January) + * - %-m: Month without zero padding (e.g., "1" for January) + * - %d: Zero-padded day (e.g., "05") + * - %-d: Day without zero padding (e.g., "5") + * + * @param date The date to format + * @param format The format string + * @returns The formatted date string + */ +export function formatCalVerDate(date: Date, format: string): string { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return format + .replace('%Y', String(year)) + .replace('%y', String(year).slice(-2)) + .replace('%-m', String(month)) + .replace('%m', String(month).padStart(2, '0')) + .replace('%-d', String(day)) + .replace('%d', String(day).padStart(2, '0')); +} + +/** + * Calculates the next CalVer version based on existing tags. + * + * The version format is: {datePart}.{patch} + * For example, with format '%y.%-m' and no existing tags: "24.12.0" + * + * @param git SimpleGit instance for checking existing tags + * @param config CalVer configuration + * @returns The next CalVer version string + */ +export async function calculateCalVer( + git: SimpleGit, + config: CalVerConfig +): Promise { + // Calculate date with offset + const date = new Date(); + date.setDate(date.getDate() - config.offset); + + // Format date part + const datePart = formatCalVerDate(date, config.format); + + logger.debug(`CalVer: using date ${date.toISOString()}, date part: ${datePart}`); + + // Find existing tags and determine next patch version + // Account for git tag prefix (e.g., 'v') when searching + const gitTagPrefix = getGitTagPrefix(); + const searchPrefix = `${gitTagPrefix}${datePart}.`; + + logger.debug(`CalVer: searching for tags with prefix: ${searchPrefix}`); + + const tags = await git.tags(); + let patch = 0; + + // Find the highest patch version for this date part + for (const tag of tags.all) { + if (tag.startsWith(searchPrefix)) { + const patchStr = tag.slice(searchPrefix.length); + const patchNum = parseInt(patchStr, 10); + if (!isNaN(patchNum) && patchNum >= patch) { + patch = patchNum + 1; + } + } + } + + const version = `${datePart}.${patch}`; + logger.info(`CalVer: determined version ${version}`); + + return version; +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 4393df63..da95e731 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -64,11 +64,23 @@ export function hasInput(): boolean { /** * Sets a GitHub Actions output variable. + * Automatically uses heredoc-style delimiter syntax for multiline values. * No-op when not running in GitHub Actions. */ export function setGitHubActionsOutput(name: string, value: string): void { const outputFile = process.env.GITHUB_OUTPUT; - if (outputFile) { + if (!outputFile) { + return; + } + + if (value.includes('\n')) { + // Use heredoc-style delimiter for multiline values + const delimiter = `EOF_${Date.now()}_${Math.random().toString(36).slice(2)}`; + appendFileSync( + outputFile, + `${name}<<${delimiter}\n${value}\n${delimiter}\n` + ); + } else { appendFileSync(outputFile, `${name}=${value}\n`); } }