Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ on:
push:
tags:
- 'v*'
# Manual rehearsal — runs the entire pipeline without publishing or
# creating a GitHub Release. Use it to verify a release would
# succeed before actually cutting the tag.
workflow_dispatch:
inputs:
version:
description: 'Version to stamp during rehearsal (no effect on real tag pushes)'
type: string
default: '0.0.0-rehearsal'

concurrency:
group: release-${{ github.ref }}
Expand All @@ -15,6 +24,10 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
# Required for npm publish --provenance: the `id-token` is what
# the npm registry consumes to attach a signed attestation
# linking the published tarball to this exact GitHub commit.
id-token: write
steps:
- uses: actions/checkout@v6
with:
Expand All @@ -30,13 +43,16 @@ jobs:

- run: pnpm install --frozen-lockfile

- name: Stamp version from tag
- name: Stamp version
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
echo "Stamping version $TAG_VERSION from tag $GITHUB_REF_NAME"
if [ "${{ github.event_name }}" = "push" ]; then
TAG_VERSION="${GITHUB_REF_NAME#v}"
else
TAG_VERSION="${{ inputs.version }}"
fi
echo "Stamping version $TAG_VERSION (event: ${{ github.event_name }})"
node -e "
const fs = require('fs');
const glob = require('path');
const files = [
'package.json',
...fs.readdirSync('packages', {withFileTypes:true}).filter(d=>d.isDirectory()).map(d=>'packages/'+d.name+'/package.json'),
Expand All @@ -58,12 +74,27 @@ jobs:

- run: pnpm test

# Pack every publishable workspace package and verify each one
# ships the files its `exports` / `main` / `types` / `bin` map
# promises. Catches the class of bug where someone removes a
# path from `files` (or moves a file out of `src`) and only
# finds out post-publish.
- name: Verify pack contents
run: node scripts/verify-pack-contents.mjs

- name: Publish to npm
run: pnpm -r publish --access public --no-git-checks
if: github.event_name == 'push'
run: pnpm -r publish --access public --no-git-checks --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Rehearsal summary
if: github.event_name == 'workflow_dispatch'
run: |
echo "::notice::Dry run complete. Pipeline passed; no packages were published and no GitHub Release was created."

- name: Create GitHub Release with auto-generated notes
if: github.event_name == 'push'
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
Expand Down
160 changes: 160 additions & 0 deletions scripts/verify-pack-contents.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* verify-pack-contents — sanity-check that every publishable package
* actually ships the files its `package.json` promises.
*
* What we check, per publishable workspace package:
* - Run `pnpm pack` to produce a tarball.
* - Read `exports` (handles nested condition objects), plus the
* top-level `main`, `module`, `types`, `bin` fields. Collect
* every relative path each one points at.
* - List the tarball contents (`tar -tzf`) and verify that every
* declared path is present.
*
* What we deliberately don't check:
* - Pattern subpath imports (`./*.ts`) — can't statically resolve
* without expanding glob; trust the publish-side to surface
* missing files when an actual import fails.
* - Non-relative export targets (e.g. external module redirects).
*
* Run from the repo root: node scripts/verify-pack-contents.mjs
*
* Exit code 0 if every declared file ships; 1 otherwise (with a
* per-package list of missing paths). Designed to be the last gate
* before `pnpm publish` in CI so a broken release fails loud, not
* silently in the npm registry.
*/

import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
const PACK_DIR = '/tmp/ac7-pack-verify';

function discoverPublishable() {
const candidates = [];
for (const parent of ['packages', 'apps']) {
const parentDir = resolve(REPO_ROOT, parent);
if (!existsSync(parentDir)) continue;
for (const entry of readdirSync(parentDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const pkgPath = resolve(parentDir, entry.name, 'package.json');
if (!existsSync(pkgPath)) continue;
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
if (pkg.private === true) continue;
candidates.push({
name: pkg.name,
dir: resolve(parentDir, entry.name),
pkg,
});
}
}
return candidates;
}

/**
* Walk a `package.json` and collect every relative path the package
* declares as a file consumers should be able to import. Handles:
*
* - `main`, `module`, `types` (string)
* - `bin` (string OR { name: path })
* - `exports` (string OR { ".": string } OR
* { ".": { import: string, types: string, ... } } OR
* { "./foo": string | object, ... })
*
* Skips entries whose target isn't a relative path beginning with
* "./" — those are either external module redirects or pattern
* imports we can't statically resolve.
*/
function declaredPaths(pkg) {
const paths = new Set();
const collect = (val) => {
if (typeof val === 'string') {
if (val.startsWith('./')) paths.add(val.slice(2));
return;
}
if (val && typeof val === 'object' && !Array.isArray(val)) {
for (const v of Object.values(val)) collect(v);
}
};

for (const field of ['main', 'module', 'types']) {
if (typeof pkg[field] === 'string') collect(pkg[field]);
}
if (typeof pkg.bin === 'string') {
collect(pkg.bin);
} else if (pkg.bin && typeof pkg.bin === 'object') {
for (const v of Object.values(pkg.bin)) collect(v);
}
if (pkg.exports !== undefined) collect(pkg.exports);

return [...paths];
}

function listTarballEntries(tgzPath) {
const out = execSync(`tar -tzf "${tgzPath}"`, { encoding: 'utf8' });
return out
.split('\n')
.filter(Boolean)
.map((line) => (line.startsWith('package/') ? line.slice('package/'.length) : line));
}

function main() {
rmSync(PACK_DIR, { recursive: true, force: true });
mkdirSync(PACK_DIR, { recursive: true });

const packages = discoverPublishable();
if (packages.length === 0) {
console.error('no publishable packages discovered — is this the repo root?');
process.exit(1);
}

console.log(`Verifying pack contents for ${packages.length} packages…\n`);

let failed = false;
for (const { name, dir, pkg } of packages) {
process.stdout.write(` ${name}: `);
let tgzName;
try {
execSync('pnpm pack --pack-destination ' + PACK_DIR, {
cwd: dir,
stdio: 'pipe',
});
tgzName = readdirSync(PACK_DIR).find(
(f) => f.startsWith(name.replace('@', '').replace('/', '-')) && f.endsWith('.tgz'),
);
} catch (err) {
console.log(
`PACK FAILED — ${(err instanceof Error ? err.message : String(err)).split('\n')[0]}`,
);
failed = true;
continue;
}
if (!tgzName) {
console.log('PACK FAILED — no .tgz produced');
failed = true;
continue;
}

const entries = new Set(listTarballEntries(resolve(PACK_DIR, tgzName)));
const expected = declaredPaths(pkg);
const missing = expected.filter((p) => !entries.has(p));
if (missing.length === 0) {
console.log(`OK (${expected.length} paths)`);
} else {
console.log(`MISSING ${missing.length}/${expected.length}`);
for (const m of missing) console.log(` - ${m}`);
failed = true;
}
}

if (failed) {
console.error('\nPack-content verification failed.');
process.exit(1);
}
console.log('\nAll publishable packages ship the files their package.json declares.');
}

main();