Skip to content

Commit 8a25cfb

Browse files
pavelfeldmandgozman
authored andcommitted
cherry-pick(#43979): devops: publish npm packages under a pending dist-tag, promote them once live
1 parent 2af2c4e commit 8a25cfb

6 files changed

Lines changed: 185 additions & 26 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: "promote release - npm dist-tags"
2+
3+
# publish_release.yml publishes every package under "<tag>-pending" (next-pending, beta-pending or
4+
# latest-pending) and leaves <tag> where it is. This moves <tag> to the new version once npm has made
5+
# all of the packages live, so next, beta and latest never point at a package whose dependency is
6+
# still missing. To retry, run it by hand with the ID of the publish run.
7+
8+
on:
9+
workflow_run:
10+
workflows: ["publish release - npm, trace viewer"]
11+
types: [completed]
12+
workflow_dispatch:
13+
inputs:
14+
run_id:
15+
description: "ID of the publish release run to promote"
16+
required: true
17+
18+
jobs:
19+
promote-npm:
20+
name: "move the npm dist-tag"
21+
runs-on: ubuntu-24.04
22+
if: github.repository == 'microsoft/playwright'
23+
timeout-minutes: 330
24+
permissions:
25+
id-token: write # This is required for OIDC login (npm dist-tag) to succeed
26+
actions: read # This is required to download npm-publish.json from the publish run
27+
contents: read # This is required for actions/checkout to succeed
28+
steps:
29+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
30+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
31+
with:
32+
node-version: lts/*
33+
registry-url: 'https://registry.npmjs.org'
34+
# npm dist-tag supports OIDC login from npm 11.21.0.
35+
- run: npm install -g npm@11.21.1
36+
# The publish run uploads npm-publish.json only after it published every package. Runs that publish
37+
# nothing, and release branches that still publish straight to <tag>, have none.
38+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
39+
id: manifest
40+
continue-on-error: true
41+
with:
42+
name: npm-publish
43+
run-id: ${{ inputs.run_id || github.event.workflow_run.id }}
44+
github-token: ${{ github.token }}
45+
- name: Move the dist-tag once all packages are live
46+
if: steps.manifest.outcome == 'success'
47+
run: node utils/promote_npm_packages.js npm-publish.json

‎.github/workflows/publish_release.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ jobs:
4545
- name: "publish release to NPM"
4646
if: github.event_name == 'release' && github.event.action == 'published'
4747
run: utils/publish_all_packages.sh --release
48+
# promote_release.yml reads it to move the dist-tag once all packages are live.
49+
- name: Upload the list of published packages
50+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
51+
with:
52+
name: npm-publish
53+
path: npm-publish.json
54+
if-no-files-found: ignore
4855

4956
publish-trace-viewer:
5057
name: "publish Trace Viewer to trace.playwright.dev"

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ playwright.env
4242
/firefox/
4343
.playwright/
4444
.npmrc
45+
/npm-publish.json

‎utils/promote_npm_packages.js‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
// Moves a dist-tag (next, beta or latest) to the packages that publish_all_packages.sh published
20+
// under "<tag>-pending", once all of them are live. npm processes a package after accepting it,
21+
// and that took up to 110 minutes, so this waits for hours before giving up.
22+
//
23+
// usage: promote_npm_packages.js <npm-publish.json>
24+
25+
const { execFileSync } = require('child_process');
26+
const fs = require('fs');
27+
const os = require('os');
28+
const path = require('path');
29+
30+
const POLL_INTERVAL_MS = 60 * 1000;
31+
const TIMEOUT_MS = 5 * 60 * 60 * 1000;
32+
33+
/** @type {{ version: string, tag: string, packages: string[] }} */
34+
const { version, tag, packages } = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
35+
const pendingTag = `${tag}-pending`;
36+
37+
/**
38+
* @param {string[]} args
39+
* @param {{ quiet?: boolean }} options
40+
*/
41+
function npm(args, { quiet = false } = {}) {
42+
return execFileSync('npm', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', quiet ? 'pipe' : 'inherit'] }).trim();
43+
}
44+
45+
/** @param {string} name */
46+
function isLive(name) {
47+
try {
48+
return npm(['view', `${name}@${version}`, 'version', '--prefer-online'], { quiet: true }) === version;
49+
} catch {
50+
return false;
51+
}
52+
}
53+
54+
async function waitUntilLive() {
55+
const deadline = Date.now() + TIMEOUT_MS;
56+
let missing = packages;
57+
while (true) {
58+
missing = missing.filter(name => !isLive(name));
59+
if (!missing.length)
60+
return;
61+
if (Date.now() > deadline)
62+
throw new Error(`Not live after ${TIMEOUT_MS / 3600000} hours: ${missing.map(name => `${name}@${version}`).join(', ')}`);
63+
console.log(`Waiting for ${missing.map(name => `${name}@${version}`).join(', ')}`);
64+
await new Promise(f => setTimeout(f, POLL_INTERVAL_MS));
65+
}
66+
}
67+
68+
function checkInstall() {
69+
// Install them together, as users do, to check that the tarballs download and the dependencies resolve.
70+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'promote-'));
71+
npm(['install', '--prefix', dir, '--ignore-scripts', '--no-audit', '--no-fund', ...packages.map(name => `${name}@${version}`)]);
72+
fs.rmSync(dir, { recursive: true, force: true });
73+
}
74+
75+
function moveDistTags() {
76+
for (const name of packages) {
77+
const distTags = JSON.parse(npm(['view', name, 'dist-tags', '--json', '--prefer-online']));
78+
const current = distTags[tag];
79+
if (current === version) {
80+
console.log(`${name}@${tag} is already ${version}`);
81+
} else if (current && publishTime(name, current) > publishTime(name, version)) {
82+
// A later run promoted a newer version first.
83+
console.log(`${name}@${tag} stays at ${current}, which was published after ${version}`);
84+
} else {
85+
npm(['dist-tag', 'add', `${name}@${version}`, tag]);
86+
}
87+
if (distTags[pendingTag] === version)
88+
npm(['dist-tag', 'rm', name, pendingTag]);
89+
}
90+
}
91+
92+
/** @type {Map<string, Record<string, string>>} */
93+
const publishTimes = new Map();
94+
95+
/**
96+
* @param {string} name
97+
* @param {string} packageVersion
98+
*/
99+
function publishTime(name, packageVersion) {
100+
if (!publishTimes.has(name))
101+
publishTimes.set(name, JSON.parse(npm(['view', name, 'time', '--json', '--prefer-online'])));
102+
return publishTimes.get(name)?.[packageVersion] ?? '';
103+
}
104+
105+
(async () => {
106+
console.log(`Promoting ${version} to @${tag}: ${packages.join(', ')}`);
107+
await waitUntilLive();
108+
checkInstall();
109+
moveDistTags();
110+
console.log('Done.');
111+
})().catch(error => {
112+
console.error(`ERROR: ${error.message}`);
113+
process.exit(1);
114+
});

