diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b8d187e6f..a3ea9e7a87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,22 +145,35 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if gh release view "${{ needs.check-publish.outputs.tag }}" > /dev/null 2>&1; then - echo "GitHub release ${{ needs.check-publish.outputs.tag }} already exists. Skipping release creation." + tag="${{ needs.check-publish.outputs.tag }}" + + if gh release view "${tag}" > /dev/null 2>&1; then + echo "GitHub release ${tag} already exists. Skipping release creation." exit 0 fi + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" > /dev/null; then + echo "Tag ${tag} already exists on origin." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${tag}" "${GITHUB_SHA}" -m "${tag}" + gh auth setup-git + git push origin "refs/tags/${tag}" + echo "Created annotated tag ${tag} at ${GITHUB_SHA}." + fi + release_notes_file="./artifacts/release-notes.md" if [ "${{ needs.check-publish.outputs.prerelease }}" = "true" ]; then - gh release create "${{ needs.check-publish.outputs.tag }}" \ - --target "${GITHUB_SHA}" \ - --title "${{ needs.check-publish.outputs.tag }}" \ + gh release create "${tag}" \ + --verify-tag \ + --title "${tag}" \ --notes-file "${release_notes_file}" \ --prerelease else - gh release create "${{ needs.check-publish.outputs.tag }}" \ - --target "${GITHUB_SHA}" \ - --title "${{ needs.check-publish.outputs.tag }}" \ + gh release create "${tag}" \ + --verify-tag \ + --title "${tag}" \ --notes-file "${release_notes_file}" fi diff --git a/resources/gen-changelog.ts b/resources/gen-changelog.ts index 590ba10b4b..ca6dfb758c 100644 --- a/resources/gen-changelog.ts +++ b/resources/gen-changelog.ts @@ -70,79 +70,107 @@ function parseFromRevArg(rawArgs: ReadonlyArray): string | null { ); } -function resolveChangelogRangeConfig( +function getTaggedVersionCommit(version: string): string | null { + const tag = `v${version}`; + if (!git().tagExists(tag)) { + return null; + } + return git({ quiet: true }).revParse(`${tag}^{}`); +} + +function getFirstParentCommit(commit: string): string | null { + const [commitWithParents] = git().revList('--parents', '-n', '1', commit); + if (commitWithParents == null) { + return null; + } + + const [, firstParent] = commitWithParents.split(' '); + return firstParent ?? null; +} + +function resolveCommitRefOrThrow(ref: string): string { + try { + return git().revParse(ref); + } catch (error) { + throw new Error( + `Unable to resolve fromRev "${ref}" to a local commit. ` + + 'Pass a reachable first-parent revision:\n' + + ' npm run changelog -- ', + { cause: error }, + ); + } +} + +function resolveChangeLogConfig( workingTreeVersion: string, fromRev: string | null, ): { title: string; - rangeStart: string; - rangeEnd: string; + commitsList: Array; } { const workingTreeReleaseTag = `v${workingTreeVersion}`; + const title = git().tagExists(workingTreeReleaseTag) + ? 'Unreleased' + : workingTreeReleaseTag; + + const commitsList: Array = []; + let rangeStart = + fromRev != null + ? resolveCommitRefOrThrow(fromRev) + : getTaggedVersionCommit(workingTreeVersion); + + let rangeStartReached = false; + let lastCheckedVersion = workingTreeVersion; + let newerCommit: string | null = null; + let newerVersion: string | null = null; + let commit: string | null = git().revParse('HEAD'); + + while (commit != null) { + const commitVersion = readPackageJSONAtRef(commit).version; + + if (rangeStart == null && commitVersion !== lastCheckedVersion) { + rangeStart = getTaggedVersionCommit(commitVersion); + lastCheckedVersion = commitVersion; + } - // packageJSON in the working tree can differ from HEAD:package.json during - // release:prepare after npm version updates files but before committing. - // Supported scenario 1: release preparation not started - // - working-tree version tag exists - // - HEAD version older than or equal to working-tree version, must also exist - if (git().tagExists(workingTreeReleaseTag)) { - return { - title: 'Unreleased', - rangeStart: fromRev ?? workingTreeReleaseTag, - rangeEnd: 'HEAD', - }; - } + if (newerCommit != null && newerVersion === commitVersion) { + commitsList.push(newerCommit); + } + + if (rangeStart != null && commit === rangeStart) { + rangeStartReached = true; + break; + } - const headVersion = readPackageJSONAtRef('HEAD').version; - const headReleaseTag = `v${headVersion}`; - - // Supported scenario 2: release preparation started - // - working-tree version tag not yet created - // - HEAD version tag exists - if (git().tagExists(headReleaseTag)) { - return { - title: workingTreeReleaseTag, - rangeStart: fromRev ?? headReleaseTag, - rangeEnd: 'HEAD', - }; + newerCommit = commit; + newerVersion = commitVersion; + commit = getFirstParentCommit(commit); } - // Supported scenario 3: - // - release preparation committed - // - working-tree version tag equal to HEAD version tag, both not yet created - // - HEAD~1 version tag exists - const parentVersion = readPackageJSONAtRef('HEAD~1').version; - const parentTag = `v${parentVersion}`; - const parentTagExists = git().tagExists(parentTag); - if (workingTreeReleaseTag === headReleaseTag && parentTagExists) { - console.warn(`Release committed, should already contain this changelog!`); - - return { - title: workingTreeReleaseTag, - rangeStart: fromRev ?? parentTag, - rangeEnd: 'HEAD~1', - }; + if (rangeStart == null || !rangeStartReached) { + throw new Error( + 'Unable to determine changelog range from local first-parent history.\n' + + 'This can happen with a shallow clone, missing tags, or an unreachable fromRev.\n' + + 'Fetch more history/tags (for example, "git fetch --tags --deepen=200") ' + + 'or pass an explicit reachable first-parent fromRev:\n' + + ' npm run changelog -- ', + ); } - throw new Error( - 'Unable to determine changelog range. One of the following scenarios must be true:\n' + - `1) HEAD/working-tree release tags exist, i.e. release preparation not started.\n` + - `2) HEAD release tag exists, but working-tree release tag not yet created, i.e. release preparation started, not yet committed.\n` + - `3) HEAD/working-tree release tags not yet created, i.e. release preparation committed, not yet released, no additional commits on branch.`, - ); + return { + title, + commitsList: commitsList.reverse(), + }; } async function genChangeLog(): Promise { const workingTreeVersion = packageJSON.version; const fromRev = parseFromRevArg(process.argv.slice(2)); - const { title, rangeStart, rangeEnd } = resolveChangelogRangeConfig( + const { title, commitsList } = resolveChangeLogConfig( workingTreeVersion, fromRev, ); - const commitsRange = `${rangeStart}..${rangeEnd}`; - const commitsList = git().revList('--reverse', commitsRange); - const allPRs = await getPRsInfo(commitsList); const date = git().log('-1', '--format=%cd', '--date=short'); diff --git a/resources/release-prepare.ts b/resources/release-prepare.ts index 0acd63706b..ad9bccb449 100644 --- a/resources/release-prepare.ts +++ b/resources/release-prepare.ts @@ -123,9 +123,12 @@ function validateBranchState(releaseBranch: string): void { let releaseBranchHead: string; try { releaseBranchHead = git({ quiet: true }).revParse(releaseBranch); - } catch { + } catch (error) { throw new Error( `Release branch "${releaseBranch}" does not exist locally.`, + { + cause: error, + }, ); } @@ -135,33 +138,55 @@ function validateBranchState(releaseBranch: string): void { '--abbrev-ref', `${releaseBranch}@{upstream}`, ); - } catch { + } catch (error) { throw new Error( `Release branch "${releaseBranch}" does not track a remote branch. ` + 'Set one first (for example: git branch --set-upstream-to ' + `/${releaseBranch} ${releaseBranch}).`, + { cause: error }, ); } const upstreamRemote = releaseBranchUpstream.split('/')[0]; try { - git().fetch('--quiet', upstreamRemote, releaseBranch); - } catch { + git().fetch('--quiet', '--tags', upstreamRemote, releaseBranch); + } catch (error) { throw new Error( - `Failed to fetch "${releaseBranchUpstream}". ` + - 'Verify network access and git remote configuration, then retry.', + `Failed to fetch "${releaseBranchUpstream}" and tags from "${upstreamRemote}". ` + + 'Check remote access, authentication, git remote configuration, ' + + 'and local/remote tag state.', + { cause: error }, ); } const upstreamReleaseBranchHead = git({ quiet: true }).revParse( `${releaseBranch}@{upstream}`, ); - if (releaseBranchHead !== upstreamReleaseBranchHead) { + const localOnlyCommits = git().revList( + `${upstreamReleaseBranchHead}..${releaseBranchHead}`, + ); + const upstreamOnlyCommits = git().revList( + `${releaseBranchHead}..${upstreamReleaseBranchHead}`, + ); + if (localOnlyCommits.length > 0 && upstreamOnlyCommits.length > 0) { + throw new Error( + `Local "${releaseBranch}" has diverged from "${releaseBranchUpstream}". ` + + 'Resolve conflicts and synchronize first (for example: ' + + `git switch ${releaseBranch} && git pull --rebase).`, + ); + } + if (upstreamOnlyCommits.length > 0) { throw new Error( - `Local "${releaseBranch}" is not up to date with "${releaseBranchUpstream}". ` + + `Local "${releaseBranch}" is behind "${releaseBranchUpstream}". ` + `Update it first (for example: git switch ${releaseBranch} && git pull --ff-only).`, ); } + if (localOnlyCommits.length > 0) { + throw new Error( + `Local "${releaseBranch}" is ahead of "${releaseBranchUpstream}". ` + + `Push or reset it before release prepare (for example: git switch ${releaseBranch} && git push).`, + ); + } const currentHead = git({ quiet: true }).revParse('HEAD'); if (currentHead !== releaseBranchHead) {