Skip to content

Commit bbd84b1

Browse files
committed
chore: guard release metadata preflight
1 parent b2cca46 commit bbd84b1

3 files changed

Lines changed: 169 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,51 @@ jobs:
108108
# the GUI into gui/dist), so even a dry-run fully verifies the build.
109109
# PREREQUISITE: configure the Trusted Publisher for this repo + workflow on npmjs.com — possible
110110
# only AFTER the package's first version exists (do the first publish locally, see the runbook).
111+
- name: Preflight release metadata
112+
env:
113+
GH_TOKEN: ${{ github.token }}
114+
run: |
115+
set -euo pipefail
116+
117+
pkg_name="$(node -p "require('./package.json').name")"
118+
release_tag="v${{ inputs.version }}"
119+
dry_run="${{ inputs.dry-run }}"
120+
121+
git fetch --force --tags origin
122+
123+
existing_tag_sha="$(git rev-parse -q --verify "refs/tags/${release_tag}^{commit}" || true)"
124+
if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" != "$GITHUB_SHA" ]; then
125+
echo "::error::${release_tag} already points at ${existing_tag_sha}, not ${GITHUB_SHA}"
126+
exit 1
127+
fi
128+
129+
if [ -n "$existing_tag_sha" ]; then
130+
if [ "$dry_run" = "true" ]; then
131+
echo "::notice::${release_tag} already exists at this commit; dry-run only"
132+
else
133+
echo "::error::${release_tag} already exists. Refusing to publish a version with pre-existing Git metadata."
134+
exit 1
135+
fi
136+
fi
137+
138+
if gh release view "$release_tag" >/dev/null 2>&1; then
139+
if [ "$dry_run" = "true" ]; then
140+
echo "::notice::GitHub Release ${release_tag} already exists; dry-run only"
141+
else
142+
echo "::error::GitHub Release ${release_tag} already exists. Choose the next unused patch version."
143+
exit 1
144+
fi
145+
fi
146+
147+
if npm view "${pkg_name}@${{ inputs.version }}" version >/dev/null 2>&1; then
148+
if [ "$dry_run" = "true" ]; then
149+
echo "::notice::${pkg_name}@${{ inputs.version }} already exists on npm; dry-run only"
150+
else
151+
echo "::error::${pkg_name}@${{ inputs.version }} already exists on npm. Choose the next unused patch version."
152+
exit 1
153+
fi
154+
fi
155+
111156
- name: Publish (or dry-run)
112157
run: |
113158
if [ "${{ inputs.dry-run }}" = "true" ]; then

scripts/release.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,100 @@ interface GhRun {
2626
url: string;
2727
}
2828

29+
interface CommandResult {
30+
exitCode: number;
31+
stdout: string;
32+
stderr: string;
33+
}
34+
2935
const CI_WORKFLOW = "ci.yml";
3036
const CI_WAIT_TIMEOUT_MS = 20 * 60 * 1000;
3137
const CI_POLL_MS = 10 * 1000;
3238

