diff --git a/.changeset/README.md b/.changeset/README.md index ac166f5..82e9d48 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -18,7 +18,15 @@ pnpm change --bump --summary "" [ - This README is NOT a changeset: the gate requires a file whose frontmatter parses as `"": `. -Publishing uses npm OIDC trusted publishing from `.github/workflows/release.yml`. -Register `@systemfsoftware/arethetypeswrong-cli` and `@systemfsoftware/arethetypeswrong` as trusted publishers on npmjs.com -pointing at this repository and that workflow filename before the first new -version can ship. OIDC cannot debut a package npm has never seen. +The release pipeline is the shared toolchain in +`systemfsoftware/pnpm-release-management`, consumed as a reusable workflow +(`.github/workflows/release.yml` and `changeset-check.yml` are thin callers +pinned to its `prm/toolchain` ref). On a push to `main` it opens or updates the +version PR when intents are pending, and otherwise tags each released version +`@vX.Y.Z` and cuts a GitHub Release from its authored changelog. A version +with no such tag is what the pipeline treats as owed a release. + +Distribution is Nix flakes consumed from git refs, not an npm registry: the git +tag, pinned downstream by a consumer's `flake.lock` rev + narHash, is the +durable record that a version shipped. There is no registry step to debut a +package. diff --git a/.changeset/unify-release-tooling.md b/.changeset/unify-release-tooling.md new file mode 100644 index 0000000..1779081 --- /dev/null +++ b/.changeset/unify-release-tooling.md @@ -0,0 +1,6 @@ +--- +"@systemfsoftware/arethetypeswrong-cli": none +"@systemfsoftware/arethetypeswrong": none +--- + +Release tooling moves to the shared `systemfsoftware/pnpm-release-management` toolchain (CI workflows and root config only); no package code changes and nothing is released diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index 59ab9b6..a664360 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -9,13 +9,9 @@ permissions: jobs: require-changeset: - if: github.head_ref != 'changeset-release/main' - name: a publishable-package change needs a changeset - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: denoland/setup-deno@v2 - - name: Require a changeset for publishable-package changes - run: ./scripts/check-changeset.ts ${{ github.event.pull_request.base.sha }} + uses: systemfsoftware/pnpm-release-management/.github/workflows/changeset-check.yml@prm/toolchain + with: + tools-ref: prm/toolchain + permissions: + contents: read + pull-requests: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15f74be..99c9a2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,104 +13,10 @@ permissions: pull-requests: write jobs: - plan: - name: plan · derive phase - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - phase: ${{ steps.plan.outputs.phase }} - env: - HUSKY: "0" - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - uses: denoland/setup-deno@v2 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Preflight intent consumption - run: pnpm version -r --dry-run - - id: plan - name: Decide release phase from repository state - run: ./scripts/plan-release.ts --output "$GITHUB_OUTPUT" - - version: - if: needs.plan.outputs.phase == 'version' - needs: [plan] - name: version · open release PR - runs-on: ubuntu-latest + release: + uses: systemfsoftware/pnpm-release-management/.github/workflows/release.yml@prm/toolchain + with: + tools-ref: prm/toolchain permissions: contents: write pull-requests: write - env: - HUSKY: "0" - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: denoland/setup-deno@v2 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Consume pending change intents - run: pnpm version -r - - name: Capture release set - run: ./scripts/tag-released-packages.ts --dry-run --json --output /tmp/captured.json && cat /tmp/captured.json - - name: Assert release notes - env: - GITHUB_TOKEN: ${{ github.token }} - run: ./scripts/create-github-releases.ts --assert --captured /tmp/captured.json - - name: Open or update the Release PR - env: - GH_TOKEN: ${{ github.token }} - BRANCH: changeset-release/main - BASE: ${{ github.event.repository.default_branch }} - run: ./scripts/open-release-pr.sh - - publish: - if: needs.plan.outputs.phase == 'publish' - permissions: - contents: write - id-token: write - name: publish · oidc · tag - needs: [plan] - runs-on: ubuntu-latest - env: - HUSKY: "0" - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - uses: denoland/setup-deno@v2 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Capture release set - run: ./scripts/tag-released-packages.ts --dry-run --json --output /tmp/captured.json - - name: Assert release notes - env: - GITHUB_TOKEN: ${{ github.token }} - run: ./scripts/create-github-releases.ts --assert --captured /tmp/captured.json - - name: Build - run: pnpm build - - name: Publish unpublished versions via OIDC - run: ./scripts/tag-released-packages.ts --unpublished --captured /tmp/captured.json --publish - - name: Tag released versions - run: ./scripts/tag-released-packages.ts --captured /tmp/captured.json - - name: GitHub Releases from authored changelogs - env: - GITHUB_TOKEN: ${{ github.token }} - run: ./scripts/create-github-releases.ts --captured /tmp/captured.json diff --git a/CONCEPTS.md b/CONCEPTS.md index 92e8313..ba9387b 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -16,26 +16,18 @@ _Avoid:_ changeset — the file format is a changeset, but the concept here is t ### Release set -The workspace packages owed a release in the current run — those whose manifest version is not yet served by the package registry. Membership is a fact about the registry, not about version control: a package leaves the set when its version is published, never when a branch advances or a tag is written. +The workspace packages owed a release in the current run — those whose manifest version carries no `@vX.Y.Z` git tag yet. Membership is a git fact: a package leaves the set when its tag is written (which the shared release pipeline does as it tags the version and cuts its GitHub Release), not when a branch advances. There is no registry to probe — Nix flakes consumed from git refs are the distribution, so the tag is the record that a version shipped. ### Release phase -The stage the release pipeline decides it is in, derived rather than configured. `publish` when the release set is non-empty; `version` when nothing is owed but unconsumed change intents remain; `none` when neither holds. Each phase gates a distinct job, so a phase derived from a wrong signal skips work silently rather than failing. An intent file that still exists after consumption is recorded is not pending. +The stage the release pipeline decides it is in, derived rather than configured. `release` when the release set is non-empty (tag the untagged versions and cut their GitHub Releases); `version` when nothing is owed but unconsumed change intents remain; `none` when neither holds. The shared toolchain derives the phase from repository state on each push to `main`, so a half-finished release resumes on the next push. An intent file that still exists after consumption is recorded is not pending. -### Published version +### Released version -A version the package registry serves for a package. The registry is the authority on this: neither a git tag nor a changelog file establishes it, and both are written downstream of a successful publish. - -### Git tag as release evidence - -Rejected as a release signal. Tags are written _after_ the step that a missing tag would cause to fail, so a detector reading tag absence cannot make its own precondition true. Recorded here because the term still appears in the pipeline's history and in older workflow steps. +A version that carries its `@vX.Y.Z` git tag. The tag is the authority on this: the shared release pipeline writes it as it tags the version and cuts the GitHub Release, and a consumer pins it downstream through `flake.lock` (rev + narHash). An untagged manifest version is owed a release; a tagged one is done. Tagging and the GitHub Release are both idempotent on tag existence, so a half-finished release resumes safely on the next push to main. ## Registry resolution -### Registry probe - -A query against the package registry asking whether a given package version is published. It has three outcomes, not two: published, unpublished, and _cannot tell_. The last is a failure, never a "no" — folding it into unpublished reclassifies a published package as owed a release. - ### Scoped name A package name carrying a scope, written `@scope/name`. A scoped name is a single path segment in a registry URL, so the scope separator must be percent-encoded rather than left literal. diff --git a/docs/solutions/tooling-decisions/ledgered-intents-are-not-pending-releases.md b/docs/solutions/tooling-decisions/ledgered-intents-are-not-pending-releases.md deleted file mode 100644 index 05c95a5..0000000 --- a/docs/solutions/tooling-decisions/ledgered-intents-are-not-pending-releases.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Ledgered leftover intents are not a pending release -date: 2026-09-11 -category: tooling-decisions -module: arethetypeswrong -problem_type: logic_error -component: tooling -severity: high -symptoms: - - "GitHub Actions opens chore(release): version packages PRs that only delete leftover .changeset files" - - "the PR three-dot-diffs files that no longer exist on main" - - "plan-release prints pending_intents>0 this_cycle=0 -> phase=version after a published cycle" -root_cause: logic_error -resolution_type: code_fix -related_components: - - scripts/plan-release.ts - - scripts/lib/pending-intents.ts - - scripts/open-release-pr.sh -tags: [release, changeset, ledger, phantom-pr, github-actions] ---- - -# Ledgered leftover intents are not a pending release - -## Problem - -After a real version PR publishes, leftover `.changeset/*.md` intent files (retained per README, recorded in `ledger.yaml`) retrigger `phase=version`. The version job then opens a PR that only deletes those files and the authored changelogs. A queued run on a stale SHA reopens the same empty tree as a new PR. - -## Symptoms - -- `plan-release: pending_intents=3 this_cycle=0 -> phase=version` on a tree whose intents are already in `ledger.yaml` -- Version job logs `No pending changes. Record one with "pnpm change"` then still commits deletions -- GitHub three-dot diff lists files already absent from `main`; two-dot vs `main` is empty - -## What Didn't Work - -- Closing the phantom PR. The next `push` to `main` with leftover files, or a queued Release run (`cancel-in-progress: false`), opened another -- Merging the deletion PR (#21). That removed the files from `main` but a stale job checked out the parent SHA and recreated `changeset-release/main` - -## Solution - -Count pending intents as `.changeset/*.md` files whose stem is **not** listed in `ledger.yaml`. Parse the ledger as YAML so quoted stems and `intents:` arrays match the on-disk filenames. - -Refuse to open a version PR unless `apps/**/package.json` or `packages/**/package.json` actually differs from `origin/$BASE`. - -## Why This Works - -`.changeset/README.md` already states a present intent file never implies a pending release. `plan-release` was counting files, not unconsumed files. After a published cycle (`this_cycle=0`), leftover files must yield `phase=none`. The package.json guard stops a stale SHA from republishing an already-squash-merged tree even if plan-release regresses. - -## Prevention - -- Gate `phase=version` on unconsumed intents, not on glob presence -- Gate the Release PR on a real version bump against latest `main`, not on a dirty working tree -- Keep a Deno test that a ledgered leftover file is not pending - -## Related Issues - -- Related: #22 (closed phantom PR) -- Related: #21 (merged deletion-only version PR) -- `docs/solutions/tooling-decisions/release-registry-over-tag-signal-frees-cycle-phase-pinning.md` — the sister signal that made `this_cycle=0` possible and unmasked this counter diff --git a/docs/solutions/tooling-decisions/release-registry-over-tag-signal-frees-cycle-phase-pinning.md b/docs/solutions/tooling-decisions/release-registry-over-tag-signal-frees-cycle-phase-pinning.md deleted file mode 100644 index f09cdc7..0000000 --- a/docs/solutions/tooling-decisions/release-registry-over-tag-signal-frees-cycle-phase-pinning.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -title: Use the registry as the released-cycle signal; the tag is downstream of it -date: 2026-09-11 -category: tooling-decisions -module: arethetypeswrong -problem_type: workflow_issue -component: tooling -severity: high -symptoms: - - "the Release assert step fails with: Missing changelog for @ — expected .changeset/changelogs/!@.md" - - "the version · open release PR job is skipped on every push to main" - - "origin ends up with zero tags for a workspace whose package versions are already on npm" - - "the cycle phase is pinned to publish forever, so pending changeset intents are never consumed" -root_cause: missing_workflow_step -resolution_type: code_fix -related_components: - - scripts/lib/cycle.ts - - scripts/plan-release.ts - - scripts/tag-released-packages.ts - - scripts/create-github-releases.ts - - .github/workflows/release.yml -tags: [release, github-actions, registry-signal, changeset, cycle-pinning, deno-permissions] ---- - -# Use the registry as the released-cycle signal; the tag is downstream of it - -## Problem - -`loadWorkspaceCycle` decided "released this cycle" from _the absence of a git -tag_, so the set of pending releases could only be emptied by a tag that the -workflow writes after the step the pending set was failing. `origin` had zero -tags, and the two public packages were already served by npm at their manifest -versions (npm registry, not repo paths), so the set stayed permanently -non-empty. - -Boundary: the defect is reachable on any push to `main`, in CI only, and it is -self-locking — the failing gate is the same gate that would have made the signal -true. It is not reproducible from a clean local checkout, because the observed -state depends on remote tag absence. - -## Symptoms - -- The publish job fails at its assert step with - `Missing changelog for @systemfsoftware/arethetypeswrong@7.0.0: expected - .changeset/changelogs/@systemfsoftware!arethetypeswrong@7.0.0.md`, for a - version npm already serves. -- The `version · open release PR` job reports **skipped** on every run, because - `plan-release` prints `this_cycle=2 -> phase=publish` and that job is gated on - `phase == 'version'`. -- `git ls-remote --tags origin` returns nothing. - -## What Didn't Work - -1. **Reading a git tag as evidence of release.** The tag is written _after_ the - gate the missing tag was failing, so the predicate could never become true. - Any detector anchored on a downstream artifact inherits this circularity. -2. **Treating the empty tag list as the anomaly.** Nothing was broken about it: - no tag was ever meant to exist until a publish succeeded, and none had. -3. **Collapsing every non-2xx into "unpublished".** This moved the deadlock - behind an HTTP error — a 429 or 5xx reclassified an already-published package - as in-cycle and re-pinned the phase. -4. (session history) Rebase-after-squash is established _branch_ hygiene here - (PR #7 was rebased onto `origin/main` after its base merged). Applied to - `main` itself it silently discarded this fix once. - -## Solution - -```ts -export const isPublished = async (name: string, version: string): Promise => { - const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/${version}`) - if (res.status === 404) return false - if (!res.ok) throw new Error(`registry returned ${res.status} for ${name}@${version}`) - return true -} - -export const loadWorkspaceCycle = async (): Promise => { - const pkgs = await publicPackages() - const published = await Promise.all(pkgs.map(({ name, version }) => isPublished(name, version))) - return pkgs - .filter((_, i) => !published[i]) - .map(({ name, version }) => ({ - name, - version, - tag: `${name}@v${version}`, - changelog: join('.changeset', 'changelogs', `${name.replace('/', '!')}@${version}.md`), - })) -} -``` - -`publicPackages` keeps only manifests that declare a name and version and are not -private. `unpublishedOf` routes through the same `isPublished`, so the boolean -and the error envelope cannot drift between call sites. - -`plan-release` also needs `registry.npmjs.org` in its `--allow-net`: it reaches -`fetch` transitively through this module, and Deno throws `NotCapable` without -the grant — the phase step cannot run at all. `tag-released-packages` and -`create-github-releases` already carried it, because both already queried the -registry before this change. - -## Architectural Invariant - -**Authoritative Truth Source, With a Three-State Probe.** The predicate that -answers "has this manifest version shipped?" must read the authority that owns -the fact, and must not fold its inability to read it into an answer. - -$$\text{isPublished}(n, v) = \begin{cases} -\text{true} & \texttt{GET registry//} \to 200 \\ -\text{false} & \texttt{GET registry//} \to 404 \\ -\text{throw} & \text{otherwise — 429, 5xx, network} -\end{cases}$$ - -Release status is owned by the registry, not by git: the tag is a _consequence_ -of a successful publish, so it can never be the evidence that the publish should -happen. The registry has no such circularity — a version either is served or is -not, independent of whether this pipeline succeeded. - -The three-state split is the second half. A two-state probe must answer _no_ -when it means _I could not tell_, and that is exactly how a published package -re-enters the cycle. `404` is the registry's only "no"; every other outcome is -"cannot tell", and cannot-tell must abort the run rather than feed the phase -machine a false negative. - -The changelog requirement follows from the same split. `pnpm version -r` bumps -each manifest it is consuming an intent for and writes one changelog per bumped -version — observed directly in a scratch clone: given intents for both packages, -it emitted exactly one file per bumped version and none for versions it left -alone. A version already at its manifest value is never bumped, so no changelog -for it can exist, and a detector that lists it as pending has created an -unsatisfiable requirement. - -**Anti-pattern (grep-able):** a release predicate that returns a boolean from a -response whose non-2xx branch is a single `return false` — - -``` -if (!res.ok) return false // ← conflates 404 with 429/5xx -``` - -The compliant shape branches on the status it can interpret and throws on the -rest. This repo already used that shape in `AttwExecutor` against the same -registry, so the fix follows existing convention rather than inventing one. - -## Prevention - -- **Never let a release detector read a downstream artifact.** Tags, GitHub - Releases, and changelog files are written after the gate that consumes them. - Detect from the authority; treat the rest as outputs. -- **Never fold an error envelope into an answer.** Throw on non-404 so a - registry incident fails the run instead of reclassifying. -- **Grant `--allow-net` to every Deno entrypoint that reaches the registry**, - including one-shot scripts: `plan-release` looks harmless but calls - `loadWorkspaceCycle`, and without the grant the phase step cannot run at all. - A script that already queries the registry keeps its grant; a script that - _newly_ gains a transitive registry call needs one added. -- **Confirm a fix is on `origin/main` before claiming it shipped.** A merged PR - is not proof — this fix was squash-merged, then orphaned when `main` was reset - and rebased onto an earlier commit, leaving the PR reading MERGED while the - tree lost the change. Use `git log origin/main`. -- **Test the three-way split, not the truth table.** Stub `fetch` for 200, 404, - 429, 500, 503: `true`, `false`, then throw. Covering only 200 and 404 leaves - the transient path silently reclassifying. -- **Beware `pnpm version -r` deleting a `none` intent.** When another intent - releases a package, `pnpm version -r` garbage-collects a sibling intent that - declared that package `none` — reproducible in a scratch clone: running it - once removed a `none`-for-all-packages intent while leaving the consuming - intents in place. `.changeset/README.md` states intent files are retained, so - the recorded contract and the observed behavior disagree. Treat a `none` file - as at risk if a later intent releases one of the packages it names. - -## Related Issues - -- PR #15 — attempted this fix; squash-merged, then orphaned by a `main` reset - and rebase. The working fix is on branch `fix/release-cycle-registry-v2`. -- `docs/solutions/tooling-decisions/registry-consumption-severs-relocation-build-cycles.md` - — the sister invariant: the registry is also the authority that breaks build - cycles, there to keep the task graph acyclic rather than to detect releases. -- `AGENTS.md` — the boundary table keeps `.github/workflows/**` read-only, which - is why this fix lives in the `scripts/` that drive the workflow. diff --git a/package.json b/package.json index d265d41..6949a53 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "prepare": "husky && effect-tsgo patch --oxlint --no-typescript --skip-missing", "test": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} test", "test:e2e": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} test:e2e", - "test:scripts": "deno test --allow-read --allow-write --allow-run --config scripts/deno.json scripts/", "typecheck": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} --continue typecheck typecheck:node", "typecheck:node": "tsc -p tsconfig.node.json --noEmit" }, diff --git a/release.jsonc b/release.jsonc new file mode 100644 index 0000000..11bd481 --- /dev/null +++ b/release.jsonc @@ -0,0 +1,25 @@ +{ + // The release pipeline is the shared systemfsoftware/pnpm-release-management + // toolchain, consumed as a reusable workflow (see .github/workflows/release.yml + // and changeset-check.yml, pinned to prm/toolchain). This config is the only + // repo-local input it reads. Distribution is Nix flakes consumed from git + // refs: a @vX.Y.Z git tag is the durable record a version shipped, so + // there is no npm-registry publish step. + "base": "main", + "branch": "changeset-release/main", + "changesetDir": ".changeset", + "changelogDir": ".changeset/changelogs", + // pnpm-native versioning: the two public packages carry independent versions, + // bumped per package by `pnpm version -r` as it consumes the intents. + "versioning": { "strategy": "pnpm" }, + // paths gate: the shared changeset-check workflow does not install this repo's + // dependencies, so a turbo-strategy gate has no turbo binary to pin against. + // Paths evidence reads the git diff and attributes a change to the package + // whose directory contains it — the strategy that works without a workspace + // install. + "gate": { "strategy": "paths" }, + "pr": { + "title": "chore(release): version packages", + "body": "Consumes the pending .changeset intents.\n\nMerging tags the released versions and creates GitHub releases.", + }, +} diff --git a/scripts/check-changeset.ts b/scripts/check-changeset.ts deleted file mode 100755 index 0975762..0000000 --- a/scripts/check-changeset.ts +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-run=git --allow-import --allow-net=jsr.io - -import { withoutAll } from '@std/collections/without-all' -import { extractYaml, test } from '@std/front-matter' -import { expandGlob } from '@std/fs/expand-glob' -import { basename } from '@std/path' -import { run } from './lib/run.ts' - -const BUMP: Record = { none: true, patch: true, minor: true, major: true } - -const intentPackages = (markdown: string) => { - if (!test(markdown)) return [] - return Object.entries(extractYaml>(markdown).attrs) - .filter(([, bump]) => typeof bump === 'string' && BUMP[bump]) - .map(([name]) => name) -} - -const publicPackages = async () => { - const names: string[] = [] - for await (const file of expandGlob('{apps,packages}/*/package.json')) { - const pkg = JSON.parse(await Deno.readTextFile(file.path)) as { - name?: string - version?: string - private?: boolean - } - if (pkg.name && pkg.version && !pkg.private) names.push(pkg.name) - } - return names -} - -const namedIntents = async () => { - const named: string[] = [] - for await (const file of expandGlob('.changeset/*.md')) { - if (basename(file.path) === 'README.md') continue - named.push(...intentPackages(await Deno.readTextFile(file.path))) - } - return named -} - -const baseSha = Deno.args[0] -if (!baseSha) { - console.error('usage: ./scripts/check-changeset.ts ') - Deno.exit(2) -} - -const changed = (await run('git', ['diff', '--name-only', `${baseSha}...HEAD`])).split('\n').filter(Boolean) -const WORKSPACE_ROOTS = ['apps', 'packages'] as const -const touched = changed.some((file) => WORKSPACE_ROOTS.some((root) => file === root || file.startsWith(`${root}/`))) - ? await publicPackages() - : [] -const missing = withoutAll(touched, await namedIntents()) - -if (missing.length === 0) { - console.log( - touched.length === 0 ? 'no publishable-package paths in the diff' : `changeset covers: ${touched.join(', ')}`, - ) - Deno.exit(0) -} - -console.error( - `::error::publishable package(s) changed with no changeset intent: ${ - missing.join(', ') - }. Author one with \`pnpm change --bump --summary "" ${missing[0]}\`.`, -) -Deno.exit(1) diff --git a/scripts/create-github-releases.ts b/scripts/create-github-releases.ts deleted file mode 100755 index 7cc5654..0000000 --- a/scripts/create-github-releases.ts +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-write --allow-run=git --allow-net=api.github.com,registry.npmjs.org --allow-env=GH_TOKEN,GITHUB_TOKEN,GITHUB_REPOSITORY --allow-import - -import { parseArgs } from '@std/cli/parse-args' -import { Octokit, RequestError } from 'octokit' -import { type CycleEntry, loadCaptured, loadWorkspaceCycle } from './lib/cycle.ts' -import { run } from './lib/run.ts' - -const flags = parseArgs(Deno.args, { - boolean: ['dry-run', 'assert'], - string: ['captured'], -}) - -const cycle: CycleEntry[] = flags.captured ? await loadCaptured(flags.captured) : await loadWorkspaceCycle() - -if (cycle.length === 0) { - console.log('no this-cycle releases — empty captured set') - Deno.exit(0) -} - -const pending: { entry: CycleEntry; body: string }[] = [] -for (const entry of cycle) { - const { name, version, changelog } = entry - let raw: string | null = null - try { - raw = await Deno.readTextFile(changelog) - } catch { - raw = null - } - if (raw === null || raw.trim().length === 0) { - const state = raw === null ? 'Missing' : 'Empty' - console.error( - `::error::${state} changelog for ${name}@${version}: expected ${changelog} — body must be the pnpm-generated changelog.`, - ) - Deno.exit(1) - } - pending.push({ entry, body: raw.trim() }) -} - -if (flags.assert) { - console.log(`assert ok: ${cycle.length} changelog(s) present`) - Deno.exit(0) -} - -if (flags['dry-run']) { - for (const { entry } of pending) { - console.log(`would create release ${entry.tag} from ${entry.changelog}`) - } - console.log(`dry run: ${pending.length} release(s)`) - Deno.exit(0) -} - -const slug = Deno.env.get('GITHUB_REPOSITORY') ?? (await run('git', ['remote', 'get-url', 'origin'])) - .trim() - .replace(/^git@github\.com:/, 'https://github.com/') - .replace(/^https?:\/\/github\.com\//, '') - .replace(/\.git$/, '') -const [owner, repo] = slug.split('/') -const octokit = new Octokit({ auth: Deno.env.get('GITHUB_TOKEN') ?? Deno.env.get('GH_TOKEN') ?? undefined }) - -const created: { tag: string; id: number }[] = [] -let loopError: Error | null = null -for (const { entry, body } of pending) { - const { tag } = entry - let exists = false - try { - await octokit.rest.repos.getReleaseByTag({ owner, repo, tag }) - exists = true - } catch (error) { - if (!(error instanceof RequestError) || error.status !== 404) { - loopError = new Error( - `looking up ${tag} in ${owner}/${repo} failed: ${error instanceof Error ? error.message : String(error)}`, - ) - break - } - } - if (exists) { - console.log(`skip ${tag} — release exists`) - continue - } - try { - const res = await octokit.rest.repos.createRelease({ - owner, - repo, - tag_name: tag, - body, - prerelease: false, - make_latest: 'false', - }) - console.log(`created release ${tag}`) - created.push({ tag, id: res.data.id }) - } catch (error) { - if (error instanceof RequestError && error.status === 409) { - console.log(`skip ${tag} — release exists`) - continue - } - loopError = new Error( - `creating release ${tag} failed: ${error instanceof Error ? error.message : String(error)}`, - ) - break - } -} - -if (created.length > 0 && !loopError) { - try { - await octokit.rest.repos.updateRelease({ owner, repo, release_id: created[0].id, make_latest: 'true' }) - console.log(`reconciled make_latest true on ${created[0].tag}`) - } catch (error) { - const msg = `reconciling make_latest for ${created[0].tag} failed: ${ - error instanceof Error ? error.message : String(error) - }` - console.error(`::error::${msg}`) - loopError = new Error(msg) - } -} - -if (loopError) { - console.error(`::error::${loopError.message}`) - Deno.exit(1) -} -console.log(`created ${created.length} release(s), skipped ${cycle.length - created.length}`) diff --git a/scripts/deno.json b/scripts/deno.json deleted file mode 100644 index 4e2bbf9..0000000 --- a/scripts/deno.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "noImplicitOverride": true - }, - "imports": { - "@std/cli/parse-args": "jsr:@std/cli@1/parse-args", - "@std/collections/without-all": "jsr:@std/collections@1/without-all", - "@std/front-matter": "jsr:@std/front-matter@1", - "@std/fs/expand-glob": "jsr:@std/fs@1/expand-glob", - "@std/assert/equals": "jsr:@std/assert@1/equals", - "@std/path": "jsr:@std/path@1", - "@std/yaml": "jsr:@std/yaml@1", - "octokit": "npm:octokit@^4" - } -} diff --git a/scripts/deno.lock b/scripts/deno.lock deleted file mode 100644 index 8efb95e..0000000 --- a/scripts/deno.lock +++ /dev/null @@ -1,308 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/assert@1": "1.0.19", - "jsr:@std/cli@1": "1.0.32", - "jsr:@std/collections@1": "1.3.0", - "jsr:@std/collections@^1.1.3": "1.3.0", - "jsr:@std/front-matter@1": "1.0.9", - "jsr:@std/fs@1": "1.0.24", - "jsr:@std/internal@^1.0.12": "1.0.14", - "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/path@1": "1.1.6", - "jsr:@std/path@^1.1.5": "1.1.6", - "jsr:@std/toml@^1.0.3": "1.0.11", - "jsr:@std/yaml@1": "1.2.0", - "jsr:@std/yaml@^1.0.5": "1.2.0", - "npm:octokit@4": "4.1.4" - }, - "jsr": { - "@std/assert@1.0.19": { - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", - "dependencies": [ - "jsr:@std/internal@^1.0.12" - ] - }, - "@std/cli@1.0.32": { - "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66" - }, - "@std/collections@1.3.0": { - "integrity": "eb36b43d784477ea0b476483ac034a14bdd182aff921c812ecf662a1fcef9498" - }, - "@std/front-matter@1.0.9": { - "integrity": "ee6201d06674cbef137dda2252f62477450b48249e7d8d9ab57a30f85ff6f051", - "dependencies": [ - "jsr:@std/toml", - "jsr:@std/yaml@^1.0.5" - ] - }, - "@std/fs@1.0.24": { - "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", - "dependencies": [ - "jsr:@std/internal@^1.0.14", - "jsr:@std/path@^1.1.5" - ] - }, - "@std/internal@1.0.14": { - "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" - }, - "@std/path@1.1.6": { - "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", - "dependencies": [ - "jsr:@std/internal@^1.0.14" - ] - }, - "@std/toml@1.0.11": { - "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", - "dependencies": [ - "jsr:@std/collections@^1.1.3" - ] - }, - "@std/yaml@1.2.0": { - "integrity": "20beb41e4983ba3437dbefac62b14061ab058e8a187596f19d28ff9035f6e6cf" - } - }, - "npm": { - "@octokit/app@15.1.6": { - "integrity": "sha512-WELCamoCJo9SN0lf3SWZccf68CF0sBNPQuLYmZ/n87p5qvBJDe9aBtr5dHkh7T9nxWZ608pizwsUbypSzZAiUw==", - "dependencies": [ - "@octokit/auth-app", - "@octokit/auth-unauthenticated", - "@octokit/core", - "@octokit/oauth-app", - "@octokit/plugin-paginate-rest", - "@octokit/types", - "@octokit/webhooks" - ] - }, - "@octokit/auth-app@7.2.2": { - "integrity": "sha512-p6hJtEyQDCJEPN9ijjhEC/kpFHMHN4Gca9r+8S0S8EJi7NaWftaEmexjxxpT1DFBeJpN4u/5RE22ArnyypupJw==", - "dependencies": [ - "@octokit/auth-oauth-app", - "@octokit/auth-oauth-user", - "@octokit/request", - "@octokit/request-error", - "@octokit/types", - "toad-cache", - "universal-github-app-jwt", - "universal-user-agent" - ] - }, - "@octokit/auth-oauth-app@8.1.4": { - "integrity": "sha512-71iBa5SflSXcclk/OL3lJzdt4iFs56OJdpBGEBl1wULp7C58uiswZLV6TdRaiAzHP1LT8ezpbHlKuxADb+4NkQ==", - "dependencies": [ - "@octokit/auth-oauth-device", - "@octokit/auth-oauth-user", - "@octokit/request", - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/auth-oauth-device@7.1.5": { - "integrity": "sha512-lR00+k7+N6xeECj0JuXeULQ2TSBB/zjTAmNF2+vyGPDEFx1dgk1hTDmL13MjbSmzusuAmuJD8Pu39rjp9jH6yw==", - "dependencies": [ - "@octokit/oauth-methods", - "@octokit/request", - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/auth-oauth-user@5.1.6": { - "integrity": "sha512-/R8vgeoulp7rJs+wfJ2LtXEVC7pjQTIqDab7wPKwVG6+2v/lUnCOub6vaHmysQBbb45FknM3tbHW8TOVqYHxCw==", - "dependencies": [ - "@octokit/auth-oauth-device", - "@octokit/oauth-methods", - "@octokit/request", - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/auth-token@5.1.2": { - "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==" - }, - "@octokit/auth-unauthenticated@6.1.3": { - "integrity": "sha512-d5gWJla3WdSl1yjbfMpET+hUSFCE15qM0KVSB0H1shyuJihf/RL1KqWoZMIaonHvlNojkL9XtLFp8QeLe+1iwA==", - "dependencies": [ - "@octokit/request-error", - "@octokit/types" - ] - }, - "@octokit/core@6.1.6": { - "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", - "dependencies": [ - "@octokit/auth-token", - "@octokit/graphql", - "@octokit/request", - "@octokit/request-error", - "@octokit/types", - "before-after-hook", - "universal-user-agent" - ] - }, - "@octokit/endpoint@10.1.4": { - "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", - "dependencies": [ - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/graphql@8.2.2": { - "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", - "dependencies": [ - "@octokit/request", - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/oauth-app@7.1.6": { - "integrity": "sha512-OMcMzY2WFARg80oJNFwWbY51TBUfLH4JGTy119cqiDawSFXSIBujxmpXiKbGWQlvfn0CxE6f7/+c6+Kr5hI2YA==", - "dependencies": [ - "@octokit/auth-oauth-app", - "@octokit/auth-oauth-user", - "@octokit/auth-unauthenticated", - "@octokit/core", - "@octokit/oauth-authorization-url", - "@octokit/oauth-methods", - "@types/aws-lambda", - "universal-user-agent" - ] - }, - "@octokit/oauth-authorization-url@7.1.1": { - "integrity": "sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA==" - }, - "@octokit/oauth-methods@5.1.5": { - "integrity": "sha512-Ev7K8bkYrYLhoOSZGVAGsLEscZQyq7XQONCBBAl2JdMg7IT3PQn/y8P0KjloPoYpI5UylqYrLeUcScaYWXwDvw==", - "dependencies": [ - "@octokit/oauth-authorization-url", - "@octokit/request", - "@octokit/request-error", - "@octokit/types" - ] - }, - "@octokit/openapi-types@25.1.0": { - "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==" - }, - "@octokit/openapi-webhooks-types@11.0.0": { - "integrity": "sha512-ZBzCFj98v3SuRM7oBas6BHZMJRadlnDoeFfvm1olVxZnYeU6Vh97FhPxyS5aLh5pN51GYv2I51l/hVUAVkGBlA==" - }, - "@octokit/plugin-paginate-graphql@5.2.4_@octokit+core@6.1.6": { - "integrity": "sha512-pLZES1jWaOynXKHOqdnwZ5ULeVR6tVVCMm+AUbp0htdcyXDU95WbkYdU4R2ej1wKj5Tu94Mee2Ne0PjPO9cCyA==", - "dependencies": [ - "@octokit/core" - ] - }, - "@octokit/plugin-paginate-rest@12.0.0_@octokit+core@6.1.6": { - "integrity": "sha512-MPd6WK1VtZ52lFrgZ0R2FlaoiWllzgqFHaSZxvp72NmoDeZ0m8GeJdg4oB6ctqMTYyrnDYp592Xma21mrgiyDA==", - "dependencies": [ - "@octokit/core", - "@octokit/types" - ] - }, - "@octokit/plugin-rest-endpoint-methods@14.0.0_@octokit+core@6.1.6": { - "integrity": "sha512-iQt6ovem4b7zZYZQtdv+PwgbL5VPq37th1m2x2TdkgimIDJpsi2A6Q/OI/23i/hR6z5mL0EgisNR4dcbmckSZQ==", - "dependencies": [ - "@octokit/core", - "@octokit/types" - ] - }, - "@octokit/plugin-retry@7.2.1_@octokit+core@6.1.6": { - "integrity": "sha512-wUc3gv0D6vNHpGxSaR3FlqJpTXGWgqmk607N9L3LvPL4QjaxDgX/1nY2mGpT37Khn+nlIXdljczkRnNdTTV3/A==", - "dependencies": [ - "@octokit/core", - "@octokit/request-error", - "@octokit/types", - "bottleneck" - ] - }, - "@octokit/plugin-throttling@10.0.0_@octokit+core@6.1.6": { - "integrity": "sha512-Kuq5/qs0DVYTHZuBAzCZStCzo2nKvVRo/TDNhCcpC2TKiOGz/DisXMCvjt3/b5kr6SCI1Y8eeeJTHBxxpFvZEg==", - "dependencies": [ - "@octokit/core", - "@octokit/types", - "bottleneck" - ] - }, - "@octokit/request-error@6.1.8": { - "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", - "dependencies": [ - "@octokit/types" - ] - }, - "@octokit/request@9.2.4": { - "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", - "dependencies": [ - "@octokit/endpoint", - "@octokit/request-error", - "@octokit/types", - "fast-content-type-parse", - "universal-user-agent" - ] - }, - "@octokit/types@14.1.0": { - "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", - "dependencies": [ - "@octokit/openapi-types" - ] - }, - "@octokit/webhooks-methods@5.1.1": { - "integrity": "sha512-NGlEHZDseJTCj8TMMFehzwa9g7On4KJMPVHDSrHxCQumL6uSQR8wIkP/qesv52fXqV1BPf4pTxwtS31ldAt9Xg==" - }, - "@octokit/webhooks@13.9.1": { - "integrity": "sha512-Nss2b4Jyn4wB3EAqAPJypGuCJFalz/ZujKBQQ5934To7Xw9xjf4hkr/EAByxQY7hp7MKd790bWGz7XYSTsHmaw==", - "dependencies": [ - "@octokit/openapi-webhooks-types", - "@octokit/request-error", - "@octokit/webhooks-methods" - ] - }, - "@types/aws-lambda@8.10.163": { - "integrity": "sha512-+4zuoEB3S8RIhimtOFT7zAEk2SbpwrKjjGl9CyYnQR6k08uynxfauIIB0pDU1ssr05F8oyGiETwpn+8eXZMqPw==" - }, - "before-after-hook@3.0.2": { - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==" - }, - "bottleneck@2.19.5": { - "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==" - }, - "fast-content-type-parse@2.0.1": { - "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==" - }, - "octokit@4.1.4": { - "integrity": "sha512-cRvxRte6FU3vAHRC9+PMSY3D+mRAs2Rd9emMoqp70UGRvJRM3sbAoim2IXRZNNsf8wVfn4sGxVBHRAP+JBVX/g==", - "dependencies": [ - "@octokit/app", - "@octokit/core", - "@octokit/oauth-app", - "@octokit/plugin-paginate-graphql", - "@octokit/plugin-paginate-rest", - "@octokit/plugin-rest-endpoint-methods", - "@octokit/plugin-retry", - "@octokit/plugin-throttling", - "@octokit/request-error", - "@octokit/types", - "@octokit/webhooks" - ] - }, - "toad-cache@3.7.4": { - "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==" - }, - "universal-github-app-jwt@2.2.2": { - "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==" - }, - "universal-user-agent@7.0.3": { - "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==" - } - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@1", - "jsr:@std/cli@1", - "jsr:@std/collections@1", - "jsr:@std/front-matter@1", - "jsr:@std/fs@1", - "jsr:@std/path@1", - "jsr:@std/yaml@1", - "npm:octokit@4" - ] - } -} diff --git a/scripts/lib/cycle.ts b/scripts/lib/cycle.ts deleted file mode 100644 index 0067d19..0000000 --- a/scripts/lib/cycle.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { join } from '@std/path' -import { run } from './run.ts' - -export type CycleEntry = { - name: string - version: string - tag: string - changelog: string -} - -type Pkg = { - name?: string - version?: string - private?: boolean -} - -type Released = { name: string; version: string } - -const publicPackages = async (): Promise => { - const pkgs = JSON.parse(await run('pnpm', ['ls', '-r', '--json', '--depth=-1'])) as Pkg[] - return pkgs - .filter((pkg): pkg is Pkg & Released => Boolean(pkg.name && pkg.version) && !pkg.private) - .map(({ name, version }) => ({ name, version })) -} - -export const isPublished = async (name: string, version: string): Promise => { - const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/${version}`) - if (res.status === 404) return false - if (!res.ok) throw new Error(`registry returned ${res.status} for ${name}@${version}`) - return true -} - -export const loadWorkspaceCycle = async (): Promise => { - const pkgs = await publicPackages() - const published = await Promise.all(pkgs.map(({ name, version }) => isPublished(name, version))) - return pkgs - .filter((_, i) => !published[i]) - .map(({ name, version }) => ({ - name, - version, - tag: `${name}@v${version}`, - changelog: join('.changeset', 'changelogs', `${name.replace('/', '!')}@${version}.md`), - })) -} - -export const loadCaptured = async (path: string): Promise => { - const raw: unknown = JSON.parse(await Deno.readTextFile(path)) - if (!Array.isArray(raw)) throw new Error('captured file must be a JSON array') - return raw as CycleEntry[] -} - -export const unpublishedOf = async (cycle: CycleEntry[]) => { - const published = await Promise.all(cycle.map(({ name, version }) => isPublished(name, version))) - return cycle.filter((_, i) => !published[i]) -} diff --git a/scripts/lib/pending-intents.property.test.ts b/scripts/lib/pending-intents.property.test.ts deleted file mode 100644 index 103e977..0000000 --- a/scripts/lib/pending-intents.property.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { assertEquals } from '@std/assert/equals' -import { join } from '@std/path' - -import { countPendingIntents } from './pending-intents.ts' - -const readmeStem = 'README' -const intentStems = ['alpha', 'beta', 'gamma'] as const - -type IntentStem = typeof intentStems[number] - -const ledgerShapes = [ - 'absent', - 'emptyObject', - 'topSequence', - 'scalarValue', - 'arrayIntents', - 'nestedIntents', - 'quotedIntents', - 'numericIntents', - 'mixedIntents', -] as const - -type LedgerShape = typeof ledgerShapes[number] - -interface LedgerState { - readonly stems: readonly string[] - readonly shape: LedgerShape - readonly consumed: readonly IntentStem[] -} - -const powerset = (items: readonly T[]): readonly (readonly T[])[] => { - let subsets: readonly (readonly T[])[] = [[]] - for (const item of items) { - subsets = [...subsets, ...subsets.map((subset) => [...subset, item])] - } - return subsets -} - -const fileStemSets = powerset([readmeStem, ...intentStems]) -const consumedStemSets = powerset(intentStems) - -const intentList = (consumed: readonly IntentStem[]): string => consumed.map((stem) => `"${stem}"`).join(', ') - -const consumedLines = (consumed: readonly IntentStem[]): string => consumed.map((stem) => ` - "${stem}"\n`).join('') - -const ledgerYaml = (state: LedgerState): string | undefined => { - const consumed = state.consumed - const shape = state.shape - if (shape === 'absent') return undefined - if (shape === 'emptyObject') return '{}\n' - if (shape === 'topSequence') return consumed.map((stem) => `- ${stem}\n`).join('') - if (shape === 'scalarValue') return '"pkg@1.0.0": 3\n' - if (shape === 'arrayIntents') return `"pkg@1.0.0": [${intentList(consumed)}]\n` - if (shape === 'nestedIntents') { - return `"pkg@1.0.0":\n dir: packages/pkg\n intents: [${intentList(consumed)}]\n` - } - if (shape === 'quotedIntents') return `"pkg@1.0.0":\n intents:\n${consumedLines(consumed)}` - if (shape === 'numericIntents') return '"pkg@1.0.0":\n intents:\n - 1\n - 2\n' - return `"pkg@1.0.0":\n intents:\n${consumedLines(consumed)} - 3\n` -} - -const recordsConsumedIntents = (shape: LedgerShape): boolean => - shape === 'arrayIntents' || shape === 'nestedIntents' || shape === 'quotedIntents' || shape === 'mixedIntents' - -const authoredConsumed = (state: LedgerState): readonly string[] => - recordsConsumedIntents(state.shape) ? state.consumed : [] - -const authoredPending = (state: LedgerState): number => - state.stems.filter((stem) => stem !== readmeStem && !authoredConsumed(state).includes(stem)).length - -const describeState = (state: LedgerState): string => JSON.stringify(state) - -Deno.test('a recorded release excuses exactly the intents it consumed, and no other changeset', async () => { - const root = await Deno.makeTempDir() - try { - let index = 0 - for (const stems of fileStemSets) { - for (const consumed of consumedStemSets) { - for (const shape of ledgerShapes) { - const state: LedgerState = { stems, shape, consumed } - const dir = join(root, `state-${index}`) - index++ - await Deno.mkdir(dir) - for (const stem of stems) { - await Deno.writeTextFile(join(dir, `${stem}.md`), '---\n"pkg": patch\n---\n\nsummary\n') - } - const ledger = ledgerYaml(state) - if (ledger !== undefined) await Deno.writeTextFile(join(dir, 'ledger.yaml'), ledger) - assertEquals(await countPendingIntents(dir), authoredPending(state), describeState(state)) - } - } - } - } finally { - await Deno.remove(root, { recursive: true }) - } -}) - -Deno.test('an unreadable ledger surfaces the parse failure instead of counting silently', async () => { - const dir = await Deno.makeTempDir() - try { - await Deno.writeTextFile(join(dir, `${intentStems[0]}.md`), '---\n"pkg": patch\n---\n\nsummary\n') - await Deno.writeTextFile(join(dir, 'ledger.yaml'), '"pkg@1.0.0": [unclosed\n') - let parseFailed = false - try { - await countPendingIntents(dir) - } catch { - parseFailed = true - } - assertEquals(parseFailed, true) - } finally { - await Deno.remove(dir, { recursive: true }) - } -}) diff --git a/scripts/lib/pending-intents.ts b/scripts/lib/pending-intents.ts deleted file mode 100644 index 47a7294..0000000 --- a/scripts/lib/pending-intents.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expandGlob } from '@std/fs/expand-glob' -import { basename, join } from '@std/path' -import { parse } from '@std/yaml' - -const consumedIntentStems = (ledgerYaml: string): Set => { - const parsed = parse(ledgerYaml) - const stems = new Set() - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return stems - const ledger = parsed as Record - for (const value of Object.values(ledger)) { - let intents: unknown - if (Array.isArray(value)) intents = value - else if (value !== null && typeof value === 'object' && 'intents' in value) intents = value.intents - else continue - if (!Array.isArray(intents)) continue - for (const intent of intents) { - if (typeof intent === 'string') stems.add(intent) - } - } - return stems -} - -export const countPendingIntents = async (changesetDir: string): Promise => { - let consumed = new Set() - try { - consumed = consumedIntentStems(await Deno.readTextFile(join(changesetDir, 'ledger.yaml'))) - } catch (error) { - if (!(error instanceof Deno.errors.NotFound)) throw error - } - let pending = 0 - for await (const entry of expandGlob(join(changesetDir, '*.md'))) { - const stem = basename(entry.path, '.md') - if (stem !== 'README' && !consumed.has(stem)) pending++ - } - return pending -} diff --git a/scripts/lib/run.ts b/scripts/lib/run.ts deleted file mode 100644 index e7295fa..0000000 --- a/scripts/lib/run.ts +++ /dev/null @@ -1,14 +0,0 @@ -const dec = new TextDecoder() -const enc = new TextEncoder() - -export const run = async (cmd: string, args: string[]) => { - const out = await new Deno.Command(cmd, { args, stdout: 'piped', stderr: 'inherit' }).output() - const stdout = dec.decode(out.stdout) - if (!out.success) { - if (stdout.length > 0) { - Deno.stderr.writeSync(enc.encode(stdout.endsWith('\n') ? stdout : `${stdout}\n`)) - } - throw new Error(`${cmd} ${args.join(' ')} failed (exit ${out.code})\n${stdout}`) - } - return stdout -} diff --git a/scripts/open-release-pr.sh b/scripts/open-release-pr.sh deleted file mode 100755 index b2ebe9c..0000000 --- a/scripts/open-release-pr.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${BRANCH:?}" -: "${BASE:?}" -: "${GH_TOKEN:?}" - -existing=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') - -git fetch --quiet origin "$BASE" - -close_if_open() { - if [ -n "$existing" ]; then - gh pr close "$existing" --delete-branch --comment "$1" - fi -} - -if [ -z "$(git status --porcelain)" ]; then - echo "no pending change intents — nothing to release" - close_if_open "No pending change intents remain." - exit 0 -fi - -if [ -z "$(git diff --name-only "origin/$BASE" -- 'apps/**/package.json' 'packages/**/package.json')" ]; then - echo "no package.json version bumps against origin/$BASE — not opening a release PR" - close_if_open "No package version bumps against $BASE." - exit 0 -fi - -git config user.name 'github-actions[bot]' -git config user.email '41898282+github-actions[bot]@users.noreply.github.com' -git switch --force-create "$BRANCH" -git add -A -- apps packages .changeset pnpm-lock.yaml -git commit -m 'chore(release): version packages' -git push --force origin "$BRANCH" - -body=$(mktemp) -trap 'rm -f "$body"' EXIT -cat > "$body" <<'BODY' -Consumes pending `.changeset/` intents via `pnpm version -r`. - -Merging runs the gate, then builds, publishes (OIDC + provenance), -and tags the changed packages. - -Review every consumed `none` intent before merging — a `none` on a -behavior-visible change is a silent non-release. - -Packages not registered as npm trusted publishers fail at publish -with an OIDC auth error; register them at https://www.npmjs.com -against workflow `release.yml` in this repository. -BODY - -gh label create release \ - --color 0E8A16 \ - --description 'Automated version-packages release PR' \ - --force - -if [ -n "$existing" ]; then - gh pr edit "$existing" \ - --title 'chore(release): version packages' \ - --body-file "$body" \ - --add-label release -else - gh pr create \ - --base "$BASE" \ - --head "$BRANCH" \ - --title 'chore(release): version packages' \ - --body-file "$body" \ - --label release -fi diff --git a/scripts/plan-release.ts b/scripts/plan-release.ts deleted file mode 100755 index a836c04..0000000 --- a/scripts/plan-release.ts +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-write --allow-run=git,pnpm --allow-import --allow-net=jsr.io,registry.npmjs.org - -import { parseArgs } from '@std/cli/parse-args' -import { loadWorkspaceCycle } from './lib/cycle.ts' -import { countPendingIntents } from './lib/pending-intents.ts' - -const pending = await countPendingIntents('.changeset') - -const owed = (await loadWorkspaceCycle()).length -const phase = owed > 0 ? 'publish' : pending > 0 ? 'version' : 'none' -const outputs = [`phase=${phase}`, `pending_intents=${pending}`, `this_cycle=${owed}`].join('\n') - -console.error(`plan-release: pending_intents=${pending} this_cycle=${owed} -> phase=${phase}`) - -const { output } = parseArgs(Deno.args, { string: ['output'] }) -if (output) await Deno.writeTextFile(output, `${outputs}\n`, { append: true }) -else console.log(outputs) diff --git a/scripts/tag-released-packages.ts b/scripts/tag-released-packages.ts deleted file mode 100755 index 082447d..0000000 --- a/scripts/tag-released-packages.ts +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-write --allow-run=git,pnpm --allow-net=jsr.io,registry.npmjs.org --allow-import - -import { parseArgs } from '@std/cli/parse-args' -import { loadCaptured, loadWorkspaceCycle, unpublishedOf } from './lib/cycle.ts' -import { run } from './lib/run.ts' - -const flags = parseArgs(Deno.args, { - boolean: ['dry-run', 'json', 'unpublished', 'publish'], - string: ['output', 'captured'], -}) - -const loaded = flags.captured ? await loadCaptured(flags.captured) : await loadWorkspaceCycle() -const cycle = flags.captured && flags.unpublished ? await unpublishedOf(loaded) : loaded - -if (flags.publish) { - if (cycle.length === 0) { - console.log('every captured version is already on npm — tagging only') - Deno.exit(0) - } - console.log(`publishing ${cycle.map((entry) => `${entry.name}@${entry.version}`).join(', ')}`) - const published = await new Deno.Command('pnpm', { - args: ['publish', '-r', '--provenance', '--access', 'public', '--no-git-checks'], - stdout: 'inherit', - stderr: 'inherit', - }).output() - if (!published.success) { - console.error( - `::error::pnpm publish -r --provenance --access public --no-git-checks failed (exit ${published.code})`, - ) - Deno.exit(published.code || 1) - } - Deno.exit(0) -} - -if (flags.output) { - await Deno.writeTextFile(flags.output, JSON.stringify(cycle, null, 2)) - console.error(`wrote ${cycle.length} captured package(s) to ${flags.output}`) -} - -if (flags.json) { - console.log(JSON.stringify(cycle)) - Deno.exit(0) -} - -if (flags['dry-run'] || flags.output) { - for (const { tag } of cycle) console.log(`would tag ${tag}`) - console.log(`dry run: ${cycle.length} tag(s)`) - Deno.exit(0) -} - -if (cycle.length === 0) { - console.log('no new tags to push') - Deno.exit(0) -} - -const made: string[] = [] -for (const { tag } of cycle) { - await run('git', ['tag', tag]) - made.push(tag) -} -await run('git', ['push', 'origin', ...made.map((t) => `refs/tags/${t}`)]) -console.log(`pushed ${made.length} tag(s): ${made.join(', ')}`) diff --git a/turbo.json b/turbo.json index bae9d99..605bb24 100644 --- a/turbo.json +++ b/turbo.json @@ -133,10 +133,6 @@ "cache": false, "outputLogs": "new-only" }, - "//#test:scripts": { - "cache": false, - "outputLogs": "new-only" - }, "clean": { "cache": false }