Skip to content
Closed
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
48 changes: 45 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,17 @@ jobs:
- name: Build
run: pnpm run build

# Enforces unified workspace versioning — every packages/*/package.json must match the tag.
- name: Verify workspace versions match tag
# Enforces unified workspace versioning: every packages/*/package.json must match the tag.
# If Charter adopts independent package versions, replace this with per-package release metadata.
- name: Verify tag and workspace versions
shell: bash
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid tag format: ${TAG}. Expected v<major>.<minor>.<patch>"
exit 1
fi

EXPECTED="${TAG#v}"
FAIL=0
for p in packages/*/package.json; do
Expand All @@ -143,7 +149,43 @@ jobs:
done
if [[ $FAIL -ne 0 ]]; then exit 1; fi

- name: Verify packed manifests
run: pnpm run publish:check

# Auth is OIDC via npm trusted publishers — no NPM_TOKEN needed.
# See: https://docs.npmjs.com/trusted-publishers
- name: Publish to npm
run: npm publish --workspaces --access public --provenance
shell: bash
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
VERSION="${TAG#v}"
mkdir -p release-tarballs

pack_package() {
local package_dir="$1"
(cd "${package_dir}" && pnpm pack --pack-destination ../../release-tarballs)
}

pack_package packages/types
pack_package packages/core
pack_package packages/adf
pack_package packages/git
pack_package packages/classify
pack_package packages/validate
pack_package packages/drift
pack_package packages/blast
pack_package packages/surface
pack_package packages/ci
pack_package packages/cli

npm publish "release-tarballs/stackbilt-types-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-core-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-adf-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-git-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-classify-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-validate-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-drift-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-blast-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-surface-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-ci-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-cli-${VERSION}.tgz" --access public --provenance
33 changes: 20 additions & 13 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pnpm run clean
pnpm run typecheck
pnpm run build
pnpm run test
pnpm run publish:check
```

## Phase 2: Version Bump
Expand All @@ -62,23 +63,29 @@ done

## Phase 3: Artifact Validation (Required)

1. Dry-run packed contents per package:
1. Verify packed package manifests do not contain `workspace:` dependency specifiers:

```bash
pnpm --filter @stackbilt/types pack --dry-run
pnpm --filter @stackbilt/core pack --dry-run
pnpm --filter @stackbilt/adf pack --dry-run
pnpm --filter @stackbilt/git pack --dry-run
pnpm --filter @stackbilt/classify pack --dry-run
pnpm --filter @stackbilt/validate pack --dry-run
pnpm --filter @stackbilt/drift pack --dry-run
pnpm --filter @stackbilt/blast pack --dry-run
pnpm --filter @stackbilt/surface pack --dry-run
pnpm --filter @stackbilt/ci pack --dry-run
pnpm --filter @stackbilt/cli pack --dry-run
pnpm run publish:check
```

2. Verify CLI behavior before publish:
2. Dry-run packed contents per package:

```bash
(cd packages/types && pnpm pack --dry-run)
(cd packages/core && pnpm pack --dry-run)
(cd packages/adf && pnpm pack --dry-run)
(cd packages/git && pnpm pack --dry-run)
(cd packages/classify && pnpm pack --dry-run)
(cd packages/validate && pnpm pack --dry-run)
(cd packages/drift && pnpm pack --dry-run)
(cd packages/blast && pnpm pack --dry-run)
(cd packages/surface && pnpm pack --dry-run)
(cd packages/ci && pnpm pack --dry-run)
(cd packages/cli && pnpm pack --dry-run)
```

3. Verify CLI behavior before publish:

```bash
node packages/cli/dist/bin.js --version
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"docs:oss:check": "node scripts/docs-sync.mjs --check --config .docsync.oss.json",
"docs:oss:auto": "node scripts/docs-oss-auto-sync.mjs --config .docsync.oss.json",
"docs:oss:auto:dry-run": "node scripts/docs-oss-auto-sync.mjs --config .docsync.oss.json --dry-run --no-push",
"publish:check": "node scripts/assert-packages-publishable.mjs",
"verify:adf": "bash -lc \"node packages/cli/dist/bin.js doctor --adf-only --ci --format json && node packages/cli/dist/bin.js adf evidence --auto-measure --ci --format json\"",
"charter:detect": "charter setup --detect-only --format json",
"charter:setup": "charter setup --preset fullstack --ci github --yes",
Expand Down
3 changes: 3 additions & 0 deletions packages/ci/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"dependencies": {
"@stackbilt/types": "workspace:^"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs"
},
"publishConfig": {
"access": "public"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/classify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"dependencies": {
"@stackbilt/types": "workspace:^"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs"
},
"publishConfig": {
"access": "public"
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"access": "public"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs",
"build": "pnpm exec tsc -p tsconfig.json"
},
"dependencies": {
Expand Down
3 changes: 3 additions & 0 deletions packages/drift/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"dependencies": {
"@stackbilt/types": "workspace:^"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs"
},
"publishConfig": {
"access": "public"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/git/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"dependencies": {
"@stackbilt/types": "workspace:^"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs"
},
"publishConfig": {
"access": "public"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/validate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"dependencies": {
"@stackbilt/types": "workspace:^"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs"
},
"publishConfig": {
"access": "public"
},
Expand Down
124 changes: 124 additions & 0 deletions scripts/assert-packages-publishable.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env node
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const packageGlobs = [
"packages/types",
"packages/core",
"packages/adf",
"packages/git",
"packages/classify",
"packages/validate",
"packages/drift",
"packages/blast",
"packages/surface",
"packages/ci",
"packages/cli",
];
const dependencyFields = [
"dependencies",
"optionalDependencies",
"peerDependencies",
"devDependencies",
];

const tempDir = mkdtempSync(join(tmpdir(), "charter-publish-check-"));
const failures = [];

function run(command, args, options) {
const result = spawnSync(command, args, {
...options,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});

if (result.status !== 0) {
throw new Error(
[
`${command} ${args.join(" ")} failed in ${options.cwd}`,
result.stdout.trim(),
result.stderr.trim(),
]
.filter(Boolean)
.join("\n"),
);
}

return result.stdout;
}

function packedPackageJson(tarball) {
return JSON.parse(run("tar", ["-xOf", tarball, "package/package.json"], { cwd: root }));
}

function workspaceDependencyEntries(manifest) {
const entries = [];

for (const field of dependencyFields) {
const dependencies = manifest[field] ?? {};
for (const [name, specifier] of Object.entries(dependencies)) {
if (typeof specifier === "string" && specifier.startsWith("workspace:")) {
entries.push(`${field}.${name}=${specifier}`);
}
}
}

return entries;
}

function tarballsIn(directory) {
return new Set(readdirSync(directory).filter((file) => file.endsWith(".tgz")));
}

function packedFilename(packageDir, output, beforePack) {
if (output.trim().length > 0) {
const packResult = JSON.parse(output);
if (typeof packResult.filename === "string") {
return packResult.filename;
}
}

const createdTarballs = readdirSync(tempDir)
.filter((file) => file.endsWith(".tgz") && !beforePack.has(file));

if (createdTarballs.length !== 1) {
throw new Error(
`Expected one tarball from pnpm pack for ${packageDir}, found ${createdTarballs.length}.`,
);
}

return join(tempDir, createdTarballs[0]);
}

try {
for (const packageDir of packageGlobs) {
const cwd = join(root, packageDir);
readFileSync(join(cwd, "package.json"), "utf8");
const beforePack = tarballsIn(tempDir);
const output = run("pnpm", ["pack", "--json", "--pack-destination", tempDir], { cwd });
const filename = packedFilename(packageDir, output, beforePack);
const packedManifest = packedPackageJson(filename);
const workspaceEntries = workspaceDependencyEntries(packedManifest);

if (workspaceEntries.length > 0) {
failures.push(`${packedManifest.name}: ${workspaceEntries.join(", ")}`);
}
}
} finally {
rmSync(tempDir, { recursive: true, force: true });
}

if (failures.length > 0) {
console.error("Packed package manifests contain workspace protocol dependencies:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
console.error("Publish with pnpm from the workspace root so workspace:^ is rewritten.");
process.exit(1);
}

console.log("All packed package manifests are publishable; no workspace: dependency specifiers found.");
36 changes: 36 additions & 0 deletions scripts/ensure-pnpm-publish.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { join } from "node:path";

const packageJson = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8"));
const dependencyFields = [
"dependencies",
"optionalDependencies",
"peerDependencies",
"devDependencies",
];

const usesWorkspaceProtocol = dependencyFields.some((field) =>
Object.values(packageJson[field] ?? {}).some(
(specifier) => typeof specifier === "string" && specifier.startsWith("workspace:"),
),
);

if (!usesWorkspaceProtocol) {
process.exit(0);
}

const userAgent = process.env.npm_config_user_agent ?? "";
const execPath = process.env.npm_execpath ?? "";
const invokedByPnpm = userAgent.includes("pnpm/") || execPath.includes("pnpm");

if (!invokedByPnpm) {
console.error(
[
`${packageJson.name} uses workspace: dependency specifiers in source package.json.`,
"Direct npm publish can leak those specifiers into the public tarball.",
"Publish with pnpm from the workspace root and run `pnpm run publish:check` before publishing.",
].join("\n"),
);
process.exit(1);
}
Loading