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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,24 @@ jobs:
- name: Bump every package and version surface, consuming the intents
run: nix develop --command version-management bump

- name: Open or update the release PR
- id: pr
name: Open or update the release PR
env:
GITHUB_TOKEN: ${{ github.token }}
run: nix develop --command github-release-management pr
run: nix develop --command github-release-management pr --output "$GITHUB_OUTPUT"

- name: Run the caller's CI on the release PR
env:
GH_TOKEN: ${{ github.token }}
CI_WORKFLOW: ${{ inputs.ci-workflow }}
PR_OUTCOME: ${{ steps.pr.outputs.outcome }}
PR_BRANCH: ${{ steps.pr.outputs.branch }}
run: |
branch="$(gh pr list --label release --state open --json headRefName --jq '.[0].headRefName // empty')"
if [ -n "$branch" ]; then gh workflow run "$CI_WORKFLOW" --ref "$branch"; fi
case "$PR_OUTCOME" in
created | updated) gh workflow run "$CI_WORKFLOW" --ref "${PR_BRANCH:?pr reported no branch}" ;;
closed | vacant) echo "no open release PR ($PR_OUTCOME); nothing to dispatch" ;;
*) echo "::error::pr reported no outcome; cannot tell whether a release PR needs CI"; exit 1 ;;
esac

release:
name: release · tag · GitHub releases
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,14 @@ agree on what this cycle owns.
| `release.yml` | `ci-workflow` (required), `artifacts-dir` | `contents: write`, `pull-requests: write`, `actions: write` |
| `changeset-check.yml` | `tools-ref`, `base-sha`, `node-version`, `devshell` | `contents: read`, `pull-requests: read` |

A pull request opened with the workflow token starts no workflows, so after
opening or updating the release PR, `release.yml` dispatches the caller's CI
workflow (`ci-workflow`, which must accept `workflow_dispatch`) on the release
branch. That gives the release PR the checks the branch protection requires.
The `pull_request` runs of a pull request opened or updated with the workflow
token wait for a maintainer's approval, so they never run on their own.
`release pr --output <file>` appends `outcome` (`created`, `updated`, `closed`
or `vacant`), `number` and `branch` to the file. `release.yml` passes
`$GITHUB_OUTPUT`, and when the outcome is `created` or `updated` it dispatches
the caller's CI workflow (`ci-workflow`, which must accept `workflow_dispatch`)
on that branch. A failed dispatch fails the job. That gives the release PR the
checks the branch protection requires.

`release.yml` runs the apps from the caller's dev shell (`nix develop`). The
revision is the one the caller's `flake.lock` pins for its
Expand Down
3 changes: 2 additions & 1 deletion apps/github-release-management/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ const pr = Command.make('pr', {
base: Flag.string('base').pipe(Flag.optional),
branch: Flag.string('branch').pipe(Flag.optional),
config: Flag.string('config').pipe(Flag.optional),
output: Flag.string('output').pipe(Flag.optional),
}, (flags) =>
Effect.gen(function*() {
const workspace = yield* workspaceOf(flags.config)
Expand All @@ -197,7 +198,7 @@ const pr = Command.make('pr', {
branch: Option.getOrUndefined(flags.branch),
})
const decision = yield* Cell.run(pullRequestCell, request)
yield* renderPullRequest(decision)
yield* renderPullRequest(decision, { branch: request.branch, output: Option.getOrUndefined(flags.output) })
}).pipe(Effect.catch(renderPullRequestRefusal)))

const release = Command.make('release').pipe(
Expand Down
28 changes: 27 additions & 1 deletion apps/github-release-management/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type ConfigRefusal,
FsPath,
type GithubReleaseRefusal,
type GitRef,
type IntegrityRefusal,
type IntentRefusal,
type LedgerIdentityRefusal,
Expand Down Expand Up @@ -454,7 +455,7 @@ export const renderRelease = (decision: GithubReleaseDecision): Effect.Effect<vo
}),
)

export const renderPullRequest = (decision: PullRequestDecision): Effect.Effect<void, never, Reporter> =>
const pullRequestLines = (decision: PullRequestDecision): Effect.Effect<void, never, Reporter> =>
Match.value(decision).pipe(
Match.tagsExhaustive({
PullRequestCreated: (created) => emit(`created release PR #${created.number}`),
Expand All @@ -471,3 +472,28 @@ export const renderPullRequest = (decision: PullRequestDecision): Effect.Effect<
PullRequestVacant: () => emit('no version changes on the tree — nothing to release'),
}),
)

