From 4887c05c810bcf85722b7be620294cf603fa4f94 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 23 Dec 2025 21:22:02 +0000 Subject: [PATCH 1/6] feat(github): Integrate action-prepare-release into Craft repo Move the action-prepare-release GitHub Action into the Craft repository root, enabling usage as `getsentry/craft@v2`. This provides better version alignment and allows Craft to dogfood its own action. - Add `floatingTags` config option to create/update floating tags (e.g., v2) - Support placeholders: {major}, {minor}, {patch} - Example: `floatingTags: ['v{major}']` creates v2 tag for version 2.15.0 - Parametrized inputs replacing hardcoded Sentry-specific values: - `blocker_label` (default: release-blocker) - `publish_repo` (default: ${{ github.repository_owner }}/publish) - `git_user_name`, `git_user_email` (default to GITHUB_ACTOR) - Smart Craft installation: - Downloads from build artifact when in getsentry/craft repo - Falls back to release download using github.action_ref - Outputs resolved version for downstream steps - build.yml: Add workflow_call trigger for reusability - release.yml: Call build workflow first, then use local action for dogfooding - Add `floatingTags: ['v{major}']` to github target in .craft.yml Repos using `getsentry/action-prepare-release@v1` can migrate to: ```yaml uses: getsentry/craft@v2 with: version: auto git_user_name: getsentry-bot # if needed git_user_email: bot@sentry.io # if needed ``` --- .craft.yml | 2 + .github/workflows/build.yml | 1 + .github/workflows/release.yml | 12 +- action.yml | 223 ++++++++++++++++++++++++++++++++++ src/commands/prepare.ts | 34 +++++- src/config.ts | 15 +++ src/targets/github.ts | 95 ++++++++++++++- 7 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 action.yml diff --git a/.craft.yml b/.craft.yml index 10759f3a..1e331b98 100644 --- a/.craft.yml +++ b/.craft.yml @@ -42,4 +42,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..b0ec492f --- /dev/null +++ b/action.yml @@ -0,0 +1,223 @@ +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 }} + +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 + + 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 }} + + Targets marked with a checkbox have already been executed. Administrators can manually tick a checkbox to force craft to skip it. + " + gh issue create -R "$PUBLISH_REPO" --title "$title" --body "$body" diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 12451551..744d0634 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -9,6 +9,8 @@ import { DEFAULT_RELEASE_BRANCH_NAME, getGlobalGitHubConfig, requiresMinVersion, + loadConfigurationFromString, + CONFIG_FILE_NAME, } from '../config'; import { logger } from '../logger'; import { ChangelogPolicy } from '../schemas/project_config'; @@ -95,6 +97,10 @@ 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', + }) .check(checkVersionOrPart); /** Command line options. */ @@ -113,6 +119,8 @@ interface PrepareOptions { noPush: boolean; /** Run publish right after */ publish: boolean; + /** Load config from specified remote branch */ + configFrom?: string; } /** @@ -496,13 +504,29 @@ async function switchToDefaultBranch( * @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}`); + await git.fetch([argv.remote, argv.configFrom]); + try { + const configContent = await git.show([ + `${argv.remote}/${argv.configFrom}:${CONFIG_FILE_NAME}`, + ]); + loadConfigurationFromString(configContent); + } catch (error) { + 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(); let newVersion = argv.newVersion; - const git = await getGitClient(); - const defaultBranch = await getDefaultBranch(git, argv.remote); logger.debug(`Default branch for the repo:`, defaultBranch); const repoStatus = await git.status(); @@ -617,6 +641,12 @@ 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 || ''); + 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..3c64a93f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -162,6 +162,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 * 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); } } From fce2ee0734a8ea79a422031e2a574e4ee8a3fbd5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 23 Dec 2025 22:05:25 +0000 Subject: [PATCH 2/6] feat(versioning): Add CalVer support and versioning policy configuration Add a new `versioning` configuration option to `.craft.yml` that supports `auto`, `manual`, and `calver` policies for version resolution. ## New Configuration ```yaml versioning: policy: auto | manual | calver # default based on minVersion calver: offset: 14 # days to go back (default: 14) format: '%y.%-m' # strftime-like format (default: '%y.%-m') ``` ## Behavior - `craft prepare` (no arg): Uses `versioning.policy` from config - `auto`: Analyze commits to determine bump type - `manual`: Error if no version specified - `calver`: Use calendar versioning - `craft prepare calver`: Explicit CalVer override - `craft prepare auto`: Explicit auto-versioning override - `craft prepare 1.2.3`: Explicit version always works ## Policy Defaults - `auto` if `minVersion >= 2.14.0` - `manual` otherwise (backward compatible) ## New CLI Options - `--calver-offset`: Override CalVer offset (days to go back) ## Environment Variables - `CRAFT_CALVER_OFFSET`: Override CalVer offset ## CalVer Format Supports strftime-like placeholders: - `%y`: 2-digit year (e.g., "24") - `%Y`: 4-digit year (e.g., "2024") - `%m`: Zero-padded month (e.g., "01") - `%-m`: Month without padding (e.g., "1") - `%d`: Zero-padded day - `%-d`: Day without padding Example: `%y.%-m` produces "24.12" for December 2024 --- action.yml | 69 +++++++----- src/commands/prepare.ts | 88 +++++++++++++-- src/config.ts | 33 ++++++ src/schemas/projectConfig.schema.ts | 37 +++++++ src/schemas/project_config.ts | 29 +++++ src/utils/__tests__/calver.test.ts | 160 ++++++++++++++++++++++++++++ src/utils/calver.ts | 95 +++++++++++++++++ src/utils/helpers.ts | 14 ++- 8 files changed, 489 insertions(+), 36 deletions(-) create mode 100644 src/utils/__tests__/calver.test.ts create mode 100644 src/utils/calver.ts diff --git a/action.yml b/action.yml index b0ec492f..31ebe3f0 100644 --- a/action.yml +++ b/action.yml @@ -49,6 +49,9 @@ outputs: 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" @@ -68,14 +71,14 @@ runs: # 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 @@ -103,16 +106,16 @@ runs: # 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 @@ -126,19 +129,19 @@ runs: 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 @@ -149,7 +152,7 @@ runs: 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" @@ -163,28 +166,28 @@ runs: 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 @@ -192,32 +195,48 @@ runs: 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 }} - - Targets marked with a checkbox have already been executed. Administrators can manually tick a checkbox to force craft to skip it. - " + + 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 744d0634..505cbcc5 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -11,9 +11,11 @@ import { 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, @@ -62,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', { @@ -101,12 +104,16 @@ export const builder: CommandBuilder = (yargs: Argv) => 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 */ @@ -121,6 +128,8 @@ interface PrepareOptions { publish: boolean; /** Load config from specified remote branch */ configFrom?: string; + /** Override CalVer offset (days to go back) */ + calverOffset?: number; } /** @@ -133,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 @@ -142,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; @@ -385,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, @@ -392,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 ( @@ -474,6 +495,7 @@ async function prepareChangelog( logger.debug('Changelog entry found:', changeset.name); logger.trace(changeset.body); + return changeset?.body; } /** @@ -539,6 +561,49 @@ export async function prepareMain(argv: PrepareOptions): Promise { checkGitStatus(repoStatus, rev); } + // If no version specified, use the versioning policy from config + if (!newVersion) { + const policy = getVersioningPolicy(); + logger.debug(`No version specified, using versioning policy: ${policy}`); + + 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' + ); + } + + // Use the policy as the version type + newVersion = policy; + } + + // Handle CalVer versioning + if (newVersion === '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.' + ); + } + + // Build CalVer config with overrides + const calverOffset = + argv.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; + + newVersion = await calculateCalVer(git, { + offset: calverOffset, + format: calverFormat, + }); + } + // Handle automatic version detection or version bump types const isVersionBumpType = isBumpType(newVersion); @@ -616,7 +681,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, @@ -646,6 +711,9 @@ export async function prepareMain(argv: PrepareOptions): Promise { 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 3c64a93f..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 { @@ -245,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/utils/__tests__/calver.test.ts b/src/utils/__tests__/calver.test.ts new file mode 100644 index 00000000..148b917f --- /dev/null +++ b/src/utils/__tests__/calver.test.ts @@ -0,0 +1,160 @@ +import { formatCalVerDate, calculateCalVer, DEFAULT_CALVER_CONFIG } from '../calver'; + +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(); + // 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'); + }); +}); diff --git a/src/utils/calver.ts b/src/utils/calver.ts new file mode 100644 index 00000000..163ca7e8 --- /dev/null +++ b/src/utils/calver.ts @@ -0,0 +1,95 @@ +import type { SimpleGit } from 'simple-git'; + +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 + const tags = await git.tags(); + let patch = 0; + + // Find the highest patch version for this date part + const tagPrefix = `${datePart}.`; + for (const tag of tags.all) { + if (tag.startsWith(tagPrefix)) { + const patchStr = tag.slice(tagPrefix.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`); } } From f4402f05296f96a58273907018a1c03aafbb7c3c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 23 Dec 2025 22:36:25 +0000 Subject: [PATCH 3/6] fix bug in changelog config --- .craft.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.craft.yml b/.craft.yml index 1e331b98..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'); From 0c7df3d330d5c7ee4e38033eb2a98a8c499424e9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 23 Dec 2025 22:42:45 +0000 Subject: [PATCH 4/6] simplify version resolution logic --- src/commands/prepare.ts | 140 +++++++++++++++++++++++++--------------- 1 file changed, 88 insertions(+), 52 deletions(-) diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 505cbcc5..8b823d88 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -520,49 +520,36 @@ 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 { - const git = await getGitClient(); - - // Handle --config-from: load config from remote branch - if (argv.configFrom) { - logger.info(`Loading configuration from remote branch: ${argv.configFrom}`); - await git.fetch([argv.remote, argv.configFrom]); - try { - const configContent = await git.show([ - `${argv.remote}/${argv.configFrom}:${CONFIG_FILE_NAME}`, - ]); - loadConfigurationFromString(configContent); - } catch (error) { - throw new ConfigurationError( - `Failed to load ${CONFIG_FILE_NAME} from branch "${argv.configFrom}": ${error.message}` - ); - } - } - - // Get repo configuration +async function resolveVersion( + git: SimpleGit, + options: ResolveVersionOptions +): Promise { const config = getConfiguration(); - const githubConfig = await getGlobalGitHubConfig(); - let newVersion = argv.newVersion; - - 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); - } + let version = options.versionArg; // If no version specified, use the versioning policy from config - if (!newVersion) { + if (!version) { const policy = getVersioningPolicy(); logger.debug(`No version specified, using versioning policy: ${policy}`); @@ -574,11 +561,11 @@ export async function prepareMain(argv: PrepareOptions): Promise { } // Use the policy as the version type - newVersion = policy; + version = policy; } // Handle CalVer versioning - if (newVersion === 'calver') { + if (version === 'calver') { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { throw new ConfigurationError( `CalVer versioning requires minVersion >= ${AUTO_VERSION_MIN_VERSION} in .craft.yml. ` + @@ -588,7 +575,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { // Build CalVer config with overrides const calverOffset = - argv.calverOffset ?? + options.calverOffset ?? (process.env.CRAFT_CALVER_OFFSET ? parseInt(process.env.CRAFT_CALVER_OFFSET, 10) : undefined) ?? @@ -598,18 +585,16 @@ export async function prepareMain(argv: PrepareOptions): Promise { const calverFormat = config.versioning?.calver?.format ?? DEFAULT_CALVER_CONFIG.format; - newVersion = await calculateCalVer(git, { + return calculateCalVer(git, { offset: calverOffset, format: calverFormat, }); } // Handle automatic version detection or version bump types - const isVersionBumpType = isBumpType(newVersion); - - if (newVersion === 'auto' || isVersionBumpType) { + 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( @@ -621,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 @@ -638,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}`); + await git.fetch([argv.remote, argv.configFrom]); + try { + const configContent = await git.show([ + `${argv.remote}/${argv.configFrom}:${CONFIG_FILE_NAME}`, + ]); + loadConfigurationFromString(configContent); + } catch (error) { + 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); From 6f3d8462587be76672300f4a9ce47c967db2317e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 23 Dec 2025 23:11:09 +0000 Subject: [PATCH 5/6] fix seer identified bug --- src/utils/__tests__/calver.test.ts | 39 ++++++++++++++++++++++++++++++ src/utils/calver.ts | 12 ++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/calver.test.ts b/src/utils/__tests__/calver.test.ts index 148b917f..2afb073e 100644 --- a/src/utils/__tests__/calver.test.ts +++ b/src/utils/__tests__/calver.test.ts @@ -1,5 +1,14 @@ 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'); @@ -65,6 +74,7 @@ describe('calculateCalVer', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetGitTagPrefix.mockReturnValue(''); // Mock Date to return a fixed date jest.useFakeTimers(); jest.setSystemTime(new Date('2024-12-23')); @@ -157,4 +167,33 @@ describe('calculateCalVer', () => { 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 index 163ca7e8..4ecccd9f 100644 --- a/src/utils/calver.ts +++ b/src/utils/calver.ts @@ -1,5 +1,6 @@ import type { SimpleGit } from 'simple-git'; +import { getGitTagPrefix } from '../config'; import { logger } from '../logger'; /** @@ -73,14 +74,19 @@ export async function calculateCalVer( 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 - const tagPrefix = `${datePart}.`; for (const tag of tags.all) { - if (tag.startsWith(tagPrefix)) { - const patchStr = tag.slice(tagPrefix.length); + if (tag.startsWith(searchPrefix)) { + const patchStr = tag.slice(searchPrefix.length); const patchNum = parseInt(patchStr, 10); if (!isNaN(patchNum) && patchNum >= patch) { patch = patchNum + 1; From 2195ee4347c6919acf36ea578fede69dabc9a848 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 23 Dec 2025 23:35:16 +0000 Subject: [PATCH 6/6] fix another bug --- src/commands/prepare.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 8b823d88..d2ed758f 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -643,13 +643,13 @@ export async function prepareMain(argv: PrepareOptions): Promise { // Handle --config-from: load config from remote branch if (argv.configFrom) { logger.info(`Loading configuration from remote branch: ${argv.configFrom}`); - await git.fetch([argv.remote, 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) { + } catch (error: any) { throw new ConfigurationError( `Failed to load ${CONFIG_FILE_NAME} from branch "${argv.configFrom}": ${error.message}` );