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
44 changes: 35 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,48 @@ permissions:
attestations: write

jobs:
# Refuse a tag the manifests do not declare (spec Β§2) β€” the binary's
# --version comes from packages/cli/deno.json. On failure, the mistake is made
# visible on the release itself, not just in this log.
# Refuse a tag the manifests do not declare (spec Β§2). Every publishable
# manifest, not only the one the binary reads its --version from: v0.13.0
# published binaries and no packages because this gate read
# packages/cli/deno.json alone and passed, while publish-packages.yml read all
# of them and refused. The weaker gate was the one standing in front of the
# irreversible half. On failure, the mistake is made visible on the release
# itself, not just in this log.
preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

- name: Tag matches the manifest version
# Membership is deno.json's name and package.json's private, which is the
# pair scripts/gen-publish-workflow.md selects the publish jobs on. Read
# from package.json's name instead and this gate admits a different set
# than the one that publishes, which is the same partial release by
# another route. Walked rather than listed, so a new package joins by
# existing β€” which is how packages/git came to be absent from it.
- name: Tag matches every publishable manifest
run: |
TAG="${{ github.ref_name }}"
declared="$(jq -r .version packages/cli/deno.json)"
if [ "v$declared" != "$TAG" ]; then
echo "::error::packages/cli/deno.json declares $declared but the tag is $TAG β€” bump the manifests first"
exit 1
fi
mismatched=0
for manifest in packages/*/deno.json; do
member="$(dirname "$manifest")"
[ -f "$member/package.json" ] || continue
name="$(jq -r '.name // ""' "$manifest")"
case "$name" in
@executablemd/*) ;;
*) continue ;;
esac
if [ "$(jq -r '.private // false' "$member/package.json")" = "true" ]; then
continue
fi
for declared_in in "$manifest" "$member/package.json"; do
declared="$(jq -r '.version // ""' "$declared_in")"
if [ "v$declared" != "$TAG" ]; then
echo "::error::$declared_in declares $declared but the tag is $TAG β€” bump the manifests first"
mismatched=1
fi
done
done
[ "$mismatched" -eq 0 ]

- name: Flag the release when the tag cannot build
if: failure()
Expand Down
17 changes: 17 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/git/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/git",
"version": "0.12.1",
"version": "0.13.0",
"license": "MIT",
"exports": {
".": "./mod.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/git/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/git",
"version": "0.12.1",
"version": "0.13.0",
"description": "The Git Plugin for executable.md: repositories, worktrees, Git operations, pull requests and issues, retained in a workflow run's Workspace.",
"type": "module",
"exports": {
Expand Down
71 changes: 71 additions & 0 deletions scripts/lib/bun-lockfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { z } from "zod";

const LockfileSchema = z.object({
workspaces: z.record(
z.string(),
z.object({
name: z.string().optional(),
version: z.string().optional(),
}),
),
});

/** What `bun.lock` records for one workspace member. */
export interface LockedWorkspace {
name?: string;
version?: string;
}

/**
* Bun writes its text lockfile as JSON with trailing commas, and nothing else
* JSON refuses β€” no comments, no unquoted keys, no single-quoted strings. So
* the file becomes parseable by dropping every comma whose next non-whitespace
* character closes its container. A comma inside a string value is not one of
* those, which is why this walks the text instead of matching it.
*/
function withoutTrailingCommas(text: string): string {
let out = "";
let inString = false;
let escaped = false;

for (let index = 0; index < text.length; index += 1) {
const char = text[index];

if (inString) {
out += char;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}

if (char === '"') {
inString = true;
out += char;
continue;
}

if (char === ",") {
let next = index + 1;
while (next < text.length && /\s/.test(text[next])) {
next += 1;
}
if (text[next] === "}" || text[next] === "]") {
continue;
}
}

out += char;
}

return out;
}

/** Every workspace member `text` records, keyed by its root-relative directory. */
export function parseBunLockfile(text: string): Record<string, LockedWorkspace> {
return LockfileSchema.parse(JSON.parse(withoutTrailingCommas(text))).workspaces;
}
72 changes: 72 additions & 0 deletions scripts/lib/publishable-members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { readTextFile } from "@effectionx/fs";
import type { Operation } from "effection";
import { z } from "zod";

import { listWorkspacePaths } from "./workspace.ts";

export const SCOPE = "@executablemd/";

const RootSchema = z.object({ workspace: z.array(z.string()) });
const IdentitySchema = z.object({ name: z.string() });
const PublicationSchema = z.object({ private: z.boolean().optional() });

/** A workspace member a tagged release publishes. */
export interface PublishableMember {
/** Root-relative directory, e.g. `packages/git`. */
dir: string;
/** The name it publishes under, from `deno.json`. */
name: string;
}

