Skip to content

Commit a57c204

Browse files
authored
internal: streamline release process (#4615)
- use annotated tag for release - make changelog generation more robust - improve logging
1 parent 5208f0f commit a57c204

File tree

3 files changed

+134
-68
lines changed

3 files changed

+134
-68
lines changed

.github/workflows/release.yml

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,22 +145,35 @@ jobs:
145145
env:
146146
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
147147
run: |
148-
if gh release view "${{ needs.check-publish.outputs.tag }}" > /dev/null 2>&1; then
149-
echo "GitHub release ${{ needs.check-publish.outputs.tag }} already exists. Skipping release creation."
148+
tag="${{ needs.check-publish.outputs.tag }}"
149+
150+
if gh release view "${tag}" > /dev/null 2>&1; then
151+
echo "GitHub release ${tag} already exists. Skipping release creation."
150152
exit 0
151153
fi
152154
155+
if git ls-remote --exit-code --tags origin "refs/tags/${tag}" > /dev/null; then
156+
echo "Tag ${tag} already exists on origin."
157+
else
158+
git config user.name "github-actions[bot]"
159+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
160+
git tag -a "${tag}" "${GITHUB_SHA}" -m "${tag}"
161+
gh auth setup-git
162+
git push origin "refs/tags/${tag}"
163+
echo "Created annotated tag ${tag} at ${GITHUB_SHA}."
164+
fi
165+
153166
release_notes_file="./artifacts/release-notes.md"
154167
155168
if [ "${{ needs.check-publish.outputs.prerelease }}" = "true" ]; then
156-
gh release create "${{ needs.check-publish.outputs.tag }}" \
157-
--target "${GITHUB_SHA}" \
158-
--title "${{ needs.check-publish.outputs.tag }}" \
169+
gh release create "${tag}" \
170+
--verify-tag \
171+
--title "${tag}" \
159172
--notes-file "${release_notes_file}" \
160173
--prerelease
161174
else
162-
gh release create "${{ needs.check-publish.outputs.tag }}" \
163-
--target "${GITHUB_SHA}" \
164-
--title "${{ needs.check-publish.outputs.tag }}" \
175+
gh release create "${tag}" \
176+
--verify-tag \
177+
--title "${tag}" \
165178
--notes-file "${release_notes_file}"
166179
fi

resources/gen-changelog.ts

Lines changed: 80 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -70,79 +70,107 @@ function parseFromRevArg(rawArgs: ReadonlyArray<string>): string | null {
7070
);
7171
}
7272

