diff --git a/README.md b/README.md index 916554e..19f14ec 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,19 @@ jobs: group: pack-${{ github.ref }} cancel-in-progress: false permissions: {} + outputs: + files: ${{ steps.pack.outputs.files }} steps: - - uses: danielroe/uppt/pack@fc0f1c61b54a79c6a354c8f9dbe821bc10a98893 # v0.5.0 + - id: pack + uses: danielroe/uppt/pack@fc0f1c61b54a79c6a354c8f9dbe821bc10a98893 # v0.5.0 # `publish` downloads the prebuilt tarball from the pack job's # artifact and stages it for publish. publish: - if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') + if: | + github.event_name == 'workflow_dispatch' + && startsWith(github.ref, 'refs/tags/v') + && needs.pack.outputs.files != '[]' needs: pack runs-on: ubuntu-latest concurrency: @@ -109,6 +115,8 @@ jobs: environment: npm # must match the trusted-publisher entry on npmjs.com steps: - uses: danielroe/uppt/publish@fc0f1c61b54a79c6a354c8f9dbe821bc10a98893 # v0.5.0 + with: + files: ${{ needs.pack.outputs.files }} ``` > [!IMPORTANT] @@ -143,7 +151,7 @@ When you merge a release PR, this subaction tags that commit, creates a GitHub R ### Packs a tarball (`danielroe/uppt/pack`) -This subaction installs the package's dependencies, runs `pnpm pack` (if you have a `pnpm-lock.yaml`) or `npm pack`, and uploads each resulting `.tgz` as a workflow artifact for the `publish` job to consume. +This subaction installs the package's dependencies, runs `pnpm pack --json` (if you have a `pnpm-lock.yaml`) or `npm pack --json`, and uploads each resulting `.tgz` as a workflow artifact for the `publish` job to consume. It exposes a `files` output (a JSON array of the produced tarball filenames) so the publish job can iterate them without re-scanning the artifact. | Input | Default | Description | | --- | --- | --- | @@ -151,6 +159,10 @@ This subaction installs the package's dependencies, runs `pnpm pack` (if you hav | `checkout` | `true` | Set to `false` if the caller has already checked out the tag ref. | | `install` | `true` | Set to `false` to handle `actions/setup-node` and dependency installation yourself. Useful when you want a pinned package manager version, a cached `node_modules`, or a hardened install policy. When `false`, the caller must put `node`, `npm`, and any package manager on PATH before `uppt/pack` runs. | +| Output | Description | +| --- | --- | +| `files` | JSON array of tarball filenames produced by `npm pack` / `pnpm pack` (e.g. `["my-pkg-1.2.3.tgz"]`). Pass through to `uppt/publish` via its `files` input. | + ### Stages a publish (`danielroe/uppt/publish`) This subaction downloads the tarball uploaded by `uppt/pack` in the same workflow run and runs `npm stage publish ./.tgz` with OIDC authentication. The staged version then needs to be approved by a maintainer with 2FA on npmjs.com before it goes live. @@ -162,6 +174,7 @@ This subaction downloads the tarball uploaded by `uppt/pack` in the same workflo | --- | --- | --- | | `node-version` | `24` | Node version for the scripts and for `npm stage publish`. Needs `--experimental-strip-types` (Node 22.6+, 24+ recommended). | | `npm-access` | `public` | npm access level (`public` or `restricted`). | +| `files` | _(scan artifact)_ | Optional JSON array of tarball filenames to publish, as emitted by `uppt/pack`'s `files` output. When omitted, every `*.tgz` in the downloaded artifact is published. | ## Prerequisites diff --git a/pack/action.yml b/pack/action.yml index bc5c2e3..f54eb3b 100644 --- a/pack/action.yml +++ b/pack/action.yml @@ -20,6 +20,11 @@ inputs: required: false default: 'true' +outputs: + files: + description: 'JSON array of tarball filenames (within the uploaded artifact) produced by `npm pack` / `pnpm pack`. Pass through to `uppt/publish` via its `files` input to skip the artifact-directory scan.' + value: ${{ steps.pack.outputs.files }} + runs: using: composite steps: @@ -90,6 +95,7 @@ runs: esac - name: Pack tarball(s) + id: pack shell: bash env: PACK_OUT_DIR: ${{ runner.temp }}/uppt-pack diff --git a/publish/action.yml b/publish/action.yml index 8538eb7..c7b79a0 100644 --- a/publish/action.yml +++ b/publish/action.yml @@ -15,6 +15,10 @@ inputs: description: 'npm access level (`public` or `restricted`).' required: false default: 'public' + files: + description: 'Optional JSON array of tarball filenames to publish, as emitted by `uppt/pack`''s `files` output. When omitted, the action publishes every `*.tgz` it finds in the downloaded artifact.' + required: false + default: '' runs: using: composite @@ -59,4 +63,5 @@ runs: env: NPM_ACCESS: ${{ inputs.npm-access }} TARBALL_DIR: ${{ runner.temp }}/uppt-tarballs + TARBALL_FILES: ${{ inputs.files }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/publish.ts diff --git a/scripts/pack.ts b/scripts/pack.ts index 29dbc47..f2e29ab 100644 --- a/scripts/pack.ts +++ b/scripts/pack.ts @@ -13,29 +13,46 @@ // PACK_OUT_DIR directory to write the `*.tgz` into (created if // missing). The action then uploads its contents as // a workflow artifact. +// GITHUB_OUTPUT set by the runner; receives `files=`. // GITHUB_REF must be `refs/tags/v*` (set automatically) import process from 'node:process' import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs' -import { resolve } from 'node:path' +import { appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs' +import { basename, resolve } from 'node:path' -function run (cmd: string, args: string[], cwd?: string) { - console.log('$', cmd, ...args, cwd ? `(cwd: ${cwd})` : '') - execFileSync(cmd, args, { stdio: 'inherit', cwd }) +function runCapture (cmd: string, args: string[]): string { + console.log('$', cmd, ...args) + return execFileSync(cmd, args, { + stdio: ['ignore', 'pipe', 'inherit'], + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }) } -function tarballGlobPrefix (pkgName: string): string { - // npm/pnpm pack names tarballs `-.tgz` for unscoped - // packages and `--.tgz` for scoped ones (the - // leading `@` is stripped and the `/` becomes `-`). - return pkgName.replace(/^@/, '').replace(/\//g, '-') -} - -function findTarballs (dir: string, prefix: string): string[] { - return readdirSync(dir) - .filter(f => f.startsWith(`${prefix}-`) && f.endsWith('.tgz')) - .sort() +// `npm pack --json` emits an array of pack records with a bare +// `filename`; `pnpm pack --json` emits a single object whose +// `filename` is an absolute path. Normalise both to basenames so the +// step output matches what `actions/upload-artifact` puts in the +// artifact (and what `publish.ts` resolves under `TARBALL_DIR`). +function parseFilenames (stdout: string): string[] { + const data = JSON.parse(stdout) as unknown + const records = Array.isArray(data) ? data : [data] + const filenames: string[] = [] + for (const entry of records) { + if (!entry || typeof entry !== 'object' || !('filename' in entry)) { + throw new Error(`Unexpected pack JSON entry without 'filename': ${JSON.stringify(entry)}`) + } + const filename = (entry as { filename: unknown }).filename + if (typeof filename !== 'string' || !filename.endsWith('.tgz')) { + throw new Error(`Unexpected pack JSON 'filename': ${JSON.stringify(filename)}`) + } + filenames.push(basename(filename)) + } + if (!filenames.length) { + throw new Error('Pack tool produced JSON with no tarball filenames') + } + return filenames } function main () { @@ -52,25 +69,29 @@ function main () { if (!outDir) throw new Error('PACK_OUT_DIR is required') mkdirSync(outDir, { recursive: true }) - const pkgPath = resolve(process.cwd(), 'package.json') - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name: string } const hasPnpmLock = existsSync(resolve(process.cwd(), 'pnpm-lock.yaml')) + let stdout: string if (hasPnpmLock) { - run('pnpm', ['pack', '--pack-destination', outDir]) + stdout = runCapture('pnpm', ['pack', '--pack-destination', outDir, '--json']) } else { - run('npm', ['pack', '--pack-destination', outDir]) + stdout = runCapture('npm', ['pack', '--pack-destination', outDir, '--json', '--silent']) } - const prefix = tarballGlobPrefix(pkg.name) - const tarballs = findTarballs(outDir, prefix) - if (!tarballs.length) { - throw new Error(`No tarball matching ${prefix}-*.tgz found in ${outDir} after pack`) + const filenames = parseFilenames(stdout) + + for (const filename of filenames) { + const tarballPath = resolve(outDir, filename) + if (!existsSync(tarballPath)) { + throw new Error(`Pack tool reported '${filename}' but it is not present in ${outDir}`) + } + const size = statSync(tarballPath).size + console.log(`Packed ${filename} (${size} bytes) for ${tag}`) } - for (const tarball of tarballs) { - const size = statSync(resolve(outDir, tarball)).size - console.log(`Packed ${tarball} (${size} bytes) for ${tag}`) + const githubOutput = process.env.GITHUB_OUTPUT + if (githubOutput) { + appendFileSync(githubOutput, `files=${JSON.stringify(filenames)}\n`) } } diff --git a/scripts/publish.ts b/scripts/publish.ts index 9a336fa..bc08974 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -4,15 +4,19 @@ // // The tarball(s) were produced by `uppt/pack` in an earlier job in the // same workflow run and downloaded into `TARBALL_DIR` by -// `actions/download-artifact`. +// `actions/download-artifact`. When `TARBALL_FILES` is set (a JSON +// array of filenames, emitted by `uppt/pack` as a step output), we +// publish exactly those, in order. Otherwise we fall back to scanning +// `TARBALL_DIR` for `*.tgz`. // // `npm publish ` doesn't run lifecycle scripts in any case // (the tarball is treated as an opaque artifact), but we still pass // `--ignore-scripts` for clarity. // // Env: -// NPM_ACCESS `public` (default) or `restricted` -// TARBALL_DIR directory holding the prebuilt `*.tgz` files +// NPM_ACCESS `public` (default) or `restricted` +// TARBALL_DIR directory holding the prebuilt `*.tgz` files +// TARBALL_FILES optional JSON array of filenames within TARBALL_DIR import process from 'node:process' import { execFileSync } from 'node:child_process' @@ -24,8 +28,23 @@ function run (cmd: string, args: string[]) { execFileSync(cmd, args, { stdio: 'inherit' }) } -function findTarballs (dir: string): string[] { - return readdirSync(dir).filter(f => f.endsWith('.tgz')).sort() +function parseTarballFiles (raw: string): string[] { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } + catch (err) { + throw new Error(`TARBALL_FILES is not valid JSON: ${(err as Error).message}`) + } + if (!Array.isArray(parsed)) { + throw new Error('TARBALL_FILES must be a JSON array of filenames') + } + for (const entry of parsed) { + if (typeof entry !== 'string' || !entry.endsWith('.tgz')) { + throw new Error(`TARBALL_FILES contains a non-tarball entry: ${JSON.stringify(entry)}`) + } + } + return parsed as string[] } function main () { @@ -35,13 +54,26 @@ function main () { if (!dir) throw new Error('TARBALL_DIR is required') if (!existsSync(dir)) throw new Error(`TARBALL_DIR does not exist: ${dir}`) - const tarballs = findTarballs(dir) - if (!tarballs.length) { - throw new Error(`No *.tgz found in ${dir}. Did the pack job upload the artifact?`) + const filesEnv = process.env.TARBALL_FILES?.trim() + let tarballs: string[] + if (filesEnv) { + tarballs = parseTarballFiles(filesEnv) + if (!tarballs.length) { + throw new Error('TARBALL_FILES was provided but is empty') + } + } + else { + tarballs = readdirSync(dir).filter(f => f.endsWith('.tgz')).sort() + if (!tarballs.length) { + throw new Error(`No *.tgz found in ${dir}. Did the pack job upload the artifact?`) + } } for (const tarball of tarballs) { const tarballPath = resolve(dir, tarball) + if (!existsSync(tarballPath)) { + throw new Error(`Tarball '${tarball}' is not present in ${dir}`) + } run('npm', ['stage', 'publish', tarballPath, '--provenance', '--ignore-scripts', `--access=${access}`]) } }