|
| 1 | +#!/usr/bin/env node |
| 2 | +// What each package on the released commit is called and which version it |
| 3 | +// carries, for `release.yml` (#4873). |
| 4 | +// |
| 5 | +// The publish jobs hold the OIDC credential and publish an artifact a build |
| 6 | +// job produced. That artifact is untrusted input: it was built after |
| 7 | +// dependency install scripts ran, and every package in this repository |
| 8 | +// trusts the same workflow and environment, so a tampered build could hand |
| 9 | +// the publish job a tarball named for a DIFFERENT trusted package, or |
| 10 | +// carrying an arbitrary version. The publish jobs therefore compare what they |
| 11 | +// are about to publish with what the released source says it should be. |
| 12 | +// |
| 13 | +// That expectation has to come from somewhere dependency code has not run. |
| 14 | +// `detect-packages` is that place: it checks out the tag and runs this script, |
| 15 | +// which reads the manifests with nothing installed and no third-party import. |
| 16 | +// Its output reaches each publish leg as a job output, not as a file a build |
| 17 | +// job could rewrite. |
| 18 | +// |
| 19 | +// Under GitHub Actions it writes two step outputs, `npm_expected` and |
| 20 | +// `pypi_expected`, each a JSON object keyed by the package's directory under |
| 21 | +// `src/`: `{ "memory": { "name": "@scope/pkg", "version": "1.0.0" } }`. It |
| 22 | +// always prints the same JSON to stdout. |
| 23 | + |
| 24 | +import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs"; |
| 25 | +import path from "node:path"; |
| 26 | +import { fileURLToPath, pathToFileURL } from "node:url"; |
| 27 | +import { readProject } from "./prepare-python-release.mjs"; |
| 28 | + |
| 29 | +const repoRoot = path.resolve( |
| 30 | + path.dirname(fileURLToPath(import.meta.url)), |
| 31 | + "..", |
| 32 | +); |
| 33 | + |
| 34 | +/** |
| 35 | + * The name and version of every package under `src/`, by registry. |
| 36 | + * |
| 37 | + * @param {string} [root] the repository root |
| 38 | + * @returns {{ npm: Record<string, { name: string, version: string }>, pypi: Record<string, { name: string, version: string }> }} |
| 39 | + */ |
| 40 | +export function expectedPackages(root = repoRoot) { |
| 41 | + const src = path.join(root, "src"); |
| 42 | + const npm = {}; |
| 43 | + const pypi = {}; |
| 44 | + for (const entry of readdirSync(src, { withFileTypes: true })) { |
| 45 | + if (!entry.isDirectory()) continue; |
| 46 | + const dir = path.join(src, entry.name); |
| 47 | + const manifest = path.join(dir, "package.json"); |
| 48 | + if (existsSync(manifest)) { |
| 49 | + const { name, version } = JSON.parse(readFileSync(manifest, "utf8")); |
| 50 | + npm[entry.name] = identity(name, version, manifest); |
| 51 | + } |
| 52 | + const pyproject = path.join(dir, "pyproject.toml"); |
| 53 | + if (existsSync(pyproject)) { |
| 54 | + const { name, version } = readProject(readFileSync(pyproject, "utf8")); |
| 55 | + pypi[entry.name] = identity(name, version, pyproject); |
| 56 | + } |
| 57 | + } |
| 58 | + return { npm, pypi }; |
| 59 | +} |
| 60 | + |
| 61 | +/** A name and a version, both non-empty strings, or a loud failure. */ |
| 62 | +function identity(name, version, file) { |
| 63 | + if (typeof name !== "string" || name === "") |
| 64 | + throw new Error(`${file} has no package name`); |
| 65 | + if (typeof version !== "string" || version === "") |
| 66 | + throw new Error(`${file} has no version`); |
| 67 | + return { name, version }; |
| 68 | +} |
| 69 | + |
| 70 | +/** |
| 71 | + * The file-name stem PyPI distributions of a project carry: the project name |
| 72 | + * with every run of `-`, `_` and `.` turned into one `_`, lowercased, then |
| 73 | + * `-<version>`. A wheel is `<stem>-<tags>.whl`, an sdist `<stem>.tar.gz`. |
| 74 | + * |
| 75 | + * @param {string} name |
| 76 | + * @param {string} version |
| 77 | + * @returns {string} |
| 78 | + */ |
| 79 | +export function distributionStem(name, version) { |
| 80 | + return `${name.replace(/[-_.]+/g, "_").toLowerCase()}-${version}`; |
| 81 | +} |
| 82 | + |
| 83 | +/** |
| 84 | + * @param {object} [options] |
| 85 | + * @param {string} [options.root] |
| 86 | + * @param {NodeJS.ProcessEnv} [options.env] |
| 87 | + * @returns {number} the exit code |
| 88 | + */ |
| 89 | +export function main({ root = repoRoot, env = process.env } = {}) { |
| 90 | + const { npm, pypi } = expectedPackages(root); |
| 91 | + // The stem is computed here, once, so the publish job's shell compares file |
| 92 | + // names with a string and does no normalizing of its own. |
| 93 | + for (const pkg of Object.values(pypi)) |
| 94 | + pkg.stem = distributionStem(pkg.name, pkg.version); |
| 95 | + console.log(JSON.stringify({ npm, pypi }, null, 2)); |
| 96 | + if (env.GITHUB_OUTPUT) |
| 97 | + appendFileSync( |
| 98 | + env.GITHUB_OUTPUT, |
| 99 | + `npm_expected=${JSON.stringify(npm)}\npypi_expected=${JSON.stringify(pypi)}\n`, |
| 100 | + ); |
| 101 | + return 0; |
| 102 | +} |
| 103 | + |
| 104 | +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) |
| 105 | + process.exit(main()); |
0 commit comments