From 361f4fb8607a95d4fb741f6da75c73211f05565f Mon Sep 17 00:00:00 2001 From: Totto16 Date: Mon, 18 Aug 2025 18:45:35 +0200 Subject: [PATCH 1/3] feat: add script for publishing all packages isn this repo to the registry, with provenance --- .github/release-script/.gitignore | 2 + .github/release-script/package-lock.json | 33 +++ .github/release-script/package.json | 14 + .github/release-script/src/index.ts | 336 +++++++++++++++++++++++ .github/release-script/tsconfig.json | 12 + .github/workflows/release.yml | 42 +++ 6 files changed, 439 insertions(+) create mode 100644 .github/release-script/.gitignore create mode 100644 .github/release-script/package-lock.json create mode 100644 .github/release-script/package.json create mode 100644 .github/release-script/src/index.ts create mode 100644 .github/release-script/tsconfig.json create mode 100644 .github/workflows/release.yml diff --git a/.github/release-script/.gitignore b/.github/release-script/.gitignore new file mode 100644 index 000000000..b38db2f29 --- /dev/null +++ b/.github/release-script/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +build/ diff --git a/.github/release-script/package-lock.json b/.github/release-script/package-lock.json new file mode 100644 index 000000000..3bf9ab934 --- /dev/null +++ b/.github/release-script/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "release-script", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "release-script", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^24.3.0" + } + }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/.github/release-script/package.json b/.github/release-script/package.json new file mode 100644 index 000000000..05bb3ccf8 --- /dev/null +++ b/.github/release-script/package.json @@ -0,0 +1,14 @@ +{ + "name": "release-script", + "version": "1.0.0", + "main": "build/index.js", + "scripts": { + "build": "tsc" + }, + "author": "", + "license": "MIT", + "description": "", + "devDependencies": { + "@types/node": "^24.3.0" + } +} diff --git a/.github/release-script/src/index.ts b/.github/release-script/src/index.ts new file mode 100644 index 000000000..b44c294cf --- /dev/null +++ b/.github/release-script/src/index.ts @@ -0,0 +1,336 @@ +import * as fsAsync from "node:fs/promises" +import * as fs from "node:fs" +import * as path from "node:path" +import * as child_process from "node:child_process" + +type MatchFn = (file: string) => boolean + +async function getAllFilesMatching( + folder: string, + fn: MatchFn +): Promise { + if (!path.isAbsolute(folder)) { + throw new Error( + `Implementation error: not an absolute path: '${folder}'` + ) + } + + const result: string[] = [] + + const subFiles = await fsAsync.readdir(folder) + + for (const file_ of subFiles) { + const file = path.join(folder, file_) + + const stat = await fsAsync.stat(file) + if (stat.isDirectory()) { + const subResult = await getAllFilesMatching(file, fn) + result.push(...subResult) + } else { + if (fn(file)) { + result.push(file) + } + } + } + + return result +} + +interface Package { + name: string + version: string + rootFolder: string +} + +interface PackageInfo { + name: string + version: string +} + +interface PackageInfoRaw extends Record { + name?: string | undefined + version?: string | undefined +} + +async function getPackageInfo(packageFile: string): Promise { + const packageInfoRaw = await ( + await fsAsync.readFile(packageFile) + ).toString() + + const packageInfo = JSON.parse(packageInfoRaw) as PackageInfoRaw + + if (typeof packageInfo.name !== "string") { + throw new Error(`Invalid package.json: ${packageFile}`) + } + + if (typeof packageInfo.version !== "string") { + throw new Error(`Invalid package.json: ${packageFile}`) + } + + return { name: packageInfo.name, version: packageInfo.version } +} + +async function getPackage(packageFile: string): Promise { + const packageInfo = await getPackageInfo(packageFile) + + const rootFolder = path.dirname(packageFile) + + return { name: packageInfo.name, version: packageInfo.version, rootFolder } +} + +interface NPMVersionInfo { + name: string + version: string +} + +interface NPMData { + versions: Record +} + +type NpmStatus = "unpublished" | "published" + +function getNormalizedRegistryUrl(url: string): string { + let result = url + if (!result.startsWith("http")) { + result = `https://${result}` + } + + if (!result.endsWith("/")) { + result += "/" + } + + return result +} + +async function getNpmStatus( + pkg: Package, + registry: string +): Promise { + const url = `${registry}${pkg.name}` + + try { + const res = await fetch(url) + if (!res.ok) + throw new Error( + `Failed to fetch package info for package ${pkg.name}: Status Code ${res.status}` + ) + + const data = (await res.json()) as NPMData + + const versionInfo = data.versions[pkg.version] + + if (versionInfo === undefined) { + return "unpublished" + } else { + return "published" + } + } catch (error) { + throw new Error( + `Failed to fetch package info for package ${pkg.name}: ${(error as Error).message}` + ) + } +} + +interface Status { + status: NpmStatus + pkg: Package +} + +type Tag = "latest" + +function getTagFromPackage(_pkg: Package): Tag { + //TODO: once we use more than one tag, get the tag from the name properly + const tag = "latest" + + return tag +} + +async function processPackage( + pkg: Package, + token: string, + registry: string, + timeoutSec: number +): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject( + new Error( + `Failed to publish for package ${pkg.name}: timeout (${timeoutSec} secs)` + ) + ) + }, timeoutSec * 1000) + + const tag = getTagFromPackage(pkg) + + const proc = child_process.spawn( + "npm", + [ + "publish", + "--tag", + tag, + "--access", + "public", + "--provenance", + "--registry", + registry, + ], + { + cwd: pkg.rootFolder, + env: { NPM_TOKEN: token }, + shell: true, + stdio: "pipe", + } + ) + + let stderr = "" + + proc.stderr.on("data", (data) => { + stderr += data.toString() + }) + + proc.on("error", (err) => { + reject( + new Error( + `Failed to publish for package ${pkg.name}: ${err.message}` + ) + ) + return + }) + + proc.on("exit", (code, signal) => { + if (signal !== null) { + console.error(stderr) + reject( + new Error( + `Failed to publish for package ${pkg.name}: caught signal ${signal}` + ) + ) + return + } + + if (code !== 0) { + console.error(stderr) + reject( + new Error( + `Failed to publish for package ${pkg.name}: process exited with status code ${code}` + ) + ) + return + } + + resolve() + return + }) + }) +} + +async function collectPackages(): Promise { + const cwd = process.cwd() + + const currentDir = __dirname + + const expectedPackagePath = path.join(cwd, ".github", "release-script") + + let packagePath = path.resolve(currentDir) + + while (!fs.existsSync(path.join(packagePath, "package.json"))) { + packagePath = path.join(packagePath, "..") + if (packagePath === "" || packagePath === "/") { + throw new Error( + `Couldn't find package path by searching for package.json` + ) + } + } + + if (packagePath !== expectedPackagePath) { + throw new Error( + `Script executed from wrong cwd: expected package path to be '${expectedPackagePath}' but it was '${packagePath}'` + ) + } + + const allPackageFiles: string[] = await getAllFilesMatching( + process.cwd(), + (file: string) => { + if (path.dirname(file).includes(".github/release-script")) { + return false + } + + return path.basename(file) === "package.json" + } + ) + + const packages: Package[] = await Promise.all( + allPackageFiles.map((packageFile) => getPackage(packageFile)) + ) + return packages +} + +function isValidRegistry(_registry: string): boolean { + //TODO: implement, if necessary + return true +} + +interface Options { + token: string + registry: string + timeoutSec: number +} + +function getOptions(): Options { + const token = process.env["NPM_TOKEN"] + + if (token === undefined || token === "") { + throw new Error(`env variable NPM_TOKEN not specified`) + } + + let registry = "https://registry.npmjs.org/" + + if (process.env["NPM_REGISTRY"] !== undefined) { + registry = process.env["NPM_REGISTRY"] + } + + if (!isValidRegistry(registry)) { + throw new Error(`Invalid registry: ${registry}`) + } + + let timeoutSec: number = 60 + + if (process.env["NPM_TIMEOUT_SEC"] !== undefined) { + const timeoutRaw = process.env["NPM_TIMEOUT_SEC"] + const timeoutNum = Number.parseInt(timeoutRaw) + + if (Number.isNaN(timeoutNum)) { + throw new Error( + `Specified invalid timeout secs, not a number: ${timeoutRaw}` + ) + } + + timeoutSec = timeoutNum + } + + return { registry: getNormalizedRegistryUrl(registry), timeoutSec, token } +} + +async function main(): Promise { + const { registry, timeoutSec, token } = getOptions() + + const packages: Package[] = await collectPackages() + + const npmStatus: Status[] = await Promise.all( + packages.map(async (pkg) => { + const status = await getNpmStatus(pkg, registry) + + return { status, pkg } + }) + ) + + await Promise.all( + npmStatus.map(async (status): Promise => { + if (status.status === "unpublished") { + await processPackage(status.pkg, token, registry, timeoutSec) + } + }) + ) +} + +void main() diff --git a/.github/release-script/tsconfig.json b/.github/release-script/tsconfig.json new file mode 100644 index 000000000..d107978f0 --- /dev/null +++ b/.github/release-script/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "strict": true, + "rootDir": "src", + "outDir": "build" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..df28bf799 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release CI + +on: + release: + types: published + push: + branches: + - "main" + +env: + node-version: 22.x + +jobs: + release: + name: Release CI + runs-on: ubuntu-24.04 + environment: npm-release + permissions: + contents: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # This also setups a .npmrc file to publish to npm + - name: Use Node.js ${{ env.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.node-version }} + + - name: Build Script + run: | + cd ./.github/release-script + npm ci + npm run build + + + - name: Publish + run: node ./.github/release-script/ + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} From 36eafa837718ff4ca76d9f971a8efd99d9ca5113 Mon Sep 17 00:00:00 2001 From: Totto16 Date: Mon, 18 Aug 2025 18:46:04 +0200 Subject: [PATCH 2/3] feat: add codeowners file --- .github/CODEOWNERS | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..b232917f0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# CODEOWNERS file for @girs/types repository +# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# Critical infrastructure files - require review from core maintainers +/.github/** @JumpLink @ewlsh +/package.json @JumpLink @ewlsh + +# Package configurations - critical for npm releases +/**/package.json @JumpLink @ewlsh + +# CODEOWNERS file itself - only core maintainers can change +/.github/CODEOWNERS @JumpLink @ewlsh + From 036ea492aace84b3f98989f9c67c69d38f29d76f Mon Sep 17 00:00:00 2001 From: Totto16 Date: Mon, 18 Aug 2025 20:25:14 +0200 Subject: [PATCH 3/3] use node directly in release script --- .github/workflows/release.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df28bf799..022a6d442 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,14 +29,7 @@ jobs: with: node-version: ${{ env.node-version }} - - name: Build Script - run: | - cd ./.github/release-script - npm ci - npm run build - - - name: Publish - run: node ./.github/release-script/ + run: node --experimental-specifier-resolution=node --experimental-strip-types --experimental-transform-types --no-warnings ./.github/release-script/src/index.ts env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }}