diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e76127682..7e66c13fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -509,6 +509,14 @@ }, { "run": "npm pack" + }, + { + "uses": "actions/upload-artifact@v7.0.1", + "with": { + "name": "package-tarball", + "path": "*.tgz", + "if-no-files-found": "error" + } } ] }, diff --git a/fjs/ci/config/module.f.mjs b/fjs/ci/config/module.f.mjs index ba7be1fe5..dfcfa8411 100644 --- a/fjs/ci/config/module.f.mjs +++ b/fjs/ci/config/module.f.mjs @@ -73,6 +73,8 @@ export const actions = /** @type {const} */({ 'actions/setup-node': 'v7.0.0', // https://github.com/marketplace/actions/cache 'actions/cache': 'v6.1.0', + // https://github.com/marketplace/actions/upload-a-build-artifact + 'actions/upload-artifact': 'v7.0.1', // https://github.com/marketplace/actions/setup-deno 'denoland/setup-deno': 'v2.0.5', // https://github.com/marketplace/actions/setup-bun diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index 57c6aaf58..20ffa63eb 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -12,6 +12,12 @@ import { node } from '../config/module.f.mjs' import { install, test, ubuntuArm, uses } from '../common/module.f.mjs' import { nixInstall, nixVersionCheckStep } from '../nix/module.f.mjs' +/** + * Name of the CI artifact carrying the `npm pack` tarball. The producing step + * is below; a consuming job downloads it by this name. + */ +export const packageArtifact = /** @type {const} */ ('package-tarball') + /** @type {(v: string) => string} */ export const major = v => v.split('.')[0] @@ -71,6 +77,16 @@ const node26Steps = [ test({ run: 'npx tsc' }), test({ run: 'npm run cov' }), test({ run: 'npm pack' }), + // Hands the tarball to a job that has no checkout, which is the only place + // the package can be checked as a consumer sees it. `if-no-files-found` + // must be `error`: the default warns and uploads nothing, so a consuming + // job would fail later on a missing artifact rather than here on the real + // cause. + test(uses('actions/upload-artifact', { + name: packageArtifact, + path: '*.tgz', + 'if-no-files-found': 'error', + })), ] /** @type {(steps: readonly MetaStep[]) => Job} */ diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index b0c869039..3209943e3 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -6,8 +6,8 @@ import { exitCode } from '../effects/node/module.f.mjs' import { ci, main } from './module.f.mjs' -import { functionalscript, node } from './config/module.f.mjs' -import { nodeNixJobs } from './node/module.f.mjs' +import { actions, functionalscript, node } from './config/module.f.mjs' +import { major, nodeNixJobs, packageArtifact } from './node/module.f.mjs' import { utf8, utf8ToString } from '../text/module.f.mjs' import { empty as emptyVec } from '../types/bit_vec/module.f.mjs' import { test, ubuntu, parseGitHubAction } from './common/module.f.mjs' @@ -222,6 +222,38 @@ export const proof = { assert(job['runs-on'] !== undefined, 'expected runs-on') assert(job.steps.length > 0, 'expected steps') }, + packageArtifact: () => { + const gha = run(false) + const job = gha.jobs[`node${major(node.default)}`] + assert(job !== undefined, 'expected the canonical Node job') + const packIndex = job.steps.findIndex(step => step.run === 'npm pack') + const uploadIndex = job.steps.findIndex( + step => step.uses === `actions/upload-artifact@${actions['actions/upload-artifact']}`) + assert(packIndex !== -1, 'expected npm pack') + assert(uploadIndex !== -1, 'expected the artifact upload') + // Uploading before packing would ship an empty artifact, and the + // failure would then surface in the consuming job rather than here, + // where the cause is. + assert(uploadIndex > packIndex, 'expected the upload to follow npm pack') + const upload = job.steps[uploadIndex]?.with + // Producer and consumer share the exported name rather than repeating + // a string literal that can drift apart. + assertEq(upload?.name, packageArtifact) + // The glob has to match what `npm pack` writes. `if-no-files-found` + // catches a glob that matches *nothing*; a glob matching the *wrong* + // files would upload them quietly, so pin it. + assertEq(upload?.path, '*.tgz') + // The action's default is to warn and upload nothing, which would make + // a packing failure look like a consumer bug. + assertEq(upload?.['if-no-files-found'], 'error') + // One producer: a second upload under the same name is a race, not + // redundancy. + assertEq( + definedValues(gha.jobs).filter(j => + j.steps.some(step => step.uses?.startsWith('actions/upload-artifact@') === true)).length, + 1, + 'expected exactly one job to upload the package') + }, jobNeeds: () => { const steps = /** @type {const} */ ([{ run: 'echo hi' }]) /** @type {(jobs: Unknown) => Unknown} */