‎utils/publish_all_packages.sh‎

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ cd "$(dirname $0)"
1717
if [[ $1 == "--help" ]]; then
1818
echo "usage: $(basename $0) [--release|--alpha|--beta]"
1919
echo
20-
echo "Publishes all packages."
20+
echo "Publishes all packages under <tag>-pending; promote_release.yml moves <tag> to them."
2121
echo
22-
echo "--release publish @latest version of all packages"
23-
echo "--alpha publish @next version of all packages"
24-
echo "--beta publish @beta version of all packages"
22+
echo "--release publish the @latest version of all packages"
23+
echo "--alpha publish the @next version of all packages"
24+
echo "--beta publish the @beta version of all packages"
2525
exit 1
2626
fi
2727

@@ -73,31 +73,21 @@ else
7373
exit 1
7474
fi
7575

76-
# A successful `npm publish` only means the registry accepted the package: it is processed
77-
# asynchronously and may show up minutes later, or never (playwright-core@1.65.0 never did,
78-
# leaving playwright@1.65.0 on @latest pointing to a missing dependency).
79-
function wait_until_available {
80-
local name="$1"
81-
local attempt
82-
for attempt in $(seq 60); do
83-
if [[ "$(npm view "${name}@${VERSION}" version --prefer-online 2>/dev/null)" == "${VERSION}" ]]; then
84-
return 0
85-
fi
86-
sleep 15
87-
done
88-
echo "ERROR: ${name}@${VERSION} was accepted by npm but did not become available within 15 minutes"
89-
return 1
90-
}
91-
76+
# A successful `npm publish` only means the registry accepted the package: it goes live after npm
77+
# processes it, usually within 3 minutes, but playwright-core@1.65.0 took 110 minutes, during which
78+
# playwright@latest depended on a missing package. So the packages are published under
79+
# "<tag>-pending" and <tag> stays where it is: promote_release.yml moves it once all of them are live,
80+
# reading the list of published packages from npm-publish.json.
9281
echo "==================== Publishing version ${VERSION} ================"
9382
node ./utils/workspace.js --ensure-consistent
94-
# Packages are listed in dependency order. Wait for each one to go live before publishing the next,
95-
# so nothing is published (and tagged) while a package it depends on is missing.
9683
PACKAGE_PATHS=$(node ./utils/workspace.js --list-public-package-paths)
84+
PACKAGE_NAMES=()
9785
for package in ${PACKAGE_PATHS}; do
98-
name=$(node -p "require('${package}/package.json').name")
99-
npm publish --access=public "${package}" --tag="${NPM_PUBLISH_TAG}"
100-
wait_until_available "${name}"
86+
PACKAGE_NAMES+=("$(node -p "require('${package}/package.json').name")")
87+
npm publish --access=public "${package}" --tag="${NPM_PUBLISH_TAG}-pending"
10188
done
10289

90+
node -e 'const [version, tag, ...packages] = process.argv.slice(1); console.log(JSON.stringify({ version, tag, packages }, null, 2));' \
91+
"${VERSION}" "${NPM_PUBLISH_TAG}" "${PACKAGE_NAMES[@]}" > npm-publish.json
92+
10393
echo "Done."

‎utils/workspace.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ async function parseCLI() {
234234
die(`\n ERROR: package-lock.json lockfileVersion must be 3`);
235235
},
236236
'--list-public-package-paths': () => {
237-
// Dependency order: publishing relies on it to never publish a package before its dependencies.
237+
// Dependency order: packages are published, and their dist-tags moved, after their dependencies.
238238
for (const pkg of workspace.publicPackagesInDependencyOrder())
239239
console.log(pkg.path);
240240
},

0 commit comments

Comments
 (0)