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
31 changes: 15 additions & 16 deletions .github/workflows/changeset-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ on:
workflow_call:
inputs:
tools-ref:
description: ref of systemfsoftware/pnpm-release-management to run
description: ignored; the check runs the release tools at this workflow's own revision
required: false
type: string
default: main
default: ""
base-sha:
description: commit the pull request is compared against
required: false
Expand Down Expand Up @@ -44,13 +44,6 @@ jobs:
with:
fetch-depth: 0

- uses: actions/checkout@v7
if: ${{ !inputs.devshell }}
with:
repository: systemfsoftware/pnpm-release-management
ref: ${{ inputs.tools-ref }}
path: .release-tools

# Hosted runners get node + pnpm from the official actions.
- uses: pnpm/action-setup@v6
if: runner.environment == 'github-hosted'
Expand All @@ -68,13 +61,16 @@ jobs:
runtime: node@${{ inputs.node-version }}

- uses: cachix/install-nix-action@v31
if: inputs.devshell && runner.environment == 'github-hosted'
if: runner.environment == 'github-hosted'

- name: Build the release tools
if: ${{ !inputs.devshell }}
- name: Build the release tools at this workflow's own revision
env:
WORKFLOW_REPOSITORY: ${{ job.workflow_repository }}
WORKFLOW_SHA: ${{ job.workflow_sha }}
run: |
pnpm --dir .release-tools install --frozen-lockfile
pnpm --dir .release-tools build
tools=$(nix build --no-link --print-out-paths \
"github:${WORKFLOW_REPOSITORY:?job.workflow_repository is empty}/${WORKFLOW_SHA:?job.workflow_sha is empty}#release-tools")
echo "RELEASE_TOOLS=$tools/bin" >> "$GITHUB_ENV"

