diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 464b0a80..36586b6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 5619c3d1..0beff042 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 diff --git a/apps/github-release-management/src/main.ts b/apps/github-release-management/src/main.ts index 64d7164e..05220a49 100644 --- a/apps/github-release-management/src/main.ts +++ b/apps/github-release-management/src/main.ts @@ -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) @@ -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( diff --git a/apps/github-release-management/src/render.ts b/apps/github-release-management/src/render.ts index e4305b05..c9c48944 100644 --- a/apps/github-release-management/src/render.ts +++ b/apps/github-release-management/src/render.ts @@ -12,6 +12,7 @@ import { type ConfigRefusal, FsPath, type GithubReleaseRefusal, + type GitRef, type IntegrityRefusal, type IntentRefusal, type LedgerIdentityRefusal, @@ -454,7 +455,7 @@ export const renderRelease = (decision: GithubReleaseDecision): Effect.Effect => +const pullRequestLines = (decision: PullRequestDecision): Effect.Effect => Match.value(decision).pipe( Match.tagsExhaustive({ PullRequestCreated: (created) => emit(`created release PR #${created.number}`), @@ -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 => + 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 => + Effect.gen(function*() { + yield* pullRequestLines(decision) + if (options.output === undefined) return + yield* appendFile(options.output, pullRequestOutputs(decision, options.branch).join('\n')) + }) diff --git a/e2e/harness.ts b/e2e/harness.ts index 73ef6e71..b26facbb 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -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' @@ -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' }, @@ -53,6 +71,10 @@ export interface CommandRecord { stderr: string } +export interface StepResult extends ExecResult { + readonly step: string +} + export interface Listener { command(record: CommandRecord): void } @@ -76,6 +98,7 @@ export interface World { must(command: string, options?: ExecOptions): Promise fails(command: string, options?: ExecOptions): Promise tool(app: string, subcommand: string, args?: string, options?: ExecOptions): Promise + job(name: string): Promise> write(path: string, content: string): Promise read(path: string): Promise github(path: string, decode: (input: unknown) => T): Promise> @@ -100,9 +123,23 @@ const RUNNER_ENV: Readonly> = { const WORKFLOW_EXPRESSIONS: Readonly> = { '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 => + 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]) => @@ -170,6 +207,37 @@ export const makeWorld = (container: StartedTestContainer, listener: Listener): }) } + const job = async (name: string): Promise> => { + 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> = {} + 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 = [] + 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 = [], @@ -265,6 +333,7 @@ export const makeWorld = (container: StartedTestContainer, listener: Listener): must, fails, tool, + job, write: writeFile, read: async (path: string): Promise => (await must(`cat ${quote(path)}`)).stdout, github, diff --git a/e2e/tests/pipeline.integration.test.ts b/e2e/tests/pipeline.integration.test.ts index f8d81419..7cbfcb68 100644 --- a/e2e/tests/pipeline.integration.test.ts +++ b/e2e/tests/pipeline.integration.test.ts @@ -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' @@ -256,7 +265,7 @@ const runPhases = async (session: Session): Promise => { 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', @@ -264,12 +273,11 @@ const runPhases = async (session: Session): Promise => { ) 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) @@ -277,13 +285,12 @@ const runPhases = async (session: Session): Promise => { 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) diff --git a/e2e/workflow-env.ts b/e2e/workflow-env.ts index cb29e214..78aefbaa 100644 --- a/e2e/workflow-env.ts +++ b/e2e/workflow-env.ts @@ -18,33 +18,63 @@ export interface ToolStep { readonly env: Readonly> } +export interface RunStep { + readonly id: string | undefined + readonly label: string + readonly run: string + readonly env: Readonly> +} + const flagsOf = (args: string): ReadonlyArray => [...args.matchAll(/(?:^|\s)(--[a-z][a-z-]*)/g)].flatMap((match) => match[1] ?? []).sort() const sameFlags = (left: ReadonlyArray, right: ReadonlyArray): boolean => left.length === right.length && left.every((flag, index) => flag === right[index]) -export const releaseToolSteps = (): Promise> => +export const releaseWorkflow = (): Promise => 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 => + 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 => { + 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, app: string, @@ -64,18 +94,11 @@ export const workflowEnvOf = ( app: string, subcommand: string, args: string, -): Record => { - 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 => + 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])]) ), ) -} diff --git a/e2e/workflow.schema.ts b/e2e/workflow.schema.ts index dce5b0c0..ca3cf161 100644 --- a/e2e/workflow.schema.ts +++ b/e2e/workflow.schema.ts @@ -10,3 +10,4 @@ export const WorkflowStep = S.Struct({ export const Workflow = S.Struct({ jobs: S.Record(S.String, S.Struct({ steps: S.optional(S.Array(WorkflowStep)) })), }) +export type Workflow = S.Schema.Type