39+
async function runQuiet(command: string[]): Promise<CommandResult> {
40+
const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
41+
const [stdout, stderr, exitCode] = await Promise.all([
42+
new Response(proc.stdout).text(),
43+
new Response(proc.stderr).text(),
44+
proc.exited,
45+
]);
46+
return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
47+
}
48+
49+
async function readPackageName(): Promise<string> {
50+
try {
51+
const pkg = JSON.parse(await Bun.file("package.json").text()) as { name?: unknown };
52+
if (typeof pkg.name !== "string" || !pkg.name) {
53+
console.error("✗ package.json is missing a valid name");
54+
process.exit(1);
55+
}
56+
return pkg.name;
57+
} catch (error) {
58+
console.error(`✗ failed to read package.json: ${error instanceof Error ? error.message : String(error)}`);
59+
process.exit(1);
60+
}
61+
}
62+
63+
async function npmVersionExists(packageName: string, version: string): Promise<boolean> {
64+
const result = await runQuiet(["npm", "view", `${packageName}@${version}`, "version"]);
65+
if (result.exitCode === 0) return true;
66+
67+
const output = `${result.stdout}\n${result.stderr}`;
68+
if (output.includes("E404") || output.includes("No match found")) return false;
69+
70+
console.error(`✗ failed to check npm version ${packageName}@${version}`);
71+
if (result.stderr) console.error(result.stderr);
72+
process.exit(1);
73+
}
74+
75+
async function remoteTagSha(tagName: string): Promise<string | null> {
76+
const result = await runQuiet(["git", "ls-remote", "origin", `refs/tags/${tagName}`, `refs/tags/${tagName}^{}`]);
77+
if (result.exitCode !== 0) {
78+
console.error(`✗ failed to check remote tag ${tagName}`);
79+
if (result.stderr) console.error(result.stderr);
80+
process.exit(1);
81+
}
82+
83+
const lines = result.stdout.split("\n").filter(Boolean);
84+
const peeled = lines.find(line => line.endsWith(`refs/tags/${tagName}^{}`));
85+
const exact = lines.find(line => line.endsWith(`refs/tags/${tagName}`));
86+
const selected = peeled ?? exact;
87+
return selected ? selected.split(/\s+/)[0] ?? null : null;
88+
}
89+
90+
async function githubReleaseExists(tagName: string): Promise<boolean> {
91+
const result = await runQuiet(["gh", "release", "view", tagName, "--json", "tagName"]);
92+
if (result.exitCode === 0) return true;
93+
94+
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
95+
if (output.includes("release not found") || output.includes("not found")) return false;
96+
97+
console.error(`✗ failed to check GitHub Release ${tagName}`);
98+
if (result.stderr) console.error(result.stderr);
99+
process.exit(1);
100+
}
101+
102+
async function assertUnusedReleaseVersion(packageName: string, version: string): Promise<void> {
103+
const releaseTag = `v${version}`;
104+
const [npmUsed, tagSha, releaseUsed] = await Promise.all([
105+
npmVersionExists(packageName, version),
106+
remoteTagSha(releaseTag),
107+
githubReleaseExists(releaseTag),
108+
]);
109+
110+
const failures: string[] = [];
111+
if (npmUsed) failures.push(`- npm already has ${packageName}@${version}`);
112+
if (tagSha) failures.push(`- remote Git tag ${releaseTag} already exists at ${tagSha}`);
113+
if (releaseUsed) failures.push(`- GitHub Release ${releaseTag} already exists`);
114+
115+
if (failures.length > 0) {
116+
console.error(`✗ release version ${version} is already partially or fully used:`);
117+
console.error(failures.join("\n"));
118+
console.error("Choose the next unused patch version, or make an explicit human decision to repair public metadata.");
119+
process.exit(1);
120+
}
121+
}
122+
33123
async function watchLatest(): Promise<void> {
34124
const id = (await $`gh run list --workflow release.yml --limit 1 --json databaseId -q '.[0].databaseId'`.text()).trim();
35125
if (!id) { console.error("No Release runs found yet."); process.exit(1); }
@@ -99,6 +189,9 @@ const dryRun = !args.includes("--publish");
99189
const branch = (await $`git rev-parse --abbrev-ref HEAD`.text()).trim();
100190
if (branch !== "main") { console.error(`✗ must be on main (currently ${branch}).`); process.exit(1); }
101191
if ((await $`git status --porcelain`.text()).trim()) { console.error("✗ working tree not clean — commit or stash first."); process.exit(1); }
192+
const packageName = await readPackageName();
193+
console.log(`→ release metadata preflight (${packageName}@${version})`);
194+
await assertUnusedReleaseVersion(packageName, version);
102195
console.log("→ typecheck");
103196
await $`bun x tsc --noEmit`;
104197

structure/06_docs-and-release.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,37 @@ typecheck and GUI build, and `scripts/release.ts` handles version bump, commit/p
6060
Cross-platform CI, and dispatching the GitHub Release workflow. Docs publishing is separate from npm
6161
release publishing.
6262

63+
## Release metadata invariants
64+
65+
Every npm release version must map cleanly across four surfaces:
66+
67+
| Surface | Required state |
68+
| --- | --- |
69+
| `package.json` | `version` equals the release workflow `version` input. |
70+
| npm registry | `@bitkyc08/opencodex@<version>` does not exist before publish, then exists after publish with the requested dist-tag. |
71+
| Git tag | `v<version>` does not exist before publish, then points at the exact release commit. |
72+
| GitHub Release | `v<version>` does not exist before publish, then is created from the exact release commit. |
73+
74+
The release must fail before `npm publish` if npm, the Git tag, or the GitHub Release already has the
75+
requested version. This prevents partial releases where npm is published but GitHub Release creation
76+
fails afterward.
77+
78+
Do not force-move public version tags by default. If release metadata is already inconsistent, treat
79+
the version as consumed and publish the next unused patch version instead. Only rewrite a public tag
80+
after an explicit human decision that the public history rewrite is acceptable.
81+
82+
Manual preflight checks when debugging a release:
83+
84+
```bash
85+
npm view @bitkyc08/opencodex@<version> version
86+
git ls-remote origin refs/tags/v<version>
87+
gh release view v<version>
88+
```
89+
90+
If any of these commands reports an existing artifact for the requested version, stop before
91+
publishing. For a non-destructive recovery, choose the next unused patch version and release that
92+
version through `scripts/release.ts`.
93+
6394
## Cross-platform CI
6495

6596
`.github/workflows/ci.yml` is the ordinary quality gate for runtime/package changes. It runs on

0 commit comments

Comments
 (0)