diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 190389bd9f..375b882810 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,22 +142,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.js b/resources/gen-changelog.js index 654a594caf..a11aa31459 100644 --- a/resources/gen-changelog.js +++ b/resources/gen-changelog.js @@ -70,14 +70,10 @@ getChangeLog() function getChangeLog() { 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 commitsListOutput = exec(`git rev-list --reverse ${commitsRange}`); - const commitsList = - commitsListOutput === '' ? [] : commitsListOutput.split('\n'); const date = exec('git log -1 --format=%cd --date=short'); return getCommitsInfo(commitsList) @@ -100,59 +96,91 @@ function parseFromRevArg(rawArgs) { ); } -function resolveChangelogRangeConfig(workingTreeVersion, fromRev) { - const workingTreeReleaseTag = `v${workingTreeVersion}`; +function getTaggedVersionCommit(version) { + const tag = `v${version}`; + if (!tagExists(tag)) { + return null; + } + return exec(`git rev-parse ${tag}^{}`); +} - // 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 (tagExists(workingTreeReleaseTag)) { - return { - title: 'Unreleased', - rangeStart: fromRev || workingTreeReleaseTag, - rangeEnd: 'HEAD', - }; +function getFirstParentCommit(commit) { + const commitWithParents = exec(`git rev-list --parents -n 1 ${commit}`); + if (commitWithParents === '') { + return null; } - 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 (tagExists(headReleaseTag)) { - return { - title: workingTreeReleaseTag, - rangeStart: fromRev || headReleaseTag, - rangeEnd: 'HEAD', - }; + const [, firstParent] = commitWithParents.split(' '); + return firstParent || null; +} + +function resolveCommitRefOrThrow(ref) { + try { + return exec(`git rev-parse ${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 }, + ); } +} - // 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 = tagExists(parentTag); - if (workingTreeReleaseTag === headReleaseTag && parentTagExists) { - console.warn('Release committed, should already contain this changelog!'); - - return { - title: workingTreeReleaseTag, - rangeStart: fromRev || parentTag, - rangeEnd: 'HEAD~1', - }; +function resolveChangeLogConfig(workingTreeVersion, fromRev) { + const workingTreeReleaseTag = `v${workingTreeVersion}`; + const title = tagExists(workingTreeReleaseTag) + ? 'Unreleased' + : workingTreeReleaseTag; + + const commitsList = []; + let rangeStart = + fromRev != null + ? resolveCommitRefOrThrow(fromRev) + : getTaggedVersionCommit(workingTreeVersion); + + let rangeStartReached = false; + let lastCheckedVersion = workingTreeVersion; + let newerCommit = null; + let newerVersion = null; + let commit = exec('git rev-parse HEAD'); + + while (commit != null) { + const commitVersion = readPackageJSONAtRef(commit).version; + + if (rangeStart == null && commitVersion !== lastCheckedVersion) { + rangeStart = getTaggedVersionCommit(commitVersion); + lastCheckedVersion = commitVersion; + } + + if (newerCommit != null && newerVersion === commitVersion) { + commitsList.push(newerCommit); + } + + if (rangeStart != null && commit === rangeStart) { + rangeStartReached = true; + break; + } + + newerCommit = commit; + newerVersion = commitVersion; + commit = getFirstParentCommit(commit); } - 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.', - ); + 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 -- ', + ); + } + + return { + title, + commitsList: commitsList.reverse(), + }; } function genChangeLog(title, date, allPRs) { diff --git a/resources/release-prepare.js b/resources/release-prepare.js index baa46c8955..041023cd18 100644 --- a/resources/release-prepare.js +++ b/resources/release-prepare.js @@ -125,9 +125,12 @@ function validateBranchState(releaseBranch) { let releaseBranchHead; try { releaseBranchHead = spawnOutput('git', ['rev-parse', releaseBranch]); - } catch { + } catch (error) { throw new Error( `Release branch "${releaseBranch}" does not exist locally.`, + { + cause: error, + }, ); } @@ -138,21 +141,24 @@ function validateBranchState(releaseBranch) { '--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 { - spawn('git', ['fetch', '--quiet', upstreamRemote, releaseBranch]); - } catch { + spawn('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 }, ); } @@ -160,12 +166,37 @@ function validateBranchState(releaseBranch) { 'rev-parse', `${releaseBranch}@{upstream}`, ]); - if (releaseBranchHead !== upstreamReleaseBranchHead) { + const localOnlyCommitsRaw = spawnOutput('git', [ + 'rev-list', + `${upstreamReleaseBranchHead}..${releaseBranchHead}`, + ]); + const upstreamOnlyCommitsRaw = spawnOutput('git', [ + 'rev-list', + `${releaseBranchHead}..${upstreamReleaseBranchHead}`, + ]); + const localOnlyCommits = + localOnlyCommitsRaw === '' ? [] : localOnlyCommitsRaw.split('\n'); + const upstreamOnlyCommits = + upstreamOnlyCommitsRaw === '' ? [] : upstreamOnlyCommitsRaw.split('\n'); + 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 = spawnOutput('git', ['rev-parse', 'HEAD']); if (currentHead !== releaseBranchHead) {