Skip to content

Commit 6ca59e1

Browse files
authored
backport: internal: streamline release process (#4615) (#4626)
- use annotated tag for release - make changelog generation more robust - improve logging
1 parent df8c53f commit 6ca59e1

File tree

3 files changed

+139
-67
lines changed

3 files changed

+139
-67
lines changed

.github/workflows/release.yml

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,22 +142,35 @@ jobs:
142142
env:
143143
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
144144
run: |
145-
if gh release view "${{ needs.check-publish.outputs.tag }}" > /dev/null 2>&1; then
146-
echo "GitHub release ${{ needs.check-publish.outputs.tag }} already exists. Skipping release creation."
145+
tag="${{ needs.check-publish.outputs.tag }}"
146+
147+
if gh release view "${tag}" > /dev/null 2>&1; then
148+
echo "GitHub release ${tag} already exists. Skipping release creation."
147149
exit 0
148150
fi
149151
152+
if git ls-remote --exit-code --tags origin "refs/tags/${tag}" > /dev/null; then
153+
echo "Tag ${tag} already exists on origin."
154+
else
155+
git config user.name "github-actions[bot]"
156+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
157+
git tag -a "${tag}" "${GITHUB_SHA}" -m "${tag}"
158+
gh auth setup-git
159+
git push origin "refs/tags/${tag}"
160+
echo "Created annotated tag ${tag} at ${GITHUB_SHA}."
161+
fi
162+
150163
release_notes_file="./artifacts/release-notes.md"
151164
152165
if [ "${{ needs.check-publish.outputs.prerelease }}" = "true" ]; then
153-
gh release create "${{ needs.check-publish.outputs.tag }}" \
154-
--target "${GITHUB_SHA}" \
155-
--title "${{ needs.check-publish.outputs.tag }}" \
166+
gh release create "${tag}" \
167+
--verify-tag \
168+
--title "${tag}" \
156169
--notes-file "${release_notes_file}" \
157170
--prerelease
158171
else
159-
gh release create "${{ needs.check-publish.outputs.tag }}" \
160-
--target "${GITHUB_SHA}" \
161-
--title "${{ needs.check-publish.outputs.tag }}" \
172+
gh release create "${tag}" \
173+
--verify-tag \
174+
--title "${tag}" \
162175
--notes-file "${release_notes_file}"
163176
fi

resources/gen-changelog.js

Lines changed: 79 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,10 @@ getChangeLog()
7070
function getChangeLog() {
7171
const workingTreeVersion = packageJSON.version;
7272
const fromRev = parseFromRevArg(process.argv.slice(2));
73-
const { title, rangeStart, rangeEnd } = resolveChangelogRangeConfig(
73+
const { title, commitsList } = resolveChangeLogConfig(
7474
workingTreeVersion,
7575
fromRev,
7676
);
77-
const commitsRange = `${rangeStart}..${rangeEnd}`;
78-
const commitsListOutput = exec(`git rev-list --reverse ${commitsRange}`);
79-
const commitsList =
80-
commitsListOutput === '' ? [] : commitsListOutput.split('\n');
8177

8278
const date = exec('git log -1 --format=%cd --date=short');
8379
return getCommitsInfo(commitsList)
@@ -100,59 +96,91 @@ function parseFromRevArg(rawArgs) {
10096
);
10197
}
10298

103-
function resolveChangelogRangeConfig(workingTreeVersion, fromRev) {
104-
const workingTreeReleaseTag = `v${workingTreeVersion}`;
99+
function getTaggedVersionCommit(version) {
100+
const tag = `v${version}`;
101+
if (!tagExists(tag)) {
102+
return null;
103+
}
104+
return exec(`git rev-parse ${tag}^{}`);
105+
}
105106

106-
// packageJSON in the working tree can differ from HEAD:package.json during
107-
// release:prepare after npm version updates files but before committing.
108-
// Supported scenario 1: release preparation not started
109-
// - working-tree version tag exists
110-
// - HEAD version older than or equal to working-tree version, must also exist
111-
if (tagExists(workingTreeReleaseTag)) {
112-
return {
113-
title: 'Unreleased',
114-
rangeStart: fromRev || workingTreeReleaseTag,
115-
rangeEnd: 'HEAD',
116-
};
107+
function getFirstParentCommit(commit) {
108+
const commitWithParents = exec(`git rev-list --parents -n 1 ${commit}`);
109+
if (commitWithParents === '') {
110+
return null;
117111
}
118112

119-
const headVersion = readPackageJSONAtRef('HEAD').version;
120-
const headReleaseTag = `v${headVersion}`;
121-
122-
// Supported scenario 2: release preparation started
123-
// - working-tree version tag not yet created
124-
// - HEAD version tag exists
125-
if (tagExists(headReleaseTag)) {
126-
return {
127-
title: workingTreeReleaseTag,
128-
rangeStart: fromRev || headReleaseTag,
129-
rangeEnd: 'HEAD',
130-
};
113+
const [, firstParent] = commitWithParents.split(' ');
114+
return firstParent || null;
115+
}
116+
117+
function resolveCommitRefOrThrow(ref) {
118+
try {
119+
return exec(`git rev-parse ${ref}`);
120+
} catch (error) {
121+
throw new Error(
122+
`Unable to resolve fromRev "${ref}" to a local commit. ` +
123+
'Pass a reachable first-parent revision:\n' +
124+
' npm run changelog -- <fromRev>',
125+
{ cause: error },
126+
);
131127
}
128+
}
132129

133-
// Supported scenario 3:
134-
// - release preparation committed
135-
// - working-tree version tag equal to HEAD version tag, both not yet created
136-
// - HEAD~1 version tag exists
137-
const parentVersion = readPackageJSONAtRef('HEAD~1').version;
138-
const parentTag = `v${parentVersion}`;
139-
const parentTagExists = tagExists(parentTag);
140-
if (workingTreeReleaseTag === headReleaseTag && parentTagExists) {
141-
console.warn('Release committed, should already contain this changelog!');
142-
143-
return {
144-
title: workingTreeReleaseTag,
145-
rangeStart: fromRev || parentTag,
146-
rangeEnd: 'HEAD~1',
147-
};
130+
function resolveChangeLogConfig(workingTreeVersion, fromRev) {
131+
const workingTreeReleaseTag = `v${workingTreeVersion}`;
132+
const title = tagExists(workingTreeReleaseTag)
133+
? 'Unreleased'
134+
: workingTreeReleaseTag;
135+
136+
const commitsList = [];
137+
let rangeStart =
138+
fromRev != null
139+
? resolveCommitRefOrThrow(fromRev)
140+
: getTaggedVersionCommit(workingTreeVersion);
141+
142+
let rangeStartReached = false;
143+
let lastCheckedVersion = workingTreeVersion;
144+
let newerCommit = null;
145+
let newerVersion = null;
146+
let commit = exec('git rev-parse HEAD');
147+
148+
while (commit != null) {
149+
const commitVersion = readPackageJSONAtRef(commit).version;
150+
151+
if (rangeStart == null && commitVersion !== lastCheckedVersion) {
152+
rangeStart = getTaggedVersionCommit(commitVersion);
153+
lastCheckedVersion = commitVersion;
154+
}
155+
156+
if (newerCommit != null && newerVersion === commitVersion) {
157+
commitsList.push(newerCommit);
158+
}
159+
160+
if (rangeStart != null && commit === rangeStart) {
161+
rangeStartReached = true;
162+
break;
163+
}
164+
165+
newerCommit = commit;
166+
newerVersion = commitVersion;
167+
commit = getFirstParentCommit(commit);
148168
}
149169

150-
throw new Error(
151-
'Unable to determine changelog range. One of the following scenarios must be true:\n' +
152-
'1) HEAD/working-tree release tags exist, i.e. release preparation not started.\n' +
153-
'2) HEAD release tag exists, but working-tree release tag not yet created, i.e. release preparation started, not yet committed.\n' +
154-
'3) HEAD/working-tree release tags not yet created, i.e. release preparation committed, not yet released, no additional commits on branch.',
155-
);
170+
if (rangeStart == null || !rangeStartReached) {
171+
throw new Error(
172+
'Unable to determine changelog range from local first-parent history.\n' +
173+
'This can happen with a shallow clone, missing tags, or an unreachable fromRev.\n' +
174+
'Fetch more history/tags (for example, "git fetch --tags --deepen=200") ' +
175+
'or pass an explicit reachable first-parent fromRev:\n' +
176+
' npm run changelog -- <fromRev>',
177+
);
178+
}
179+
180+
return {
181+
title,
182+
commitsList: commitsList.reverse(),
183+
};
156184
}
157185

158186
function genChangeLog(title, date, allPRs) {

resources/release-prepare.js

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,12 @@ function validateBranchState(releaseBranch) {
125125
let releaseBranchHead;
126126
try {
127127
releaseBranchHead = spawnOutput('git', ['rev-parse', releaseBranch]);
128-
} catch {
128+
} catch (error) {
129129
throw new Error(
130130
`Release branch "${releaseBranch}" does not exist locally.`,
131+
{
132+
cause: error,
133+
},
131134
);
132135
}
133136

@@ -138,34 +141,62 @@ function validateBranchState(releaseBranch) {
138141
'--abbrev-ref',
139142
`${releaseBranch}@{upstream}`,
140143
]);
141-
} catch {
144+
} catch (error) {
142145
throw new Error(
143146
`Release branch "${releaseBranch}" does not track a remote branch. ` +
144147
'Set one first (for example: git branch --set-upstream-to ' +
145148
`<remote>/${releaseBranch} ${releaseBranch}).`,
149+
{ cause: error },
146150
);
147151
}
148152

149153
const upstreamRemote = releaseBranchUpstream.split('/')[0];
150154
try {
151-
spawn('git', ['fetch', '--quiet', upstreamRemote, releaseBranch]);
152-
} catch {
155+
spawn('git', ['fetch', '--quiet', '--tags', upstreamRemote, releaseBranch]);
156+
} catch (error) {
153157
throw new Error(
154-
`Failed to fetch "${releaseBranchUpstream}". ` +
155-
'Verify network access and git remote configuration, then retry.',
158+
`Failed to fetch "${releaseBranchUpstream}" and tags from "${upstreamRemote}". ` +
159+
'Check remote access, authentication, git remote configuration, ' +
160+
'and local/remote tag state.',
161+
{ cause: error },
156162
);
157163
}
158164

159165
const upstreamReleaseBranchHead = spawnOutput('git', [
160166
'rev-parse',
161167
`${releaseBranch}@{upstream}`,
162168
]);
163-
if (releaseBranchHead !== upstreamReleaseBranchHead) {
169+
const localOnlyCommitsRaw = spawnOutput('git', [
170+
'rev-list',
171+
`${upstreamReleaseBranchHead}..${releaseBranchHead}`,
172+
]);
173+
const upstreamOnlyCommitsRaw = spawnOutput('git', [
174+
'rev-list',
175+
`${releaseBranchHead}..${upstreamReleaseBranchHead}`,
176+
]);
177+
const localOnlyCommits =
178+
localOnlyCommitsRaw === '' ? [] : localOnlyCommitsRaw.split('\n');
179+
const upstreamOnlyCommits =
180+
upstreamOnlyCommitsRaw === '' ? [] : upstreamOnlyCommitsRaw.split('\n');
181+
if (localOnlyCommits.length > 0 && upstreamOnlyCommits.length > 0) {
182+
throw new Error(
183+
`Local "${releaseBranch}" has diverged from "${releaseBranchUpstream}". ` +
184+
'Resolve conflicts and synchronize first (for example: ' +
185+
`git switch ${releaseBranch} && git pull --rebase).`,
186+
);
187+
}
188+
if (upstreamOnlyCommits.length > 0) {
164189
throw new Error(
165-
`Local "${releaseBranch}" is not up to date with "${releaseBranchUpstream}". ` +
190+
`Local "${releaseBranch}" is behind "${releaseBranchUpstream}". ` +
166191
`Update it first (for example: git switch ${releaseBranch} && git pull --ff-only).`,
167192
);
168193
}
194+
if (localOnlyCommits.length > 0) {
195+
throw new Error(
196+
`Local "${releaseBranch}" is ahead of "${releaseBranchUpstream}". ` +
197+
`Push or reset it before release prepare (for example: git switch ${releaseBranch} && git push).`,
198+
);
199+
}
169200

170201
const currentHead = spawnOutput('git', ['rev-parse', 'HEAD']);
171202
if (currentHead !== releaseBranchHead) {

0 commit comments

Comments
 (0)