From eba0cf5c5fdea7070d21d864d71b4fdf72df1069 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:33:47 -0400 Subject: [PATCH 1/4] =?UTF-8?q?ci:=20spec-driven=20release=20process=20?= =?UTF-8?q?=E2=80=94=20maintainer=20tags,=20binaries=20gate=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto main as the branch's net change (the iterative history lives in PR #87's review threads). - specs/release-process-spec.md: single reference for the release process — UI-driven releases, sequence diagram, version lockstep, OIDC trusted publishers, protection configuration, new-package bootstrap, recovery. - Rolling draft release with a changelog of merged PRs (release-drafter, SHA-pinned); publishing the draft cuts the release. Maintainers create tags; no workflow does, and no dispatch path remains in the release path. - publish-packages.yml is generated by scripts/gen-publish-workflow.md — an executable markdown document — and its version job validates the tag against every manifest, then waits for release.yml to succeed, so npm versions never exist without matching binaries. release.yml fails fast on a tag the manifests do not declare. - cli reads its version from cli/deno.json; the manifests are the single version source. - ReleaseSpecWarning renders a warning in the PR review comment when release configuration changes without a matching spec update (AGENTS.md rule 8). --- .github/PUBLISHING.md | 71 -------- .github/release-drafter.yml | 8 + .github/workflows/draft-release.yml | 21 +++ .github/workflows/publish-one.yml | 14 +- .github/workflows/publish-packages.yml | 65 +++++--- .github/workflows/release.yml | 23 +-- .reviews/ReviewPR.local.md | 5 + .reviews/ReviewPR.md | 4 + .reviews/components/ReleaseSpecWarning.md | 41 +++++ cli/src/cli.ts | 3 +- deno.json | 2 +- scripts/gen-publish-workflow.md | 188 ++++++++++++++++++++++ scripts/gen-publish-workflow.ts | 142 ---------------- specs/release-process-spec.md | 145 +++++++++++++++++ 14 files changed, 474 insertions(+), 258 deletions(-) delete mode 100644 .github/PUBLISHING.md create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/draft-release.yml create mode 100644 .reviews/components/ReleaseSpecWarning.md create mode 100644 scripts/gen-publish-workflow.md delete mode 100644 scripts/gen-publish-workflow.ts create mode 100644 specs/release-process-spec.md diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md deleted file mode 100644 index 6fe7a48..0000000 --- a/.github/PUBLISHING.md +++ /dev/null @@ -1,71 +0,0 @@ -# Publishing `@executablemd` packages - -Pushing a `vX.Y.Z` tag runs two workflows: `release.yml` compiles the `xmd` -binaries, and `publish-packages.yml` publishes every `@executablemd/*` library -to npm (primary) and JSR (secondary). `publish-packages.yml` is generated from -the manifests by `scripts/gen-publish-workflow.ts` and calls the reusable -`publish-one.yml` once per package, ordered by `needs:` so dependencies publish -before dependents. `publish-packages.yml` also runs manually via -`workflow_dispatch` with a `tag` input. - -## Packages - -- `@executablemd/durable-streams` -- `@executablemd/runtime` -- `@executablemd/code-review-agent` -- `@executablemd/core` -- `@executablemd/cli` - -## npm authentication (OIDC, no token) - -`publish-one.yml` authenticates to npm with GitHub Actions OIDC trusted -publishing; the repo holds no npm token. Each package has a trusted publisher -configured at npmjs.com → the package → **Settings → Trusted Publisher → -GitHub Actions**: - -| Field | Value | -| -------------------- | ----------------------- | -| Organization or user | `taras` | -| Repository | `executable.md` | -| Workflow filename | `publish-packages.yml` | -| Environment name | _(empty)_ | -| Allowed actions | `npm publish` | - -The workflow filename is the **calling** workflow (`publish-packages.yml`), not -the reusable `publish-one.yml`: for `workflow_call` / `workflow_dispatch`, npm -validates the caller's filename. - -The npm account owns the `@executablemd` scope, and packages publish public -(`npm publish --access public`). - -## Adding a new package - -npm exposes trusted-publisher settings only on a package that already exists, and -the workflow carries no npm token, so a brand-new package is bootstrapped by hand -once: - -1. Make it a workspace member: add its directory to `workspace` in the root - `deno.json`, give it a `deno.json` whose `name` is under `@executablemd`, and - a `package.json` declaring its dependencies (`workspace:*` for internal - `@executablemd` siblings). `gen-publish-workflow.ts` then picks it up - automatically — run `deno task gen:publish-workflow` and commit the result - (CI fails if `publish-packages.yml` is stale). -2. Publish its first version by hand, as a maintainer who is logged in to npm - (`npm login`) and owns the `@executablemd` scope: - ```sh - deno run -A scripts/build-npm.ts - ( cd /npm && npm publish --access public ) - ``` -3. Configure the trusted publisher on the now-existing package with the table - above. -4. Optionally create/link the package on jsr.io (JSR is best-effort and does not - fail the run). - -Every later release publishes the package automatically over OIDC. - -## Consumer note - -`@executablemd/core`, `@executablemd/runtime`, and `@executablemd/cli` depend on -effection's 4.x prerelease, which npm's peer resolver rejects against -`@effectionx/*` (`^3 || ^4`). Installing them needs `--legacy-peer-deps` until -effection 4 is stable. diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..7c92034 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,8 @@ +# Config for draft-release.yml — see specs/release-process-spec.md §3. +name-template: "v$RESOLVED_VERSION" +tag-template: "v$RESOLVED_VERSION" +change-template: "- $TITLE (#$NUMBER) @$AUTHOR" +template: | + ## What's changed + + $CHANGES diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml new file mode 100644 index 0000000..14baa42 --- /dev/null +++ b/.github/workflows/draft-release.yml @@ -0,0 +1,21 @@ +name: Draft release + +# Keeps a rolling draft release: every merge to main updates its changelog +# with the PRs merged since the last published release. Publishing the draft +# creates the tag that starts the release. See specs/release-process-spec.md. + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: read + +jobs: + draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-one.yml b/.github/workflows/publish-one.yml index dec2dff..051ede4 100644 --- a/.github/workflows/publish-one.yml +++ b/.github/workflows/publish-one.yml @@ -1,8 +1,7 @@ name: Publish one package -# Reusable: build one @executablemd workspace member with dnt and publish it to -# npm (OIDC) and JSR (best-effort). publish-packages.yml calls this once per -# package and orders the calls with `needs:`. +# Reusable: build one @executablemd workspace member with dnt and publish it +# to npm (OIDC) and JSR (best-effort). See specs/release-process-spec.md. on: workflow_call: @@ -23,9 +22,8 @@ permissions: jobs: publish: runs-on: ubuntu-latest - # Environment protection rules (required reviewers, v* tag-only deployment - # rule) gate every publish; the npm trusted publisher is also bound to this - # environment name, so npm rejects OIDC tokens minted outside it. + # Gates every publish behind environment protection rules; the npm trusted + # publisher is bound to this name (spec §4). environment: npm-publish env: # @effectionx/* peer-depend on effection `^3 || ^4`, which npm won't match @@ -60,9 +58,7 @@ jobs: echo "build failed after 4 attempts" >&2 exit 1 - # Skip an already-published version so re-running a tag's workflow (or - # tagging a version that was hand-bootstrapped) is idempotent instead of - # failing on npm's no-republish rule. + # Idempotent: skip a version npm already has, so tag re-runs succeed. - name: Publish to npm working-directory: ${{ inputs.package }}/npm run: | diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 96cf0a7..d8057c0 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -1,34 +1,16 @@ name: Publish packages -# GENERATED by scripts/gen-publish-workflow.ts — do not edit by hand. -# Run `deno task gen:publish-workflow` after adding/removing a workspace -# package or changing its @executablemd dependencies, then commit the result. -# -# One tag ships everything: release.yml builds the binary; this workflow fans -# out one publish-one.yml run per @executablemd package, ordered with `needs:` -# so dependencies publish before dependents (leaves run in parallel). -# -# Prerequisites (one-time) — see .github/PUBLISHING.md: -# - npm: an OIDC trusted publisher per @executablemd package. The workflow -# filename npm validates is the CALLER (publish-packages.yml), not the -# reusable publish-one.yml. No npm token is used. -# - JSR: create/link the @executablemd packages to this repo (best-effort). -# -# Note for consumers: core/runtime/cli depend on effection's 4.x prerelease, -# which npm's peer resolver rejects against @effectionx/* (`^3 || ^4`). Until -# effection 4 is stable, installing those packages needs `--legacy-peer-deps`. +# GENERATED by scripts/gen-publish-workflow.md — do not edit by hand. +# Run `deno task gen:publish-workflow` and commit the result. +# See specs/release-process-spec.md. on: push: tags: ["v*"] - workflow_dispatch: - inputs: - tag: - description: "Tag to publish (e.g. v0.2.0)" - required: true permissions: contents: read + actions: read # the version job polls the release.yml run id-token: write # granted to the reusable publish-one.yml jobs for OIDC jobs: @@ -37,11 +19,48 @@ jobs: outputs: value: ${{ steps.resolve.outputs.value }} steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - id: resolve run: | - TAG="${{ github.event.inputs.tag || github.ref_name }}" + TAG="${{ github.ref_name }}" echo "value=${TAG#v}" >> "$GITHUB_OUTPUT" + # Refuse a tag the manifests do not declare (spec §2). + - name: Validate the manifests declare this version + run: | + VERSION="${{ steps.resolve.outputs.value }}" + for f in durable-streams/deno.json runtime/deno.json core/deno.json cli/deno.json packages/code-review-agent/deno.json; do + declared="$(jq -r .version "$f")" + if [ "$declared" != "$VERSION" ]; then + echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests" + exit 1 + fi + done + + # Packages publish only after the binaries exist (spec §1): poll the + # release.yml run for this commit and fail if it fails. + - name: Wait for the binary release to succeed + env: + GH_TOKEN: ${{ github.token }} + run: | + for i in $(seq 1 60); do + RUNS="repos/${{ github.repository }}/actions/workflows/release.yml/runs?head_sha=${{ github.sha }}&per_page=1" + STATUS="$(gh api "$RUNS" --jq '.workflow_runs[0].status // "queued"')" + CONCLUSION="$(gh api "$RUNS" --jq '.workflow_runs[0].conclusion // "none"')" + if [ "$STATUS" = "completed" ]; then + if [ "$CONCLUSION" = "success" ]; then + echo "release.yml succeeded — binaries are published" + exit 0 + fi + echo "::error::release.yml concluded '$CONCLUSION' — refusing to publish packages without binaries" + exit 1 + fi + sleep 30 + done + echo "::error::timed out waiting for release.yml" + exit 1 + durable-streams: needs: [version] uses: ./.github/workflows/publish-one.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 358f373..e947ee7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,11 +3,6 @@ name: Release on: push: tags: ["v*"] - workflow_dispatch: - inputs: - tag: - description: "Tag to build and release (e.g. v0.1.0)" - required: true permissions: contents: write @@ -31,12 +26,18 @@ jobs: - target: x86_64-pc-windows-msvc artifact: xmd-x86_64-pc-windows-msvc.exe steps: - # For a tag push, github.ref is the tag. For workflow_dispatch, check out - # the requested tag explicitly so the built revision matches the release - # name (checkout fails if the tag does not exist — fail closed). - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - ref: ${{ github.event.inputs.tag || github.ref }} + + # Refuse a tag the manifests do not declare (spec §2) — the binary's + # --version comes from cli/deno.json. + - name: Tag matches the manifest version + run: | + TAG="${{ github.ref_name }}" + declared="$(jq -r .version cli/deno.json)" + if [ "v$declared" != "$TAG" ]; then + echo "::error::cli/deno.json declares $declared but the tag is $TAG — bump the manifests first" + exit 1 + fi - uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3 with: @@ -78,7 +79,7 @@ jobs: - name: Publish to GitHub Release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: - tag_name: ${{ github.event.inputs.tag || github.ref_name }} + tag_name: ${{ github.ref_name }} draft: false fail_on_unmatched_files: true files: | diff --git a/.reviews/ReviewPR.local.md b/.reviews/ReviewPR.local.md index b7bd3c4..cab9582 100644 --- a/.reviews/ReviewPR.local.md +++ b/.reviews/ReviewPR.local.md @@ -34,6 +34,9 @@ const pr = parseDiff(rawDiff, rawFiles, { body: PR_BODY, number: PR_NUMBER, }); + +// TODO: we need an easier way to work with diffs here. +const changedFilePaths = pr.files.map((file) => file.path); ``` ```bash silent exec @@ -130,6 +133,8 @@ import { parseDiagnostics } from "@executablemd/code-review-agent"; const diagnostics = parseDiagnostics(rawDiagnostics, pr, doctor); ``` + + diff --git a/.reviews/ReviewPR.md b/.reviews/ReviewPR.md index 9514bd2..199d536 100644 --- a/.reviews/ReviewPR.md +++ b/.reviews/ReviewPR.md @@ -42,6 +42,9 @@ const pr = parseDiff(rawDiff, rawFiles, { body: prBody.trim(), number: PR_NUMBER, }); + +// TODO: we need an easier way to work with diffs here. +const changedFilePaths = pr.files.map((file) => file.path); ``` ```bash silent exec @@ -138,6 +141,7 @@ const diagnostics = parseDiagnostics(rawDiagnostics, pr, doctor); + diff --git a/.reviews/components/ReleaseSpecWarning.md b/.reviews/components/ReleaseSpecWarning.md new file mode 100644 index 0000000..edf1556 --- /dev/null +++ b/.reviews/components/ReleaseSpecWarning.md @@ -0,0 +1,41 @@ +--- +inputs: + files: + type: array + required: true +--- + +# Release Config Files + +The following is the list of files used in the release process. Update this +list when a file is added or removed. + + + +- .github/workflows/draft-release.yml +- .github/workflows/release.yml +- .github/workflows/publish-one.yml +- .github/workflows/publish-packages.yml +- .github/release-drafter.yml +- scripts/gen-publish-workflow.md +- scripts/build-npm.ts + + + +```ts eval +const releaseChanged = files.filter((path) => releaseConfigFiles.includes(`- ${path}`)); +const changedList = releaseChanged.join(", "); + +// TODO: evaluate whether the diff's changes are actually reflected in the +// spec document, not just whether the spec file was touched. +``` + + 0 && !files.includes("specs/release-process-spec.md")}> + +> [!WARNING] +> This PR changes release configuration ({changedList}) without touching +> `specs/release-process-spec.md`. Review the spec and update it to match, or +> state `spec-reviewed: no changes needed` in the PR description (AGENTS.md +> rule 8). + + diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 8f984f6..8126c93 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -27,6 +27,7 @@ import { program, object, field, cli, commands, type Mods } from "configliere"; import { z } from "zod"; import { runDocument, useNormalizedOutput, useTerminalOutput } from "@executablemd/core"; import { FileStream } from "./file-stream.ts"; +import denoJson from "../deno.json" with { type: "json" }; // --------------------------------------------------------------------------- // Workaround: field.default exists at runtime but is missing from the .d.ts @@ -67,7 +68,7 @@ const runConfig = object({ const xmd = program({ name: "xmd", - version: "0.2.0", + version: denoJson.version, config: commands({ run: runConfig }, { default: "run" }), }); diff --git a/deno.json b/deno.json index ea60e1b..6b457c8 100644 --- a/deno.json +++ b/deno.json @@ -35,7 +35,7 @@ "tasks": { "xmd": "deno run --allow-all cli/src/cli.ts", "build": "deno compile --node-modules-dir=none --exclude-unused-npm --allow-all --include packages/code-review-agent --output dist/xmd cli/src/cli.ts", - "gen:publish-workflow": "deno run -A scripts/gen-publish-workflow.ts", + "gen:publish-workflow": "deno run --allow-all cli/src/cli.ts run scripts/gen-publish-workflow.md", "test": "deno test --allow-all core/tests/ durable-streams/tests/ packages/code-review-agent/tests/", "check": "deno check core/mod.ts", "lint": "npx oxlint -c .oxlintrc.json core/src/ cli/src/ durable-streams/ runtime/ && npx oxfmt --check core/src/ cli/src/ durable-streams/ runtime/ test-support/", diff --git a/scripts/gen-publish-workflow.md b/scripts/gen-publish-workflow.md new file mode 100644 index 0000000..a039c3c --- /dev/null +++ b/scripts/gen-publish-workflow.md @@ -0,0 +1,188 @@ +--- +title: Generate publish-packages.yml +--- + +# Generate publish-packages.yml + +`deno task gen:publish-workflow` runs this document. It derives the +publishable packages and their `needs:` order from the workspace manifests and +writes `.github/workflows/publish-packages.yml`. CI regenerates the file and +fails when the committed copy is stale. See specs/release-process-spec.md §3. + +## Workflow header + +The static top of the workflow, through the `version` job's tag resolution: + + + +```yaml +name: Publish packages + +# GENERATED by scripts/gen-publish-workflow.md — do not edit by hand. +# Run `deno task gen:publish-workflow` and commit the result. +# See specs/release-process-spec.md. + +on: + push: + tags: ["v*"] + +permissions: + contents: read + actions: read # the version job polls the release.yml run + id-token: write # granted to the reusable publish-one.yml jobs for OIDC + +jobs: + version: + runs-on: ubuntu-latest + outputs: + value: ${{ steps.resolve.outputs.value }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - id: resolve + run: | + TAG="${{ github.ref_name }}" + echo "value=${TAG#v}" >> "$GITHUB_OUTPUT" +``` + + + +## Version guards + +Two more steps in the `version` job: refuse a tag the manifests do not +declare, and hold packages until the binary release succeeds. `__MANIFESTS__` +becomes the space-separated list of every publishable member's deno.json: + + + +```yaml + # Refuse a tag the manifests do not declare (spec §2). + - name: Validate the manifests declare this version + run: | + VERSION="${{ steps.resolve.outputs.value }}" + for f in __MANIFESTS__; do + declared="$(jq -r .version "$f")" + if [ "$declared" != "$VERSION" ]; then + echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests" + exit 1 + fi + done + + # Packages publish only after the binaries exist (spec §1): poll the + # release.yml run for this commit and fail if it fails. + - name: Wait for the binary release to succeed + env: + GH_TOKEN: ${{ github.token }} + run: | + for i in $(seq 1 60); do + RUNS="repos/${{ github.repository }}/actions/workflows/release.yml/runs?head_sha=${{ github.sha }}&per_page=1" + STATUS="$(gh api "$RUNS" --jq '.workflow_runs[0].status // "queued"')" + CONCLUSION="$(gh api "$RUNS" --jq '.workflow_runs[0].conclusion // "none"')" + if [ "$STATUS" = "completed" ]; then + if [ "$CONCLUSION" = "success" ]; then + echo "release.yml succeeded — binaries are published" + exit 0 + fi + echo "::error::release.yml concluded '$CONCLUSION' — refusing to publish packages without binaries" + exit 1 + fi + sleep 30 + done + echo "::error::timed out waiting for release.yml" + exit 1 +``` + + + +## Package job + +One job per publishable package, dependencies before dependents. `__JOB__`, +`__NEEDS__`, and `__PACKAGE__` come from the member's manifests: + + + +```yaml + __JOB__: + needs: [__NEEDS__] + uses: ./.github/workflows/publish-one.yml + with: + package: __PACKAGE__ + version: ${{ needs.version.outputs.value }} +``` + + + +## Generate + +```ts eval +import { readTextFile, writeTextFile } from "@effectionx/fs"; + +const SCOPE = "@executablemd/"; + +const root = JSON.parse(yield* readTextFile("deno.json")); + +const members = []; +for (const dir of root.workspace) { + let denoJson; + let pkgJson; + try { + denoJson = JSON.parse(yield* readTextFile(`${dir}/deno.json`)); + pkgJson = JSON.parse(yield* readTextFile(`${dir}/package.json`)); + } catch { + continue; + } + if (typeof denoJson.name !== "string" || !denoJson.name.startsWith(SCOPE)) { + continue; + } + const deps = Object.keys(pkgJson.dependencies ?? {}); + members.push({ + dir, + job: denoJson.name.slice(SCOPE.length), + internal: deps.filter((dep) => dep.startsWith(SCOPE)).map((dep) => dep.slice(SCOPE.length)), + }); +} + +const publishable = new Set(members.map((member) => member.job)); +const byJob = new Map(members.map((member) => [member.job, member])); +const ordered = []; +const seen = new Set(); +const visit = (member) => { + if (seen.has(member.job)) { + return; + } + seen.add(member.job); + for (const dep of member.internal) { + const target = byJob.get(dep); + if (target) { + visit(target); + } + } + ordered.push(member); +}; +for (const member of members) { + visit(member); +} + +const manifests = ordered.map((member) => `${member.dir}/deno.json`).join(" "); +const jobs = ordered.map((member) => { + const needs = ["version", ...member.internal.filter((dep) => publishable.has(dep))]; + return jobTemplate + .replaceAll("__JOB__", member.job) + .replaceAll("__NEEDS__", needs.join(", ")) + .replaceAll("__PACKAGE__", member.dir); +}); + +const workflow = [ + headerTemplate, + "", + guardsTemplate.replaceAll("__MANIFESTS__", manifests), + "", + jobs.join("\n\n"), +].join("\n") + "\n"; + +yield* writeTextFile(".github/workflows/publish-packages.yml", workflow); + +const summary = `generated .github/workflows/publish-packages.yml (${ordered.length} packages)`; +``` + +{summary} diff --git a/scripts/gen-publish-workflow.ts b/scripts/gen-publish-workflow.ts deleted file mode 100644 index 0cc986b..0000000 --- a/scripts/gen-publish-workflow.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Generate .github/workflows/publish-packages.yml from the workspace manifests. - * - * Usage: - * deno task gen:publish-workflow - * - * The orchestrator fans out one publish-one.yml call per @executablemd package, - * ordered with `needs:` so dependencies publish before dependents. The set of - * packages and their order are derived here from each member's deno.json (name) - * and package.json (internal dependencies), so the workflow never drifts from - * the manifests. CI fails if the committed file is stale (see ci.yml). - */ - -import { exit, main } from "effection"; -import { readTextFile, writeTextFile } from "@effectionx/fs"; -import { z } from "npm:zod@^4"; - -const SCOPE = "@executablemd/"; - -const NamedSchema = z.object({ name: z.string() }); -const DepsSchema = z.object({ dependencies: z.record(z.string(), z.string()).optional() }); -const RootSchema = z.object({ workspace: z.array(z.string()) }); - -interface Member { - dir: string; - job: string; - internal: string[]; -} - -const HEADER = `name: Publish packages - -# GENERATED by scripts/gen-publish-workflow.ts — do not edit by hand. -# Run \`deno task gen:publish-workflow\` after adding/removing a workspace -# package or changing its @executablemd dependencies, then commit the result. -# -# One tag ships everything: release.yml builds the binary; this workflow fans -# out one publish-one.yml run per @executablemd package, ordered with \`needs:\` -# so dependencies publish before dependents (leaves run in parallel). -# -# Prerequisites (one-time) — see .github/PUBLISHING.md: -# - npm: an OIDC trusted publisher per @executablemd package. The workflow -# filename npm validates is the CALLER (publish-packages.yml), not the -# reusable publish-one.yml. No npm token is used. -# - JSR: create/link the @executablemd packages to this repo (best-effort). -# -# Note for consumers: core/runtime/cli depend on effection's 4.x prerelease, -# which npm's peer resolver rejects against @effectionx/* (\`^3 || ^4\`). Until -# effection 4 is stable, installing those packages needs \`--legacy-peer-deps\`. - -on: - push: - tags: ["v*"] - workflow_dispatch: - inputs: - tag: - description: "Tag to publish (e.g. v0.2.0)" - required: true - -permissions: - contents: read - id-token: write # granted to the reusable publish-one.yml jobs for OIDC - -jobs: - version: - runs-on: ubuntu-latest - outputs: - value: \${{ steps.resolve.outputs.value }} - steps: - - id: resolve - run: | - TAG="\${{ github.event.inputs.tag || github.ref_name }}" - echo "value=\${TAG#v}" >> "$GITHUB_OUTPUT" -`; - -await main(function* () { - const repoRoot = new URL("../", import.meta.url); - const root = RootSchema.parse(JSON.parse(yield* readTextFile(new URL("deno.json", repoRoot)))); - - const members: Member[] = []; - for (const dir of root.workspace) { - let denoText: string; - let pkgText: string; - try { - denoText = yield* readTextFile(new URL(`${dir}/deno.json`, repoRoot)); - pkgText = yield* readTextFile(new URL(`${dir}/package.json`, repoRoot)); - } catch { - continue; - } - const named = NamedSchema.safeParse(JSON.parse(denoText)); - if (!named.success || !named.data.name.startsWith(SCOPE)) { - continue; - } - const deps = DepsSchema.parse(JSON.parse(pkgText)).dependencies ?? {}; - members.push({ - dir, - job: named.data.name.slice(SCOPE.length), - internal: Object.keys(deps) - .filter((dep) => dep.startsWith(SCOPE)) - .map((dep) => dep.slice(SCOPE.length)), - }); - } - - const publishable = new Set(members.map((member) => member.job)); - - // Topological order: dependencies before dependents. - const byJob = new Map(members.map((member) => [member.job, member])); - const ordered: Member[] = []; - const seen = new Set(); - const visit = (member: Member): void => { - if (seen.has(member.job)) { - return; - } - seen.add(member.job); - for (const dep of member.internal) { - const target = byJob.get(dep); - if (target) { - visit(target); - } - } - ordered.push(member); - }; - for (const member of members) { - visit(member); - } - - const lines = [HEADER]; - for (const member of ordered) { - const needs = ["version", ...member.internal.filter((dep) => publishable.has(dep))]; - lines.push(` ${member.job}:`); - lines.push(` needs: [${needs.join(", ")}]`); - lines.push(" uses: ./.github/workflows/publish-one.yml"); - lines.push(" with:"); - lines.push(` package: ${member.dir}`); - lines.push(" version: ${{ needs.version.outputs.value }}"); - lines.push(""); - } - - const out = new URL(".github/workflows/publish-packages.yml", repoRoot); - yield* writeTextFile(out, lines.join("\n").trimEnd() + "\n"); - console.log(`generated .github/workflows/publish-packages.yml (${ordered.length} packages)`); - yield* exit(0); -}); diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md new file mode 100644 index 0000000..25ead4f --- /dev/null +++ b/specs/release-process-spec.md @@ -0,0 +1,145 @@ +# Specification: Release process + +* **Status:** Current +* **Scope:** How a version of executable.md ships: tagging, binary release, and + npm/JSR package publishing. + +--- + +## 1. Overview + +Every merge to `main` updates a rolling draft release whose notes list the +PRs merged since the last published release. A release is triggered by a +maintainer publishing that draft via the GitHub Releases UI, which creates a +`vX.Y.Z` tag from `main`. No workflow creates tags. + +The tag starts two workflows: `release.yml` compiles the `xmd` binaries and +attaches them to the release, and `publish-packages.yml` publishes every +`@executablemd/*` package to npm (primary) and JSR (secondary). Binaries come +first: `publish-packages.yml` publishes nothing until `release.yml` succeeds, +so npm versions never exist without matching binaries. Both workflows build +from the tag's commit. + +```mermaid +sequenceDiagram + actor M as Maintainer + participant DR as draft-release.yml + participant GH as GitHub + participant R as release.yml + participant PP as publish-packages.yml + participant PO as publish-one.yml (per package) + participant NPM as npm + participant JSR as JSR + + GH->>DR: push: main (PR merged) + DR->>GH: update draft release changelog + M->>GH: publish the draft → tag vX.Y.Z from main + GH->>R: push: tags v* + GH->>PP: push: tags v* + R->>R: validate tag matches cli/deno.json + R->>GH: compile xmd per target,
attach binaries + checksums to the release + PP->>PP: validate tag matches every manifest,
wait for release.yml to succeed + PP->>PO: one call per package,
needs-ordered (deps first) + M-->>PO: approve npm-publish environment + PO->>NPM: npm publish --access public (OIDC,
skipped if version already published) + PO->>JSR: deno publish (best-effort) +``` + +## 2. Version lockstep + +Every package (`core`, `cli`, `durable-streams`, `runtime`, +`packages/code-review-agent`) declares the same version in its `deno.json` and +`package.json`. `cli/src/cli.ts` imports `cli/deno.json` and reads `version` +from it, so the compiled binary reports the manifest version — the manifests +are the single source. The npm version derives from the tag, and both +workflows refuse a tag the manifests do not declare, so the two cannot +diverge. + +To cut a release: bump the version in every manifest, merge to `main`, then +create the `vX.Y.Z` tag. + +## 3. Workflows + +- **`draft-release.yml`** (`push: main`): maintains the rolling draft release + with release-drafter (config: `.github/release-drafter.yml`). Each merged PR + appends a changelog line; publishing the draft cuts the release. Before + publishing, set the tag to the version the manifests declare — the guards + below refuse a mismatched tag. +- **`release.yml`** (`push: tags v*`): validates the tag against + `cli/deno.json`, compiles `xmd` per target with + `--include packages/code-review-agent`, and attaches the binaries and sha256 + checksums to the tag's GitHub Release. +- **`publish-packages.yml`** (`push: tags v*`): GENERATED by + `scripts/gen-publish-workflow.md` — an executable markdown document that + derives the jobs from the workspace manifests — never edited by hand. Run + `deno task gen:publish-workflow` after adding/removing a + workspace package or changing its `@executablemd` dependencies; CI fails if + the committed file is stale. Its `version` job validates the tag against + every manifest and polls the `release.yml` run for the same commit, failing + if the binary build fails. It then fans out one `publish-one.yml` call per + package, ordered with `needs:` so dependencies publish before dependents + (leaves run in parallel). +- **`publish-one.yml`** (`workflow_call`, inputs `package`/`version`): builds + one package with dnt (`scripts/build-npm.ts`) and publishes it. Runs in the + `npm-publish` environment. npm publishing is idempotent: it skips an + already-published version. JSR publishing is best-effort and does not fail + the run. + +## 4. npm authentication (OIDC, no token) + +`publish-one.yml` authenticates to npm with GitHub Actions OIDC trusted +publishing; the repo holds no npm token. Each package's trusted publisher on +npmjs.com (Settings → Trusted Publisher → GitHub Actions): + +| Field | Value | +| -------------------- | ---------------------- | +| Organization or user | `taras` | +| Repository | `executable.md` | +| Workflow filename | `publish-packages.yml` | +| Environment name | `npm-publish` | +| Allowed actions | `npm publish` | + +npm validates the **calling** workflow's filename for `workflow_call`, not the +reusable `publish-one.yml`. Binding the environment name makes npm reject OIDC +tokens minted outside the gated environment. + +## 5. Protection configuration + +- The **`npm-publish` environment** requires reviewer approval and deploys + only for `v*` tags, and every npm trusted publisher is bound to it (§4). +- **Rulesets** require PRs into `main` and restrict `v*` tag creation to + maintainers. + +## 6. Adding a new package + +npm exposes trusted-publisher settings only on a package that already exists, +and the workflows carry no npm token, so bootstrap a new package by hand once: + +1. Add its directory to `workspace` in the root `deno.json`, with a `deno.json` + (name under `@executablemd`) and a `package.json` declaring its dependencies + (`workspace:*` for internal siblings). Run `deno task gen:publish-workflow` + and commit the regenerated orchestrator. +2. Publish its first version by hand as a logged-in `@executablemd` scope + owner: + ```sh + deno run -A scripts/build-npm.ts + ( cd /npm && npm publish --access public ) + ``` +3. Configure its trusted publisher with the table in §4. +4. Create the package on jsr.io under the `@executablemd` scope and link it to + this repository — `deno publish` fails for a package that does not exist on + JSR (the pipeline treats that failure as non-fatal, but the package will + not appear on JSR until it is created). + +## 7. Recovery + +Re-run failed jobs on the tag's own workflow run. Publishing skips an +already-published version, so re-runs and re-tags of hand-bootstrapped +versions succeed. No dispatch path publishes outside a tag. + +## 8. Consumer note + +`@executablemd/core`, `@executablemd/runtime`, and `@executablemd/cli` depend +on effection's 4.x prerelease, which npm's peer resolver rejects against +`@effectionx/*` (`^3 || ^4`). Installing them requires `--legacy-peer-deps` +until effection 4 is stable. From dd5578c149e4bf84e957be40395ff568b6617ca3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:35:47 -0400 Subject: [PATCH 2/4] review: ReleaseSpecWarning declares its output via MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component's heading, maintenance note, and file list are documentation — they execute (the capture feeds the check) but never render into the review comment; only the conditional warning inside does (#88). --- .reviews/components/ReleaseSpecWarning.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.reviews/components/ReleaseSpecWarning.md b/.reviews/components/ReleaseSpecWarning.md index edf1556..13a14d3 100644 --- a/.reviews/components/ReleaseSpecWarning.md +++ b/.reviews/components/ReleaseSpecWarning.md @@ -30,6 +30,8 @@ const changedList = releaseChanged.join(", "); // spec document, not just whether the spec file was touched. ``` + + 0 && !files.includes("specs/release-process-spec.md")}> > [!WARNING] @@ -39,3 +41,5 @@ const changedList = releaseChanged.join(", "); > rule 8). + + From 2c1d7e745c57c1341ca3fdaddb3ab198555b2dce Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:39:32 -0400 Subject: [PATCH 3/4] release: bump to 0.3.0; pin CI to the v0.3.0 binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review documents now use (#88), which the v0.2.0 binary predates — its CI review run fails to resolve the component and leaks documentation into the PR comment. Bump every manifest to 0.3.0 (spec §2 ritual) and pin review.yml / repo-analysis.yml to v0.3.0: those jobs fail loudly at install until the v0.3.0 draft release is published after merge, then run a binary that understands . --- .github/workflows/repo-analysis.yml | 2 +- .github/workflows/review.yml | 2 +- cli/deno.json | 2 +- cli/package.json | 2 +- cli/src/cli.ts | 0 core/deno.json | 2 +- core/package.json | 2 +- durable-streams/deno.json | 2 +- durable-streams/package.json | 2 +- packages/code-review-agent/deno.json | 2 +- packages/code-review-agent/package.json | 2 +- runtime/deno.json | 2 +- runtime/package.json | 2 +- 13 files changed, 12 insertions(+), 12 deletions(-) mode change 100644 => 100755 cli/src/cli.ts diff --git a/.github/workflows/repo-analysis.yml b/.github/workflows/repo-analysis.yml index e260e31..be42361 100644 --- a/.github/workflows/repo-analysis.yml +++ b/.github/workflows/repo-analysis.yml @@ -33,7 +33,7 @@ jobs: # analysis behavior; bump the pin as part of each release. - name: Install xmd release binary run: | - curl -fsSL https://executable.md/install.sh | XMD_VERSION=v0.2.0 sh + curl -fsSL https://executable.md/install.sh | XMD_VERSION=v0.3.0 sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run repo analysis diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 5dd31b3..b076e74 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -19,7 +19,7 @@ jobs: # PR-review behavior; bump the pin as part of each release. - name: Install xmd release binary run: | - curl -fsSL https://executable.md/install.sh | XMD_VERSION=v0.2.0 sh + curl -fsSL https://executable.md/install.sh | XMD_VERSION=v0.3.0 sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run review diff --git a/cli/deno.json b/cli/deno.json index 7146ae7..2e1064b 100644 --- a/cli/deno.json +++ b/cli/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/cli", - "version": "0.2.0", + "version": "0.3.0", "exports": "./src/cli.ts", "imports": { "@executablemd/core": "../core/mod.ts", diff --git a/cli/package.json b/cli/package.json index bd3a817..2b963dd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/cli", - "version": "0.2.0", + "version": "0.3.0", "description": "The xmd command-line interface for executable.md.", "bin": { "xmd": "./src/cli.ts" diff --git a/cli/src/cli.ts b/cli/src/cli.ts old mode 100644 new mode 100755 diff --git a/core/deno.json b/core/deno.json index 293e813..0344c8f 100644 --- a/core/deno.json +++ b/core/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/core", - "version": "0.2.0", + "version": "0.3.0", "exports": { ".": "./mod.ts" }, diff --git a/core/package.json b/core/package.json index f152c9e..dbc033b 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/core", - "version": "0.2.0", + "version": "0.3.0", "description": "Core engine that evaluates executable.md documents.", "type": "module", "exports": { diff --git a/durable-streams/deno.json b/durable-streams/deno.json index c31797e..6e9c838 100644 --- a/durable-streams/deno.json +++ b/durable-streams/deno.json @@ -1,5 +1,5 @@ { "name": "@executablemd/durable-streams", - "version": "0.2.0", + "version": "0.3.0", "exports": "./mod.ts" } diff --git a/durable-streams/package.json b/durable-streams/package.json index cef0be7..75d98b4 100644 --- a/durable-streams/package.json +++ b/durable-streams/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/durable-streams", - "version": "0.2.0", + "version": "0.3.0", "description": "Durable, replayable event streams for executable.md.", "type": "module", "exports": "./mod.ts", diff --git a/packages/code-review-agent/deno.json b/packages/code-review-agent/deno.json index 3678b76..bfbcb02 100644 --- a/packages/code-review-agent/deno.json +++ b/packages/code-review-agent/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/code-review-agent", - "version": "0.2.0", + "version": "0.3.0", "exports": { ".": "./mod.ts" } diff --git a/packages/code-review-agent/package.json b/packages/code-review-agent/package.json index 5b270f7..c93cbc9 100644 --- a/packages/code-review-agent/package.json +++ b/packages/code-review-agent/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/code-review-agent", - "version": "0.2.0", + "version": "0.3.0", "description": "Parsers that turn git diff and Oxlint output into typed structures for executable.md reviews.", "type": "module", "exports": "./mod.ts" diff --git a/runtime/deno.json b/runtime/deno.json index 8f95eac..adc99ef 100644 --- a/runtime/deno.json +++ b/runtime/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/runtime", - "version": "0.2.0", + "version": "0.3.0", "exports": { ".": "./mod.ts", "./test": "./test/mod.ts" diff --git a/runtime/package.json b/runtime/package.json index 1ccac92..7778e39 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/runtime", - "version": "0.2.0", + "version": "0.3.0", "description": "Runtime host APIs for executable.md documents.", "type": "module", "exports": { From 5e1e58048075b580791d50b3dc3196acd44be33f Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:40:51 -0400 Subject: [PATCH 4/4] chore: restore cli.ts file mode (pnpm install flips it via the bin field) --- cli/src/cli.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 cli/src/cli.ts diff --git a/cli/src/cli.ts b/cli/src/cli.ts old mode 100755 new mode 100644