73-
function resolveChangelogRangeConfig(
73+
function getTaggedVersionCommit(version: string): string | null {
74+
const tag = `v${version}`;
75+
if (!git().tagExists(tag)) {
76+
return null;
77+
}
78+
return git({ quiet: true }).revParse(`${tag}^{}`);
79+
}
80+
81+
function getFirstParentCommit(commit: string): string | null {
82+
const [commitWithParents] = git().revList('--parents', '-n', '1', commit);
83+
if (commitWithParents == null) {
84+
return null;
85+
}
86+
87+
const [, firstParent] = commitWithParents.split(' ');
88+
return firstParent ?? null;
89+
}
90+
91+
function resolveCommitRefOrThrow(ref: string): string {
92+
try {
93+
return git().revParse(ref);
94+
} catch (error) {
95+
throw new Error(
96+
`Unable to resolve fromRev "${ref}" to a local commit. ` +
97+
'Pass a reachable first-parent revision:\n' +
98+
' npm run changelog -- <fromRev>',
99+
{ cause: error },
100+
);
101+
}
102+
}
103+
104+
function resolveChangeLogConfig(
74105
workingTreeVersion: string,
75106
fromRev: string | null,
76107
): {
77108
title: string;
78-
rangeStart: string;
79-
rangeEnd: string;
109+
commitsList: Array<string>;
80110
} {
81111
const workingTreeReleaseTag = `v${workingTreeVersion}`;
112+
const title = git().tagExists(workingTreeReleaseTag)
113+
? 'Unreleased'
114+
: workingTreeReleaseTag;
115+
116+
const commitsList: Array<string> = [];
117+
let rangeStart =
118+
fromRev != null
119+
? resolveCommitRefOrThrow(fromRev)
120+
: getTaggedVersionCommit(workingTreeVersion);
121+
122+
let rangeStartReached = false;
123+
let lastCheckedVersion = workingTreeVersion;
124+
let newerCommit: string | null = null;
125+
let newerVersion: string | null = null;
126+
let commit: string | null = git().revParse('HEAD');
127+
128+
while (commit != null) {
129+
const commitVersion = readPackageJSONAtRef(commit).version;
130+
131+
if (rangeStart == null && commitVersion !== lastCheckedVersion) {
132+
rangeStart = getTaggedVersionCommit(commitVersion);
133+
lastCheckedVersion = commitVersion;
134+
}
82135

83-
// packageJSON in the working tree can differ from HEAD:package.json during
84-
// release:prepare after npm version updates files but before committing.
85-
// Supported scenario 1: release preparation not started
86-
// - working-tree version tag exists
87-
// - HEAD version older than or equal to working-tree version, must also exist
88-
if (git().tagExists(workingTreeReleaseTag)) {
89-
return {
90-
title: 'Unreleased',
91-
rangeStart: fromRev ?? workingTreeReleaseTag,
92-
rangeEnd: 'HEAD',
93-
};
94-
}
136+
if (newerCommit != null && newerVersion === commitVersion) {
137+
commitsList.push(newerCommit);
138+
}
139+
140+
if (rangeStart != null && commit === rangeStart) {
141+
rangeStartReached = true;
142+
break;
143+
}
95144

96-
const headVersion = readPackageJSONAtRef('HEAD').version;
97-
const headReleaseTag = `v${headVersion}`;
98-
99-
// Supported scenario 2: release preparation started
100-
// - working-tree version tag not yet created
101-
// - HEAD version tag exists
102-
if (git().tagExists(headReleaseTag)) {
103-
return {
104-
title: workingTreeReleaseTag,
105-
rangeStart: fromRev ?? headReleaseTag,
106-
rangeEnd: 'HEAD',
107-
};
145+
newerCommit = commit;
146+
newerVersion = commitVersion;
147+
commit = getFirstParentCommit(commit);
108148
}
109149

110-
// Supported scenario 3:
111-
// - release preparation committed
112-
// - working-tree version tag equal to HEAD version tag, both not yet created
113-
// - HEAD~1 version tag exists
114-
const parentVersion = readPackageJSONAtRef('HEAD~1').version;
115-
const parentTag = `v${parentVersion}`;
116-
const parentTagExists = git().tagExists(parentTag);
117-
if (workingTreeReleaseTag === headReleaseTag && parentTagExists) {
118-
console.warn(`Release committed, should already contain this changelog!`);
119-
120-
return {
121-
title: workingTreeReleaseTag,
122-
rangeStart: fromRev ?? parentTag,
123-
rangeEnd: 'HEAD~1',
124-
};
150+
if (rangeStart == null || !rangeStartReached) {
151+
throw new Error(
152+
'Unable to determine changelog range from local first-parent history.\n' +
153+
'This can happen with a shallow clone, missing tags, or an unreachable fromRev.\n' +
154+
'Fetch more history/tags (for example, "git fetch --tags --deepen=200") ' +
155+
'or pass an explicit reachable first-parent fromRev:\n' +
156+
' npm run changelog -- <fromRev>',
157+
);
125158
}
126159

127-
throw new Error(
128-
'Unable to determine changelog range. One of the following scenarios must be true:\n' +
129-
`1) HEAD/working-tree release tags exist, i.e. release preparation not started.\n` +
130-
`2) HEAD release tag exists, but working-tree release tag not yet created, i.e. release preparation started, not yet committed.\n` +
131-
`3) HEAD/working-tree release tags not yet created, i.e. release preparation committed, not yet released, no additional commits on branch.`,
132-
);
160+
return {
161+
title,
162+
commitsList: commitsList.reverse(),
163+
};
133164
}
134165

135166
async function genChangeLog(): Promise<string> {
136167
const workingTreeVersion = packageJSON.version;
137168
const fromRev = parseFromRevArg(process.argv.slice(2));
138-
const { title, rangeStart, rangeEnd } = resolveChangelogRangeConfig(
169+
const { title, commitsList } = resolveChangeLogConfig(
139170
workingTreeVersion,
140171
fromRev,
141172
);
142173

143-
const commitsRange = `${rangeStart}..${rangeEnd}`;
144-
const commitsList = git().revList('--reverse', commitsRange);
145-
146174
const allPRs = await getPRsInfo(commitsList);
147175
const date = git().log('-1', '--format=%cd', '--date=short');
148176

resources/release-prepare.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,12 @@ function validateBranchState(releaseBranch: string): void {
123123
let releaseBranchHead: string;
124124
try {
125125
releaseBranchHead = git({ quiet: true }).revParse(releaseBranch);
126-
} catch {
126+
} catch (error) {
127127
throw new Error(
128128
`Release branch "${releaseBranch}" does not exist locally.`,
129+
{
130+
cause: error,
131+
},
129132
);
130133
}
131134

@@ -135,33 +138,55 @@ function validateBranchState(releaseBranch: string): void {
135138
'--abbrev-ref',
136139
`${releaseBranch}@{upstream}`,
137140
);
138-
} catch {
141+
} catch (error) {
139142
throw new Error(
140143
`Release branch "${releaseBranch}" does not track a remote branch. ` +
141144
'Set one first (for example: git branch --set-upstream-to ' +
142145
`<remote>/${releaseBranch} ${releaseBranch}).`,
146+
{ cause: error },
143147
);
144148
}
145149

146150
const upstreamRemote = releaseBranchUpstream.split('/')[0];
147151
try {
148-
git().fetch('--quiet', upstreamRemote, releaseBranch);
149-
} catch {
152+
git().fetch('--quiet', '--tags', upstreamRemote, releaseBranch);
153+
} catch (error) {
150154
throw new Error(
151-
`Failed to fetch "${releaseBranchUpstream}". ` +
152-
'Verify network access and git remote configuration, then retry.',
155+
`Failed to fetch "${releaseBranchUpstream}" and tags from "${upstreamRemote}". ` +
156+
'Check remote access, authentication, git remote configuration, ' +
157+
'and local/remote tag state.',
158+
{ cause: error },
153159
);
154160
}
155161

156162
const upstreamReleaseBranchHead = git({ quiet: true }).revParse(
157163
`${releaseBranch}@{upstream}`,
158164
);
159-
if (releaseBranchHead !== upstreamReleaseBranchHead) {
165+
const localOnlyCommits = git().revList(
166+
`${upstreamReleaseBranchHead}..${releaseBranchHead}`,
167+
);
168+
const upstreamOnlyCommits = git().revList(
169+
`${releaseBranchHead}..${upstreamReleaseBranchHead}`,
170+
);
171+
if (localOnlyCommits.length > 0 && upstreamOnlyCommits.length > 0) {
172+
throw new Error(
173+
`Local "${releaseBranch}" has diverged from "${releaseBranchUpstream}". ` +
174+
'Resolve conflicts and synchronize first (for example: ' +
175+
`git switch ${releaseBranch} && git pull --rebase).`,
176+
);
177+
}
178+
if (upstreamOnlyCommits.length > 0) {
160179
throw new Error(
161-
`Local "${releaseBranch}" is not up to date with "${releaseBranchUpstream}". ` +
180+
`Local "${releaseBranch}" is behind "${releaseBranchUpstream}". ` +
162181
`Update it first (for example: git switch ${releaseBranch} && git pull --ff-only).`,
163182
);
164183
}
184+
if (localOnlyCommits.length > 0) {
185+
throw new Error(
186+
`Local "${releaseBranch}" is ahead of "${releaseBranchUpstream}". ` +
187+
`Push or reset it before release prepare (for example: git switch ${releaseBranch} && git push).`,
188+
);
189+
}
165190

166191
const currentHead = git({ quiet: true }).revParse('HEAD');
167192
if (currentHead !== releaseBranchHead) {

0 commit comments

Comments
 (0)