- name: Install the caller's workspace
if: ${{ !inputs.devshell }}
Expand All @@ -98,8 +94,11 @@ jobs:
BASE_SHA: ${{ inputs.base-sha || github.event.pull_request.base.sha }}
DEVSHELL: ${{ inputs.devshell }}
run: |
# The caller's sandbox binds only the store paths its arguments resolve
# to, so it gets the binary itself, not the release-tools symlink to it.
check=$(realpath "$RELEASE_TOOLS/changeset-management")
if [ "$DEVSHELL" = true ]; then
nix develop --command sandbox -- changeset-management check "$BASE_SHA"
nix develop --command sandbox -- "$check" check "$BASE_SHA"
else
node .release-tools/apps/changeset-management/dist/main.js check "$BASE_SHA"
"$check" check "$BASE_SHA"
fi
1 change: 0 additions & 1 deletion .github/workflows/changeset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,4 @@ jobs:
check:
uses: ./.github/workflows/changeset-check.yml
with:
tools-ref: ${{ github.event.pull_request.head.sha }}
devshell: true
65 changes: 38 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ Apps are Node programs built with tsdown into self-contained ESM bundles, then
compiled with `deno compile` into one binary each. The flake exports them, and
`packages.<system>.release-tools` joins the three release apps. A repository
takes this flake as an input, pinned by its `flake.lock`, and puts
`release-tools` in its dev shell to run the apps locally. `release.yml` does not
use that pin: it builds the `release-tools` of its own commit and runs them in
the caller's dev shell (see [CI](#ci)). Each job sets `WORKFLOW_REPOSITORY` and
`release-tools` in its dev shell to run the apps locally. `release.yml` and
`changeset-check.yml` do not use that pin: they build the `release-tools` of
their own commit (see [CI](#ci)). Each job sets `WORKFLOW_REPOSITORY` and
`WORKFLOW_SHA` from the `job.workflow_repository` and `job.workflow_sha`
contexts and fails when either is empty:

Expand Down Expand Up @@ -263,10 +263,10 @@ agree on what this cycle owns.

## CI

| Workflow | Inputs | Caller must grant |
| --------------------- | --------------------------------------------------- | ----------------------------------------------------------- |
| `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` |
| Workflow | Inputs | Caller must grant |
| --------------------- | ------------------------------------------------------------- | ----------------------------------------------------------- |
| `release.yml` | `ci-workflow` (required), `artifacts-dir` | `contents: write`, `pull-requests: write`, `actions: write` |
| `changeset-check.yml` | `base-sha`, `node-version`, `devshell`, `tools-ref` (ignored) | `contents: read`, `pull-requests: read` |

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.
Expand All @@ -277,28 +277,36 @@ 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 built from its own commit, not the caller's. Each
job builds `github:${{ job.workflow_repository }}/${{ job.workflow_sha }}#release-tools`,
`release.yml` and `changeset-check.yml` run the apps built from their own
commit, not the caller's. Each job builds
`github:${{ job.workflow_repository }}/${{ job.workflow_sha }}#release-tools`,
the `release-tools` of the exact revision of this repository the caller's
`uses:` resolved to, and runs those binaries by path inside the caller's dev
shell (`nix develop --command "$RELEASE_TOOLS/<app>" …`). A caller on
`release.yml@main` therefore always gets the flags `release.yml@main` passes,
whatever revision its `flake.lock` pins for its `pnpm-release-management`
input; that pin still decides the `release-tools` in its dev shell for local
use, and `nix flake update pnpm-release-management` still moves it. The
caller's `devShells.<system>.default` must provide pnpm, the `sandbox` and
`uses:` resolved to, and runs those binaries by path (`"$RELEASE_TOOLS/<app>"`).
`release.yml` runs them inside the caller's dev shell
(`nix develop --command "$RELEASE_TOOLS/<app>" …`). A caller on `@main`
therefore always gets the flags `@main` passes, whatever revision its
`flake.lock` pins for its `pnpm-release-management` input; that pin still
decides the `release-tools` in its dev shell for local use, and
`nix flake update pnpm-release-management` still moves it. The caller's
`devShells.<system>.default` must provide pnpm, the `sandbox` and
`SANDBOX_PNPM_STORE` (see [Distribution through Nix](#distribution-through-nix)).
The job needs the `job.workflow_*` context: github.com provides it (self-hosted
runners from actions/runner v2.334.0), GitHub Enterprise Server does not. A job
that cannot read it fails before running any tool.
that cannot read it fails before running any tool. Both workflows need Nix:
they install it on hosted runners, and a self-hosted runner must provide it.

`tools-ref` pins the revision of this repository that a release runs from;
`@main` tracks the tip. Without `devshell`, the changeset check checks this
repository out into `.release-tools`, builds it with pnpm, and runs its
`dist/main.js` bundles against the caller's workspace.
Without `devshell`, the changeset check installs the caller's workspace with
plain pnpm and runs `"$RELEASE_TOOLS/changeset-management" check` against it.
It still builds that binary with Nix, so a self-hosted runner needs Nix in this
mode too.

`devshell: true` makes the changeset check install Nix and run the caller's
own `bootstrap` script inside its `nix develop` shell
`tools-ref` is accepted and ignored. It can be removed once no caller passes it:
stryker-js-effect's `changeset-check.yml` still passes `tools-ref: main`, and
removing an input a caller passes fails that caller's workflow, so drop it from
the callers first.

`devshell: true` makes the changeset check run the caller's own `bootstrap`
script inside its `nix develop` shell
(`nix develop --command pnpm run bootstrap`) instead of a plain
`pnpm install`, then run the check in that shell. The bootstrap script is
where the caller installs its workspace inside its own sandbox, so no
Expand All @@ -309,10 +317,13 @@ bubblewrap sandbox can start. A caller needs this mode when its lockfile points
at tarballs its flake builds, such as `file:.sfs-deps/<name>-<version>.tgz`; a
plain install cannot read those. With `devshell: true`, `node-version` is
ignored for the caller's install and check: they use the dev shell's node, and
the check runs as `nix develop --command sandbox -- changeset-management check`
from the `release-tools` in that dev shell, so `tools-ref` is ignored too. The
default, `false`, installs with plain pnpm as before. This repository's own CI
calls the check with `devshell: true` on every pull request.
the check runs the workflow's own binary inside the caller's sandbox,
`nix develop --command sandbox -- "$(realpath "$RELEASE_TOOLS/changeset-management")" check`.
The `realpath` matters: the sandbox binds only the store paths its command
resolves to, and `release-tools` holds symlinks into the per-app store paths,
so the unresolved path does not exist inside it. The default, `false`,
installs with plain pnpm. This repository's own CI calls the check with
`devshell: true` on every pull request.

## Distribution through Nix

Expand Down
3 changes: 2 additions & 1 deletion e2e/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const ROOT_MANIFEST = `{
"name": "@e2e/root",
"private": true,
"version": "1.0.0",
"packageManager": "pnpm@11.27.0"
"packageManager": "pnpm@11.27.0",
"scripts": { "bootstrap": "pnpm install --frozen-lockfile" }
}
`

Expand Down
67 changes: 60 additions & 7 deletions e2e/harness.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { GenericContainer, type StartedTestContainer, Wait } from 'testcontainers'
import { releaseWorkflow, resolveExpressions, runStepsOf, toolStepsOf, workflowEnvOf } from './workflow-env.js'
import {
conditionHolds,
readWorkflow,
resolveExpressions,
runStepsOf,
toolStepsOf,
workflowEnvOf,
type WorkflowFile,
} from './workflow-env.js'
import type { Workflow } from './workflow.schema.js'

export const IMAGE = 'pnpm-release-management-e2e:local'
export const BINS_DIR = '/opt/prm'
Expand Down Expand Up @@ -76,6 +85,23 @@ echo "${app} at ${CALLER_LOCK_REV}, the revision the caller's flake.lock pins, r
exit 1
`

const SANDBOX_STUB = `#!/bin/sh
if [ "$1" = -- ]; then shift; fi
case "$1" in
-*)
echo "sandbox stub: only 'sandbox [--] COMMAND' is modelled, got: sandbox $*" >&2
exit 2
;;
/*)
if [ "$(realpath -m "$1")" != "$1" ]; then
echo "bwrap: execvp $1: No such file or directory (the sandbox binds the store paths a command resolves to, not the symlinks naming it)" >&2
exit 1
fi
;;
esac
exec "$@"
`

export const REDIRECTED_HOSTS = [
{ host: 'api.github.com', ipAddress: '127.0.0.1' },
{ host: 'registry.npmjs.org', ipAddress: '127.0.0.1' },
Expand Down Expand Up @@ -121,6 +147,11 @@ export interface StepResult extends ExecResult {
readonly outputs: Readonly<Record<string, string>>
}

export interface JobOptions {
readonly workflow?: WorkflowFile
readonly inputs?: Readonly<Record<string, string>>
}

export interface Listener {
command(record: CommandRecord): void
}
Expand All @@ -144,7 +175,10 @@ 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>>
job(
name: string,
options?: JobOptions,
): 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 Down Expand Up @@ -173,10 +207,14 @@ const WORKFLOW_EXPRESSIONS: Readonly<Record<string, string>> = {
'inputs.artifacts-dir': ARTIFACTS_DIR,
'job.workflow_repository': WORKFLOW_REPOSITORY,
'job.workflow_sha': WORKFLOW_SHA,
'runner.environment': 'self-hosted',
}

const RELEASE_WORKFLOW = await releaseWorkflow()
const RELEASE_TOOL_STEPS = toolStepsOf(RELEASE_WORKFLOW)
const WORKFLOWS: Readonly<Record<WorkflowFile, Workflow>> = {
'release.yml': await readWorkflow('release.yml'),
'changeset-check.yml': await readWorkflow('changeset-check.yml'),
}
const RELEASE_TOOL_STEPS = toolStepsOf(WORKFLOWS['release.yml'])

const outputsOf = (text: string): Record<string, string> =>
Object.fromEntries(
Expand Down Expand Up @@ -205,6 +243,7 @@ const RUNNER_SETUP = [
installScript(`${RUNNER_BIN}/gh`, GH_STUB),
installScript(`${RUNNER_BIN}/nix`, NIX_STUB),
...RELEASE_APPS.map((app) => installScript(`${CALLER_DEVSHELL_BIN}/${app}`, callerLockTool(app))),
installScript(`${CALLER_DEVSHELL_BIN}/sandbox`, SANDBOX_STUB),
].join('\n')

export const makeWorld = (container: StartedTestContainer, listener: Listener): World => {
Expand Down Expand Up @@ -264,19 +303,33 @@ export const makeWorld = (container: StartedTestContainer, listener: Listener):
})
}

const job = async (name: string): Promise<ReadonlyArray<StepResult>> => {
const job = async (
name: string,
{ workflow = 'release.yml', inputs = {} }: JobOptions = {},
): Promise<ReadonlyArray<StepResult>> => {
await must(RUNNER_SETUP)
const githubEnv = `${STEP_DIR}/${name}.env`
await must(`: > ${quote(githubEnv)}`)
let jobEnv: Record<string, string> = {}
const outputs: Record<string, Record<string, string>> = {}
const lookup = (expression: string): string | undefined => {
const operand = (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]] ?? ''
const input = expression.match(/^inputs\.([\w-]+)$/)
if (input?.[1] !== undefined && inputs[input[1]] !== undefined) return inputs[input[1]]
return WORKFLOW_EXPRESSIONS[expression]
}
const lookup = (expression: string): string | undefined => {
for (const alternative of expression.split('||').map((part) => part.trim())) {
const value = operand(alternative)
if (value === undefined) return undefined
if (value !== '') return value
}
return ''
}
const results: Array<StepResult> = []
for (const [index, step] of runStepsOf(RELEASE_WORKFLOW, name).entries()) {
for (const [index, step] of runStepsOf(WORKFLOWS[workflow], name).entries()) {
if (!conditionHolds(step.condition, lookup)) continue
const script = `${STEP_DIR}/${name}-${index}.sh`
const output = `${STEP_DIR}/${name}-${index}.output`
await writeFile(script, resolveExpressions(step.run, lookup))
Expand Down
31 changes: 31 additions & 0 deletions e2e/tests/pipeline.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,37 @@ const runPhases = async (session: Session): Promise<void> => {
expect(`${passed.stdout}${passed.stderr}`).toContain('@e2e/alpha')
})

for (
const { phase, devshell, install } of [
{
phase:
'changeset check job runs its own revision of the tools inside a caller dev shell whose lock pins an older one',
devshell: 'true',
install: "Install the caller's workspace through its own bootstrap script",
},
{
phase: 'changeset check job without a dev shell runs its own revision of the tools too',
devshell: 'false',
install: "Install the caller's workspace",
},
]
) {
await session.phase(phase, async (world) => {
const steps = await world.job('require-intent', {
workflow: 'changeset-check.yml',
inputs: { 'base-sha': base, devshell },
})
expect(steps.filter((step) => step.code !== 0)).toEqual([])
expect(steps.map((step) => step.step)).toEqual([
"Build the release tools at this workflow's own revision",
install,
'Require an intent for every publishable-package change',
])
const checked = steps.at(-1)
expect(`${checked?.stdout}${checked?.stderr}`).toContain('@e2e/alpha')
})
}

await session.phase('plan asks for a version step', async (world) => {
const tarballs = await packTarballs(world)
const planned = await world.tool('github-release-management', 'plan', `--tarballs ${tarballs}`)
Expand Down
Loading
Loading