const pullRequestOutputs = (decision: PullRequestDecision, branch: GitRef): ReadonlyArray<string> =>
Match.value(decision).pipe(
Match.tagsExhaustive({
PullRequestCreated: (created) => ['outcome=created', `number=${created.number}`, `branch=${branch}`],
PullRequestUpdated: (updated) => ['outcome=updated', `number=${updated.number}`, `branch=${branch}`],
PullRequestClosed: (closed) => ['outcome=closed', `number=${closed.number}`, `branch=${closed.branch.branch}`],
PullRequestVacant: (vacant) => ['outcome=vacant', `branch=${vacant.branch}`],
}),
)

export interface PullRequestOptions {
readonly branch: GitRef
readonly output: string | undefined
}

export const renderPullRequest = (
decision: PullRequestDecision,
options: PullRequestOptions,
): Effect.Effect<void, OutputUnwritable, Reporter | FileSystem.FileSystem> =>
Effect.gen(function*() {
yield* pullRequestLines(decision)
if (options.output === undefined) return
yield* appendFile(options.output, pullRequestOutputs(decision, options.branch).join('\n'))
})
73 changes: 71 additions & 2 deletions e2e/harness.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { GenericContainer, type StartedTestContainer, Wait } from 'testcontainers'
import { releaseToolSteps, workflowEnvOf } from './workflow-env.js'
import { releaseWorkflow, resolveExpressions, runStepsOf, toolStepsOf, workflowEnvOf } from './workflow-env.js'

export const IMAGE = 'pnpm-release-management-e2e:local'
export const BINS_DIR = '/opt/prm'
Expand All @@ -12,6 +12,24 @@ export const GITHUB_REPOSITORY = 'admin/fixture'
export const REGISTRY = 'https://registry.npmjs.org'
export const REGISTRY_LOG = '/tmp/verdaccio/verdaccio.log'
export const REGISTRY_STORAGE = '/tmp/verdaccio/storage'
export const CI_WORKFLOW = 'ci.yml'
export const GH_DISPATCHES = '/tmp/gh-dispatches.log'

const RUNNER_BIN = '/opt/runner-bin'
const STEP_DIR = '/tmp/steps'

const GH_STUB = `#!/bin/sh
if [ -z "$GH_TOKEN" ]; then
echo 'gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable.' >&2
exit 4
fi
if [ "$1 $2" = "workflow run" ]; then
printf '%s\\n' "$*" >> ${GH_DISPATCHES}
exit 0
fi
echo "gh stub: only 'gh workflow run' is modelled, got: gh $*" >&2
exit 2
`

