diff --git a/packages/release-please/package-lock.json b/packages/release-please/package-lock.json index dae23f0031f..057fb922178 100644 --- a/packages/release-please/package-lock.json +++ b/packages/release-please/package-lock.json @@ -17,7 +17,7 @@ "@octokit/webhooks": "^10.1.5", "gcf-utils": "^16.2.1", "jsonwebtoken": "^9.0.0", - "release-please": "^16.15.0" + "release-please": "^16.18.0" }, "devDependencies": { "@types/mocha": "^10.0.0", @@ -7452,9 +7452,10 @@ } }, "node_modules/release-please": { - "version": "16.15.0", - "resolved": "https://registry.npmjs.org/release-please/-/release-please-16.15.0.tgz", - "integrity": "sha512-C55PsUOMzAbPSrdqF/KKAqhaYVRGlarNNWgW/DyAsg15U4g/TkxXVpEZqAV1o38CoEoKhssnKTGnb5/eT4/DUw==", + "version": "16.18.0", + "resolved": "https://registry.npmjs.org/release-please/-/release-please-16.18.0.tgz", + "integrity": "sha512-23AmvwFSvnMqNRHrSTc+rjTZNMY7YN4JbrPMqAZRIpNNpv9GU0XO1U2IQ9QzNuTNp/sxUU/2gT+9pi7Nu7OntQ==", + "license": "Apache-2.0", "dependencies": { "@conventional-commits/parser": "^0.4.1", "@google-automations/git-file-utils": "^2.0.0", @@ -7476,7 +7477,7 @@ "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.0.0", - "jsonpath-plus": "^10.3.0", + "jsonpath-plus": "^10.0.0", "node-html-parser": "^6.0.0", "parse-github-repo-url": "^1.4.1", "semver": "^7.5.3", diff --git a/packages/release-please/package.json b/packages/release-please/package.json index f082a9daaa1..9590c3a3dfe 100644 --- a/packages/release-please/package.json +++ b/packages/release-please/package.json @@ -36,7 +36,7 @@ "@octokit/webhooks": "^10.1.5", "gcf-utils": "^16.2.1", "jsonwebtoken": "^9.0.0", - "release-please": "^16.15.0" + "release-please": "^16.18.0" }, "devDependencies": { "@types/mocha": "^10.0.0", diff --git a/packages/release-please/src/config-constants.ts b/packages/release-please/src/config-constants.ts index c8baa8d3ec6..52b6a5ea83e 100644 --- a/packages/release-please/src/config-constants.ts +++ b/packages/release-please/src/config-constants.ts @@ -42,6 +42,7 @@ export interface BranchOptions { changelogType?: ChangelogNotesType; initialVersion?: string; onDemand?: boolean; + tagPullRequestNumber?: boolean; } export interface BranchConfiguration extends BranchOptions { diff --git a/packages/release-please/src/release-please.ts b/packages/release-please/src/release-please.ts index a2e36d29566..eb738f8bad6 100644 --- a/packages/release-please/src/release-please.ts +++ b/packages/release-please/src/release-please.ts @@ -305,6 +305,7 @@ async function runBranchConfigurationWithConfigurationHandlingWithoutLock( repoLanguage, repoUrl, branchConfiguration, + octokit, options ); } catch (e) { @@ -356,6 +357,7 @@ async function runBranchConfiguration( repoLanguage: string | null, repoUrl: string, branchConfiguration: BranchConfiguration, + octokit: Octokit, options: RunBranchOptions ) { const logger = options.logger ?? defaultLogger; @@ -383,13 +385,37 @@ async function runBranchConfiguration( plugins ); try { - const numReleases = await Runner.createReleases(manifest); - logger.info(`Created ${numReleases} releases`); - if (numReleases > 0) { + const releases = await Runner.createReleases(manifest); + logger.info(`Created ${releases.length} releases`); + if (releases.length > 0) { // we created a release, reload config which may include the latest // version manifest = null; } + if (branchConfiguration.tagPullRequestNumber) { + // Record the pull request number to the commit as + // a tag. + + // It's possible for manifest.createReleases() to create + // releases for multiple pull requests. Find the unique + // pair of pull request numbers and their commit SHAs. + const prNumberToSha = new Map(); + for (const release of releases) { + prNumberToSha.set(release.prNumber, release.sha); + } + for (const [prNumber, sha] of prNumberToSha.entries()) { + // A Git (lightweight) tag is a ref. + const tagName = `release-please-${prNumber}`; + logger.info(`Creating ${tagName} pointing to ${sha}`); + const tagResponse = await Runner.createLightweightTag( + octokit, + github.repository, + tagName, + sha + ); + logger.debug('Got tag response: ', tagResponse); + } + } } catch (e) { if (e instanceof Errors.DuplicateReleaseError) { // In the future, this could raise an issue against the diff --git a/packages/release-please/src/runner.ts b/packages/release-please/src/runner.ts index 2e6c330a723..e0ad137dd97 100644 --- a/packages/release-please/src/runner.ts +++ b/packages/release-please/src/runner.ts @@ -12,14 +12,46 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Manifest} from 'release-please'; +import {Octokit, RestEndpointMethodTypes} from '@octokit/rest'; +import {CreatedRelease, Manifest} from 'release-please'; +import {Repository} from 'release-please/build/src/repository'; export class Runner { static createPullRequests = async (manifest: Manifest) => { await manifest.createPullRequests(); }; - static createReleases = async (manifest: Manifest): Promise => { + static createReleases = async ( + manifest: Manifest + ): Promise => { const releases = await manifest.createReleases(); - return releases.filter(release => !!release).length; + return releases.filter(release => !!release); + }; + + /** + * Creates a lightweight tag in the GitHub repository. + * + * @param octokit + * @param repository + * @param tagName The tag name to create. It must not have 'refs/tags' prefix. + * @param sha The sha to create tag to. + * @returns + */ + static createLightweightTag = async ( + octokit: Octokit, + repository: Repository, + tagName: string, + sha: string + ): Promise => { + // A lightweight tag only requires this create references API call, + // rather than a tag object (/repos/{owner}/{repo}/git/tags). + // https://docs.github.com/en/rest/git/tags?apiVersion=2022-11-28#create-a-tag-object + // https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#create-a-reference + const tagRefName = `refs/tags/${tagName}`; + return octokit.git.createRef({ + owner: repository.owner, + repo: repository.repo, + ref: tagRefName, + sha: sha, + }); }; } diff --git a/packages/release-please/test/fixtures/config/manifest_tag_pr_number.yml b/packages/release-please/test/fixtures/config/manifest_tag_pr_number.yml new file mode 100644 index 00000000000..f43c1986564 --- /dev/null +++ b/packages/release-please/test/fixtures/config/manifest_tag_pr_number.yml @@ -0,0 +1,4 @@ +primaryBranch: master +manifest: true +handleGHRelease: true +tagPullRequestNumber: true diff --git a/packages/release-please/test/release-please.ts b/packages/release-please/test/release-please.ts index 88b34e8c87b..d3acbeed8fc 100644 --- a/packages/release-please/test/release-please.ts +++ b/packages/release-please/test/release-please.ts @@ -47,6 +47,7 @@ describe('ReleasePleaseBot', () => { let getConfigStub: sinon.SinonStub; let createPullRequestsStub: sinon.SinonStub; let createReleasesStub: sinon.SinonStub; + let createLightweightTagStub: sinon.SinonStub; beforeEach(() => { probot = createProbot({ @@ -62,6 +63,7 @@ describe('ReleasePleaseBot', () => { getConfigStub = sandbox.stub(botConfigModule, 'getConfig'); createPullRequestsStub = sandbox.stub(Runner, 'createPullRequests'); createReleasesStub = sandbox.stub(Runner, 'createReleases'); + createLightweightTagStub = sandbox.stub(Runner, 'createLightweightTag'); sandbox .stub(gcfUtilsModule, 'getAuthenticatedOctokit') .resolves(new Octokit({auth: 'faketoken'})); @@ -72,6 +74,9 @@ describe('ReleasePleaseBot', () => { ) { await f(); } as any); + + // No release for test cases except explicitly set in each case. + createReleasesStub.resolves([]); }); afterEach(() => { @@ -735,6 +740,93 @@ describe('ReleasePleaseBot', () => { }) ); }); + + it('should tag pull request number if configured', async () => { + getConfigStub.resolves(loadConfig('manifest_tag_pr_number.yml')); + // We want the PR number 789 to be in the tag + const exampleRelease = { + id: 'v4.5.6', + path: 'foo', + version: 'v4.5.6', + major: 4, + minor: 5, + patch: 6, + prNumber: 789, + sha: '853ab2395d7777f8f3f8cb2b7106d3a3d17490e9', + }; + createReleasesStub.resolves([exampleRelease]); + createLightweightTagStub.resolves({}); + + await probot.receive( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + {name: 'push', payload: payload as any, id: 'abc123'} + ); + + sinon.assert.calledOnce(createReleasesStub); + sinon.assert.calledOnceWithExactly( + createLightweightTagStub, + sinon.match.instanceOf(Octokit), + sinon.match + .has('repo', 'google-auth-library-java') + .and(sinon.match.has('owner', 'chingor13')), + 'release-please-789', + '853ab2395d7777f8f3f8cb2b7106d3a3d17490e9' + ); + // When there's a release, it reloads the manifest. + sinon.assert.calledTwice(fromManifestStub); + sinon.assert.calledOnce(createPullRequestsStub); + }); + + it('should tag each SHA for pull requests number if configured', async () => { + getConfigStub.resolves(loadConfig('manifest_tag_pr_number.yml')); + // 2 pull requests created 3 releases. First + // two share the same SHA and PR number. + const release1 = { + id: 'foo/v3.0.1', + path: 'foo', + version: 'v3.0.1', + major: 3, + minor: 0, + patch: 1, + prNumber: 789, + sha: '853ab2395d7777f8f3f8cb2b7106d3a3d17490e9', + }; + const release2 = { + id: 'bar/v4.0.1', + path: 'bar', + version: 'v4.0.1', + major: 4, + minor: 0, + patch: 1, + // These values below are the same as the + // release1's. + prNumber: 789, + sha: '853ab2395d7777f8f3f8cb2b7106d3a3d17490e9', + }; + const release3 = { + id: 'v5.0.2', + path: 'foo', + version: 'v5.0.2', + major: 5, + minor: 0, + patch: 2, + prNumber: 790, + sha: 'f5528f1d94206836a8ceb9bed5eeaa768e002fb4', + }; + createReleasesStub.resolves([release1, release2, release3]); + await probot.receive( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + {name: 'push', payload: payload as any, id: 'abc123'} + ); + + sinon.assert.calledOnce(createReleasesStub); + // Because the first 2 releases share the pull request and SHA, + // there should be 2 new tags. + sinon.assert.calledTwice(createLightweightTagStub); + // When there's a release, it reloads the manifest. + sinon.assert.calledTwice(fromManifestStub); + sinon.assert.calledOnce(createPullRequestsStub); + }); }); it('should handle a misconfigured repository', async () => { diff --git a/packages/release-please/test/runner.ts b/packages/release-please/test/runner.ts new file mode 100644 index 00000000000..e873b3a35cb --- /dev/null +++ b/packages/release-please/test/runner.ts @@ -0,0 +1,52 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import nock from 'nock'; +import {Runner} from '../src/runner'; +import {Octokit} from '@octokit/rest'; +import assert from 'assert'; + +nock.disableNetConnect(); + +describe('Release Please Runner', () => { + let octokit: Octokit; + beforeEach(() => { + octokit = new Octokit({auth: 'faketoken'}); + }); + afterEach(() => { + nock.cleanAll(); + }); + + it('should create a ref for lightweight tag', async () => { + const requests = nock('https://api.github.com') + .post('/repos/owner1/repo1/git/refs', body => { + assert.equal(body['ref'], 'refs/tags/release-please-123'); + assert.equal(body['sha'], 'abcdefg'); + return true; + }) + .reply(200); + + await Runner.createLightweightTag( + octokit, + { + owner: 'owner1', + repo: 'repo1', + defaultBranch: 'main', + }, + 'release-please-123', + 'abcdefg' + ); + requests.done(); + }); +});