Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/unreleased-tarballs-compared-at-pull-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@systemfsoftware/github-release-engine": minor
"@systemfsoftware/github-release-management": minor
"@systemfsoftware/process-adapter": minor
"@systemfsoftware/release-language": minor
"@systemfsoftware/changeset-engine": none
---

A released package that a pull request changes under a `none` intent is now refused before merge, not by a red Release run on `main`. `github-release-management digests <base>` lists the released packages the change evidence names and the pending intents do not release. With `--tarballs <dir>` it compares each tarball with its recorded integrity by `plan`'s rules and refuses a mismatch with `tag digests refused: <package>@v<version> changed without a release`, naming both integrities, the first differing file and the fix. The reusable changeset check builds `.#workspace-tarballs` only when that list is non-empty.

New exports: `unreleasedTarballsCell` from `github-release-engine`; `evidenceRefusalLines` from `process-adapter`; `evidenceOf`, `taskOf` and the `ChangeEvidenceRefusal` schema from `release-language`.
19 changes: 19 additions & 0 deletions .github/workflows/changeset-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,25 @@ jobs:
run: |
"$RELEASE_TOOLS/changeset-management" check "$BASE_SHA"

- name: List released packages that change without a release
id: digests
env:
BASE_SHA: ${{ inputs.base-sha || github.event.pull_request.base.sha }}
run: |
"$RELEASE_TOOLS/github-release-management" digests "$BASE_SHA" --output "$GITHUB_OUTPUT"

- name: Compare their flake-built tarballs with their release tags
if: steps.digests.outputs.packages != ''
env:
BASE_SHA: ${{ inputs.base-sha || github.event.pull_request.base.sha }}
PACKAGES: ${{ steps.digests.outputs.packages }}
run: |
if ! tarballs=$(nix build --no-link --print-out-paths .#workspace-tarballs); then
echo "::error::tag digests: cannot build .#workspace-tarballs to compare ${PACKAGES} with their release tags - the caller's flake must expose workspace-tarballs, as release.yml requires"
exit 1
fi
"$RELEASE_TOOLS/github-release-management" digests "$BASE_SHA" --tarballs "$tarballs"

- name: Record the job's wall time
if: always()
env:
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ no git config.
| `release pr` | Commits the bumped tree to the release branch, opens, refreshes, or closes the release PR |
| `release adopt` | Records every pre-adoption release tag's bytes in the adoption ledger |
| `release plan` | Derives the release phase from repository state |
| `release digests` | Lists, or with tarballs compares, released packages a change leaves unreleased |
| `release tag` | Captures the cycle, then pushes one tag per released package |
| `release release` | Creates GitHub Releases from the generated changelogs |
| `hooks pre-commit` | Formats and checks the staged set before a commit lands |
Expand All @@ -273,6 +274,7 @@ Flags worth knowing:
| ------------------- | --------------------------------------------------------------------------------------------- |
| `changeset check` | `<base-sha-or-ref>`, `--base`, `--skip-liveness` |
| `release plan` | `--output <file>`, `--deferred <file>`, `--remote <name>` |
| `release digests` | `<base-sha-or-ref>`, `--tarballs <dir>`, `--output <file>`, `--remote <name>` |
| `release adopt` | `--registry <url>`, `--output <file>`, `--remote <name>` |
| `release tag` | `--captured <file>`, `--output <file>`, `--exclude`, `--json`, `--dry-run`, `--remote <name>` |
| `release release` | `--captured <file>`, `--assert`, `--dry-run` |
Expand Down Expand Up @@ -327,6 +329,18 @@ Turbo that judges the pull request is the platform binary the check fetches
and verifies against the caller's lockfile (see [Configuration](#configuration)),
so the job needs no `node_modules`.

After the intent verdict, the check runs `github-release-management digests`
against the same base. It lists every released package that the change evidence
names and the pending intents do not release: a package with a tag at its
current version whose rows are all `none`. When that list is empty, the job
builds nothing more. Otherwise it builds the caller's `.#workspace-tarballs`,
the output `release.yml` tags from, and compares each listed package's tarball
with the integrity its release recorded, by `release plan`'s rules. A changed
tarball fails with `tag digests refused: <package>@v<version> changed without a
release`, naming both integrities, the first differing file and the fix: a real
bump in the intent, or reverting the change. A caller whose flake has no
`workspace-tarballs` output fails at that step with a line saying to add it.

`tools-ref` and `devshell` are accepted and ignored: `devshell` configured the
install the check no longer performs, and `tools-ref` never did anything.
Removing an input a caller passes fails that caller's workflow, so an input can
Expand Down
14 changes: 1 addition & 13 deletions apps/changeset-management/src/boundary.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import { LedgerLive } from '@systemfsoftware/adoption-adapter'
import { resolveWorkspaceRoot } from '@systemfsoftware/cli-adapter'
import {
type Gate,
GitRef,
type ReleaseConfig,
ReleaseConfigStore,
type RepoRoot,
type TaskName,
} from '@systemfsoftware/release-language'
import { GitRef, type ReleaseConfig, ReleaseConfigStore, type RepoRoot } from '@systemfsoftware/release-language'
import { ChangesetStoreLive, WorkspaceStoreLive } from '@systemfsoftware/workspace-adapter'
import { Effect, Layer, Option } from 'effect'
import * as S from 'effect/Schema'
Expand All @@ -33,11 +26,6 @@ export const storesOf = (workspace: Workspace) =>
LedgerLive(workspace.root),
)