export const REDIRECTED_HOSTS = [
{ host: 'api.github.com', ipAddress: '127.0.0.1' },
Expand Down Expand Up @@ -53,6 +71,10 @@ export interface CommandRecord {
stderr: string
}

export interface StepResult extends ExecResult {
readonly step: string
}

export interface Listener {
command(record: CommandRecord): void
}
Expand All @@ -76,6 +98,7 @@ export interface World {
must(command: string, options?: ExecOptions): Promise<ExecResult>
fails(command: string, options?: ExecOptions): Promise<ExecResult>
tool(app: string, subcommand: string, args?: string, options?: ExecOptions): Promise<ExecResult>
job(name: string): Promise<ReadonlyArray<StepResult>>
write(path: string, content: string): Promise<void>
read(path: string): Promise<string>
github<T>(path: string, decode: (input: unknown) => T): Promise<GitHubResponse<T>>
Expand All @@ -100,9 +123,23 @@ const RUNNER_ENV: Readonly<Record<string, string>> = {

const WORKFLOW_EXPRESSIONS: Readonly<Record<string, string>> = {
'github.token': GITHUB_TOKEN,
'inputs.ci-workflow': CI_WORKFLOW,
}

const RELEASE_TOOL_STEPS = await releaseToolSteps()
const RELEASE_WORKFLOW = await releaseWorkflow()
const RELEASE_TOOL_STEPS = toolStepsOf(RELEASE_WORKFLOW)

const outputsOf = (text: string): Record<string, string> =>
Object.fromEntries(
text.split('\n').flatMap((line) => {
const at = line.indexOf('=')
if (at <= 0) return []
return [[line.slice(0, at), line.slice(at + 1)]]
}),
)

const withBins = (run: string): string =>
run.replaceAll(/nix develop --command (\S+)/g, (_, app: string) => quote(`${BINS_DIR}/${app}`))

const shellScript = (command: string, options: ExecOptions): string => {
const lines = Object.entries({ ...RUNNER_ENV, ...options.env }).map(([name, value]) =>
Expand Down Expand Up @@ -170,6 +207,37 @@ export const makeWorld = (container: StartedTestContainer, listener: Listener):
})
}

const job = async (name: string): Promise<ReadonlyArray<StepResult>> => {
await must(
`mkdir -p ${RUNNER_BIN} ${STEP_DIR} && touch ${GH_DISPATCHES} && ` +
`cat > ${RUNNER_BIN}/gh <<'GH_STUB_EOF'\n${GH_STUB}GH_STUB_EOF\nchmod 0755 ${RUNNER_BIN}/gh`,
)
const outputs: Record<string, Record<string, string>> = {}
const lookup = (expression: string): string | undefined => {
const step = expression.match(/^steps\.([\w-]+)\.outputs\.([\w-]+)$/)
if (step?.[1] !== undefined && step[2] !== undefined) return outputs[step[1]]?.[step[2]] ?? ''
return WORKFLOW_EXPRESSIONS[expression]
}
const results: Array<StepResult> = []
for (const [index, step] of runStepsOf(RELEASE_WORKFLOW, name).entries()) {
const script = `${STEP_DIR}/${name}-${index}.sh`
const output = `${STEP_DIR}/${name}-${index}.output`
await writeFile(script, resolveExpressions(withBins(step.run), lookup))
await must(`: > ${quote(output)}`)
const env = Object.fromEntries(
Object.entries(step.env).map(([variable, value]) => [variable, resolveExpressions(value, lookup)]),
)
const result = await run(
`PATH=${RUNNER_BIN}:$PATH bash --noprofile --norc -eo pipefail ${quote(script)}`,
{ cwd: FIXTURE, env: { ...env, GITHUB_OUTPUT: output } },
)
results.push({ step: step.label, ...result })
if (result.code !== 0) return results
if (step.id !== undefined) outputs[step.id] = outputsOf((await must(`cat ${quote(output)}`)).stdout)
}
return results
}

const curlJson = async (
url: string,
headers: ReadonlyArray<string> = [],
Expand Down Expand Up @@ -265,6 +333,7 @@ export const makeWorld = (container: StartedTestContainer, listener: Listener):
must,
fails,
tool,
job,
write: writeFile,
read: async (path: string): Promise<string> => (await must(`cat ${quote(path)}`)).stdout,
github,
Expand Down
37 changes: 22 additions & 15 deletions e2e/tests/pipeline.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ import { Effect } from 'effect'
import * as S from 'effect/Schema'
import { expect } from 'vitest'
import { headSha, MEMBERS, packTarballs, setupFixture } from '../fixture.js'
import { FIXTURE, parseJson, REGISTRY, REGISTRY_LOG, REGISTRY_STORAGE, type World } from '../harness.js'
import {
CI_WORKFLOW,
FIXTURE,
GH_DISPATCHES,
parseJson,
REGISTRY,
REGISTRY_LOG,
REGISTRY_STORAGE,
type World,
} from '../harness.js'
import { Session } from '../session.js'
import { CapturedEntry, Manifest, Pulls, Release, Releases } from './__fixtures__/pipeline.schema.js'

Expand Down Expand Up @@ -256,34 +265,32 @@ const runPhases = async (session: Session): Promise<void> => {
expect(output).toContain('phase=none')
})

await session.phase('release PR opens with the release label', async (world) => {
await session.phase('version job opens the release PR and dispatches CI on its branch', async (world) => {
const created = await world.tool(
'changeset-management',
'new',
'@e2e/beta --bump patch --summary "beta stops dropping the last frame" --slug beta-fix',
)
expect(created.code).toBe(0)

const versioned = await world.tool('version-management', 'bump')
expect(versioned.code).toBe(0)

const opened = await world.tool('github-release-management', 'pr')
expect(opened.code).toBe(0)
expect(`${opened.stdout}${opened.stderr}`).toContain('created release PR #')
const steps = await world.job('version')
expect(steps.filter((step) => step.code !== 0)).toEqual([])
const opened = steps.find((step) => step.step === 'pr')
expect(`${opened?.stdout}${opened?.stderr}`).toContain('created release PR #')
expect(await world.read(GH_DISPATCHES)).toBe(`workflow run ${CI_WORKFLOW} --ref changeset-release/main\n`)

const pulls = await fetchOpenPulls(world)
expect(pulls.body?.length).toBe(1)
expect(pulls.body?.[0]?.title).toBe('chore(release): version packages')
expect(pulls.body?.[0]?.labels.map((label) => label.name)).toEqual(['release'])
})

await session.phase('release PR closes once a bump leaves the tree unchanged', async (world) => {
const versioned = await world.tool('version-management', 'bump')
expect(versioned.code).toBe(0)

const closed = await world.tool('github-release-management', 'pr')
expect(closed.code).toBe(0)
expect(`${closed.stdout}${closed.stderr}`).toContain('closed release PR #')
await session.phase('version job closes the release PR and dispatches nothing', async (world) => {
const steps = await world.job('version')
expect(steps.filter((step) => step.code !== 0)).toEqual([])
const closed = steps.find((step) => step.step === 'pr')
expect(`${closed?.stdout}${closed?.stderr}`).toContain('closed release PR #')
expect(await world.read(GH_DISPATCHES)).toBe(`workflow run ${CI_WORKFLOW} --ref changeset-release/main\n`)

const pulls = await fetchOpenPulls(world)
expect(pulls.body?.length).toBe(0)
Expand Down
75 changes: 49 additions & 26 deletions e2e/workflow-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,63 @@ export interface ToolStep {
readonly env: Readonly<Record<string, string>>
}

export interface RunStep {
readonly id: string | undefined
readonly label: string
readonly run: string
readonly env: Readonly<Record<string, string>>
}

const flagsOf = (args: string): ReadonlyArray<string> =>
[...args.matchAll(/(?:^|\s)(--[a-z][a-z-]*)/g)].flatMap((match) => match[1] ?? []).sort()

const sameFlags = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
left.length === right.length && left.every((flag, index) => flag === right[index])

export const releaseToolSteps = (): Promise<ReadonlyArray<ToolStep>> =>
export const releaseWorkflow = (): Promise<Workflow> =>
Effect.runPromise(
Effect.gen(function*() {
const fs = yield* FileSystem
const text = yield* fs.readFileString(RELEASE_WORKFLOW)
const workflow = yield* S.decodeUnknownEffect(Workflow)(parse(text))
return Object.entries(workflow.jobs).flatMap(([job, { steps = [] }]) =>
steps.flatMap((step) =>
[...(step.run ?? '').matchAll(TOOL_COMMAND)].map((match) => ({
job,
step: step.name ?? step.id ?? '(unnamed)',
app: match[1] ?? '',
subcommand: match[2] ?? '',
flags: flagsOf(match[3] ?? ''),
env: step.env ?? {},
}))
)
)
return yield* S.decodeUnknownEffect(Workflow)(parse(text))
}).pipe(Effect.provide(NodeServices.layer)),
)

export const toolStepsOf = (workflow: Workflow): ReadonlyArray<ToolStep> =>
Object.entries(workflow.jobs).flatMap(([job, { steps = [] }]) =>
steps.flatMap((step) =>
[...(step.run ?? '').matchAll(TOOL_COMMAND)].map((match) => ({
job,
step: step.name ?? step.id ?? '(unnamed)',
app: match[1] ?? '',
subcommand: match[2] ?? '',
flags: flagsOf(match[3] ?? ''),
env: step.env ?? {},
}))
)
)

export const runStepsOf = (workflow: Workflow, job: string): ReadonlyArray<RunStep> => {
const steps = workflow.jobs[job]?.steps
if (steps === undefined) throw new Error(`release.yml has no job ${job}`)
return steps.flatMap((step) => {
if (step.run === undefined) return []
return [{ id: step.id, label: step.id ?? step.name ?? step.run, run: step.run, env: step.env ?? {} }]
})
}

export const resolveExpressions = (
value: string,
lookup: (expression: string) => string | undefined,
): string =>
value.replaceAll(/\$\{\{\s*([^}]+?)\s*\}\}/g, (_, expression: string) => {
const resolved = lookup(expression)
if (resolved === undefined) {
throw new Error(`release.yml uses \${{ ${expression} }}, which the e2e cannot resolve`)
}
return resolved
})

const stepsRunning = (
steps: ReadonlyArray<ToolStep>,
app: string,
Expand All @@ -64,18 +94,11 @@ export const workflowEnvOf = (
app: string,
subcommand: string,
args: string,
): Record<string, string> => {
const resolve = (value: string): string =>
value.replaceAll(/\$\{\{\s*([^}]+?)\s*\}\}/g, (_, expression: string) => {
const resolved = expressions[expression]
if (resolved === undefined) {
throw new Error(`release.yml uses \${{ ${expression} }}, which the e2e cannot resolve`)
}
return resolved
})
return Object.fromEntries(
): Record<string, string> =>
Object.fromEntries(
stepsRunning(steps, app, subcommand, args).flatMap((step) =>
Object.entries(step.env).map(([name, value]) => [name, resolve(value)])
Object.entries(step.env).map((
[name, value],
) => [name, resolveExpressions(value, (expression) => expressions[expression])])
),
)
}
Loading
Loading