Skip to content

Commit 6612238

Browse files
committed
cherry-pick(#44077): devops: add publish_manual.yml workflow for patch releases
1 parent 31cf023 commit 6612238

5 files changed

Lines changed: 348 additions & 3 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
name: "publish patch release - npm"
2+
3+
# A patch release has two steps:
4+
# 1. Run this workflow with a release branch. It sets the next patch version on the branch, creates
5+
# a draft GitHub release and publishes the packages to npm under "latest-pending".
6+
# 2. Wait until `utils/check_npm_packages.sh <version>` passes. Then edit the notes of the draft
7+
# release and publish it. That starts this workflow again, to move "latest" to the new version.
8+
#
9+
# Step 2 runs the workflow file of the released commit, so the release branch must have this file.
10+
# The packages on npm must list publish_manual.yml as a trusted publisher that can run
11+
# "npm publish" and "npm dist-tag".
12+
13+
on:
14+
workflow_dispatch:
15+
inputs:
16+
branch:
17+
description: "Release branch, for example release-1.65"
18+
required: true
19+
type: string
20+
release:
21+
types: [published]
22+
23+
jobs:
24+
prepare-release:
25+
name: "set version, draft release"
26+
runs-on: ubuntu-24.04
27+
if: github.event_name == 'workflow_dispatch' && github.repository == 'microsoft/playwright'
28+
permissions:
29+
contents: write # This is required to push the version commit and to create the draft release
30+
issues: read # This is required to list the fixed issues in the release notes
31+
pull-requests: read # This is required to list the fixed issues in the release notes
32+
outputs:
33+
sha: ${{ steps.commit.outputs.sha }}
34+
env:
35+
BRANCH: ${{ inputs.branch }}
36+
steps:
37+
- name: Check the branch name
38+
run: |
39+
if [[ ! "$BRANCH" =~ ^release-[0-9]+\.[0-9]+$ ]]; then
40+
echo "ERROR: '$BRANCH' is not a release branch, expected a name like release-1.65"
41+
exit 1
42+
fi
43+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
44+
with:
45+
ref: ${{ inputs.branch }}
46+
# The release notes need the commits since the previous release tag.
47+
fetch-depth: 0
48+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
49+
with:
50+
node-version: lts/*
51+
- run: npm ci
52+
- run: npm run build
53+
- run: npx playwright install --with-deps
54+
- name: Set the next patch version
55+
id: version
56+
run: |
57+
PREVIOUS=$(node utils/workspace.js --get-version)
58+
if [[ ! "$PREVIOUS" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
59+
echo "ERROR: the version of $BRANCH is $PREVIOUS, expected a released version like 1.65.1"
60+
exit 1
61+
fi
62+
if ! git rev-parse --quiet --verify "refs/tags/v$PREVIOUS" > /dev/null; then
63+
echo "ERROR: there is no v$PREVIOUS tag, so $PREVIOUS is not released yet"
64+
exit 1
65+
fi
66+
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
67+
./utils/workspace.js --set-version "$VERSION"
68+
npm run doc
69+
echo "previous=$PREVIOUS" >> "$GITHUB_OUTPUT"
70+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
71+
- name: Write the release notes
72+
run: |
73+
node utils/draft_patch_release_notes.js "v$PREVIOUS" > "$RUNNER_TEMP/release-notes.md"
74+
cat "$RUNNER_TEMP/release-notes.md"
75+
env:
76+
PREVIOUS: ${{ steps.version.outputs.previous }}
77+
GH_TOKEN: ${{ github.token }}
78+
# The draft comes before the commit, so that it exists even when the commit fails. It targets the
79+
# branch: GitHub creates the tag from the head of the branch when the release is published.
80+
# The workflow token makes github-actions[bot] the author of the release. The promote-npm job
81+
# below and the publish-npm job of publish_release.yml use the author to recognize this release.
82+
- name: Create the draft release
83+
run: |
84+
if gh release view "v$VERSION" > /dev/null 2>&1; then
85+
# An earlier run created the draft and failed after that.
86+
gh release edit "v$VERSION" --draft --target "$BRANCH" --notes-file "$RUNNER_TEMP/release-notes.md"
87+
else
88+
gh release create "v$VERSION" --draft --title "v$VERSION" --target "$BRANCH" --notes-file "$RUNNER_TEMP/release-notes.md"
89+
fi
90+
env:
91+
VERSION: ${{ steps.version.outputs.version }}
92+
GH_TOKEN: ${{ github.token }}
93+
# A push with the workflow token does not start other workflows, so publish_release.yml does not
94+
# publish a beta version for this commit.
95+
- name: Commit the version
96+
id: commit
97+
run: |
98+
git config user.name "microsoft-playwright-automation[bot]"
99+
git config user.email "203992400+microsoft-playwright-automation[bot]@users.noreply.github.com"
100+
git commit --all --message "chore: mark v$VERSION"
101+
git push origin "HEAD:refs/heads/$BRANCH"
102+
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
103+
env:
104+
VERSION: ${{ steps.version.outputs.version }}
105+
106+
# Publishes every package under "latest-pending" and leaves "latest" where it is, see
107+
# utils/publish_all_packages.sh. The promote-npm job moves "latest" when the release is published.
108+
publish-npm:
109+
name: "publish NPM"
110+
needs: prepare-release
111+
runs-on: ubuntu-24.04
112+
permissions:
113+
id-token: write # This is required for OIDC login (NPM publish) to succeed
114+
contents: read # This is required for actions/checkout to succeed
115+
steps:
116+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
117+
with:
118+
ref: ${{ needs.prepare-release.outputs.sha }}
119+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
120+
with:
121+
node-version: lts/*
122+
registry-url: 'https://registry.npmjs.org'
123+
- run: npm ci
124+
- run: npm run build
125+
- run: utils/publish_all_packages.sh --release
126+
127+
# Runs when a person publishes the draft release that prepare-release created. To retry, re-run the job.
128+
promote-npm:
129+
name: "move the npm dist-tag"
130+
if: github.event_name == 'release' && github.event.release.author.login == 'github-actions[bot]' && github.repository == 'microsoft/playwright'
131+
runs-on: ubuntu-24.04
132+
timeout-minutes: 330
133+
permissions:
134+
id-token: write # This is required for OIDC login (npm dist-tag) to succeed
135+
contents: read # This is required for actions/checkout to succeed
136+
steps:
137+
# Checks out the commit of the release tag.
138+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
139+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
140+
with:
141+
node-version: lts/*
142+
registry-url: 'https://registry.npmjs.org'
143+
# npm dist-tag supports OIDC login from npm 11.21.0.
144+
- run: npm install -g npm@11.21.1
145+
# Fails when npm has not made all packages live yet. Re-run the job later in that case.
146+
- name: Check that all packages are live
147+
run: utils/check_npm_packages.sh "${TAG#v}"
148+
env:
149+
TAG: ${{ github.event.release.tag_name }}
150+
# The same list that utils/publish_all_packages.sh writes after it publishes the packages.
151+
- name: List the published packages
152+
run: |
153+
node -e '
154+
const { workspace } = require("./utils/workspace.js");
155+
const { version } = require("./package.json");
156+
if (`v${version}` !== process.env.TAG)
157+
throw new Error(`The release tag is ${process.env.TAG}, but the version of its commit is ${version}`);
158+
const packages = workspace.publicPackagesInDependencyOrder().map(pkg => pkg.packageJSON.name);
159+
console.log(JSON.stringify({ version, tag: "latest", packages }, null, 2));
160+
' > npm-publish.json
161+
cat npm-publish.json
162+
env:
163+
TAG: ${{ github.event.release.tag_name }}
164+
- name: Move the dist-tag once all packages are live
165+
run: node utils/promote_npm_packages.js npm-publish.json

‎.github/workflows/publish_release.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ jobs:
1414
publish-npm:
1515
name: "publish NPM"
1616
runs-on: ubuntu-24.04
17-
if: github.repository == 'microsoft/playwright'
17+
# github-actions[bot] is the author of the release created by publish_manual.yml,
18+
# that does not need to publish from here.
19+
if: github.repository == 'microsoft/playwright' && !(github.event_name == 'release' && github.event.release.author.login == 'github-actions[bot]')
1820
permissions:
1921
id-token: write # This is required for OIDC login (NPM publish) to succeed
2022
contents: read # This is required for actions/checkout to succeed

‎utils/check_npm_packages.sh‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env bash
2+
set -e
3+
4+
if [[ $# != 1 || $1 == "--help" ]]; then
5+
echo "usage: $(basename $0) <version>"
6+
echo
7+
echo "Checks that all packages of the given version, for example 1.65.2, are live on npm."
8+
echo
9+
echo "publish_all_packages.sh publishes the packages under <tag>-pending. npm makes a package live"
10+
echo "after it processes it, and that can take hours. Run this before you publish the GitHub release:"
11+
echo "the promote-npm job of publish_manual.yml moves <tag> to the version only when this check passes."
12+
exit 1
13+
fi
14+
15+
VERSION="$1"
16+
if [[ "${VERSION}" == v* ]]; then
17+
echo "ERROR: the version must not start with \"v\""
18+
exit 1
19+
fi
20+
21+
if ! command -v npm >/dev/null; then
22+
echo "ERROR: NPM is not found"
23+
exit 1
24+
fi
25+
26+
cd "$(dirname $0)/.."
27+
28+
PACKAGES=()
29+
MISSING=()
30+
for package in $(node ./utils/workspace.js --list-public-package-paths); do
31+
PACKAGE="$(node -p "require('${package}/package.json').name")@${VERSION}"
32+
PACKAGES+=("${PACKAGE}")
33+
if [[ "$(npm view "${PACKAGE}" version --prefer-online 2>/dev/null)" == "${VERSION}" ]]; then
34+
echo "live: ${PACKAGE}"
35+
else
36+
echo "NOT live: ${PACKAGE}"
37+
MISSING+=("${PACKAGE}")
38+
fi
39+
done
40+
41+
if [[ ${#MISSING[@]} != 0 ]]; then
42+
echo "ERROR: ${#MISSING[@]} of ${#PACKAGES[@]} packages are not live on npm"
43+
exit 1
44+
fi
45+
46+
echo "All ${#PACKAGES[@]} packages of version ${VERSION} are live on npm."

‎utils/draft_patch_release_notes.js‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Copyright (c) Microsoft Corporation.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
// @ts-check
18+
19+
// Prints the draft notes of a patch release, for publish_manual.yml: the issues that the commits
20+
// since the previous release fixed, and the browser versions when browsers.json changed.
21+
// Run it before the commit that sets the new version.
22+
//
23+
// usage: draft_patch_release_notes.js <previous-release-tag>
24+
//
25+
// Needs the gh CLI with a token that can read the issues and pull requests of the repository.
26+
27+
const { execFileSync } = require('child_process');
28+
const fs = require('fs');
29+
const path = require('path');
30+
31+
const ROOT_PATH = path.join(__dirname, '..');
32+
const REPOSITORY = 'microsoft/playwright';
33+
const BROWSERS_JSON = 'packages/playwright-core/browsers.json';
34+
const BROWSER_TITLES = { chromium: 'Chromium', firefox: 'Mozilla Firefox', webkit: 'WebKit' };
35+
36+
/**
37+
* @param {string} command
38+
* @param {string[]} args
39+
*/
40+
function run(command, args) {
41+
return execFileSync(command, args, { cwd: ROOT_PATH, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
42+
}
43+
44+
/**
45+
* @param {string} previousTag
46+
* @returns {number[][]} The pull requests of each commit, oldest commit first.
47+
*/
48+
function listPullRequests(previousTag) {
49+
const log = run('git', ['log', '--reverse', '--format=%s', `${previousTag}..HEAD`]);
50+
return log.split('\n').filter(Boolean).map(subject => {
51+
// "cherry-pick(#123): fix: foo (#456)" came from the pull request 123, "fix: foo (#123)" is the pull request 123.
52+
const cherryPick = subject.match(/^cherry-pick\(([^)]*)\)/);
53+
if (cherryPick)
54+
return [...cherryPick[1].matchAll(/#(\d+)/g)].map(match => +match[1]);
55+
const squash = subject.match(/\(#(\d+)\)$/);
56+
return squash ? [+squash[1]] : [];
57+
});
58+
}
59+
60+
/**
61+
* @param {number[]} pullRequests
62+
* @returns {Map<number, { number: number, title: string, url: string, repository: { nameWithOwner: string } }[]>}
63+
*/
64+
function fetchFixedIssues(pullRequests) {
65+
const result = new Map();
66+
if (!pullRequests.length)
67+
return result;
68+
const [owner, name] = REPOSITORY.split('/');
69+
// A number can belong to an issue: issueOrPullRequest returns an empty object for it instead of an error.
70+
const query = `query {
71+
repository(owner: "${owner}", name: "${name}") {
72+
${pullRequests.map(number => `pr${number}: issueOrPullRequest(number: ${number}) {
73+
... on PullRequest { closingIssuesReferences(first: 50) { nodes { number title url repository { nameWithOwner } } } }
74+
}`).join('\n')}
75+
}
76+
}`;
77+
let output;
78+
try {
79+
output = run('gh', ['api', 'graphql', '-f', `query=${query}`]);
80+
} catch (error) {
81+
// gh fails when a number in a commit title does not exist, and still prints the other results.
82+
output = error.stdout;
83+
}
84+
const repository = JSON.parse(output || '{}').data?.repository;
85+
if (!repository)
86+
throw new Error('Failed to fetch the pull requests from GitHub');
87+
for (const number of pullRequests)
88+
result.set(number, repository[`pr${number}`]?.closingIssuesReferences?.nodes ?? []);
89+
return result;
90+
}
91+
92+
/** @param {string} previousTag */
93+
function browserVersions(previousTag) {
94+
// Compares with the working tree, because `npm run doc` can update the versions in browsers.json.
95+
if (!run('git', ['diff', '--name-only', previousTag, '--', BROWSERS_JSON]))
96+
return [];
97+
const { browsers } = JSON.parse(fs.readFileSync(path.join(ROOT_PATH, BROWSERS_JSON), 'utf8'));
98+
return Object.entries(BROWSER_TITLES).map(([name, title]) => {
99+
const browser = browsers.find(browser => browser.name === name);
100+
return `- ${title} ${browser.browserVersion}`;
101+
});
102+
}
103+
104+
const previousTag = process.argv[2];
105+
if (!previousTag) {
106+
console.error('usage: draft_patch_release_notes.js <previous-release-tag>');
107+
process.exit(1);
108+
}
109+
110+
const pullRequests = listPullRequests(previousTag);
111+
if (!pullRequests.length) {
112+
console.error(`ERROR: there are no commits after ${previousTag}`);
113+
process.exit(1);
114+
}
115+
116+
const fixedIssues = fetchFixedIssues([...new Set(pullRequests.flat())]);
117+
/** @type {Map<string, string>} */
118+
const issueLines = new Map();
119+
for (const issue of pullRequests.flat().flatMap(number => fixedIssues.get(number) ?? [])) {
120+
const reference = issue.repository.nameWithOwner === REPOSITORY ? `#${issue.number}` : `${issue.repository.nameWithOwner}#${issue.number}`;
121+
issueLines.set(issue.url, `- ${reference} ${issue.title}`);
122+
}
123+
124+
const sections = [];
125+
if (issueLines.size)
126+
sections.push(['### Bug Fixes', '', ...issueLines.values()]);
127+
const versions = browserVersions(previousTag);
128+
if (versions.length)
129+
sections.push(['### Browser Versions', '', ...versions]);
130+
131+
console.log(sections.map(section => section.join('\n')).join('\n\n'));

‎utils/publish_all_packages.sh‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ cd "$(dirname $0)"
1717
if [[ $1 == "--help" ]]; then
1818
echo "usage: $(basename $0) [--release|--alpha|--beta]"
1919
echo
20-
echo "Publishes all packages under <tag>-pending; the promote-npm job of publish_release.yml moves <tag> to them."
20+
echo "Publishes all packages under <tag>-pending; the promote-npm job of publish_release.yml or publish_manual.yml moves <tag> to them."
2121
echo
2222
echo "--release publish the @latest version of all packages"
2323
echo "--alpha publish the @next version of all packages"
@@ -77,7 +77,8 @@ fi
7777
# processes it, usually within 3 minutes, but playwright-core@1.65.0 took 110 minutes, during which
7878
# playwright@latest depended on a missing package. So the packages are published under
7979
# "<tag>-pending" and <tag> stays where it is: the promote-npm job of publish_release.yml moves it once
80-
# all of them are live, reading the list of published packages from npm-publish.json.
80+
# all of them are live, reading the list of published packages from npm-publish.json. For a patch
81+
# release, the promote-npm job of publish_manual.yml moves it when the GitHub release is published.
8182
echo "==================== Publishing version ${VERSION} ================"
8283
node ./utils/workspace.js --ensure-consistent
8384
PACKAGE_PATHS=$(node ./utils/workspace.js --list-public-package-paths)

0 commit comments

Comments
 (0)