Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
132 changes: 80 additions & 52 deletions resources/gen-changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,79 +70,107 @@ function parseFromRevArg(rawArgs: ReadonlyArray<string>): 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 -- <fromRev>',
{ cause: error },
);
}
}

function resolveChangeLogConfig(
workingTreeVersion: string,
fromRev: string | null,
): {
title: string;
rangeStart: string;
rangeEnd: string;
commitsList: Array<string>;
} {
const workingTreeReleaseTag = `v${workingTreeVersion}`;
const title = git().tagExists(workingTreeReleaseTag)
? 'Unreleased'
: workingTreeReleaseTag;

const commitsList: Array<string> = [];
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 -- <fromRev>',
);
}

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<string> {
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');

Expand Down
41 changes: 33 additions & 8 deletions resources/release-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
);
}

Expand All @@ -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 ' +
`<remote>/${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) {
Expand Down
Loading