export const taskOf = (gate: Gate): TaskName | undefined => {
if (gate.strategy === 'turbo') return gate.task
return undefined
}

export const baseRefOf = (
positional: Option.Option<string>,
flag: Option.Option<string>,
Expand Down
3 changes: 2 additions & 1 deletion apps/changeset-management/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { program, ReporterLive } from '@systemfsoftware/cli-adapter'
import { Cell } from '@systemfsoftware/effect-cell-types'
import { GitLive } from '@systemfsoftware/git-adapter'
import { ChangeEvidenceLive } from '@systemfsoftware/process-adapter'
import { taskOf } from '@systemfsoftware/release-language'
import { ReleaseConfigStoreLive } from '@systemfsoftware/workspace-adapter'
import { Effect, Layer, Option } from 'effect'
import { Argument, Command, Flag } from 'effect/unstable/cli'
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient'
import { baseRefOf, storesOf, taskOf, workspaceOf } from './boundary.js'
import { baseRefOf, storesOf, workspaceOf } from './boundary.js'
import { renderGate, renderRefusal, renderStaged } from './render.js'

const VERSION = '0.0.0'
Expand Down
14 changes: 2 additions & 12 deletions apps/changeset-management/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { GateReport } from '@systemfsoftware/changeset-engine'
import { Reporter, type WorkspaceRootNotAbsolute } from '@systemfsoftware/cli-adapter'
import { evidenceRefusalLines } from '@systemfsoftware/process-adapter'
import type {
ChangeEvidenceRefusal,
ConfigRefusal,
Expand Down Expand Up @@ -48,18 +49,7 @@ export const renderRefusal = (refusal: AppRefusal): Effect.Effect<void, never, R
NewIntentSummaryMissing: () => '--summary is required, and must be one line',
NewIntentPackagesEmpty: () => 'name at least one package',
NewIntentPackageNameMalformed: (malformed) => `not workspace packages: ${malformed.given}`,
GateUnknownPackage: (unknown) => `${unknown.path} names non-member package "${unknown.package}"`,
GateIntentMissing: (missing) => `no intent names: ${missing.packages.join(', ')}`,
EvidenceFileUnreadable: (unreadable) => `cannot read ${unreadable.path}`,
EvidenceCommandFailed: (failed) => `${failed.program} failed: ${failed.detail}`,
TurboPinUnusable: (unusable) => `turbo pin unusable: ${unusable.detail}`,
PathsGateUnderTurbo: (refused) =>
`paths gate refused: ${refused.turboConfig} makes turbo the source of change evidence — set "gate": { "strategy": "turbo" } in release.jsonc`,
WorktreeUnavailable: (unavailable) => `worktree unavailable: ${unavailable.detail}`,
ProcessUnstartable: (unstartable) => `cannot start ${unstartable.program}: ${unstartable.reason}`,
ProcessUnobservable: (unobservable) => `cannot read ${unobservable.program} output: ${unobservable.reason}`,
TurboDryRunUnreadable: (unreadable) => `turbo dry-run unreadable (${unreadable.context}): ${unreadable.reason}`,
TurboDryRunDrifted: (drifted) => `turbo dry-run drifted (${drifted.context}): ${drifted.reason}`,
...evidenceRefusalLines,
WorkspaceRootNotAbsolute: (notAbsolute) => `repository root "${notAbsolute.given}" is not an absolute path`,
LedgerUnreadable: (unreadable) => `cannot read release ledger ${unreadable.path}: ${unreadable.reason}`,
LedgerMalformed: (malformed) => `cannot parse release ledger ${malformed.path}: ${malformed.reason}`,
Expand Down
20 changes: 20 additions & 0 deletions apps/github-release-management/src/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
PlanRequest,
PullRequestRequest,
TagRequest,
UnreleasedTarballsRequest,
} from '@systemfsoftware/github-release-engine'
import {
type ConfigRefusal,
RelativePath,
type ReleaseConfig,
ReleaseConfigStore,
type RepoRoot,
taskOf,
} from '@systemfsoftware/release-language'
import { Effect, type FileSystem, Option, Path } from 'effect'
import * as S from 'effect/Schema'
Expand Down Expand Up @@ -60,6 +62,24 @@ export const planRequestOf = (workspace: Workspace, flags: PlanFlags) =>
}),
)

