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
11 changes: 11 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ jobs:
- name: Publish dry-run
run: deno run --allow-read --allow-write --allow-run .llm/tools/run-publish.ts --dry-run

# Real-publish-equivalent preflight. `deno publish --dry-run` is a blind
# spot: it can pass while the REAL publish module-graph build rejects a
# module — exactly how alpha.5 produced a partial/half publish (23 of 31
# members). This step runs the real `deno publish` (not --dry-run) with an
# invalid token, so it builds every member's publish graph in the real code
# path and then fails at the auth boundary BEFORE any upload. A graph/check
# error here fails the job before the Publish step below, so a release can
# never half-publish. It never uploads (the token is invalid by design).
- name: Publish preflight (real graph build, no upload)
run: deno run --allow-read --allow-write --allow-run .llm/tools/run-publish.ts --preflight

# Deno 2.9 publish resilience (relied on by the atomic whole-workspace
# publish in .llm/tools/publish-workspace.ts):
# - skip-already-published (denoland/deno#35134): members already on the
Expand Down
78 changes: 74 additions & 4 deletions .llm/tools/publish-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
type JsonObject = Record<string, unknown>;

export type PublishMode = 'dry-run' | 'publish';
export type PublishMode = 'dry-run' | 'publish' | 'preflight';

// A deliberately invalid bearer token. The preflight runs the REAL `deno publish`
// (not `--dry-run`) so it exercises the real publish module-graph build, but this
// token guarantees the run fails at the auth boundary BEFORE any upload — so
// nothing is ever published by a preflight.
const PREFLIGHT_INVALID_TOKEN = 'jsr-preflight-no-upload-invalid-token';

export interface PublishableMember {
readonly path: string;
Expand Down Expand Up @@ -57,11 +63,14 @@ export async function publishWorkspace(
// this: text imports are stable (no flag exists) and `--unstable-raw-imports`
// only toggles `bytes` imports, which this workspace does not use. Plain
// string constants publish cleanly with no flag.
const args = ['publish', '--allow-dirty', '--allow-slow-types'];
if (options.mode === 'dry-run') {
args.push('--dry-run');
const baseArgs = ['publish', '--allow-dirty', '--allow-slow-types'];

if (options.mode === 'preflight') {
await runPublishPreflight(baseArgs, root);
return { members };
}

const args = options.mode === 'dry-run' ? [...baseArgs, '--dry-run'] : baseArgs;
const command = new Deno.Command('deno', {
args,
cwd: root,
Expand All @@ -82,6 +91,67 @@ export async function publishWorkspace(
}
}

/**
* Real-publish-equivalent preflight: run the actual `deno publish` (NOT
* `--dry-run`) with an invalid token so it builds every member's publish module
* graph in the real code path, then fails at the auth boundary before any
* upload. This closes the `deno publish --dry-run` blind spot — dry-run can pass
* while the real publish-graph build rejects a module, which is how the alpha.5
* release produced a partial/half publish (23 of 31 members) before this gate
* existed.
*
* Classification (the run never uploads, because the token is invalid):
* - reached the auth boundary (invalid-bearer-token) => the full graph built
* for every member => PASS.
* - exited 0 => every member is already published at this version (registry
* skip), so there is nothing to build => PASS (no-op).
* - failed before the auth boundary => a graph/check error the real publish
* would also hit => FAIL, so the release stops before uploading anything.
*/
async function runPublishPreflight(
baseArgs: readonly string[],
root: string,
): Promise<void> {
const args = [...baseArgs, '--no-provenance', '--token', PREFLIGHT_INVALID_TOKEN];
const command = new Deno.Command('deno', {
args,
cwd: root,
stdout: 'piped',
stderr: 'piped',
});
const result = await command.output();
// Surface the real publish output in the CI log.
await Deno.stdout.write(result.stdout);
await Deno.stderr.write(result.stderr);
const decoder = new TextDecoder();
const combined = `${decoder.decode(result.stdout)}\n${decoder.decode(result.stderr)}`;

if (reachedAuthBoundary(combined)) {
console.log(
'\n[preflight] PASS: real `deno publish` graph built for every member; ' +
'stopped at the auth boundary with no upload.',
);
return;
}
if (result.code === 0) {
console.log(
'\n[preflight] PASS: every member already published at this version ' +
'(registry skip); nothing to build.',
);
return;
}
throw new Error(
'Publish preflight FAILED: the real `deno publish` graph build errored before ' +
'the auth boundary, so a real publish would fail and could half-publish. ' +
`deno publish exit ${result.code}. See the output above for the failing module.`,
);
}

function reachedAuthBoundary(output: string): boolean {
return /invalidBearerToken|provided bearer token is invalid|unauthorized|HTTP 401/i
.test(output);
}

export async function discoverWorkspaceMembers(
repoRoot: string = Deno.cwd(),
): Promise<PublishableMember[]> {
Expand Down
11 changes: 10 additions & 1 deletion .llm/tools/run-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {

interface Options {
readonly dryRun: boolean;
readonly preflight: boolean;
readonly printJsrLinks: boolean;
readonly updateReleaseBody: boolean;
readonly version?: string;
Expand All @@ -29,12 +30,15 @@ if (options.printJsrLinks) {
const members = await discoverWorkspaceMembers();
const updated = updateReleaseBodyWithJsrLinks(body, members, version);
await Deno.writeTextFile(options.outFile, updated);
} else if (options.preflight) {
await publishWorkspace({ mode: 'preflight' });
} else {
await publishWorkspace({ mode: options.dryRun ? 'dry-run' : 'publish' });
}

function parseArgs(args: readonly string[]): Options {
let dryRun = false;
let preflight = false;
let printJsrLinks = false;
let updateReleaseBody = false;
let version: string | undefined;
Expand All @@ -45,6 +49,8 @@ function parseArgs(args: readonly string[]): Options {
const arg = args[index];
if (arg === '--dry-run') {
dryRun = true;
} else if (arg === '--preflight') {
preflight = true;
} else if (arg === '--print-jsr-links') {
printJsrLinks = true;
} else if (arg === '--update-release-body') {
Expand All @@ -66,8 +72,11 @@ function parseArgs(args: readonly string[]): Options {
if (printJsrLinks && updateReleaseBody) {
throw new Error('--print-jsr-links and --update-release-body cannot be combined.');
}
if (preflight && dryRun) {
throw new Error('--preflight and --dry-run cannot be combined.');
}

return { dryRun, printJsrLinks, updateReleaseBody, version, bodyFile, outFile };
return { dryRun, preflight, printJsrLinks, updateReleaseBody, version, bodyFile, outFile };
}

function readValue(args: readonly string[], index: number, flag: string): string {
Expand Down
Loading