/**
* The name a member publishes under, or `undefined` when it publishes nothing.
*
* Identity is `deno.json`'s `name` and the exclusion is `package.json`'s
* `private`, which is the pair `scripts/gen-publish-workflow.md` selects the
* publish jobs on and `bumpManifests` stamps on. Nothing requires the two
* manifests to agree about a member's name, so a gate that read
* `package.json`'s would admit a different set than the one that actually
* publishes β€” and a set the binary gate and the package gate disagree about is
* how a tag comes to publish one half of a release.
*
* A member missing either manifest publishes nothing: no `deno.json` is no JSR
* entry, and no `package.json` is no npm package.
*/
export function publishedName(denoJson: unknown, packageJson: unknown): string | undefined {
const identity = IdentitySchema.safeParse(denoJson);
if (!identity.success || !identity.data.name.startsWith(SCOPE)) {
return undefined;
}
const publication = PublicationSchema.safeParse(packageJson);
if (publication.success && publication.data.private === true) {
return undefined;
}
return identity.data.name;
}

/**
* Every member a tagged release publishes, walked from the root `workspace`
* globs rather than a list, so a new package joins by existing.
*/
export function* publishableMembers(repoRoot: URL): Operation<PublishableMember[]> {
const root = RootSchema.parse(JSON.parse(yield* readTextFile(new URL("deno.json", repoRoot))));
const found: PublishableMember[] = [];

for (const dir of yield* listWorkspacePaths(root.workspace, repoRoot)) {
let denoJson: unknown;
let packageJson: unknown;
try {
denoJson = JSON.parse(yield* readTextFile(new URL(`${dir}/deno.json`, repoRoot)));
packageJson = JSON.parse(yield* readTextFile(new URL(`${dir}/package.json`, repoRoot)));
} catch {
continue;
}

const name = publishedName(denoJson, packageJson);
if (name !== undefined) {
found.push({ dir, name });
}
}

return found;
}
80 changes: 80 additions & 0 deletions scripts/lib/version-lockstep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { readTextFile } from "@effectionx/fs";
import type { Operation } from "effection";
import { z } from "zod";

import { parseBunLockfile } from "./bun-lockfile.ts";
import { publishableMembers } from "./publishable-members.ts";

const VersionSchema = z.object({ version: z.string() });

/** The two manifests a publishable member declares its version in. */
const MANIFESTS = ["deno.json", "package.json"];

function* declaredVersion(url: URL): Operation<string | undefined> {
let text: string;
try {
text = yield* readTextFile(url);
} catch {
return undefined;
}
const parsed = VersionSchema.safeParse(JSON.parse(text));
return parsed.success ? parsed.data.version : undefined;
}

/**
* Everything that breaks version lockstep in the workspace at `repoRoot`, as
* messages naming the manifest at fault. An empty list is the whole claim: the
* release publishes one version, and every manifest and the lockfile declare
* it.
*
* Membership comes from `publishableMembers`, so this reads the same set the
* publish workflow generates jobs for. Both tag-time gates make the same
* assertion, but only after the tag has been pushed β€” which is after the
* binaries have published. Reading it here moves the answer to the moment the
* drift is introduced.
*/
export function* versionLockstepFindings(repoRoot: URL): Operation<string[]> {
const members = yield* publishableMembers(repoRoot);
const findings: string[] = [];
const declared = new Map<string, string[]>();

for (const member of members) {
for (const manifest of MANIFESTS) {
const path = `${member.dir}/${manifest}`;
const version = yield* declaredVersion(new URL(path, repoRoot));
if (version === undefined) {
findings.push(`${path} declares no version`);
continue;
}
declared.set(version, [...(declared.get(version) ?? []), path]);
}
}

if (declared.size > 1) {
// Insertion order, which is the workspace walk's own sorted order, so the
// message reads the same way twice.
const groups = [...declared].map(([version, paths]) => `${version} (${paths.join(", ")})`);
findings.push(`the workspace declares more than one version: ${groups.join("; ")}`);
}

const locked = parseBunLockfile(yield* readTextFile(new URL("bun.lock", repoRoot)));
// The lockfile is compared against the version only once the manifests agree
// on one. Against a workspace that does not, every entry would be reported
// for a mismatch the finding above already names.
const [expected] = declared.size === 1 ? [...declared.keys()] : [undefined];

for (const member of members) {
const entry = locked[member.dir];
if (entry === undefined) {
findings.push(`bun.lock has no workspace entry for ${member.dir}`);
continue;
}
if (expected !== undefined && entry.version !== expected) {
findings.push(
`bun.lock records ${member.dir} at ${entry.version ?? "no version"}, not ${expected}`,
);
}
}

return findings;
}
6 changes: 6 additions & 0 deletions scripts/runtime-test-exclusions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [
"subject is scripts/build-web-client.ts, which runs `deno bundle` and calls Deno.execPath()/makeTempFile β€” Deno-only",
issue: DERIVED_SCOPE,
},
{
path: "scripts/tests/publishable-membership-agreement.test.ts",
reason:
"runs the publish-workflow generator over a fixture workspace by spawning the CLI through Deno.execPath(), which is the only way to exercise an eval block's selection rule (#237); the rule itself is asserted portably by publishedName's cases in version-lockstep.test.ts, and what this adds β€” that the generator and the release preflight reach that same rule β€” is Deno's own release tooling",
issue: DERIVED_SCOPE,
},
{
path: "scripts/tests/prepared-state.test.ts",
reason:
Expand Down
Loading
Loading