export interface UnreleasedTarballsFlags {
readonly base: string
readonly remote: string | undefined
readonly tarballs: string | undefined
}

export const unreleasedTarballsRequestOf = (workspace: Workspace, flags: UnreleasedTarballsFlags) =>
decodeFlags(
S.decodeUnknownEffect(UnreleasedTarballsRequest)({
root: workspace.root,
ref: flags.base,
strategy: workspace.config.gate.strategy,
task: taskOf(workspace.config.gate),
remote: flags.remote,
tarballs: flags.tarballs,
}),
)

export interface AdoptFlags {
readonly registry: string
readonly output: string
Expand Down
37 changes: 34 additions & 3 deletions apps/github-release-management/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {
planCell,
pullRequestCell,
tagCell,
unreleasedTarballsCell,
} from '@systemfsoftware/github-release-engine'
import { ProcessLive } from '@systemfsoftware/process-adapter'
import { ChangeEvidenceLive, ProcessLive } from '@systemfsoftware/process-adapter'
import { ReleaseConfigStore, RepoRoot } from '@systemfsoftware/release-language'
import { TarballLive } from '@systemfsoftware/tarball-adapter'
import {
Expand All @@ -25,7 +26,7 @@ import {
} from '@systemfsoftware/workspace-adapter'
import { Effect, Layer, Option } from 'effect'
import * as S from 'effect/Schema'
import { Command, Flag } from 'effect/unstable/cli'
import { Argument, Command, Flag } from 'effect/unstable/cli'
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient'
import {
adoptRequestOf,
Expand All @@ -34,6 +35,7 @@ import {
pullRequestRequestOf,
releaseRequestOf,
tagRequestOf,
unreleasedTarballsRequestOf,
workspaceOf,
} from './boundary.js'
import {
Expand All @@ -47,6 +49,8 @@ import {
renderReleaseRefusal,
renderTag,
renderTagRefusal,
renderUnreleasedTarballs,
renderUnreleasedTarballsRefusal,
} from './render.js'

const VERSION = '0.0.0'
Expand Down Expand Up @@ -132,6 +136,33 @@ const plan = Command.make('plan', {
yield* renderPlan(report, Option.getOrUndefined(flags.output))
}).pipe(Effect.catch(renderPlanRefusal)))

const EvidenceLive = Layer.provide(
ChangeEvidenceLive,
Layer.merge(GitLive, RegistryLive.pipe(Layer.provide(FetchHttpClient.layer))),
).pipe(Layer.provide(NodeServices.layer))

const digests = Command.make('digests', {
base: Argument.string('base-sha-or-ref'),
output: Flag.string('output').pipe(Flag.optional),
remote: Flag.string('remote').pipe(Flag.optional),
tarballs: Flag.string('tarballs').pipe(Flag.optional),
config: Flag.string('config').pipe(Flag.optional),
}, (flags) =>
Effect.gen(function*() {
const workspace = yield* workspaceOf(flags.config)
const request = yield* unreleasedTarballsRequestOf(workspace, {
base: flags.base,
remote: Option.getOrUndefined(flags.remote),
tarballs: Option.getOrUndefined(flags.tarballs),
})
const decision = yield* Cell.run(unreleasedTarballsCell, request).pipe(
Effect.provide(TarballLive),
Effect.provide(ChangesetsPortLive({ root: workspace.root, base: workspace.config.base })),
Effect.provide(EvidenceLive),
)
yield* renderUnreleasedTarballs(decision, Option.getOrUndefined(flags.output))
}).pipe(Effect.catch(renderUnreleasedTarballsRefusal)))

const tag = Command.make('tag', {
dryRun: Flag.boolean('dry-run').pipe(Flag.withDefault(false)),
json: Flag.boolean('json').pipe(Flag.withDefault(false)),
Expand Down Expand Up @@ -203,7 +234,7 @@ const pr = Command.make('pr', {

const release = Command.make('release').pipe(
Command.withDescription('Plan release phases, open release PRs, tag and publish GitHub releases'),
Command.withSubcommands([adopt, plan, pr, tag, releaseCommand]),
Command.withSubcommands([adopt, plan, digests, pr, tag, releaseCommand]),
)

NodeRuntime.runMain(
Expand Down
62 changes: 62 additions & 0 deletions apps/github-release-management/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import {
type PlanReport,
type PullRequestDecision,
type TagDecision,
type UnreleasedTarballsDecision,
} from '@systemfsoftware/github-release-engine'
import { evidenceRefusalLines } from '@systemfsoftware/process-adapter'
import {
type AdoptionFailure,
ChangeEvidenceRefusal,
type ConfigRefusal,
FsPath,
type GithubReleaseRefusal,
Expand All @@ -25,6 +28,7 @@ import {
type ReleaseLedgerEntry,
type ReleaseTag,
type TagAtOtherCommit,
TagIntegrityMismatch,
type TagRefusal,
type TarballRefusal,
type VersionBurned,
Expand All @@ -34,6 +38,7 @@ import {
} from '@systemfsoftware/release-language'
import { Effect, FileSystem } from 'effect'
import * as Match from 'effect/Match'
import * as S from 'effect/Schema'
import { type BoundaryRefusal, OutputUnreadable, OutputUnwritable } from './boundary.schema.js'

type PlatformRefusal = ConfigRefusal | BoundaryRefusal | WorkspaceRootNotAbsolute
Expand Down Expand Up @@ -360,6 +365,63 @@ export const renderPlan = (
yield* appendFile(output, block)
})

export type UnreleasedTarballsFailure = PlanFailure | TarballRefusal | ChangeEvidenceRefusal

const differingFileOf = (file: string): string => {
if (file === '') return 'every packed file matches, so the archive metadata differs'
return `first differing file ${file}`
}

const unreleasedMismatchLine = (mismatch: TagIntegrityMismatch): string =>
`tag digests refused: ${mismatch.package}@v${mismatch.version} changed without a release — ` +
`recorded ${mismatch.recorded}, built ${mismatch.current}, ${differingFileOf(mismatch.file)}; ` +
`give ${mismatch.package} a real bump in its intent, or revert its change`

export const renderUnreleasedTarballsRefusal = (
refusal: UnreleasedTarballsFailure,
): Effect.Effect<void, never, Reporter> => {
if (S.is(ChangeEvidenceRefusal)(refusal)) {
return refuse(Match.value(refusal).pipe(Match.tagsExhaustive(evidenceRefusalLines)))
}
if (S.is(TagIntegrityMismatch)(refusal)) {
return refuse(unreleasedMismatchLine(refusal))
}
return renderPlanRefusal(refusal)
}

export const renderUnreleasedTarballs = (
decision: UnreleasedTarballsDecision,
output: string | undefined,
): Effect.Effect<void, OutputUnwritable, Reporter | FileSystem.FileSystem> =>
Effect.gen(function*() {
const { packages, report } = Match.value(decision).pipe(
Match.tagsExhaustive({
UnreleasedVacant: () => ({
packages: [],
report: note('tag digests: the change evidence names no released package that changes without a release'),
}),
UnreleasedListed: (listed) => ({
packages: listed.packages,
report: note(
`tag digests: ${listed.packages.join(', ')} change(s) without a release; ` +
'their flake-built tarballs are compared with their release tags',
),
}),
UnreleasedMatched: (matched) => ({
packages: matched.packages,
report: emit(`tag digests: ${matched.packages.length} package(s) match their release tags`),
}),
}),
)
yield* report
const line = `packages=${packages.join(' ')}`
if (output === undefined) {
yield* emit(line)
return
}
yield* appendFile(output, line)
})

export type AdoptFailure = PlatformRefusal | MemberRefusal | TagRefusal | LedgerRefusal | AdoptionRefused

export const renderAdoptRefusal = (refusal: AdoptFailure): Effect.Effect<void, never, Reporter> =>
Expand Down
Loading
Loading