From b69b88a7531206ca0930145fb0bebbb44e80dc01 Mon Sep 17 00:00:00 2001 From: "rhods-devops-app[bot]" <131765678+rhods-devops-app[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:01:37 -0500 Subject: [PATCH 1/4] chore: reconcile component repo PipelineRuns on main (#59) Validated-Publication-Base: 5d956c2446309dd1a3094a88eb3515e640050c63 Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .tekton/odh-openshell-dashboard-push.yaml | 47 ----------------------- 1 file changed, 47 deletions(-) delete mode 100644 .tekton/odh-openshell-dashboard-push.yaml diff --git a/.tekton/odh-openshell-dashboard-push.yaml b/.tekton/odh-openshell-dashboard-push.yaml deleted file mode 100644 index 8c7a2231..00000000 --- a/.tekton/odh-openshell-dashboard-push.yaml +++ /dev/null @@ -1,47 +0,0 @@ -apiVersion: tekton.dev/v1 -kind: PipelineRun -# -metadata: - annotations: - build.appstudio.openshift.io/repo: https://github.com/opendatahub-io/openshell-dashboard?rev={{revision}} - build.appstudio.redhat.com/commit_sha: '{{revision}}' - build.appstudio.redhat.com/target_branch: '{{target_branch}}' - pipelinesascode.tekton.dev/cancel-in-progress: "false" - pipelinesascode.tekton.dev/max-keep-runs: "3" - pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch - == "main" - creationTimestamp: null - labels: - appstudio.openshift.io/application: opendatahub-builds - appstudio.openshift.io/component: odh-openshell-dashboard-ci - pipelines.appstudio.openshift.io/type: build - name: odh-openshell-dashboard-on-push - namespace: open-data-hub-tenant -spec: - params: - - name: git-url - value: '{{source_url}}' - - name: revision - value: '{{revision}}' - - name: output-image - value: quay.io/opendatahub/odh-openshell-dashboard:odh-stable - - name: dockerfile - value: deploy/Dockerfile - - name: path-context - value: . - pipelineRef: - resolver: git - params: - - name: url - value: https://github.com/opendatahub-io/odh-konflux-central.git - - name: revision - value: main - - name: pathInRepo - value: pipeline/multi-arch-container-build.yaml - taskRunTemplate: - serviceAccountName: build-pipeline-odh-openshell-dashboard-ci - workspaces: - - name: git-auth - secret: - secretName: '{{ git_auth_secret }}' -status: {} From 2b5be7319f9e87ef32212c6064b7f11641401d1b Mon Sep 17 00:00:00 2001 From: "konflux-internal-p02[bot]" <170854209+konflux-internal-p02[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 05:03:06 +0000 Subject: [PATCH 2/4] chore(deps): update registry.access.redhat.com/ubi9/go-toolset docker tag to v1.26.10-1794208260 (#61) Signed-off-by: konflux-internal-p02 <170854209+konflux-internal-p02[bot]@users.noreply.github.com> Co-authored-by: konflux-internal-p02[bot] <170854209+konflux-internal-p02[bot]@users.noreply.github.com> --- deploy/Dockerfile.konflux | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/Dockerfile.konflux b/deploy/Dockerfile.konflux index ea3d37cb..810a7bcf 100644 --- a/deploy/Dockerfile.konflux +++ b/deploy/Dockerfile.konflux @@ -2,7 +2,7 @@ # into RUN instructions. For local hermetic builds, use deploy/konflux/build-local.sh. # Base images are digest-pinned and maintained by Renovate/MintMaker. ARG NODE_IMAGE=registry.access.redhat.com/ubi9/nodejs-22:1-1793903405@sha256:18efae02a19cdfbda303ff98d096cfcc2e123f300aff146e7dab1e75b9116dec -ARG GO_IMAGE=registry.access.redhat.com/ubi9/go-toolset:1.26.10-1793736700@sha256:c2bd0309bf78ae317024fdfc5602a31704e0dba9572f6d4b0bd4b681033dc965 +ARG GO_IMAGE=registry.access.redhat.com/ubi9/go-toolset:1.26.10-1794208260@sha256:0e3894cea4fc077084245e8aa2befc8a7d91a4f60fbf850f0b5b555cbe62ee26 ARG RUNTIME_IMAGE=registry.access.redhat.com/ubi9/ubi-minimal:9.8-1793862054@sha256:4b79c93106c14ad1c234907f83441ff0e4f8d24e78426a36e159098e9f7e6fa5 # --- Frontend build --- From 227035a26890bf95d7ee3b03d9fdc68b7d1692da Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Tue, 10 Nov 2026 14:16:01 -0600 Subject: [PATCH 3/4] chore: follow upstream with bump branches instead of `next` (#133) * chore: follow upstream with bump branches instead of `next` Since the move to the ai-openshell org, the `next` machinery no longer works: nobody is an admin, main's ruleset resets approval on every push (and `next` was force-pushed hourly), and the enterprise refuses the long-lived UPSTREAM_BOT_TOKEN. - Follow upstream runs once a day. For a new stable OpenShell release it creates bump/openshell-X.Y.Z from main with one signed-off pin-move commit, starts CI on it with workflow_dispatch (a PR opened with the default token starts none) and opens a PR into main. It makes a bump branch once and never rebases, moves or force-pushes it. - A person can run it with a `version` to make a prep branch for a pre-release. Check A still runs nightly against upstream's newest pre-release and keeps the compat-migrate issue, which now says how to start a prep branch. - publish.yml is manual only: no release after a merge, and next-merge.mjs is gone. Release branches are made by hand. - ci.yml no longer runs for `next`, can be started by hand (publishing nothing), and `pins a stable release` applies to every PR. - The pin move accepts the SDK's own tag (upstream tags sdk/go/v0.1.7), checked with `go list -m -json` to be the release commit. Found by a dry run against 0.1.7, which every bump from now on would have hit. - Only the default token is used; UPSTREAM_BOT_TOKEN and UPSTREAM_AUTOMERGE can be deleted. - ADR 0005 gets a dated amendment; the upstream README, releasing, contributing, rules and skills docs follow. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Gage Krumbach * chore: open bumps with the bump-bot App, and pin the workflow's actions Simon (infra) keeps "Allow GitHub Actions to create and approve pull requests" off org-wide, and a pull request opened with the default token runs no CI. So the bump job now takes a token from the organization's bump-bot GitHub App, the same setup as sdk-go: - The job runs in the `bump-bot` environment, which only main can deploy to, and gets its token with actions/create-github-app-token from BUMP_BOT_CLIENT_ID and BUMP_BOT_PRIVATE_KEY, asked for with contents and pull requests write only. Its own GITHUB_TOKEN is read-only. - The App token reaches only the steps that list pull requests and publish, as the default token did. - No more starting CI by hand: ci.yml drops workflow_dispatch and the check-* changes that went with it, and publish.py drops the dispatch. - Every action in follow-upstream.yml is pinned by commit SHA; a test holds it to that. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Gage Krumbach * fix: preview bumps from any ref, and never publish one main caught up with Two points from Dan's review of #133: - A dry run off main could not preview the bump: the one bump job needed the bump-bot environment, which only main may use. It is now two jobs. `bump` makes the commit with no secret and no environment (read-only token, pull-requests: read to list the branch's pull requests), so it runs from any ref, and hands the commit on as a git bundle. `publish-bump` runs only on main and never in a dry run, in the bump-bot environment, and builds and downloads nothing. - The commit was made from main as the run found it, so a pin that moved on main during the run could still be published backward. publish-bump now checks main out as it is, and `follow.py check-forward` refuses the bump unless its target is still ahead of main's pin. The App token is made only after that check. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Gage Krumbach --------- Signed-off-by: Gage Krumbach Co-authored-by: Claude Opus 5.5 --- .claude/rules/bff-go.md | 13 +- .claude/rules/openshell-api.md | 18 +- .claude/skills/add-rpc/SKILL.md | 9 +- .claude/skills/proto-sync/SKILL.md | 61 +- .github/workflows/ci.yml | 37 +- .github/workflows/follow-upstream.yml | 300 +++++---- .github/workflows/publish.yml | 106 +--- AGENTS.md | 2 +- CONTRIBUTING.md | 6 +- deploy/ci/gateway-pins.json | 7 +- deploy/ci/upstream/README.md | 208 ++++--- deploy/ci/upstream/bump.py | 146 +++++ deploy/ci/upstream/follow.py | 114 ++-- deploy/ci/upstream/nextbranch.py | 392 ------------ deploy/ci/upstream/pinmove.py | 33 +- deploy/ci/upstream/pins.py | 13 +- deploy/ci/upstream/plan.py | 52 +- deploy/ci/upstream/publish.py | 340 +++-------- deploy/ci/upstream/report.py | 301 +++------ deploy/ci/upstream/tests/support.py | 39 +- deploy/ci/upstream/tests/test_bump.py | 59 ++ deploy/ci/upstream/tests/test_cli.py | 262 ++++---- deploy/ci/upstream/tests/test_nextbranch.py | 577 ------------------ deploy/ci/upstream/tests/test_pinmove.py | 18 + deploy/ci/upstream/tests/test_pins.py | 4 +- deploy/ci/upstream/tests/test_plan.py | 49 +- deploy/ci/upstream/tests/test_publish.py | 494 ++++----------- deploy/ci/upstream/tests/test_report.py | 89 ++- deploy/ci/upstream/tests/test_versions.py | 2 +- deploy/ci/upstream/tests/test_workflows.py | 326 ++++++---- ...0005-gateway-compatibility-and-releases.md | 45 +- docs/development.md | 2 +- docs/releasing.md | 27 +- scripts/gateway-range.mjs | 9 +- scripts/gateway-range.test.mjs | 7 +- scripts/readme-gateway-range.mjs | 2 +- scripts/release/cut-release.mjs | 16 +- scripts/release/cut-release.test.mjs | 8 +- scripts/release/next-merge.mjs | 92 --- scripts/release/next-merge.test.mjs | 101 --- scripts/release/next-version.mjs | 8 +- scripts/release/publish-workflow.test.mjs | 77 +-- 42 files changed, 1596 insertions(+), 2875 deletions(-) create mode 100644 deploy/ci/upstream/bump.py delete mode 100644 deploy/ci/upstream/nextbranch.py create mode 100644 deploy/ci/upstream/tests/test_bump.py delete mode 100644 deploy/ci/upstream/tests/test_nextbranch.py delete mode 100644 scripts/release/next-merge.mjs delete mode 100644 scripts/release/next-merge.test.mjs diff --git a/.claude/rules/bff-go.md b/.claude/rules/bff-go.md index 2ed8e9ec..a5fa9265 100644 --- a/.claude/rules/bff-go.md +++ b/.claude/rules/bff-go.md @@ -191,12 +191,13 @@ answers a request without a bearer with `unauthorized` (`pkg/auth/proxy.go`). The SDK is pinned to the commit of an OpenShell **release tag** and moves together with the gateway the branch pins, in one commit (ADR 0005, decision 7; hard facts 17 and 20 in `openshell-api.md`). The Follow upstream workflow makes -that commit, on the `next` branch: never make it by hand, and never -`go get ...@latest` or a branch. A person does the work the move makes -necessary, in a pull request into `next`: the call sites and the test doubles -in `mock_sdk_test.go` when `check-backend` is red on the `next` pull request, -and the dashboard itself when `compat` is red there or the issue labelled -`compat-migrate` is open. How the workflow works is in +that commit, on a `bump/openshell-X.Y.Z` branch with a pull request into +`main`: never make it by hand, and never `go get ...@latest` or a branch. The +bot creates the branch and never touches it again. A person does the work the +move makes necessary, pushed to that bump branch: the call sites and the test +doubles in `mock_sdk_test.go` when `check-backend` is red on the bump pull +request, and the dashboard itself when `compat` is red there or the issue +labelled `compat-migrate` is open. How the workflow works is in [deploy/ci/upstream/README.md](../../deploy/ci/upstream/README.md). To inspect an RPC or a type, read the SDK package (`openshell/v1`, `types/*`) diff --git a/.claude/rules/openshell-api.md b/.claude/rules/openshell-api.md index fb33f77a..233992ed 100644 --- a/.claude/rules/openshell-api.md +++ b/.claude/rules/openshell-api.md @@ -6,7 +6,7 @@ alwaysApply: false # OpenShell API Reference -**THE OPENSHELL GO SDK IS THE SOURCE OF TRUTH.** The BFF calls `github.com/NVIDIA/OpenShell/sdk/go` directly, and this repository holds no copy of the gateway's proto files and no generated stubs. Before writing any handler or TypeScript type, read the actual RPC/message definitions in `$(go env GOMODCACHE)/github.com/!n!v!i!d!i!a/!open!shell/sdk/go@/openshell/v1/` (hand-written client wrappers with doc comments; `types/*.go` for message shapes) or via `go doc`. Never invent RPCs, fields, or lifecycle states. If a UI idea has no backing RPC, flag it — do not fabricate an endpoint. The SDK pin moves only together with the gateway the branch pins, to the commit of an upstream *release tag*, and the Follow upstream workflow makes that move on the `next` branch (hard facts 17 and 20). Never run `go get github.com/NVIDIA/OpenShell/sdk/go@latest`. +**THE OPENSHELL GO SDK IS THE SOURCE OF TRUTH.** The BFF calls `github.com/NVIDIA/OpenShell/sdk/go` directly, and this repository holds no copy of the gateway's proto files and no generated stubs. Before writing any handler or TypeScript type, read the actual RPC/message definitions in `$(go env GOMODCACHE)/github.com/!n!v!i!d!i!a/!open!shell/sdk/go@/openshell/v1/` (hand-written client wrappers with doc comments; `types/*.go` for message shapes) or via `go doc`. Never invent RPCs, fields, or lifecycle states. If a UI idea has no backing RPC, flag it — do not fabricate an endpoint. The SDK pin moves only together with the gateway the branch pins, to the commit of an upstream *release tag*, and the Follow upstream workflow makes that move on a `bump/openshell-X.Y.Z` branch (hard facts 17 and 20). Never run `go get github.com/NVIDIA/OpenShell/sdk/go@latest`. **There are three direct gRPC calls**, all in `backend/pkg/clients`, each for a named gap in the SDK and all on the proto client the SDK ships. `rawexec.go` does binary-safe file uploads, because the public `Exec().Run(...)` API takes no stdin and `Exec().Interactive(...)` always asks for a PTY (hard fact 22). `rawprovider.go` reads the keys of the credentials a provider holds, because the SDK's `ProviderFromProto` drops the map the gateway returns them in (hard fact 8); `rawprovider_allworkspaces.go` is the same read for the all-workspaces list. `rawprofile.go` reads and writes provider profiles, because the SDK's profile endpoint type keeps a host, a port and a protocol and drops everything else the gateway stores (hard fact 24). Do not add another unless the SDK has a gap you can name, and delete each one when its gap closes. @@ -44,14 +44,14 @@ Skip `GatewayInterceptor`, `SupervisorMiddleware`, `ComputeDriver` — internal/ 15. **`Delete` returns `(*DeletionResult, error)`, and a nil error does not mean the resource is gone.** `DeletionOutcome` is one of Unspecified / Completed / Accepted / AlreadyAbsent. Only **Completed** and **AlreadyAbsent** establish completion; **Accepted** means the gateway queued asynchronous cleanup, and unrecognized numeric values must not be treated as completion. Convert through `models.FromSDKDeletion` rather than hardcoding `{"deleted": true}`. 16. **Network policy enum fields are proto enums, not free strings.** `access`, `enforcement` and `tls` on a policy network endpoint serialize through protojson as `NETWORK_ACCESS_PRESET_*`, `NETWORK_ENFORCEMENT_MODE_*`, `NETWORK_TLS_MODE_*`. The gateway does not accept the lowercase spellings (`read-only`, `enforce`, `verify`). -17. **Three links, each proven separately — and the gateway and the SDK move together, on `next` (ADR 0005, decisions 6 to 8).** The chain is gateway → SDK → BFF → UI. +17. **Three links, each proven separately — and the gateway and the SDK move together, on a bump branch (ADR 0005, decisions 6 to 8 and the amendment of 2026-11-06).** The chain is gateway → SDK → BFF → UI. - **Wire** (gateway ↔ SDK) is proven only by running `backend/test/compat` against a real gateway; it is never inferred from version numbers. **Source** (SDK ↔ BFF) is proven by the compiler, `go vet` and the unit tests. BFF and UI ship together. - **Say which link failed.** A compile error after an SDK move is a source migration, never "the gateway is incompatible". - **A branch pins ONE upstream release** in `deploy/ci/gateway-pins.json` — the gateway and supervisor images and the SDK at that release's tag — and every change is tested against that gateway. - - **Moving to another release is one commit that moves all of it:** the pins file, `backend/go.mod`, `backend/go.sum`, the built-in release line when the minor changes, and the README's generated block. On `main` and `next` nobody makes that commit by hand. `follow-upstream.yml` reads upstream's tags every hour and makes it on the `next` branch, as the first commit after `main`, titled `fix: move to OpenShell X.Y.Z`. `next` is the bump pull request opened early, and merging it moves `main` and cuts a release: the next patch, or `X.Y.0` when the move starts a new gateway minor. - - **Upstream pre-releases are tested ahead of the stable release in two ways:** *check A*, the BFF as `main` builds it against the target gateway (a failure is the one issue labelled `compat-migrate`), and *check B*, CI on the `next` pull request, the dashboard on the target's SDK against the target's gateway. - - **A change that needs the upcoming release** — an SDK method or an RPC the released gateway does not have — goes to `next`; everything else goes to `main`. - - **An agent never moves the pins, never pushes to `next` and never opens its pull request.** If a move is needed and the workflow has not made it, stop and say so. + - **Moving to another release is one commit that moves all of it:** the pins file, `backend/go.mod`, `backend/go.sum`, the built-in release line when the minor changes, and the README's generated block. Nobody makes that commit by hand. `follow-upstream.yml` reads upstream's tags once a day and, for a new stable release, makes it on `bump/openshell-X.Y.Z`: `main` plus that one commit, titled `fix: move to OpenShell X.Y.Z`, with a pull request into `main`. A person can run it with a `version` to bump to a given release, a pre-release included. The bot creates a bump branch once and never rebases, updates or force-pushes it; after that it is an ordinary branch. Merging the pull request moves `main` and releases nothing: a person cuts the release (`publish.yml`), the next patch, or `X.Y.0` when the move starts a new gateway minor. + - **Upstream pre-releases are tested ahead of the stable release in two ways:** *check A*, the BFF as `main` builds it against the target gateway (a failure is the one issue labelled `compat-migrate`), and CI on the bump pull request, the dashboard on the target's SDK against the target's gateway. + - **A change that needs the upcoming release** — an SDK method or an RPC the released gateway does not have — goes on that release's bump branch; before upstream ships it, on a prep branch made by running Follow upstream with the pre-release as `version`. Everything else goes to `main`. + - **An agent never moves the pins by hand.** The Follow upstream bump does. An agent may push fixes to a bump branch when asked, like any other branch. If a move is needed and no bump branch exists, stop and say so: a person runs the workflow. - **The compat suite proves only what it varies.** A request field the gateway silently drops fails nothing (fact 18), so new compat tests should vary what the dashboard sends, starting with a non-default workspace. 18. **The wire link really breaks — loudly or silently — so what a build supports is measured, not assumed.** The example is the change from `string workspace` to a `WorkspaceSelector workspace_scope` message on every request, between gateway 0.0.116 and 0.1.0 (measured 2026-10-05). - **Upstream made the change in two steps.** An SDK in between put the selector at *new* field numbers and reserved the old ones (`GetSandboxRequest` 2 → 3, `ListSandboxesRequest` 4 → 6, `CreateSandboxRequest` 5 → 8). Release 0.1.0 then moved it onto the *old* `workspace` numbers (`GetSandboxRequest` 2, `ListSandboxesRequest` 4, `ListProvidersRequest` 3; 7 on `CreateSandboxRequest`). @@ -62,12 +62,12 @@ Skip `GatewayInterceptor`, `SupervisorMiddleware`, `ComputeDriver` — internal/ - **What a build supports is one gateway minor release line, not a range of versions (ADR 0005).** The line is the major.minor of the release `deploy/ci/gateway-pins.json` names (`node scripts/gateway-range.mjs` prints it), and that release is the one gateway the compat suite runs against for every change to the branch. Every gateway of the line is `supported` — any patch, pre-release, dev build or rebuild — and the BFF reports any other line as `unsupported`. The rest of the line is claimed because upstream promises no Stable break inside a minor, not because it was run here. - **Every compat run passes `-count=1`,** because Go's test cache cannot see the gateway and replays a pass against one gateway as `ok (cached)` for another. 19. **The gateway's TOML config is versioned and the schemas are mutually exclusive.** Releases up to `0.0.116` need `version = 1`; `0.1.0` and newer need `version = 2`, which renames `compute_drivers` (array) to `compute_driver` (string), switches `image_pull_policy` to snake_case (`if_not_present`), and drops `sandbox_namespace`. A v1 config on a v2 gateway fails to start outright. `deploy/ci/gateway.e2e.v1.toml.tmpl` / `.v2.` hold both; select with `OPENSHELL_CONFIG_SCHEMA` (`auto` tries v2, then v1). `gateway-pins.json` names the schema of the pinned gateway (`config_schema`); a pin move carries it over, so a release that needs a new schema fails the compat suite until a person adds the template. -20. **A branch pins one upstream release — never `latest`, `dev` or `@latest`, and a pre-release only on `next`.** +20. **A branch pins one upstream release — never `latest`, `dev` or `@latest`, and a pre-release never on `main`.** - **Tags.** On ghcr.io the gateway tag `latest` is the newest *release* and `dev` is upstream HEAD; both move. Upstream tags a stable release `vX.Y.Z` and a pre-release `vX.Y.Z-pre.N`. Those two shapes are the only tags followed; anything else (`dev`, `vm-runtime`) is ignored. Upstream HEAD is not watched: pre-releases are, and a stable release is cut from one. - **The pins file.** `deploy/ci/gateway-pins.json` holds `release` (without the `v`), that release's gateway and supervisor images pinned by digest (`tag@sha256:…`, the tag being the release), `sdk`, `sandbox_image` and `config_schema`. - - **Which branch pins what.** `main` and `release/**` pin a stable release. `next` pins the upcoming one, a pre-release until upstream cuts it, and `ci.yml`'s `pins a stable release` check refuses a pre-release in a pull request into any other branch. That check is deliberately not `compat`: it means "not released upstream yet", not "does not work". + - **Which branch pins what.** `main` and `release/**` pin a stable release. A bump branch pins the release in its name; only a prep branch a person asked for pins a pre-release, and `ci.yml`'s `pins a stable release` check refuses a pre-release in every pull request, so a prep branch cannot merge until upstream ships. That check is deliberately not `compat`: it means "not released upstream yet", not "does not work". - **Never pin a `dev` build, with or without a digest.** A dev gateway pulls `ghcr.io/nvidia/openshell/sandbox:dev` at runtime, so it drifts with upstream HEAD anyway. - - **The SDK pin.** The SDK module has no tags of its own, so the pin is the pseudo-version of the commit the release tag points at (`v0.1.3` → `sdk/go@v0.0.0-20261009050449-e1f3c82caa3e`), written identically in `backend/go.mod` and in the `sdk` field. CI fails when they differ, when the pins file is malformed, or when it is not in canonical form (`python3 deploy/ci/upstream/follow.py format-pins` rewrites it). CI cannot tell a release tag's commit from any other commit; the Follow upstream workflow can, and its run summary says so when `main`'s SDK is not at the tag of the release it pins. + - **The SDK pin.** The pin is the SDK at the commit the release tag points at, written identically in `backend/go.mod` and in the `sdk` field. Up to 0.1.6 the SDK module had no tags of its own, so the pin is a pseudo-version of that commit (`v0.1.3` → `sdk/go@v0.0.0-20261009050449-e1f3c82caa3e`). From 0.1.7 upstream tags the module itself (`sdk/go/v0.1.7`), `go get` of the release commit resolves to that tag, and the pin is `v0.1.7`; the bump checks with `go list -m -json` that the tag is the release commit. CI fails when they differ, when the pins file is malformed, or when it is not in canonical form (`python3 deploy/ci/upstream/follow.py format-pins` rewrites it). CI cannot tell a release tag's commit from any other commit; the Follow upstream workflow can, and its run summary says so when `main`'s SDK is not at the tag of the release it pins. - **The line in the BFF.** One more thing restates the pins: the gateway release line compiled into the BFF (`BuiltInGatewayReleaseLine` in `backend/pkg/models/gateway_release_line.go`), so that an image knows its line with no build args and no environment. It must be the major.minor of the pinned release, and `node scripts/gateway-range.mjs --check` fails in CI when it is not; the pin move changes the constant when the minor changes. 21. **Gateway settings are typed and type-checked.** `SettingValue` is a oneof of string, bool, int and bytes. Each registered key takes exactly one kind and the gateway refuses any other (`setting 'ocsf_json_enabled' expects bool value`). Gateways 0.1.0 to 0.1.2 register the same four keys: `ocsf_json_enabled` and `agent_policy_proposals_enabled` are bools, `ocsf_schema_version` and `proposal_approval_mode` are strings with a fixed set of values. `GetGatewayConfig` lists every registered key, and one that was never set comes back with an *empty* value: no kind. The API does not expose the registry (the CLI and TUI link it in), so the type of an unset setting cannot be read. The BFF mirrors all of this: a setting is a JSON string, boolean or integer in both directions, absent when unset, and nothing is coerced — the Settings page edits a value in the type it has and asks for the type when it has none. diff --git a/.claude/skills/add-rpc/SKILL.md b/.claude/skills/add-rpc/SKILL.md index a5052a44..13a324c8 100644 --- a/.claude/skills/add-rpc/SKILL.md +++ b/.claude/skills/add-rpc/SKILL.md @@ -29,9 +29,12 @@ Start with `openshell/v1/` and `types/` in the pinned module version. Note: If the capability exists upstream but not in the **pinned** SDK, do not run `go get ...@latest` to reach it. The SDK moves with the gateway, to the commit of an upstream release tag, and the Follow upstream workflow makes that move -on the `next` branch (ADR 0005, decision 7; see "SDK updates" in -`.claude/rules/bff-go.md`). If the capability is in the upcoming release, the -endpoint is work for `next`: branch from it and open the pull request into it. +on a `bump/openshell-X.Y.Z` branch (ADR 0005, decision 7 and the amendment of +2026-11-06; see "SDK updates" in `.claude/rules/bff-go.md`). If the capability +is in the upcoming release, the endpoint is work for that release's bump +branch, or, before upstream ships it, for a prep branch made by running Follow +upstream with the pre-release as `version`. Do not start that run yourself: +say what is needed and stop. Also check that the gateway this branch pins (`node scripts/gateway-range.mjs` prints its release) actually serves the RPC: a gateway answers gRPC `UNIMPLEMENTED` for an RPC it does not have. diff --git a/.claude/skills/proto-sync/SKILL.md b/.claude/skills/proto-sync/SKILL.md index 1eff4d1c..709981c0 100644 --- a/.claude/skills/proto-sync/SKILL.md +++ b/.claude/skills/proto-sync/SKILL.md @@ -17,22 +17,25 @@ to the commit of an upstream release tag, and a workflow makes that move (ADR 0005, decision 7; hard facts 17 and 20 in `.claude/rules/openshell-api.md`): -- `.github/workflows/follow-upstream.yml` reads upstream's tags every hour. It - keeps the `next` branch one commit ahead of `main`: the pins file, `go.mod`, - `go.sum`, the built-in release line when the minor changes, and the README's - generated block, all moved to the upcoming release. `next` pins the newest - pre-release until upstream cuts the stable release, then that. -- Never `go get ...@latest` or a branch. Never move the pins in a pull request - of your own, never push to `next`, and never open its pull request. -- If no `next` exists, upstream has nothing ahead of `main` +- `.github/workflows/follow-upstream.yml` reads upstream's tags once a day. + For a new stable release it creates `bump/openshell-X.Y.Z`: `main` plus one + commit that moves the pins file, `go.mod`, `go.sum`, the built-in release + line when the minor changes, and the README's generated block. It opens a + pull request into `main`. A person can also run it with a `version`, a + pre-release included, to get a prep branch before upstream ships. +- The bot creates a bump branch once and never touches it again: it is an + ordinary branch after that. +- Never `go get ...@latest` or a branch. Never move the pins in a commit of + your own, and never start the workflow yourself. +- If no bump branch exists, either upstream has nothing ahead of `main` (`python3 deploy/ci/upstream/follow.py plan --out /tmp/plan.json` says what - it sees). If one should exist and does not, stop and say so. + it sees) or nobody has asked for a prep branch. If one should exist and does + not, stop and say so. What this skill is for is the work a move leaves: the BFF does not build -against the new SDK, the compat suite fails on `next`, or a new capability is -worth exposing. All of it is done **on `next`**: branch from it, and open the -pull request into it. It cannot land on `main`, where the SDK is still the old -one. +against the new SDK, the compat suite fails on the bump pull request, or a new +capability is worth exposing. All of it is done **on the bump branch**. It +cannot land on `main`, where the SDK is still the old one. ## Steps @@ -40,8 +43,9 @@ one. ```bash git fetch origin -git log --oneline --reverse origin/main..origin/next # the first line is the pin move -git diff origin/main origin/next -- deploy/ci/gateway-pins.json backend/go.mod +git branch -r --list 'origin/bump/openshell-*' +git log --oneline --reverse origin/main..origin/bump/openshell- # the first line is the pin move +git diff origin/main origin/bump/openshell- -- deploy/ci/gateway-pins.json backend/go.mod ``` Then compare the two SDK versions: @@ -49,12 +53,15 @@ Then compare the two SDK versions: - `sdk/go/openshell/v1/types/` for request/response field details - `sdk/go/proto/sandboxv1/` only when the frontend policy protojson contract is involved -### 2. Branch from `next` +### 2. Check out the bump branch ```bash -git checkout -b fix/sdk- origin/next +git switch bump/openshell- ``` +If it has fallen behind `main` or conflicts with it, rebase it onto +`origin/main` first; the bot will not. + ### 3. Update call sites if the SDK shape changed - Handlers should keep calling their injected `pkg/services` interface (`h.svc`), which wraps the SDK sub-client @@ -87,19 +94,21 @@ Docker and `jq`), with nothing set it runs that same gateway, by digest: make compat ``` -### 6. Commit and open the pull request into `next` +### 6. Commit and push to the bump branch ```bash git commit -s -m "fix(bff): follow the OpenShell SDK" -gh pr create --base next +git push ``` -Do not touch `deploy/ci/gateway-pins.json`, the SDK line of `backend/go.mod` -or the README's generated block: the pin move owns them, and a commit on top -that edits the same lines stops the workflow from moving the pin again. The -workflow rebases `next` when `main` moves and force-pushes it, so rebase your -branch afterwards (`git fetch origin && git rebase --fork-point origin/next`). +Push only when asked to; otherwise leave the commit and say what it does. Do +not touch `deploy/ci/gateway-pins.json`, the SDK line of `backend/go.mod` or +the README's generated block: the pin move owns them. + +Work done on a prep branch (a pre-release) does not merge from there. When the +stable release ships, the daily run opens its own bump branch; cherry-pick the +fixes onto it and close the prep pull request. Use `fix:` for what a user would notice breaking, `feat:` for a new -capability. The commits reach `main` as they are when `next` merges, on the -day upstream releases, and the release notes are written from their titles. +capability. Merging the bump pull request releases nothing; a person cuts the +release ([docs/releasing.md](../../../docs/releasing.md)). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e229bf..608319da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,19 @@ name: CI # Every change is tested against the gateway pinned by the branch it merges -# into (ADR 0005, decision 6), so this runs for all three kinds of branch: +# into (ADR 0005, decision 6): # # main one stable upstream release # release/X.Y the line before it, for critical and security fixes -# next the upcoming release, usually a pre-release. A pull request -# into `next` is work that needs it; the pull request from -# `next` into main is the move itself, and this workflow -# running there is "check B" (follow-upstream.yml). # -# Nothing runs on a push to `next`: it is rebased and force-pushed, and its -# pull request into main already tests every commit that lands on it. +# A bump (follow-upstream.yml) is a pull request into main from the branch +# bump/openshell-, opened by the bump-bot App, and this workflow +# running on it tests the move. on: push: branches: [main, "release/**"] pull_request: - branches: [main, next, "release/**"] + branches: [main, "release/**"] concurrency: group: ci-${{ github.ref }} @@ -189,19 +186,15 @@ jobs: # NOT a compatibility result. It answers one question: does this branch pin # a stable upstream release? # - # `main` and `release/**` pin stable releases only. `next` pins the upcoming - # release, which is a pre-release until upstream cuts the stable one, so the - # pull request from `next` into main fails HERE, by name, for as long as that - # lasts. That is what keeps it from merging, and it is kept apart from - # `compat` on purpose: a red `compat` means the dashboard does not work with + # `main` and `release/**` pin stable releases only. A bump to a pre-release + # is a prep branch, and its pull request into main fails HERE, by name, until + # upstream cuts the stable release. That is what keeps it from merging, and + # it is kept apart from `compat` on purpose: a red `compat` means the dashboard does not work with # the gateway, and a red check here only means "not released upstream yet". - # The Follow upstream workflow re-pins `next` when the stable release is out. - # - # A pull request INTO `next` is work that needs the pre-release, so the job - # does not run there. + # The Follow upstream workflow opens a bump to the stable release when it is + # out. stable-release: name: pins a stable release - if: github.event_name == 'push' || github.base_ref != 'next' runs-on: ubuntu-latest permissions: contents: read @@ -212,8 +205,8 @@ jobs: # The compatibility suite against the one gateway this branch pins. # - # The gateway is a RELEASE pinned by digest: a stable release, or on `next` a - # pre-release. Never a `dev` build. A dev gateway resolves a moving + # The gateway is a RELEASE pinned by digest: a stable release, or on a prep + # branch a pre-release. Never a `dev` build. A dev gateway resolves a moving # ghcr.io/nvidia/openshell/sandbox:dev image at runtime, so even pinned by # digest it drifts with upstream HEAD, which is what turned this job red on # main on 2026-10-02 with no change on our side. @@ -402,9 +395,7 @@ jobs: # the commit (and the PR number, on a pull request). It needs only `build`, # deliberately: these tags say "this is what that commit built", which is true # whether or not the commit is any good, and they are how a pull request gets - # an image to try before its checks finish. That includes a pull request into - # `next` and the pull request from `next` into main: both get `pr-` and - # `sha-<7>`, built on the pre-release's SDK. + # an image to try before its checks finish. # # It does NOT tag `latest`. That tag is a recommendation. Moved here, on # every push to main with only `build` to wait for, it could point at a diff --git a/.github/workflows/follow-upstream.yml b/.github/workflows/follow-upstream.yml index 7679045d..24e3c67f 100644 --- a/.github/workflows/follow-upstream.yml +++ b/.github/workflows/follow-upstream.yml @@ -1,93 +1,81 @@ -# Follow upstream OpenShell releases (ADR 0005, decisions 6, 7 and 8). +# Follow upstream OpenShell releases (ADR 0005, decisions 6 to 8, as amended +# on 2026-11-06). # # A branch of this repository is built on ONE upstream release, named by # deploy/ci/gateway-pins.json, and ci.yml tests every change against it. This -# workflow is what moves that pin. Every hour it reads upstream's git tags and +# workflow is what moves that pin. Once a day it reads upstream's git tags and # picks a target: # # the newest stable release (vX.Y.Z) above the one main pins, if there is one; # otherwise the newest pre-release (vX.Y.Z-pre.N) leading up to a release # above it; otherwise nothing, and the run ends. # -# With a target it does two things, and they answer different questions. +# A person can also run it by hand with a `version`, and that is the target. # -# next Keeps the `next` branch: main, then ONE commit that moves the -# pin (gateway and supervisor images, and the Go SDK at the tag's -# commit, together), then whatever people put on it. `next` is -# the bump pull request, opened early: a draft while the target is -# a pre-release, ready for review once upstream cuts the stable -# release. ci.yml running on that pull request is CHECK B, the -# dashboard built on the upcoming SDK against the upcoming gateway. +# With a target it does two things. +# +# bump Opens a bump: the branch bump/openshell-, which is main +# plus ONE commit that moves the pin (gateway and supervisor +# images, and the Go SDK at the tag's commit, together), and a +# pull request into main. A scheduled run bumps only to a stable +# release; a version asked for by hand is bumped to as it is, so +# a pre-release gets a prep branch. The bot makes a bump branch +# ONCE: when the branch exists, or any pull request has come from +# it, it does nothing. It never rebases, moves or force-pushes +# one; people push the fixes a move needs to it like any branch. # # check A Runs the compatibility suite with the BFF as main builds it # today against the target's gateway. That is what a dashboard # already installed will meet when the gateway is upgraded under -# it. A failure goes to one issue, rewritten in place and closed -# when it passes again. +# it, and the early warning for a pre-release. A failure goes to +# one issue, rewritten in place and closed when it passes again. # -# Moving to a release is one change: the gateway images and the SDK move -# together, in one commit. +# Nothing here releases. A release is cut by hand (publish.yml). # # Invariants. Each is enforced in code, not only stated here: # 1. Only vX.Y.Z and vX.Y.Z-pre.N are followed. versions.py # dev / HEAD and @latest are never pinned. pins.py, pinmove.py # 2. Images are pinned by digest, and check A plan.py -# tests the digests `next` pins. +# tests the digests the bump pins. # 3. Every compat run uses -count=1. tests/test_workflows.py # 4. No ${{ }} inside a run: script; values tests/test_workflows.py # reach scripts through env:. # 5. Least-privilege permissions per job. tests/test_workflows.py -# 6. The job that can write (next) builds and tests/test_workflows.py -# tests nothing and starts no container. It -# runs git, `go get`, `go mod tidy`, a registry -# lookup and this repository's own scripts. -# Only its `gh` and `git push` steps see a -# token, and those run nothing else. +# 6. The job that can write (publish-bump) builds, tests/test_workflows.py +# tests and fetches nothing. The job that makes +# the commit (bump) holds no write token and no +# environment, so a dry run previews it from any +# ref. Only the publish step sees the App token. # 7. The job that runs upstream's gateway image tests/test_workflows.py # (check-a) can read the repository and # nothing more. -# 8. A conflict leaves `next` exactly as it was. nextbranch.py -# 9. Only a pull request whose head is `next` in publish.py -# THIS repository is ever touched. -# -# All decision logic lives in deploy/ci/upstream/ with unit tests. This file -# only checks out, sets up toolchains and starts containers. -# -# TOKENS. The `next` job uses the secret UPSTREAM_BOT_TOKEN when it is set -# and the workflow's default token otherwise. -# - With the default token, GitHub starts no workflow for what is pushed or -# opened, so CI does not run on the `next` pull request by itself (its -# description says what to press), and the repository setting "Allow -# GitHub Actions to create and approve pull requests" has to be on for the -# pull request to be opened at all. The default token may also never -# create or update a workflow file, so GitHub can refuse the push of a -# `next` that was rebased onto a main that changed one. That has not been -# seen here yet; the job says what to do if it happens. -# - UPSTREAM_BOT_TOKEN is a fine-grained or GitHub App token with Contents, -# Pull requests and Workflows read/write on this repository. With it, CI -# starts by itself. +# 8. A bump branch is created, never moved, and publish.py, follow.py +# never moves main's pin backward. # -# AUTO-MERGE is off unless the repository variable UPSTREAM_AUTOMERGE is -# `true`. Then a `next` pull request that is ready and on a stable release is -# set to merge by rebase once its required checks pass. The workflow only asks -# GitHub to turn auto-merge on. It never merges a pull request itself, and -# GitHub refuses the request while nothing is required of the pull request: -# it needs "Allow auto-merge" and at least one required check on main. -# -# A red run means the workflow itself broke, could not publish, or could not -# run check A. A finding never turns it red: a failing check A goes to the -# issue, and a conflict on `next` to a comment on its pull request. +# TOKENS. The publish-bump job pushes and opens the pull request with a token of the +# organization's bump-bot GitHub App, made at the start of the job from the +# App's client ID and private key. Those secrets live in the `bump-bot` +# environment, which only `main` can deploy to, so a run from any other ref +# cannot reach them. An App's pull request starts CI like a person's does; +# one opened with the workflow's default token would start none, which is why +# the default token is not used for it. The token is asked for with the two +# permissions the job needs, contents and pull requests, and nothing else. +# Every other job uses the default token, read only except the report's +# issues: write. + name: Follow upstream on: schedule: - # Every hour, at 17 minutes past. Upstream cut 0.1.0, 0.1.1 and 0.1.2 - # inside four days, and the point of `next` is to have the move waiting - # when a release lands, not a day later. Off the top of the hour, when - # scheduled runs queue up behind everyone else's. - - cron: "17 * * * *" + # Once a day, at 06:17 UTC: off the top of the hour, when scheduled runs + # queue up behind everyone else's. + - cron: "17 6 * * *" workflow_dispatch: inputs: + version: + description: "OpenShell release to bump to, without the v (0.2.0 or 0.2.0-pre.1). Empty: the newest stable release above main's pin" + type: string + default: "" dry_run: description: "Dry run: say what would be done; push, open and edit nothing" type: boolean @@ -96,8 +84,8 @@ on: # Nothing by default. Every job asks for exactly what it uses. permissions: {} -# `next`, its pull request and the issue are singletons; two runs that can -# write them would fight over them, so runs on main queue behind each other. A +# A bump branch and the issue are written by one run at a time, so runs on main +# queue behind each other. A # run from any other ref is a dry run that touches none of them. It gets a # group of its own: GitHub keeps only ONE pending run per group, so two dry # runs in the shared group could cancel the scheduled run that was waiting. @@ -114,8 +102,8 @@ env: jobs: plan: name: What is upstream ahead of main? - # A fork inherits this file. On a schedule it must not start keeping a - # `next` branch of its own; someone who starts it by hand there meant to. + # A fork inherits this file. On a schedule it must not start opening bumps + # of its own; someone who starts it by hand there meant to. if: ${{ github.event_name != 'schedule' || !github.event.repository.fork }} runs-on: ubuntu-latest permissions: @@ -126,131 +114,177 @@ jobs: ready: ${{ steps.plan.outputs.ready }} target: ${{ steps.plan.outputs.target }} same_line: ${{ steps.plan.outputs.same_line }} + bump: ${{ steps.plan.outputs.bump }} + branch: ${{ steps.plan.outputs.branch }} gateway_image: ${{ steps.plan.outputs.gateway_image }} supervisor_image: ${{ steps.plan.outputs.supervisor_image }} sandbox_image: ${{ steps.plan.outputs.sandbox_image }} config_schema: ${{ steps.plan.outputs.config_schema }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Unit-test the upstream logic run: python3 -m unittest discover -s deploy/ci/upstream # Upstream's tags (git ls-remote) and the digests the target's images # have right now (docker buildx imagetools inspect). Resolved once, here, - # so check A tests exactly the images `next` pins even if upstream pushes - # a tag again while the run is going. + # so check A tests exactly the images the bump pins even if upstream + # pushes a tag again while the run is going. - name: Find the release to follow id: plan + env: + VERSION: ${{ inputs.version }} run: | set -euo pipefail - $FOLLOW plan --dry-run "$DRY_RUN" --out /tmp/upstream/plan.json --summary /tmp/upstream/plan.md + $FOLLOW plan --version "$VERSION" --dry-run "$DRY_RUN" --out /tmp/upstream/plan.json --summary /tmp/upstream/plan.md cat /tmp/upstream/plan.md >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: upstream-plan path: /tmp/upstream/plan.json if-no-files-found: error retention-days: 7 - # The only job that can write to the repository, so it is the one that must - # not run anything from upstream. It builds nothing, tests nothing and - # starts no container. What it runs: git; `go get` and `go mod tidy`, which - # download modules and execute none of them; a registry lookup when a lock - # file has to be regenerated; and this repository's own scripts, from the - # commit the workflow runs at. - # - # The token is not in the job's environment and is not left in the checkout - # (persist-credentials: false). Two steps are handed it: the one that lists - # the pull requests from `next`, and the one that publishes. Everything that - # reaches the network for modules runs between them, without it. - # - # The work is done in a scratch worktree on a detached HEAD. Nothing is - # pushed until all of it has succeeded, so a rebase that stops on a conflict - # leaves `next` exactly as it was. - next: - name: Keep next on the upcoming release + # Makes the bump commit. It holds no write token and needs no environment, + # so it runs from any ref: a dry run anywhere previews the bump in full. It + # runs git; `go get`, `go mod tidy` and `go list -m`, which download modules + # and execute none of them; and this repository's own scripts. The commit is + # handed to publish-bump as a git bundle. + bump: + name: Make the bump to OpenShell ${{ needs.plan.outputs.target }} needs: plan + if: needs.plan.outputs.bump == 'true' runs-on: ubuntu-latest permissions: - contents: write # push next; create release/ before a new minor merges - pull-requests: write # open, rewrite, comment on or close the pull request from next + contents: read + pull-requests: read # which pull requests have come from the bump branch env: GH_REPO: ${{ github.repository }} REPO_URL: ${{ github.server_url }}/${{ github.repository }} - # 'true' or 'false', never the secret: the pull request says whether CI - # will start by itself. - HAS_BOT_TOKEN: ${{ secrets.UPSTREAM_BOT_TOKEN != '' }} - AUTOMERGE: ${{ vars.UPSTREAM_AUTOMERGE == 'true' }} + BRANCH: ${{ needs.plan.outputs.branch }} + outputs: + made: ${{ steps.bump.outputs.made }} steps: - # Every branch and all of history: `next` is rebased onto main, and - # whether a release branch exists is read from what was fetched. - - uses: actions/checkout@v4 + # Every branch: whether the bump branch exists is read from what was + # fetched. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 persist-credentials: false - # For `go get` and `go mod tidy` only. No cache is restored into a job - # that can write. - - uses: actions/setup-go@v5 + # No cache is restored into a job whose result is published. + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: backend/go.mod cache: false # For scripts/readme-gateway-range.mjs only. - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: upstream-plan path: /tmp/upstream # `--head` matches the branch NAME, in this repository and in every # fork, so what comes back is only a list of candidates. Which of them - # is ours is decided by publish.py from isCrossRepository. The merged - # ones say whether what is on `next` has already reached main. - - name: List the pull requests from next + # is ours is decided by publish.py from isCrossRepository. + - name: List the pull requests from the bump branch env: - GH_TOKEN: ${{ secrets.UPSTREAM_BOT_TOKEN || github.token }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - gh pr list --head next --base main --state open --limit 100 \ - --json number,title,body,isDraft,headRefName,headRefOid,baseRefName,isCrossRepository,autoMergeRequest,comments \ - > /tmp/upstream/open.json - gh pr list --head next --base main --state merged --limit 100 \ - --json number,headRefName,headRefOid,baseRefName,isCrossRepository \ - > /tmp/upstream/merged.json - - # No token here. Decides everything and pushes nothing: it leaves the - # commit `next` should be on under a local ref, and the steps to take on - # GitHub in publish.json. - - name: Build what next should be + gh pr list --head "$BRANCH" --state all --limit 100 \ + --json number,headRefName,isCrossRepository,state,url \ + > /tmp/upstream/pulls.json + + - name: Make the bump commit + id: bump env: # setup-go pins the toolchain to the one it installed. A release # whose SDK needs a newer Go has to be able to say so in go.mod, or - # `next` could not be made for it at all; check B then shows what - # else has to follow. + # the bump could not be made for it at all; CI on the bump then + # shows what else has to follow. GOTOOLCHAIN: auto run: | set -euo pipefail - $FOLLOW next --plan /tmp/upstream/plan.json \ - --open-prs /tmp/upstream/open.json --merged-prs /tmp/upstream/merged.json \ - --has-bot-token "$HAS_BOT_TOKEN" --automerge "$AUTOMERGE" --repo-url "$REPO_URL" \ - --dry-run "$DRY_RUN" --out /tmp/upstream/next - cat /tmp/upstream/next/summary.md >> "$GITHUB_STEP_SUMMARY" - - # `publish` runs `git push` and `gh` with the arguments the step above - # chose, and nothing else. In a dry run it prints them. - - name: Push next and keep its pull request + $FOLLOW bump --plan /tmp/upstream/plan.json --pulls /tmp/upstream/pulls.json \ + --repo-url "$REPO_URL" --dry-run "$DRY_RUN" --out /tmp/upstream/bump + cat /tmp/upstream/bump/summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Bundle the bump commit for publish-bump + if: steps.bump.outputs.made == 'true' + run: git bundle create /tmp/upstream/bump/bump.bundle refs/upstream/bump + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: upstream-bump + path: /tmp/upstream/bump + if-no-files-found: error + retention-days: 7 + + # Pushes the bump and opens its pull request, with a token of the bump-bot + # App. Its secrets are in the `bump-bot` environment, which only main can + # use, so this job runs only for a run on main that is not a dry run. It + # builds nothing and fetches no module: it checks main out as it is NOW, + # refuses a bump main has caught up with, takes the commit from the bundle, + # and only then asks for the token, which only the publishing step is handed. + publish-bump: + name: Open the bump to OpenShell ${{ needs.plan.outputs.target }} + needs: [plan, bump] + # The same test as DRY_RUN above, which a job-level `if` cannot read. + if: needs.bump.outputs.made == 'true' && github.ref == 'refs/heads/main' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + runs-on: ubuntu-latest + environment: bump-bot + permissions: + contents: read + steps: + # main as it is now, not as the plan found it. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: main + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: upstream-plan + path: /tmp/upstream + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: upstream-bump + path: /tmp/upstream/bump + + # The bump commit was made from main as it was when the run started. If + # main's pin has reached or passed the target since, publishing would + # move main back, and the bot never refreshes a bump branch. + - name: Refuse a bump main has caught up with + run: $FOLLOW check-forward --plan /tmp/upstream/plan.json --pins deploy/ci/gateway-pins.json + + - name: Take the bump commit from the bundle + run: git fetch -q /tmp/upstream/bump/bump.bundle refs/upstream/bump:refs/upstream/bump + + # A token of the bump-bot App, limited to this repository and to what + # the step after it does with it. + - name: Get a token for the bump-bot App + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ secrets.BUMP_BOT_CLIENT_ID }} + private-key: ${{ secrets.BUMP_BOT_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + # `publish` runs `git push` and `gh` with the arguments the bump job + # chose, and nothing else. + - name: Push the branch and open the pull request env: - GH_TOKEN: ${{ secrets.UPSTREAM_BOT_TOKEN || github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail status=0 - $FOLLOW publish --plan /tmp/upstream/next/publish.json --dry-run "$DRY_RUN" \ - --summary /tmp/upstream/next/published.md || status=$? - if [ -f /tmp/upstream/next/published.md ]; then - cat /tmp/upstream/next/published.md >> "$GITHUB_STEP_SUMMARY" + $FOLLOW publish --plan /tmp/upstream/bump/publish.json --dry-run "$DRY_RUN" \ + --summary /tmp/upstream/bump/published.md || status=$? + if [ -f /tmp/upstream/bump/published.md ]; then + cat /tmp/upstream/bump/published.md >> "$GITHUB_STEP_SUMMARY" fi exit "$status" @@ -282,8 +316,8 @@ jobs: # proves it did not. GOFLAGS: -mod=readonly steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum @@ -357,7 +391,7 @@ jobs: $FOLLOW record-check --pull "$PULL" --stack "$STACK" --bff "$BFF" --compat "$COMPAT" \ --out /tmp/upstream/check-a.json - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: upstream-check-a path: /tmp/upstream/check-a.json @@ -393,12 +427,12 @@ jobs: REPO_URL: ${{ github.server_url }}/${{ github.repository }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: upstream-plan path: /tmp/upstream - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 # No result is a state the report handles: check A was skipped, or it # broke before it could record one. continue-on-error: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 276cccc9..5e6ee8d2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,14 +3,8 @@ # A release is started by hand: run this workflow on main, or on a # release/. branch, and leave "dry run" ticked to see the version # and the notes first; run it again with "dry run" cleared to publish. Merging -# a pull request never publishes anything by itself, whatever its title says. -# -# The one exception is the move to a new OpenShell release, which reaches main -# as the pull request from the `next` branch (follow-upstream.yml). Once it is -# merged and CI has passed on main, this workflow releases it without being -# asked: that release is the point of the pull request and the one most likely -# to be forgotten. Which commits qualify is decided from where they came from, -# not from their title; see scripts/release/next-merge.mjs. +# a pull request never publishes anything by itself, whatever its title says, +# and that includes the pull request that moves to a new OpenShell release. # # Nobody chooses the version (ADR 0005, decision 11). The dashboard shares the # gateway's minor release line, so a release is .: the line of the @@ -41,12 +35,6 @@ on: description: "Dry run: show the version and the notes, publish nothing" type: boolean default: true - # Only for a merged pull request from `next`; every other CI run that - # finishes on main ends this workflow without releasing. - workflow_run: - workflows: [CI] - types: [completed] - branches: [main] concurrency: group: publish @@ -55,33 +43,15 @@ concurrency: jobs: publish: runs-on: ubuntu-latest - # Two ways in: - # - # workflow_dispatch a maintainer asked for a release, on main or on a - # release/. branch. The exact shape of - # the branch name, and that a release branch pins its - # own line, is checked by cut-release.mjs. - # workflow_run CI passed for a PUSH to main in THIS repository. - # Releases only if that push is the merge of `next`. - # - # The event and repository checks are not redundant. `branches: [main]` - # above matches the head branch NAME of the run that finished, so a pull - # request opened from a fork's own `main` branch also arrives here with - # head_branch == 'main'. Without them this job — which can write to the - # repository — would check out and run that fork's code. + # A maintainer asked for a release, on main or on a + # release/. branch. The exact shape of the branch name, and + # that a release branch pins its own line, is checked by cut-release.mjs. if: > - (github.event_name == 'workflow_dispatch' && - (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))) || - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' && - github.event.workflow_run.head_repository.full_name == github.repository) + github.event_name == 'workflow_dispatch' && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) permissions: # To create the release, and the tag with it. contents: write - # To ask whether the commit was merged from `next`. - pull-requests: read # To ask whether CI passed for the commit a person wants to release. actions: read outputs: @@ -92,9 +62,8 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - # For workflow_run, publish exactly the commit whose CI completed. - # For manual runs, publish the tip of the branch it was started on. - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + # The tip of the branch the run was started on. + ref: ${{ github.sha }} # Full history and every tag: the version is worked out from the # tags, the notes from the history, and the step after reads both. fetch-depth: 0 @@ -104,54 +73,36 @@ jobs: with: node-version: '22.14.0' - # Decides whether this run releases, before anything else runs. It does - # not decide which version: nobody does (cut-release.mjs works it out). + # Checks the run before anything else runs. It does not decide which + # version: nobody does (cut-release.mjs works it out). # - # asked for by a person yes; a dry run unless they cleared the box. A - # real release also needs CI to have passed for - # this commit: the image the release tags is - # the one that CI run built. A commit is asked - # about, not a branch: a release branch that was - # just created points at a commit main's CI - # already built and tested. - # CI finished on main only when the commit was merged from the - # `next` branch of this repository. Anything - # else: nothing, and the job ends green. + # A dry run unless the person cleared the box. A real release also needs + # CI to have passed for this commit: the image the release tags is the + # one that CI run built. A commit is asked about, not a branch: a release + # branch that was just created points at a commit main's CI already + # built and tested. - name: Does this run release? id: plan env: - EVENT: ${{ github.event_name }} - BRANCH: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }} + BRANCH: ${{ github.ref_name }} DRY_RUN: ${{ inputs.dry_run }} REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail sha="$(git rev-parse HEAD)" - release="false" - dry_run="false" - if [ "$EVENT" = "workflow_dispatch" ]; then - release="true" - dry_run="$DRY_RUN" - green="$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$sha&event=push&status=success&per_page=1" --jq '.total_count')" - if [ "$green" -lt 1 ]; then - if [ "$dry_run" = "true" ]; then - echo "::warning::CI has not passed for $sha. This dry run goes ahead; a real release of this commit would be refused." - else - echo "::error::CI has not passed for $sha, so there is no tested image to release. Wait for CI, or fix it, then run this again." - exit 1 - fi - fi - echo "Asked for by a person, on $BRANCH$([ "$dry_run" = "true" ] && echo ' (dry run)')." | tee -a "$GITHUB_STEP_SUMMARY" - else - from="$(node scripts/release/next-merge.mjs --sha "$sha" --repo "$REPO" | sed -n 's/^from=//p')" - if [ -n "$from" ]; then - release="true" - echo "Merged from $from, the move to a new OpenShell release: releasing it." | tee -a "$GITHUB_STEP_SUMMARY" + release="true" + dry_run="$DRY_RUN" + green="$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$sha&event=push&status=success&per_page=1" --jq '.total_count')" + if [ "$green" -lt 1 ]; then + if [ "$dry_run" = "true" ]; then + echo "::warning::CI has not passed for $sha. This dry run goes ahead; a real release of this commit would be refused." else - echo "CI passed for $sha, which was not merged from next. Nothing is released automatically; a release is started by hand (docs/releasing.md)." | tee -a "$GITHUB_STEP_SUMMARY" + echo "::error::CI has not passed for $sha, so there is no tested image to release. Wait for CI, or fix it, then run this again." + exit 1 fi fi + echo "Asked for by a person, on $BRANCH$([ "$dry_run" = "true" ] && echo ' (dry run)')." | tee -a "$GITHUB_STEP_SUMMARY" { echo "release=$release" echo "dry_run=$dry_run" @@ -182,9 +133,8 @@ jobs: # "Was a release cut" is answered by the repository, not by what the step # before printed: is there a vX.Y.Z tag of this commit's line on the - # commit we checked out? That holds for both triggers, it is empty when - # nothing was released (a commit nobody asked to release), and it is - # still true on a re-run after a release whose image tagging failed. See + # commit we checked out? It is empty when nothing was released, and it + # is still true on a re-run after a release whose image tagging failed. See # scripts/release/released-version.mjs. # # Not asked in a dry run. A dry run for a commit that is already diff --git a/AGENTS.md b/AGENTS.md index 7bd29520..c5c67faa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ make build # build the container image - **Relay-only auth** ([ADR 0002](docs/adrs/0002-auth-relay-only-bff.md)). The BFF never terminates authentication: the auth proxy (oauth2-proxy, which the Helm chart runs as a sidecar) owns sign-in, sessions, refresh and CSRF and sets `x-forwarded-access-token`. Bearer chain: proxy header → `Authorization: Bearer` → 401. The BFF never validates tokens and never authorizes; the gateway does both. The only auth switch is `AUTH_DISABLED`, for local development. - **The gateway's API as it is, through the OpenShell Go SDK** ([ADR 0003](docs/adrs/0003-gateway-api-openshell-sdk.md)). Never invent RPCs, fields, lifecycle states or objects: Sandbox is the object the gateway has, labels categorize, and there is no Agent. The BFF translates nothing. Bodies mirror the gateway's messages, and when the gateway wants something a particular way (a credential under its env var name, a provider's profile scope, a setting in its own type) the UI sends it that way; the OpenShell CLI and TUI are the reference (hard facts 8, 21 and 22 in `.claude/rules/openshell-api.md`). Use the SDK. The exceptions are three direct gRPC calls in `backend/pkg/clients`, each for a named gap in the SDK: `rawexec.go` (binary-safe upload), `rawprovider.go` with `rawprovider_allworkspaces.go` (the names of a provider's credentials) and `rawprofile.go` (provider profiles whole). Secrets never reach the browser. State is polled, and the terminal is the one WebSocket. - **Frontend** ([ADR 0004](docs/adrs/0004-frontend.md)). PatternFly 6 only: no other component library and no project CSS files. `src/app/` owns the frame; a page takes props, loads its data through the `src/api/` hooks and renders no app-shell chrome. Every request goes through `src/api/client.ts`. Feature flags hide features in the UI; they are not access control. New UI text goes through `useI18n` from `src/i18n`; most pages still have theirs inline. -- **One gateway release line** ([ADR 0005](docs/adrs/0005-gateway-compatibility-and-releases.md)). A dashboard `0.1.x` is for gateways `0.1.x`. A branch is built on one OpenShell release, named in `deploy/ci/gateway-pins.json`, and every change is tested against it. The Follow upstream workflow moves the pins and the SDK, on the `next` branch; never move them by hand (hard facts 17 and 20). +- **One gateway release line** ([ADR 0005](docs/adrs/0005-gateway-compatibility-and-releases.md)). A dashboard `0.1.x` is for gateways `0.1.x`. A branch is built on one OpenShell release, named in `deploy/ci/gateway-pins.json`, and every change is tested against it. The Follow upstream workflow moves the pins and the SDK, on a `bump/openshell-X.Y.Z` branch with a pull request into `main`; never move them by hand (hard facts 17 and 20). ## Roles diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93b58b0b..9830bbd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ Then follow [Run it locally](docs/development.md#run-it-locally): `make setup`, **`main`, by default.** `main` is built on one released OpenShell gateway, and CI tests every pull request against it. -**`next`, only if it cannot build or pass on `main`.** `next` is `main` moved to upstream's newest pre-release: its gateway in CI, and its Go SDK. Work that needs something only that release has (a new SDK method, an RPC the released gateway does not serve) branches from `next` and targets it, and reaches `main` when `next` merges on the day upstream releases. A workflow rebases `next` onto `main` and force-pushes it, so rebase your branch afterwards (`git fetch origin && git rebase --fork-point origin/next`). If there is no `next`, upstream has nothing ahead of `main`. +**A bump branch, only if it cannot build or pass on `main`.** `bump/openshell-X.Y.Z` is `main` moved to an upstream release: its gateway in CI, and its Go SDK. The Follow upstream workflow creates one for each new stable release and never touches it again, so fixes the move needs are pushed to it directly. Work that needs something only an upcoming release has (a new SDK method, an RPC the released gateway does not serve) goes on that release's bump branch. Before upstream ships it, ask a maintainer to start a prep branch for the pre-release ([`deploy/ci/upstream/README.md`](deploy/ci/upstream/README.md#working-on-a-bump)); its pull request cannot merge until the stable release is out. **`release/X.Y`**, for a critical or security fix to the release line before `main`'s. Nothing else is backported ([ADR 0005](docs/adrs/0005-gateway-compatibility-and-releases.md), decision 5). @@ -38,7 +38,7 @@ Use [Conventional Commits](https://www.conventionalcommits.org/), such as `fix: #### Merging does not release; the title is the release note -Merging a pull request releases nothing. A person starts a release with the `Release` workflow ([docs/releasing.md](docs/releasing.md)); the one exception is the pull request from `next`, which is released once it is merged. Nobody chooses the version, and no title changes it ([ADR 0005](docs/adrs/0005-gateway-compatibility-and-releases.md), decision 11): a `feat:` does not make a minor, and a `!` does not make a major. +Merging a pull request releases nothing. A person starts a release with the `Release` workflow ([docs/releasing.md](docs/releasing.md)), the move to a new OpenShell release included. Nobody chooses the version, and no title changes it ([ADR 0005](docs/adrs/0005-gateway-compatibility-and-releases.md), decision 11): a `feat:` does not make a minor, and a `!` does not make a major. The title still matters: it is the release note. With a squash merge the pull request **title** is the commit message, and a release's notes list the title of every commit since the release before, as written. Only a commit whose type or scope is `ci` (`ci: …`, `fix(ci): …`) is left out; `isCiCommit` in [`scripts/release/release-notes.mjs`](scripts/release/release-notes.mjs) decides. @@ -50,7 +50,7 @@ The title still matters: it is the release note. With a squash merge the pull re `deploy/ci/gateway-pins.json` names the one OpenShell release a branch is built on: the gateway and supervisor images CI runs, and the Go SDK in `backend/go.mod`. The three come from one upstream release tag and move together, in one commit ([ADR 0005](docs/adrs/0005-gateway-compatibility-and-releases.md), decision 7). -You do not move it. The [Follow upstream](.github/workflows/follow-upstream.yml) workflow finds new releases from upstream's tags and makes the move on `next`; merging `next` is how `main` gets it. A pull request into `main` or `release/**` that pins a pre-release fails the `pins a stable release` check. [`deploy/ci/upstream/README.md`](deploy/ci/upstream/README.md#a-pin-move) lists every file a pin move changes. +You do not move it. The [Follow upstream](.github/workflows/follow-upstream.yml) workflow finds new releases from upstream's tags and makes the move on a bump branch, `bump/openshell-X.Y.Z`, with a pull request into `main`; merging it is how `main` gets it. A pull request that pins a pre-release fails the `pins a stable release` check. [`deploy/ci/upstream/README.md`](deploy/ci/upstream/README.md#a-pin-move) lists every file a pin move changes. ### Developer Certificate of Origin (DCO) diff --git a/deploy/ci/gateway-pins.json b/deploy/ci/gateway-pins.json index 1d3bad35..09f916ab 100644 --- a/deploy/ci/gateway-pins.json +++ b/deploy/ci/gateway-pins.json @@ -9,8 +9,8 @@ "release line this build is for is its major.minor; that line is also", "compiled into the BFF, and `node scripts/gateway-range.mjs --check` fails", "when the two disagree. On `main` and `release/**` this is a stable", - "release, X.Y.Z. Only `next` pins a pre-release, X.Y.Z-pre.N, and CI", - "refuses one in a pull request into any other branch.", + "release, X.Y.Z. Only a prep bump branch pins a pre-release,", + "X.Y.Z-pre.N, and CI refuses one in every pull request.", "", "sdk: the exact version required in backend/go.mod: the SDK at the commit", "upstream tagged this release with. CI fails when the two disagree.", @@ -30,7 +30,8 @@ "", "Nobody edits this by hand to follow upstream. The Follow upstream", "workflow finds new releases from upstream's tags and moves all of it on", - "the `next` branch (deploy/ci/upstream/README.md). It rewrites the whole", + "a bump/openshell-X.Y.Z branch, with a pull request into main", + "(deploy/ci/upstream/README.md). It rewrites the whole", "file, so CI requires the exact form it writes: after a hand edit, run", "`python3 deploy/ci/upstream/follow.py format-pins`." ], diff --git a/deploy/ci/upstream/README.md b/deploy/ci/upstream/README.md index f332e2f9..5832c654 100644 --- a/deploy/ci/upstream/README.md +++ b/deploy/ci/upstream/README.md @@ -4,7 +4,8 @@ Everything `.github/workflows/follow-upstream.yml` decides, and the pins validation `ci.yml` runs on every PR, lives here as plain Python so it can be run and tested without a runner, a network or a container. The decision behind it is -[ADR 0005](../../../docs/adrs/0005-gateway-compatibility-and-releases.md), decisions 6 to 8. +[ADR 0005](../../../docs/adrs/0005-gateway-compatibility-and-releases.md), +decisions 6 to 8 and the amendment of 2026-11-06. Standard library only. Works on Python 3.9 and newer. The tests need `git`. @@ -27,54 +28,121 @@ release's tag and move together, in one commit. | Branch | Pins | Who moves it | |---|---|---| -| `main` | a stable release, `X.Y.Z` | the merge of `next` | +| `main` | a stable release, `X.Y.Z` | the merge of a bump pull request | | `release/X.Y` | a stable release of the line before `main`'s | a person, for critical and security fixes | -| `next` | the upcoming release: a pre-release `X.Y.Z-pre.N`, then the stable release | this workflow | +| `bump/openshell-X.Y.Z` | the release in its name; a pre-release only when a person asked for one | this workflow, once, when it creates the branch | `ci.yml` runs the compatibility suite against the pinned gateway for every -change, whichever of these it merges into. Its `pins a stable release` check -fails for a pull request into `main` or `release/**` whose pins name a -pre-release. That is what keeps `next` from merging early, and it is a check -of its own so that it is not read as a compatibility failure. - -## What the workflow does, every hour - -1. **Plan.** Read upstream's tags. Only `vX.Y.Z` and `vX.Y.Z-pre.N` count; - `dev`, `vm-runtime` and anything else are ignored. The target is the newest - stable release above the one `main` pins. If there is none, it is the newest - pre-release that leads up to a release above it. If there is none of those - either, there is nothing to do. Several stable releases waiting are not - walked through: the branch goes straight to the newest. The target's image - digests are resolved once, here, so check A tests what `next` pins. -2. **`next`.** Keep the branch in this shape: - - ``` - main ── fix: move to OpenShell X.Y.Z ── a person's commit ── another ... - ``` - - - No `next`: create it from `main` and make the pin move. - - `main` moved: rebase `next` onto it. Not otherwise. - - The target changed: make the move again on top, as an `amend!` commit, and - fold it into the first commit with `git rebase --autosquash`. There is - always one pin commit and it is always first. It is recognised by its - `OpenShell-Release:` trailer. - - Push with `--force-with-lease`, and keep the pull request `next` → `main`: - a draft while the pin is a pre-release, ready for review once it is the - stable release, titled `fix: move to OpenShell X.Y.Z`. - - A stable target on a new minor: create `release/` from `main` - first, if it does not exist. - - Nothing ahead and nothing but the pin move on it, or its pull request - merged: delete it. -3. **Check A.** Build the BFF from `main` as it is, start the target's gateway - by digest, run the compatibility suite. A failure goes to one issue, - labelled `compat-migrate`, rewritten in place and closed when it passes. -4. **Check B** has no job here. It is `ci.yml` running on the `next` pull - request: the dashboard built on the target's SDK against the target's gateway. +change. Its `pins a stable release` check fails for every pull request whose +pins name a pre-release. That is what keeps a prep branch from merging before +upstream ships, and it is a check of its own so that it is not read as a +compatibility failure. + +## What the workflow does + +It runs once a day, and when a person starts it. + +| Input | Default | What it does | +|---|---|---| +| `version` | empty | The OpenShell release to bump to, without the `v` (`0.2.0`, `0.2.0-pre.1`). Empty means the newest stable release above what `main` pins. | +| `dry_run` | ticked | Say what would be done. Nothing is pushed, opened or edited. | + +1. **`plan`.** Read upstream's tags. Only `vX.Y.Z` and `vX.Y.Z-pre.N` count; + `dev`, `vm-runtime` and anything else are ignored. The target is the + `version` asked for, if there is one. Otherwise it is the newest stable + release above the one `main` pins, else the newest pre-release that leads + up to a release above it, else nothing. Several stable releases waiting are + not walked through: the target is the newest. The target's image digests + are resolved once, here, so the bump pins what check A tested. A scheduled + run bumps only to a stable release; a run with a `version` bumps to that + version, stable or pre-release. +2. **`bump`.** Make `bump/openshell-X.Y.Z`: + - If that branch exists, or any pull request has ever come from it in this + repository (open, closed or merged), do nothing. + - Otherwise, take `main` and add one commit, the pin move: titled + `fix: move to OpenShell X.Y.Z`, with the trailer + `OpenShell-Release: X.Y.Z`, signed off, by `github-actions[bot]`. + This job holds no write token and needs no environment, so a dry run from + any branch previews the bump in full. The commit is handed on as a git + bundle. +3. **`publish-bump`.** Only on `main`, and never in a dry run: + - Check `main` out as it is now and refuse the bump unless its target is + still ahead of what `main` pins (`follow.py check-forward`). The commit + was made from `main` as the run found it, and a bump that `main` has + caught up with would move it back. + - Take the commit from the bundle, then get a token for the bump-bot App. + - Push the new branch. The push is refused if the branch appeared in the + meantime. + - Open the pull request into `main`. The App opens it, so CI runs on it + like on anyone's pull request. +4. **`check-a`.** Build the BFF from `main` as it is, start the target's + gateway by digest, run the compatibility suite. +5. **`report`.** A check A failure goes to one issue, labelled + `compat-migrate`, rewritten in place and closed when it passes. The issue + says what to do: wait for upstream, or start a prep branch. | | Dashboard | Gateway | Tells you | |---|---|---|---| | check A | `main`, on the SDK it ships with | the target | whether a dashboard that is installed today keeps working | -| check B | `next`, on the target's SDK | the target | whether the move will pass on release day | +| CI on the bump pull request | the bump branch, on the target's SDK | the target | whether the move passes, and what has to change before it merges | + +## What the bot does and never does + +It creates a bump branch and opens its pull request. That is all. + +- It never rebases, updates or force-pushes a bump branch. Once the branch + exists it is an ordinary branch, and people push fixes to it. +- It never merges a pull request and never turns on auto-merge. +- It never cuts a release. A person runs `publish.yml` + ([docs/releasing.md](../../../docs/releasing.md)). +- It never creates `release/X.Y`. A person does, before merging a bump that + starts a new gateway minor. +- It pushes and opens the pull request with a token of the organization's + bump-bot GitHub App, asked for with contents and pull requests write and + nothing else. The App's client ID and private key are the secrets + `BUMP_BOT_CLIENT_ID` and `BUMP_BOT_PRIVATE_KEY` in the `bump-bot` + environment, which only `main` can deploy to. + +## Working on a bump + +**CI is red on the bump pull request.** Fix it on the bump branch: check it +out, commit, push. The call sites and the test doubles when `check-backend` +fails, the dashboard itself when `compat` fails. The +[proto-sync skill](../../../.claude/skills/proto-sync/SKILL.md) walks through +it. + +**You need something the upcoming release has before upstream ships it** (a +new SDK method, an RPC the released gateway does not serve). Start a prep +branch: run Follow upstream with `version` set to the pre-release and +**Dry run** cleared. + +```sh +gh workflow run follow-upstream.yml --ref main -f version=0.2.0-pre.1 -f dry_run=false +``` + +Do the work on `bump/openshell-0.2.0-pre.1`. Its pull request fails +`pins a stable release` until upstream ships the stable release; that is +intended. When it ships, the daily run opens `bump/openshell-0.2.0`. +Cherry-pick the fixes from the prep branch onto it, then close the prep pull +request. + +**The bump pull request conflicts with `main`, or has fallen behind it.** The +bot will not refresh it. Rebase it yourself and push: + +```sh +git fetch origin +git switch bump/openshell-X.Y.Z +git rebase origin/main +git push --force-with-lease +``` + +A conflict in a lock file is resolved by regenerating it: `backend/go.sum` +with `go mod tidy`, `frontend/package-lock.json` with `npm install +--package-lock-only --ignore-scripts`. + +**You do not want this release.** Close the pull request. The bot will not make +that branch again, even after the branch is deleted. A newer upstream release +gets a branch of its own. ## A pin move @@ -91,50 +159,34 @@ The workload image and the config schema are carried over. A release that needs a new config schema fails the compatibility suite, and a person adds the template. -## Conflicts - -A conflict in a lock file is resolved by regenerating it: `backend/go.sum` -with `go mod tidy`, `frontend/package-lock.json` with `npm install ---package-lock-only --ignore-scripts`. Any other conflict aborts the rebase. -Nothing is pushed, so `next` is exactly as it was, and the pull request gets -one comment naming the files. The comment is not repeated while `next` and the -files in conflict stay the same. - -To carry on, rebase `next` onto `main` by hand, resolve, and force-push. The -next run takes it from there. - ## Who may write | Job | Permissions | Runs | |---|---|---| | `plan` | `contents: read` | this directory's code, `git ls-remote`, registry lookups | -| `next` | `contents: write`, `pull-requests: write` | git, `go get`, `go mod tidy`, the README script, and for a lock file `npm install --package-lock-only --ignore-scripts`. No build, no test, no container | +| `bump` | `contents: read`, `pull-requests: read`; no secret, no environment | git, `go get`, `go mod tidy`, `go list -m`, the README script. No build, no test, no container | +| `publish-bump` | `contents: read`, plus the bump-bot App's token (contents and pull requests write), in the `bump-bot` environment | git, and `git push` and `gh` through `follow.py publish`. No toolchain, no module download | | `check-a` | `contents: read` | upstream's gateway image, the BFF, the compat suite | | `report` | `contents: read`, `issues: write` | this directory's code and `gh issue` | -In the `next` job the token is handed to two steps only: the one that lists -the pull requests from `next`, and `follow.py publish`, which runs `git push` -and `gh` with the arguments the tokenless step before it chose and refuses -anything else. The scripts it runs come from the commit the workflow runs at, +The App's token exists only in `publish-bump`, made after the forward check +and after the commit is taken from the bundle, and is handed only to +`follow.py publish`, which runs `git push` and `gh` with the arguments the +`bump` job chose and refuses anything else. The scripts it runs come from the commit the workflow runs at, never from the tree it is changing. `tests/test_workflows.py` and -`tests/test_publish.py` enforce all of this. - -Only a pull request whose head is `next` in **this** repository is ever -created, rewritten, commented on or closed. `gh pr list --head next` also -matches a fork's branch of the same name; `publish.py` picks ours. +`tests/test_publish.py` enforce this. ## Settings it depends on | | Without it | |---|---| -| Secret `UPSTREAM_BOT_TOKEN`: a fine-grained or GitHub App token with Contents, Pull requests and Workflows read/write | The workflow's default token is used. CI then does not start on the `next` pull request by itself (close and reopen it). That token may never create or update a workflow file, so GitHub can also refuse the push of a `next` rebased onto a `main` that changed one; the run fails with a message that says so | -| Setting *Allow GitHub Actions to create and approve pull requests* (needed only with the default token) | The pull request cannot be opened. The run fails with a message that says so, and a `next` it had just created is deleted again | -| Variable `UPSTREAM_AUTOMERGE` = `true` | Nothing is merged automatically. With it, a ready `next` pull request on a stable release is set to merge by rebase once its required checks pass. The workflow only asks GitHub to turn auto-merge on and never merges a pull request itself, so this also needs *Allow auto-merge* and at least one required check on `main`: with nothing required there is nothing to wait for, and GitHub refuses | +| The `bump-bot` environment, limited to `main`, with the secrets `BUMP_BOT_CLIENT_ID` and `BUMP_BOT_PRIVATE_KEY` | `publish-bump` cannot get a token and fails. Plan, check A and the report still run | +| The bump-bot App installed on this repository with Contents and Pull requests read/write | The token cannot push or open the pull request, and the run fails | + +*Allow GitHub Actions to create and approve pull requests* is not needed and stays off. -Merging `next` into `main` cuts a release by itself once CI has passed on -`main` (`scripts/release/next-merge.mjs`, [docs/releasing.md](../../../docs/releasing.md)). -Its version is worked out from the pin: the dashboard's next patch, or `X.Y.0` -when the move starts a new gateway minor. +The secret `UPSTREAM_BOT_TOKEN` and the variable `UPSTREAM_AUTOMERGE` are no +longer read. Delete them. A manual run is a dry run unless its box is cleared, and a run from any branch but `main` is always one: it prints what it would push, open and edit. @@ -146,20 +198,18 @@ but `main` is always one: it prints what it would push, open and edit. | `versions.py` | Which tags are releases, how they order, which one is the target. | | `pins.py` | Is `gateway-pins.json` well formed and in canonical form? Does it name a stable release? The move to another release. | | `upstream.py` | What is learned from the network: tags and image digests. Tests replace it. | -| `plan.py` | The target and what moving to it would pin, as data for the other jobs. | +| `plan.py` | The target, whether to bump to it, and what moving to it would pin, as data for the other jobs. | | `pinmove.py` | The pin move, in a working tree. Tests replace the commands it runs. | -| `nextbranch.py` | The git work on `next`: create, rebase, fold a new move into the pin commit, handle conflicts. | -| `publish.py` | Which pull request is ours and every step to take on GitHub (no token), and running those steps (token). | -| `report.py` | The commit message, the pull request, the conflict comment, check A's issue, the run summaries. | +| `bump.py` | The bump branch's one commit: `main` plus the pin move, in a scratch worktree. | +| `publish.py` | Whether the bump branch or a pull request from it exists already, and every step to take on GitHub (no token), and running those steps (token). | +| `report.py` | The commit message, the pull request, check A's issue, the run summaries. | | `follow.py` | The command line the workflows call. | | `tests/` | `support.py` has the stand-in tools and a throwaway git repository; `fixtures/upstream.json` is a subset of upstream's real tags with the real ghcr.io digests of 2026-10-09. | ## Not done -- **`release/` is created, not maintained.** A commit that reaches `main` - between its creation and the merge of `next` is not on it. If a release was - cut from `main` in that time, the release branch has to be brought up to it - before a release can be cut from the branch (docs/releasing.md). +- **A bump branch is never refreshed.** It is `main` as it was when the bot + made it, plus whatever people pushed. A person rebases it. - **A silent drop is still invisible.** The compatibility suite proves what it varies. A release that silently drops a request field the suite does not vary would pass both checks; the field-by-field diff of the request messages diff --git a/deploy/ci/upstream/bump.py b/deploy/ci/upstream/bump.py new file mode 100644 index 00000000..1331475e --- /dev/null +++ b/deploy/ci/upstream/bump.py @@ -0,0 +1,146 @@ +"""A bump branch: main plus one commit that moves the pins. + +The bump is the move to an upstream release (ADR 0005, decision 7, as amended +on 2026-11-06). This file makes that commit in a scratch worktree, on a +detached HEAD started from main. It never pushes and never touches a remote: +the result is a commit, recorded under a local ref, and a description of what +happened. Publishing is a separate step (publish.py). + +The bot makes a bump branch once and never touches it again. It does not +rebase it, move its pin or force-push it: from the moment it exists it is an +ordinary branch that people push fixes to. The commit carries an +`OpenShell-Release:` trailer naming the release it pins. +""" + +import os +import shutil +import subprocess +import tempfile + +import pinmove + +# Where the prepared commit is kept until it is pushed. A commit made on a +# detached HEAD in a worktree that is then removed is otherwise unreachable. +LOCAL_REF = "refs/upstream/bump" + +TRAILER = "OpenShell-Release" + +BOT_NAME = "github-actions[bot]" +BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com" + +# Settings a rebase or a commit can pick up from whoever runs this. They are +# pinned so that a run on a laptop behaves like a run on a runner: no editor, +# no hook, no signing prompt, no conflict resolution remembered from earlier. +GIT_CONFIG = ( + "user.name=" + BOT_NAME, + "user.email=" + BOT_EMAIL, + "commit.gpgsign=false", + "core.hooksPath=/dev/null", + "core.editor=true", + "sequence.editor=true", + "rerere.enabled=false", + "rebase.autoStash=false", + "rebase.updateRefs=false", + "advice.detachedHead=false", +) + +class GitError(Exception): + """git did something this file does not know how to carry on from.""" + + +class Git(object): + """git in one directory, with the settings above and nothing interactive.""" + + def __init__(self, cwd): + self.cwd = cwd + + def run(self, *args, **kwargs): + check = kwargs.pop("check", True) + # A hook or a wrapper may export GIT_DIR and friends, which would point + # every command at some other repository. + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update(GIT_EDITOR="true", GIT_SEQUENCE_EDITOR="true", GIT_TERMINAL_PROMPT="0") + command = ["git"] + for setting in GIT_CONFIG: + command += ["-c", setting] + command += list(args) + done = subprocess.run( + command, cwd=self.cwd, env=env, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + if check and done.returncode != 0: + raise GitError("`git %s` failed in %s:\n%s%s" % (" ".join(args), self.cwd, done.stdout, done.stderr)) + return done + + def out(self, *args): + return self.run(*args).stdout.strip() + + def maybe(self, *args): + """The command's output, or None when it failed.""" + done = self.run(*args, check=False) + return done.stdout.strip() if done.returncode == 0 else None + + +def _stage_pin_move(git): + """Stage the pin move's files. True when there is something to commit. + + The move may change PIN_PATHS and nothing else. A change anywhere else + means a tool did something it was not asked to, and it is not committed. + """ + changed = [text[3:] for text in git.run("status", "--porcelain").stdout.splitlines() if text.strip()] + foreign = [path for path in changed if path not in pinmove.PIN_PATHS] + if foreign: + raise GitError( + "the pin move changed files it must not: %s. Nothing was committed." % ", ".join(sorted(foreign)) + ) + if not changed: + return False + git.run("add", "--", *changed) + return git.run("diff", "--cached", "--quiet", check=False).returncode != 0 + + +def _commit(git, message): + """Commit what is staged, signed off, with exactly this message.""" + directory = tempfile.mkdtemp(prefix="upstream-message-") + try: + path = os.path.join(directory, "message") + with open(path, "w", encoding="utf-8") as fh: + fh.write(message) + # -s: this repository requires a DCO sign-off on every commit. + git.run("commit", "-s", "-q", "-F", path) + finally: + shutil.rmtree(directory, ignore_errors=True) + + +def prepare(repo, target, mover, message, main_ref="HEAD"): + """Make main plus the pin move for `target`, and record it under LOCAL_REF. + + repo: a clone that has `main_ref`. + target: the plan's target. + mover(tree, target): the pin move (pinmove.apply with its tools bound). + message(target, moved): the commit message, trailer included. + + Returns {"action": "create" | "none", "reason", "main", "sha", "moved"}. + `none` means the move changed nothing: main already pins the target. + """ + git = Git(repo) + main = git.out("rev-parse", "--verify", "%s^{commit}" % main_ref) + outcome = {"action": "none", "reason": "", "main": main, "sha": None, "moved": None} + scratch = tempfile.mkdtemp(prefix="upstream-bump-") + tree = os.path.join(scratch, "tree") + git.run("worktree", "add", "--detach", tree, main) + work = Git(tree) + try: + moved = mover(tree, target) + outcome["moved"] = moved + if not _stage_pin_move(work): + outcome["reason"] = "main already pins OpenShell %s" % target["version"] + return outcome + _commit(work, message(target, moved)) + sha = work.out("rev-parse", "HEAD") + git.run("update-ref", LOCAL_REF, sha) + outcome.update(action="create", sha=sha, reason="main plus the move to OpenShell %s" % target["version"]) + return outcome + finally: + git.run("worktree", "remove", "--force", tree, check=False) + shutil.rmtree(scratch, ignore_errors=True) + git.run("worktree", "prune", check=False) diff --git a/deploy/ci/upstream/follow.py b/deploy/ci/upstream/follow.py index b84cd4f5..f7fd004a 100644 --- a/deploy/ci/upstream/follow.py +++ b/deploy/ci/upstream/follow.py @@ -10,8 +10,9 @@ format-pins rewrite the pins file in canonical form require-stable fail when the pins name a pre-release (main, release/**) plan which upstream release is ahead of this branch, if any - next build what `next` should be, and decide what to publish - publish push it and keep its pull request (git push and gh only) + bump make main plus the pin move, and decide what to publish + check-forward refuse a bump that main has caught up with since the plan + publish push the bump branch and open its pull request record-check turn check A's step outcomes into a result check-report what check A's result means; render its issue @@ -25,7 +26,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import nextbranch # noqa: E402 +import bump # noqa: E402 import pinmove # noqa: E402 import pins # noqa: E402 import plan as plan_module # noqa: E402 @@ -138,7 +139,7 @@ def cmd_require_stable(args): def cmd_plan(args, upstream=None): doc = _load_valid_pins(args.pins, args.go_mod) - made = plan_module.build(doc, upstream or upstream_module.Upstream()) + made = plan_module.build(doc, upstream or upstream_module.Upstream(), requested=args.version or None) _write(args.out, json.dumps(made, indent=2) + "\n") summary = report.plan_summary(made, dry_run=args.dry_run == "true") if args.summary: @@ -153,6 +154,9 @@ def cmd_plan(args, upstream=None): "ready": "true" if target.get("ready") else "false", "target": target.get("version", ""), "same_line": "true" if target.get("same_line") else "false", + # Something to open a bump branch for, with images to pin. + "bump": "true" if target.get("bump") and target.get("ready") else "false", + "branch": target.get("branch", ""), "gateway_image": target.get("gateway_image") or "", "supervisor_image": target.get("supervisor_image") or "", "sandbox_image": made["sandbox_image"], @@ -167,64 +171,73 @@ def _echo(text): sys.stdout.flush() -def cmd_next(args, run=None): +def cmd_bump(args, run=None): run = run or pinmove.run_command made = _json(args.plan) target = made.get("target") dry_run = args.dry_run == "true" repo = os.path.abspath(args.repo) tools = os.path.abspath(args.tools) - git = nextbranch.Git(repo) - existing = git.maybe("rev-parse", "--verify", "-q", "%s^{commit}" % args.next_ref) - - if target and not target["ready"]: - # Tagged, but its images are not out yet. Nothing can be pinned, and a - # rebase for its own sake can wait an hour with it. - outcome = { - "action": "none", - "reason": target["waiting_for"], - "main": git.out("rev-parse", "--verify", "%s^{commit}" % args.main_ref), - "next": existing, - "sha": None, - "people": [], - "pins": None, - } - else: - outcome = nextbranch.prepare( + git = bump.Git(repo) + main = git.out("rev-parse", "--verify", "%s^{commit}" % args.main_ref) + pulls = _json(args.pulls, []) + branch_sha = None + if target and target.get("branch"): + branch_sha = git.maybe("rev-parse", "--verify", "-q", "refs/remotes/origin/%s^{commit}" % target["branch"]) + + outcome = {"action": "none", "reason": "", "main": main, "sha": None, "moved": None} + # Decided before any pin is moved: a branch that exists, or a pull request + # that came from it, means the bot is done with this release. + if publish_module.blocked(made, pulls, branch_sha) is None: + outcome = bump.prepare( repo, target, mover=lambda tree, to: pinmove.apply(tree, to, tools=tools, run=run, echo=_echo), message=lambda to, moved: report.commit_message(to, made["pinned"]), - lockfiles=nextbranch.lockfile_regenerators(run=run, echo=_echo), main_ref=args.main_ref, - next_ref=args.next_ref, - spent=publish_module.is_spent(_json(args.merged_prs, []), existing), ) + decided = publish_module.build(made, outcome, pulls=pulls, branch_sha=branch_sha, repo_url=args.repo_url) - release_branch_sha = None - if target and target.get("release_branch"): - release_branch_sha = git.maybe( - "rev-parse", "--verify", "-q", "refs/remotes/origin/%s^{commit}" % target["release_branch"] - ) - decided = publish_module.build( - made, - outcome, - open_pulls=_json(args.open_prs, []), - has_bot_token=args.has_bot_token == "true", - automerge=args.automerge == "true", - repo_url=args.repo_url, - release_branch_sha=release_branch_sha, - ) _write(os.path.join(args.out, "outcome.json"), json.dumps(outcome, indent=2) + "\n") _write(os.path.join(args.out, "publish.json"), json.dumps(decided, indent=2) + "\n") - summary = report.next_summary(outcome, decided, dry_run=dry_run) + summary = report.bump_summary(outcome, decided, dry_run=dry_run) _write(os.path.join(args.out, "summary.md"), summary) print(summary) + # Whether there is a commit for the publish job to push. It is handed + # over as a git bundle of LOCAL_REF. + _set_outputs({"made": "true" if decided.get("push") else "false", "sha": (decided.get("push") or {}).get("sha", "")}) + return 0 + + +def cmd_check_forward(args): + """Fail unless the plan's target is still ahead of what main pins now. + + The bump commit was made from main as the run found it. Before it is + published, main is read again: if its pin has reached or passed the + target in the meantime, the bump would move main back, and the bot + never refreshes a bump branch, so it is refused here. + """ + made = _json(args.plan) or {} + target = made.get("target") or {} + doc = _load_valid_pins(args.pins) + now = doc["release"] + if not target.get("version"): + return _fail(["the plan has no target, so there is nothing to publish"]) + if versions.order(target["version"]) <= versions.order(now): + return _fail( + [ + "main now pins OpenShell %s, which is not below the bump's target %s. main moved while " + "this run worked; nothing is published, and the next run plans again." % (now, target["version"]) + ] + ) + print("main pins OpenShell %s; the bump to %s still moves it forward" % (now, target["version"])) return 0 def cmd_publish(args): decided = _json(args.plan) + if not isinstance(decided, dict): + return _fail(["%s is missing or empty: the bump step did not finish, so there is nothing to publish" % args.plan]) dry_run = args.dry_run == "true" try: notes = publish_module.execute(decided, echo=_echo, dry_run=dry_run) @@ -294,25 +307,26 @@ def add(name, func, help_text): p.add_argument("--pins", default=DEFAULT_PINS) p.add_argument("--go-mod", default=DEFAULT_GO_MOD) p.add_argument("--dry-run", default="false", choices=flag) + p.add_argument("--version", default="", help="bump to this release (no leading v) instead of the newest stable one") p.add_argument("--summary", default="", help="also write the summary here") p.add_argument("--out", required=True) - p = add("next", cmd_next, "build what next should be and decide what to publish") + p = add("bump", cmd_bump, "make main plus the pin move and decide what to publish") p.add_argument("--plan", required=True) - p.add_argument("--repo", default=".", help="the clone to work in; it needs --main-ref and, if it exists, --next-ref") + p.add_argument("--repo", default=".", help="the clone to work in; it needs --main-ref and the remote's branches") p.add_argument("--tools", default=".", help="the checkout whose scripts are run: the one the workflow runs from") p.add_argument("--main-ref", default="HEAD") - p.add_argument("--next-ref", default=nextbranch.NEXT_REF) - p.add_argument("--open-prs", default="", help="`gh pr list --head next --state open --json ...` output") - p.add_argument("--merged-prs", default="", help="`gh pr list --head next --state merged --json ...` output") - p.add_argument("--has-bot-token", default="false", choices=flag) - p.add_argument("--automerge", default="false", choices=flag) + p.add_argument("--pulls", default="", help="`gh pr list --head --state all --json ...` output") p.add_argument("--repo-url", default="", help="https://host/owner/repo, so links work in a pull request") p.add_argument("--dry-run", default="false", choices=flag) p.add_argument("--out", required=True) - p = add("publish", cmd_publish, "push next and keep its pull request") - p.add_argument("--plan", required=True, help="the publish.json that `next` wrote") + p = add("check-forward", cmd_check_forward, "refuse a bump main has caught up with") + p.add_argument("--plan", required=True) + p.add_argument("--pins", default=DEFAULT_PINS, help="the pins of main as it is now") + + p = add("publish", cmd_publish, "push the bump branch and open its pull request") + p.add_argument("--plan", required=True, help="the publish.json that `bump` wrote") p.add_argument("--dry-run", default="false", choices=flag) p.add_argument("--summary", default="", help="write notes for the run summary here") @@ -344,7 +358,7 @@ def main(argv=None): upstream_module.UpstreamError, versions.VersionError, pinmove.PinMoveError, - nextbranch.GitError, + bump.GitError, publish_module.PublishError, ) as err: return _fail([str(err)]) diff --git a/deploy/ci/upstream/nextbranch.py b/deploy/ci/upstream/nextbranch.py deleted file mode 100644 index 2d0a50bf..00000000 --- a/deploy/ci/upstream/nextbranch.py +++ /dev/null @@ -1,392 +0,0 @@ -"""`next`: main, the pin move, then people's commits. - -`next` is the move to the upcoming upstream release, opened early -(ADR 0005, decision 8). This file does the git work that keeps it in that -shape, in a scratch worktree and on a detached HEAD. It never pushes and never -touches a remote: the result is a commit, recorded under a local ref, and a -description of what happened. Publishing is a separate step (publish.py), so a -run that stops halfway here leaves `next` exactly as it was. - -The shape: - - main ── pin move ── a person's commit ── another ... - - * The pin move is one commit and stays the first after `main`. It carries - an `OpenShell-Release:` trailer, which is how it is recognised; its title - can be reworded without confusing anything. - * When the target changes (a newer pre-release, or the stable release), the - new move is committed on top as an `amend!` commit and folded into the - first one with `git rebase --autosquash`. An `amend!` commit is a fixup - that also replaces the message, so the title names the release the commit - now pins. - * When `main` has moved, `next` is rebased onto it first. When it has not, - it is not: a rebase changes every commit id for nothing. - -Conflicts. A lock file (`backend/go.sum`, `frontend/package-lock.json`) is -regenerated by the tool that owns it and the rebase carries on. Anything else -aborts the rebase and is reported; nobody's work is guessed at. -""" - -import json -import os -import re -import shutil -import subprocess -import tempfile - -import pinmove - -NEXT_REF = "refs/remotes/origin/next" -# Where the prepared commit is kept until it is pushed. A commit made on a -# detached HEAD in a worktree that is then removed is otherwise unreachable. -LOCAL_REF = "refs/upstream/next" - -TRAILER = "OpenShell-Release" -TRAILER_RE = re.compile(r"^%s: (\S+)\s*$" % TRAILER, re.M) - -BOT_NAME = "github-actions[bot]" -BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com" - -# Settings a rebase or a commit can pick up from whoever runs this. They are -# pinned so that a run on a laptop behaves like a run on a runner: no editor, -# no hook, no signing prompt, no conflict resolution remembered from earlier. -GIT_CONFIG = ( - "user.name=" + BOT_NAME, - "user.email=" + BOT_EMAIL, - "commit.gpgsign=false", - "core.hooksPath=/dev/null", - "core.editor=true", - "sequence.editor=true", - "rerere.enabled=false", - "rebase.autoStash=false", - "rebase.updateRefs=false", - "advice.detachedHead=false", -) - -# More stops than any real `next` has commits. A rebase that stops this often -# is not converging. -MAX_STOPS = 500 - - -class GitError(Exception): - """git did something this file does not know how to carry on from.""" - - -class Git(object): - """git in one directory, with the settings above and nothing interactive.""" - - def __init__(self, cwd): - self.cwd = cwd - - def run(self, *args, **kwargs): - check = kwargs.pop("check", True) - # A hook or a wrapper may export GIT_DIR and friends, which would point - # every command at some other repository. - env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} - env.update(GIT_EDITOR="true", GIT_SEQUENCE_EDITOR="true", GIT_TERMINAL_PROMPT="0") - command = ["git"] - for setting in GIT_CONFIG: - command += ["-c", setting] - command += list(args) - done = subprocess.run( - command, cwd=self.cwd, env=env, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - if check and done.returncode != 0: - raise GitError("`git %s` failed in %s:\n%s%s" % (" ".join(args), self.cwd, done.stdout, done.stderr)) - return done - - def out(self, *args): - return self.run(*args).stdout.strip() - - def maybe(self, *args): - """The command's output, or None when it failed.""" - done = self.run(*args, check=False) - return done.stdout.strip() if done.returncode == 0 else None - - -def commits_between(git, base, tip): - """The commits of base..tip, oldest first, each with the release its trailer names.""" - text = git.run("log", "--reverse", "--format=%H%x1f%s%x1f%an%x1f%B%x1e", "%s..%s" % (base, tip)).stdout - found = [] - for record in text.split("\x1e"): - record = record.strip("\n") - if not record: - continue - sha, subject, author, body = record.split("\x1f", 3) - trailer = TRAILER_RE.search(body) - found.append({"sha": sha, "subject": subject, "author": author, "pin": trailer.group(1) if trailer else None}) - return found - - -def people(commits): - """Every commit that is not the pin move: the work people put on `next`.""" - return [commit for commit in commits if not commit["pin"]] - - -def pins_at(git, ref): - """The pins document a commit holds, or None when it has none that parses.""" - text = git.maybe("show", "%s:%s" % (ref, pinmove.PINS)) - if text is None: - return None - try: - return json.loads(text) - except ValueError: - return None - - -def _is_ancestor(git, ancestor, descendant): - return git.run("merge-base", "--is-ancestor", ancestor, descendant, check=False).returncode == 0 - - -def _rebasing(git): - for name in ("rebase-merge", "rebase-apply"): - path = git.out("rev-parse", "--git-path", name) - if os.path.isdir(path if os.path.isabs(path) else os.path.join(git.cwd, path)): - return True - return False - - -def _stopped_at(git): - sha = git.maybe("rev-parse", "--verify", "-q", "REBASE_HEAD") - if not sha: - return None - return {"sha": sha, "subject": git.out("log", "-1", "--format=%s", sha)} - - -def _rebase(git, args, lockfiles, regenerated, stage): - """Run a rebase to its end. None when it finished; the conflict when it could not. - - lockfiles: {path: callable(tree) -> other paths it rewrote}. A stop whose - conflicts are all in these files is resolved by regenerating them. Any - other stop aborts the rebase, which puts the worktree back where it was. - """ - done = git.run(*args, check=False) - for _ in range(MAX_STOPS): - if done.returncode == 0: - return None - if not _rebasing(git): - raise GitError("`git %s` failed without leaving a rebase to resolve:\n%s%s" % (" ".join(args), done.stdout, done.stderr)) - files = git.out("diff", "--name-only", "--diff-filter=U").splitlines() - stopped = _stopped_at(git) - if not files: - output = done.stdout + done.stderr - git.run("rebase", "--abort", check=False) - raise GitError("the rebase stopped with nothing in conflict, which is not expected:\n%s" % output) - if any(path not in lockfiles for path in files): - git.run("rebase", "--abort") - return {"stage": stage, "files": files, "commit": stopped} - try: - for path in files: - # Take the side that is already built, so the file parses, then - # let the tool that owns it write what the merged manifest needs. - git.run("checkout", "--ours", "--", path) - also = lockfiles[path](git.cwd) - git.run("add", "--", path, *also) - regenerated.append(path) - except Exception: - git.run("rebase", "--abort", check=False) - raise - done = git.run("rebase", "--continue", check=False) - git.run("rebase", "--abort", check=False) - raise GitError("the rebase stopped more than %d times; giving up" % MAX_STOPS) - - -def _stage_pin_move(git): - """Stage the pin move's files. True when there is something to commit. - - The move may change PIN_PATHS and nothing else. A change anywhere else - means a tool did something it was not asked to, and it is not committed. - """ - changed = [text[3:] for text in git.run("status", "--porcelain").stdout.splitlines() if text.strip()] - foreign = [path for path in changed if path not in pinmove.PIN_PATHS] - if foreign: - raise GitError( - "the pin move changed files it must not: %s. Nothing was committed." % ", ".join(sorted(foreign)) - ) - if not changed: - return False - git.run("add", "--", *changed) - return git.run("diff", "--cached", "--quiet", check=False).returncode != 0 - - -def _commit(git, message): - """Commit what is staged, signed off, with exactly this message.""" - directory = tempfile.mkdtemp(prefix="upstream-message-") - try: - path = os.path.join(directory, "message") - with open(path, "w", encoding="utf-8") as fh: - fh.write(message) - # -s: this repository requires a DCO sign-off on every commit. - git.run("commit", "-s", "-q", "-F", path) - finally: - shutil.rmtree(directory, ignore_errors=True) - - -def lockfile_regenerators(run=None, echo=None): - """{lock file: how to regenerate it}, for a tree that is stopped mid-rebase. - - `go mod tidy` and `npm install --package-lock-only --ignore-scripts` read - the manifest and ask a registry. Neither builds or runs a dependency. - """ - run = run or pinmove.run_command - - def go_sum(tree): - pinmove.must(run, ["go", "mod", "tidy"], os.path.join(tree, "backend"), echo) - # tidy may also correct go.mod: two tidy go.mod files do not always - # merge into a tidy one. - return [pinmove.GO_MOD] - - def package_lock(tree): - pinmove.must( - run, - ["npm", "install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], - os.path.join(tree, "frontend"), - echo, - ) - return [] - - return {pinmove.GO_SUM: go_sum, "frontend/package-lock.json": package_lock} - - -def prepare(repo, target, mover, message, lockfiles=None, main_ref="HEAD", next_ref=NEXT_REF, spent=False): - """Work out what `next` should be, and build it. - - repo: a clone that has `main_ref` and, if the branch exists, `next_ref`. - target: the plan's target, or None when upstream has nothing ahead of main. - mover(tree, target): the pin move (pinmove.apply with its tools bound). - message(target, moved): the pin commit's message, trailer included. - spent: the pull request from `next`, at the commit `next` is on, was - merged. What is on the branch is in `main` already, whatever the merge - method made of the commit ids. - - Returns what happened as a dict. `action` is one of: - - create `next` did not exist; `sha` is main plus the pin move - update `sha` is what `next` should be force-pushed to - none `next` is already right - conflict it could not be rebased or re-pinned; `conflict` says where - delete nothing but the bot's commit is on it and nothing is ahead - leave nothing is ahead, and people's commits are on it - """ - lockfiles = lockfiles or {} - git = Git(repo) - main = git.out("rev-parse", "--verify", "%s^{commit}" % main_ref) - existing = git.maybe("rev-parse", "--verify", "-q", "%s^{commit}" % next_ref) - outcome = { - "action": "none", - "reason": "", - "main": main, - "next": existing, - "sha": None, - "rebased": False, - "pin_moved": False, - "moved": None, - "regenerated": [], - "conflict": None, - "people": [], - "pins": pins_at(git, existing) if existing else None, - } - - if target is None: - if existing is None: - outcome["reason"] = "upstream has no release ahead of main, and there is no next branch" - return outcome - outcome["people"] = people(commits_between(git, main, existing)) - if spent: - outcome.update(action="delete", reason="its pull request was merged, and upstream has no release ahead of main") - elif not outcome["people"]: - outcome.update( - action="delete", - reason="nothing but the pin move is on it, and upstream has no release ahead of main", - ) - else: - outcome.update( - action="leave", - reason="upstream has no release ahead of main, but commits that are not the pin move are on it", - ) - return outcome - - # A spent `next` holds what main already has. It starts again from main. - start = main if existing is None or spent else existing - scratch = tempfile.mkdtemp(prefix="upstream-next-") - tree = os.path.join(scratch, "tree") - git.run("worktree", "add", "--detach", tree, start) - work = Git(tree) - try: - if start != main and not _is_ancestor(work, main, "HEAD"): - conflict = _rebase( - work, ["rebase", "--empty=drop", main], lockfiles, outcome["regenerated"], "rebase" - ) - if conflict: - outcome.update(action="conflict", conflict=conflict) - outcome["people"] = people(commits_between(git, main, existing)) - outcome["reason"] = "next could not be rebased onto main" - return outcome - outcome["rebased"] = True - - commits = commits_between(work, main, "HEAD") - pin = commits[0] if commits and commits[0]["pin"] else None - conflict = None - if commits and pin is None: - # Someone made `next` by hand, so no pin move starts it. Make the - # move on main and put their commits back on top of it. - tip = work.out("rev-parse", "HEAD") - work.run("checkout", "-q", "--detach", main) - moved = mover(tree, target) - if _stage_pin_move(work): - _commit(work, message(target, moved)) - base = work.out("rev-parse", "HEAD") - conflict = _rebase( - work, ["rebase", "--empty=drop", "--onto", base, main, tip], lockfiles, outcome["regenerated"], "pin" - ) - outcome["pin_moved"] = True - else: - work.run("checkout", "-q", "--detach", tip) - else: - moved = mover(tree, target) - if _stage_pin_move(work): - text = message(target, moved) - if pin is None: - _commit(work, text) - else: - # Fold the new move into the pin commit, message included. - _commit(work, "amend! %s\n\n%s" % (pin["subject"], text)) - conflict = _rebase( - work, - ["rebase", "-i", "--autosquash", "--empty=drop", main], - lockfiles, - outcome["regenerated"], - "pin", - ) - outcome["pin_moved"] = True - outcome["moved"] = moved - if conflict: - outcome.update(action="conflict", conflict=conflict, pin_moved=False) - outcome["people"] = people(commits_between(git, main, existing)) if existing else [] - outcome["reason"] = "the pin on next could not be moved to OpenShell %s" % target["version"] - return outcome - - new = work.out("rev-parse", "HEAD") - after = commits_between(work, main, "HEAD") - outcome["people"] = after[1:] if after and after[0]["pin"] else after - outcome["sha"] = new - outcome["pins"] = pins_at(work, "HEAD") - git.run("update-ref", LOCAL_REF, new) - if existing is None: - outcome.update(action="create", reason="there was no next branch") - elif new == existing: - outcome.update(action="none", reason="next is on main and already pins OpenShell %s" % target["version"]) - else: - reasons = [] - if spent: - reasons.append("its pull request was merged, so it starts again from main") - if outcome["rebased"]: - reasons.append("main has moved") - if outcome["pin_moved"]: - reasons.append("the pin moves to OpenShell %s" % target["version"]) - outcome.update(action="update", reason=" and ".join(reasons) or "next changed") - return outcome - finally: - git.run("worktree", "remove", "--force", tree, check=False) - shutil.rmtree(scratch, ignore_errors=True) - git.run("worktree", "prune", check=False) diff --git a/deploy/ci/upstream/pinmove.py b/deploy/ci/upstream/pinmove.py index 0ea7cf7a..10e5ee88 100644 --- a/deploy/ci/upstream/pinmove.py +++ b/deploy/ci/upstream/pinmove.py @@ -13,11 +13,12 @@ So it is pinned to the commit the release tag points at, with `go get @` and `go mod tidy`. Never `@latest`. -What runs here is `go get`, `go mod tidy` and this repository's own README -script. None of them builds or executes anything from upstream, which is why +What runs here is `go get`, `go mod tidy`, `go list -m` and this +repository's own README script. None of them builds or executes anything from upstream, which is why the job that holds a write token is allowed to call this. """ +import json import os import re import subprocess @@ -80,6 +81,32 @@ def sdk_commands(commit): return [["go", "get", "%s@%s" % (SDK_MODULE, commit)], ["go", "mod", "tidy"]] +def _tagged_commit(run, backend, version, echo): + """The commit a tagged SDK version was cut from, or None. + + From OpenShell 0.1.7 upstream tags the SDK module itself (sdk/go/vX.Y.Z), + so `go get @` resolves to that tag rather than to a + pseudo-version, and the version no longer names the commit. `go list -m + -json` reads the module's metadata, which records the commit the tag + points at; it builds and runs nothing. + """ + if pins.sdk_commit(version) or not version: + return None + command = ["go", "list", "-m", "-json", "%s@%s" % (SDK_MODULE, version)] + if echo: + echo("$ " + " ".join(command)) + code, output = run(command, backend) + if code != 0: + raise PinMoveError("`%s` failed in %s:\n%s" % (" ".join(command), backend, output.rstrip())) + try: + found = json.loads(output[output.index("{") :]) + except ValueError: + raise PinMoveError("`%s` printed no module metadata:\n%s" % (" ".join(command), output.rstrip())) + origin = found.get("Origin") or {} + commit = origin.get("Hash") + return commit[:12] if isinstance(commit, str) and len(commit) >= 12 else None + + def at_target(doc, tree, target): """Is this tree already on the target: its release, its images and its SDK commit? @@ -128,7 +155,7 @@ def apply(tree, target, tools, run=None, echo=None): sdk = pins.go_mod_sdk(_read(os.path.join(tree, GO_MOD))) if sdk is None: raise PinMoveError("%s no longer requires %s after the move" % (GO_MOD, SDK_MODULE)) - commit = pins.sdk_commit(sdk) + commit = pins.sdk_commit(sdk) or _tagged_commit(run, backend, sdk, echo) if not commit or not target["commit"].startswith(commit): raise PinMoveError( "%s requires SDK %s after `go get`, which is not commit %s of %s. Nothing is pinned " diff --git a/deploy/ci/upstream/pins.py b/deploy/ci/upstream/pins.py index 3727f2b8..72f9c8dc 100644 --- a/deploy/ci/upstream/pins.py +++ b/deploy/ci/upstream/pins.py @@ -161,7 +161,7 @@ def validate(doc, schemas=None, go_mod_version=None): else: problems.append( "release must be an upstream release X.Y.Z, or a pre-release X.Y.Z-pre.N on " - "`next`, without the leading v. Got %r." % (release,) + "a prep branch, without the leading v. Got %r." % (release,) ) sdk = doc.get("sdk") @@ -204,8 +204,9 @@ def validate(doc, schemas=None, go_mod_version=None): def require_stable(doc): """Problems, if this pins document may not merge into `main` or `release/**`. - Only a stable release is pinned there. `next` pins a pre-release by - definition, and is the only branch that does (ADR 0005, decision 6). + Only a stable release is pinned there. A prep branch, a bump to a + pre-release made by hand, is the only branch that pins one (ADR 0005, + amendment of 2026-11-06). """ release = doc.get("release") if isinstance(doc, dict) else None if versions.parse(release) is None: @@ -214,9 +215,9 @@ def require_stable(doc): return [] return [ "this branch pins OpenShell %s, a pre-release. `main` and `release/**` pin stable " - "releases only. This is how `next` looks until upstream releases %s: the pull request " - "stays open, and the upstream workflow re-pins it to the stable release, after which " - "this check passes. It says nothing about compatibility; the `compat` job does." + "releases only. This is a prep branch: it cannot merge until upstream releases %s, when " + "the upstream workflow opens a bump to the stable release. It says nothing about " + "compatibility; the `compat` job does." % (release, versions.base(release)) ] diff --git a/deploy/ci/upstream/plan.py b/deploy/ci/upstream/plan.py index 1dc4d97d..1522383b 100644 --- a/deploy/ci/upstream/plan.py +++ b/deploy/ci/upstream/plan.py @@ -1,9 +1,18 @@ """What is upstream ahead of this branch, and what would moving to it pin? One run of the workflow answers this once, in a job that can only read, and -every later job works from the answer: the `next` job pins exactly the image +every later job works from the answer: the `bump` job pins exactly the image digests check A tested, even if upstream re-pushes a tag in between. +Two answers come out of it: + + target the release check A tests main against. On a schedule, the newest + stable release above the pin, else the newest pre-release leading + up to a release above it. When a person asks for a version, that. + bump whether to open a bump branch for the target. A scheduled run + bumps only to a stable release; a version a person asked for is + bumped to whatever it is, so a pre-release gets a prep branch. + Nothing here opens a socket. The network is the Upstream object passed in. """ @@ -40,11 +49,36 @@ def _pin_on_tag(doc, tags): return tagged.startswith(commit) -def build(doc, upstream): +BRANCH_PREFIX = "bump/openshell-" + + +def branch_name(version): + """The branch a bump to this release is made on.""" + return BRANCH_PREFIX + version + + +def _requested(found, pinned, version): + """The release a person asked for, checked against what upstream has.""" + if versions.parse(version) is None: + raise PlanError( + "%r is not an OpenShell release. Write it as X.Y.Z or X.Y.Z-pre.N, without the leading v." % version + ) + match = [release for release in found if release["version"] == version] + if not match: + raise PlanError("upstream has no tag v%s. Nothing was bumped." % version) + if versions.order(version) <= versions.order(pinned): + raise PlanError( + "main already pins OpenShell %s, which is not below %s. A bump only moves forward." % (pinned, version) + ) + return match[0] + + +def build(doc, upstream, requested=None): """Everything one run needs to know, as plain data. doc: the pins of the branch the workflow runs on, which is `main` for every run that acts. + requested: a release a person asked to bump to (no leading v), or None. """ problems = pins.validate(doc) if problems: @@ -69,10 +103,14 @@ def build(doc, upstream): "config_schema": doc["config_schema"], "sandbox_image": doc["sandbox_image"], "newest_stable": next((release["version"] for release in found if release["stable"]), None), + "requested": requested or None, "target": None, } - target = versions.select_target(pinned, found) + if requested: + target = _requested(found, pinned, requested) + else: + target = versions.select_target(pinned, found) if target is None: return plan @@ -98,10 +136,10 @@ def build(doc, upstream): ), key=versions.order, ), - # The branch that keeps the line `main` is about to leave. It is made - # when the move is to a stable release, which is the first moment the - # pull request could merge. - "release_branch": "release/%s" % versions.line(pinned) if new_line and target["stable"] else None, + # A schedule bumps only to a stable release. A version a person asked + # for is bumped to as it is: a pre-release gets a prep branch. + "bump": bool(requested) or target["stable"], + "branch": branch_name(target["version"]), "gateway_image": gateway, "supervisor_image": supervisor, "ready": waiting_for is None, diff --git a/deploy/ci/upstream/publish.py b/deploy/ci/upstream/publish.py index fe61804e..d28b18ac 100644 --- a/deploy/ci/upstream/publish.py +++ b/deploy/ci/upstream/publish.py @@ -1,31 +1,28 @@ -"""Putting what was prepared on GitHub: the branch, its pull request, a comment. +"""Putting a bump on GitHub: the branch and its pull request. Split in two on purpose. -build() decides. It reads what the `next` job prepared and what GitHub says -about the pull requests from `next`, and writes down every step to take. It -runs without a token. +build() decides. It reads what the `bump` job prepared and what GitHub says +about pull requests from the bump branch, and writes down every step to take. +It runs without a token. execute() acts. It is handed the token, so it does as little as possible: it runs `git push` and `gh` with exactly the arguments build() chose, in a fixed -order, and stops at the first one that fails. It runs nothing else, and a -test holds it to that. +order. It runs nothing else, and a test holds it to that. -Only a pull request whose head is `next` IN THIS REPOSITORY is ever created, -rewritten, commented on or closed. `gh pr list --head next` also matches a -fork's branch of the same name, and anyone can open one. +The bot makes a bump branch once. When the branch exists already, or any pull +request has ever come from it in this repository (open, closed or merged), it +does nothing: an open one is people's to finish, a closed one means "not this +one", and a merged one is done. """ import os -import re import shutil import subprocess import tempfile import report -import versions -NEXT = report.NEXT BASE = report.BASE REMOTE = "origin" @@ -38,135 +35,67 @@ class PublishError(Exception): """A step failed in a way a person has to look at.""" -def own_pull(pulls, branch=NEXT, base=BASE): - """The pull request from `branch` IN THIS REPOSITORY into `base`, or None. +def own_pulls(pulls, branch): + """The pull requests from `branch` IN THIS REPOSITORY, in any state. - pulls: what `gh pr list --head --json - number,title,body,isDraft,headRefName,headRefOid,baseRefName,isCrossRepository,comments` - printed. A pull request is ours only when GitHub says outright that it is - not cross-repository; an entry without that field is not shown to be - ours and is left alone. + pulls: what `gh pr list --head --state all --json + number,headRefName,isCrossRepository,state,url` printed. `--head` matches + the branch NAME in this repository and in every fork, so a pull request is + ours only when GitHub says outright that it is not cross-repository. """ - own = [ + return [ pull for pull in pulls or [] if pull.get("isCrossRepository") is False and pull.get("headRefName") == branch - and pull.get("baseRefName") == base and isinstance(pull.get("number"), int) ] - # The oldest is the one the workflow opened and keeps rewriting. - return min(own, key=lambda pull: pull["number"]) if own else None -def is_spent(merged_pulls, sha): - """Was a pull request from `next`, at exactly this commit, merged? +def blocked(plan, pulls=None, branch_sha=None): + """Why there is nothing to bump, or None when the bump branch should be made. - Then everything on the branch is in `main`, whichever merge method put it - there and whatever that did to the commit ids. + branch_sha: where the bump branch is on the remote, or None when it does + not exist. Checked before any pin is moved. """ - return bool(sha) and any( - pull.get("isCrossRepository") is False - and pull.get("headRefName") == NEXT - and pull.get("baseRefName") == BASE - and pull.get("headRefOid") == sha - for pull in merged_pulls or [] - ) - - -def _same_text(left, right): - normal = lambda text: (text or "").replace("\r\n", "\n").strip() - return normal(left) == normal(right) - + target = plan.get("target") or {} + branch = target.get("branch") + if not branch or not target.get("bump"): + return "there is nothing to bump to" + if not target.get("ready"): + return target.get("waiting_for") or "the release's images are not published yet" + own = own_pulls(pulls, branch) + if own: + first = min(own, key=lambda pull: pull["number"]) + return "pull request #%d (%s) already came from `%s`" % ( + first["number"], + str(first.get("state") or "?").lower(), + branch, + ) + if branch_sha: + return "`%s` exists already (at %s) with no pull request from it; open one, or delete the branch" % ( + branch, + branch_sha[:12], + ) + return None -def build(plan, outcome, open_pulls=None, has_bot_token=False, automerge=False, repo_url="", release_branch_sha=None): - """Every step to take on GitHub for what the `next` job prepared, as plain data. - release_branch_sha: where `release/` is on the remote, or None - when it does not exist. Only looked at when the plan names such a branch. - """ - target = plan.get("target") - action = outcome["action"] - own = own_pull(open_pulls) - publish = { - "action": action, - "release_branch": None, - "push": None, - "pr": None, - "comment": None, - "automerge": False, - "delete": None, - } - - if action == "delete": - publish["delete"] = { - "expected": outcome["next"], - "pr": own["number"] if own else None, - "comment": "Closing: %s. The Follow upstream workflow opens `next` again when upstream " - "tags a release ahead of `main`." % outcome["reason"], - } +def build(plan, outcome, pulls=None, branch_sha=None, repo_url=""): + """Every step to take on GitHub for what the `bump` job prepared, as plain data.""" + target = plan.get("target") or {} + branch = target.get("branch") + publish = {"branch": branch, "skip": None, "push": None, "pr": None, "compare": None} + publish["skip"] = blocked(plan, pulls, branch_sha) + if publish["skip"]: return publish - if action == "leave" or target is None or not target["ready"]: + if outcome.get("action") != "create": + publish["skip"] = outcome.get("reason") or "the move changed nothing" return publish - # The branch for the line main is about to leave. It has to exist before - # the pull request can merge, and the pull request cannot merge before the - # target is a stable release, which is when the plan first names it. - branch_note = None - if target.get("release_branch"): - name = target["release_branch"] - if release_branch_sha is None: - publish["release_branch"] = {"name": name, "sha": outcome["main"]} - branch_note = (name, outcome["main"], True) - else: - branch_note = (name, release_branch_sha, False) - - if action in ("create", "update"): - publish["push"] = {"sha": outcome["sha"], "expected": outcome["next"] or "", "created": action == "create"} - - doc = outcome.get("pins") - if not doc or versions.parse(doc.get("release")) is None: - raise PublishError( - "`next` holds no %s this workflow can read, so its pull request cannot be described. " - "Someone changed the branch by hand; it needs a person." % report.PINS_PATH - ) - release = doc["release"] - draft = not versions.is_stable(release) - title = report.pr_title(release) - body = report.pr_body(plan, outcome, has_bot_token, automerge, repo_url, branch_note) - if own is None: - publish["pr"] = {"number": None, "title": title, "body": body, "draft": draft} - else: - change = None - if bool(own.get("isDraft")) != draft: - change = "draft" if draft else "ready" - publish["pr"] = { - "number": own["number"], - "title": title, - "body": body, - "draft": draft, - "set_title": own.get("title") != title, - "set_body": not _same_text(own.get("body"), body), - "set_draft": change, - } - - if action == "conflict": - marker = report.CONFLICT_MARKER % report.conflict_key(outcome) - already = own is not None and any(marker in (comment.get("body") or "") for comment in own.get("comments") or []) - if not already: - publish["comment"] = {"body": report.conflict_comment(plan, outcome)} - - # Never for a pre-release, and never for a branch that is not on the - # release the plan is moving to. Not again once GitHub has it on. - already_on = own is not None and bool(own.get("autoMergeRequest")) - publish["automerge"] = bool( - automerge - and not draft - and action != "conflict" - and release == target["version"] - and target["stable"] - and not already_on - ) + publish["push"] = {"sha": outcome["sha"]} + publish["pr"] = {"title": report.pr_title(target["version"]), "body": report.pr_body(plan, repo_url)} + if repo_url: + publish["compare"] = "%s/compare/%s...%s?expand=1" % (repo_url.rstrip("/"), BASE, branch) return publish @@ -224,150 +153,49 @@ def __call__(self, command): return code, out, err -def _lease(branch, expected): - return "--force-with-lease=refs/heads/%s:%s" % (branch, expected) - - -# Asks GitHub to merge the pull request by rebase once what the base branch -# requires of it is met. It is the mutation, deliberately, and never -# `gh pr merge --auto`: gh merges on the spot when the pull request is already -# mergeable, and into a branch that requires nothing a pull request is -# mergeable whatever its checks say. The mutation only ever turns auto-merge -# on, and GitHub refuses it where there is nothing to wait for. -AUTOMERGE = ( - "mutation($id: ID!) { enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: REBASE}) " - "{ clientMutationId } }" -) - - -def _enable_automerge(call, number): - """Turn auto-merge on. Returns why it could not be, or None when it is on.""" - code, out, err = call(["gh", "pr", "view", str(number or ""), "--json", "id", "--jq", ".id"]) - if code != 0: - return " ".join(err.split()) or "the pull request could not be read" - code, _, err = call(["gh", "api", "graphql", "-f", "query=" + AUTOMERGE, "-f", "id=" + out.strip()]) - if code != 0: - return " ".join(err.split()) or "gh gave no reason" - return None - - def execute(publish, run=None, echo=print, dry_run=False): """Carry out what build() decided. Returns notes for the run summary. - Raises PublishError when a step fails and a person has to look. A lease - that no longer holds is not that: someone pushed to `next` while this run - worked, and the next run starts from what they pushed. + Raises PublishError when a step fails and a person has to look. """ + if publish.get("skip"): + return ["Nothing to publish: %s." % publish["skip"]] call = _Runner(run or run_command, echo, dry_run) try: return _steps(publish, call, dry_run) finally: - # The pull request body and the comment were written to files for gh. + # The pull request body was written to a file for gh. shutil.rmtree(call.directory, ignore_errors=True) def _steps(publish, call, dry_run): notes = [] + branch = publish["branch"] - branch = publish.get("release_branch") - if branch: - # An empty lease: the branch must not exist. It is never moved. - code, _, err = call(_push_command(_lease(branch["name"], ""), REMOTE, "%s:refs/heads/%s" % (branch["sha"], branch["name"]))) - if code != 0: - raise PublishError("could not create %s:\n%s" % (branch["name"], err.strip())) - - push = publish.get("push") - if push: - code, _, err = call(_push_command(_lease(NEXT, push["expected"]), REMOTE, "%s:refs/heads/%s" % (push["sha"], NEXT))) - if code != 0: - if "stale info" in err: - notes.append( - "`next` moved while this run was working, so nothing was pushed and its pull " - "request was not touched. The next run starts from what is there now." - ) - return notes - hint = "" - if "workflow" in err and "permission" in err: - hint = ( - "\nGitHub refuses a push that changes a workflow file unless the token may write " - "workflows, and the workflow's default token never may. `main` gained a workflow " - "change that this rebase carries. Set the secret UPSTREAM_BOT_TOKEN to a token " - "with Contents, Pull requests and Workflows read/write, or rebase `next` onto " - "`main` by hand once." - ) - raise PublishError("could not push %s:\n%s%s" % (NEXT, err.strip(), hint)) - - pull = publish.get("pr") - number = pull["number"] if pull else None - if pull and number is None: - command = ["gh", "pr", "create", "--head", NEXT, "--base", BASE, "--title", pull["title"], "--body-file", call.file(pull["body"])] - if pull["draft"]: - command.append("--draft") - code, out, err = call(command) - if code != 0: - if push and push["created"]: - # No pull request, so the branch just pushed would be an orphan - # that looks like a pending change. Take it away again. - gone, _, _ = call(_push_command(_lease(NEXT, push["sha"]), REMOTE, ":refs/heads/%s" % NEXT)) - left = "The branch was deleted again, so nothing is left behind." if gone == 0 else "The branch could NOT be deleted again; delete `next` by hand." - else: - left = "`next` itself is as this run pushed it." - raise PublishError( - "GitHub refused to create the pull request %s -> %s:\n%s\n" - "With the workflow's default token that is what happens while the repository setting " - "'Allow GitHub Actions to create and approve pull requests' is off. Do ONE of these, then " - "run the workflow again: (1) enable that setting under Settings > Actions > General > " - "Workflow permissions; or (2) add the repository secret UPSTREAM_BOT_TOKEN, a fine-grained " - "or GitHub App token with Contents, Pull requests and Workflows read/write on this " - "repository. %s" % (NEXT, BASE, err.strip(), left) - ) - found = re.search(r"/pull/(\d+)\s*$", out.strip()) - number = int(found.group(1)) if found else None - if not dry_run: - notes.append("Opened %s." % (out.strip() or "the pull request")) - elif pull: - if pull.get("set_title") or pull.get("set_body"): - command = ["gh", "api", "--method", "PATCH", "repos/{owner}/{repo}/pulls/%d" % number, "--silent"] - if pull.get("set_title"): - command += ["-f", "title=%s" % pull["title"]] - if pull.get("set_body"): - command += ["-F", "body=@%s" % call.file(pull["body"])] - code, _, err = call(command) - if code != 0: - raise PublishError("could not update pull request #%d:\n%s" % (number, err.strip())) - if pull.get("set_draft"): - command = ["gh", "pr", "ready", str(number)] + (["--undo"] if pull["set_draft"] == "draft" else []) - code, _, err = call(command) - if code != 0: - raise PublishError("could not mark pull request #%d %s:\n%s" % (number, pull["set_draft"], err.strip())) - - comment = publish.get("comment") - if comment and (number is not None or dry_run): - code, _, err = call(["gh", "pr", "comment", str(number or ""), "--body-file", call.file(comment["body"])]) - if code != 0: - raise PublishError("could not comment on pull request #%s:\n%s" % (number, err.strip())) - - if publish.get("automerge") and (number is not None or dry_run): - refused = _enable_automerge(call, number) - if refused: - # Not a reason to fail the run: the pull request is there and can - # be merged by hand. Say why it will not merge by itself. - notes.append( - "**Auto-merge could not be turned on** for pull request #%s: %s. It needs the repository " - "setting 'Allow auto-merge' and at least one required check on `main`; without a required " - "check there is nothing for auto-merge to wait for, and GitHub refuses it." % (number, refused) - ) - - delete = publish.get("delete") - if delete: - if delete.get("pr") is not None: - code, _, err = call(["gh", "pr", "close", str(delete["pr"]), "--comment", delete["comment"]]) - if code != 0: - raise PublishError("could not close pull request #%d:\n%s" % (delete["pr"], err.strip())) - code, _, err = call(_push_command(_lease(NEXT, delete["expected"]), REMOTE, ":refs/heads/%s" % NEXT)) - if code != 0: - if "stale info" in err: - notes.append("`next` moved while this run was working, so it was not deleted.") - return notes - raise PublishError("could not delete %s:\n%s" % (NEXT, err.strip())) + # An empty lease: the branch must not exist. The bot never moves one. + push = publish["push"] + code, _, err = call(_push_command("--force-with-lease=refs/heads/%s:" % branch, REMOTE, "%s:refs/heads/%s" % (push["sha"], branch))) + if code != 0: + if "stale info" in err: + notes.append("`%s` appeared while this run was working, so nothing was pushed." % branch) + return notes + raise PublishError("could not push %s:\n%s" % (branch, err.strip())) + if not dry_run: + notes.append("Pushed `%s` at %s." % (branch, push["sha"][:12])) + + # Opened with the bump-bot App's token, the pull request starts CI like + # any other. + pull = publish["pr"] + code, out, err = call( + ["gh", "pr", "create", "--head", branch, "--base", BASE, "--title", pull["title"], "--body-file", call.file(pull["body"])] + ) + if code != 0: + raise PublishError( + "GitHub refused to open the pull request %s -> %s:\n%s\n" + "The branch is pushed; open the pull request by hand%s. If this keeps happening, check that " + "the bump-bot App is installed on this repository with Pull requests: write." + % (branch, BASE, err.strip(), ": " + publish["compare"] if publish.get("compare") else "") + ) + if not dry_run: + notes.append("Opened %s." % (out.strip() or "the pull request")) return notes diff --git a/deploy/ci/upstream/report.py b/deploy/ci/upstream/report.py index b728f5b4..d22e322b 100644 --- a/deploy/ci/upstream/report.py +++ b/deploy/ci/upstream/report.py @@ -1,20 +1,12 @@ """Everything the workflow writes for a person to read. -The pin commit's message, the `next` pull request, the comment left when -`next` cannot be rebased, the issue check A keeps, and the run summaries. - -Two rules about what the text may claim: - - * it says only what this run did or observed. A pull request body is - written before the push, so it describes the branch, not the run; - * the pull request body is the same on every run that finds nothing new. - It carries no run link and no timestamp, so an hourly run that changes - nothing rewrites nothing. +The bump commit's message, the bump pull request, the issue check A keeps, and +the run summaries. Each says only what this run did or observed. """ import textwrap -import nextbranch +import bump import versions from upstream import UPSTREAM_URL @@ -22,19 +14,15 @@ WORKFLOW_PATH = ".github/workflows/follow-upstream.yml" PINS_PATH = "deploy/ci/gateway-pins.json" -NEXT = "next" BASE = "main" -# The pull request title is the pin commit's title, and the notes of the -# release that follows the merge list it as written. The title is not what -# releases it, and not what numbers it: publish.yml releases a merged pull -# request whose head was `next` in this repository -# (scripts/release/next-merge.mjs), at the version worked out from the pin +# The bump commit's title, and its pull request's. The release that follows +# the merge lists it as written. Nothing reads it: a release is cut by hand +# (publish.yml), at the version worked out from the pin # (scripts/release/next-version.mjs). PR_TITLE = "fix: move to OpenShell %s" -PR_MARKER = "" -CONFLICT_MARKER = "" +PR_MARKER = "" ISSUE_MARKER = "" # The label of the one issue check A keeps. ISSUE_LABEL = "compat-migrate" @@ -99,187 +87,70 @@ def commit_message(target, pinned): ) if not target["stable"]: paragraphs.append( - "A pre-release, so this is on next and cannot merge. The commit is rewritten in place " - "when upstream tags another pre-release or releases %s." % target["base"] + "A pre-release, so this is a prep branch and cannot merge: its `pins a stable release` " + "check fails until upstream releases %s. Fixes the move needs go on this branch." % target["base"] ) body = "\n\n".join( textwrap.fill(paragraph, 72, break_long_words=False, break_on_hyphens=False) for paragraph in paragraphs ) - return "%s\n\n%s\n\n%s: %s\n" % (pr_title(version), body, nextbranch.TRAILER, version) - - -# --- the next pull request ------------------------------------------------- + return "%s\n\n%s\n\n%s: %s\n" % (pr_title(version), body, bump.TRAILER, version) -def _ci_note(has_bot_token): - if has_bot_token: - return ( - "**CI** runs on every push to `next`. Its `compat` job is the compatibility suite " - "against the release pinned here, on the SDK pinned here." - ) - return ( - "**CI does not start by itself on this pull request.** `next` is pushed with the " - "workflow's default token, and GitHub starts no workflow for what that token pushes. " - "To run CI on the commit that is here now, close this pull request and reopen it. With " - "the repository secret `UPSTREAM_BOT_TOKEN` set, it starts by itself." - ) +# --- the bump pull request ----------------------------------------------- -def pr_body(plan, outcome, has_bot_token=False, automerge=False, repo_url="", release_branch=None): - """The description of the `next` pull request, from what is on the branch.""" - doc = outcome["pins"] - release = doc["release"] - stable = versions.is_stable(release) - pinned = plan["pinned"] - target = plan.get("target") or {} - people = outcome.get("people") or [] - new_line = versions.line(release) != pinned["line"] - +def pr_body(plan, repo_url=""): + """The description of a bump pull request. Written once, when it is opened.""" + target, pinned = plan["target"], plan["pinned"] + version = target["version"] + new_line = not target["same_line"] lines = [ PR_MARKER, - "This is `next`: the move to the upcoming OpenShell release, opened early (%s, decisions 7 " - "and 8). The %s workflow keeps the branch and rewrites this description, so an edit made " - "here by hand is lost." - % (_link("ADR 0005", repo_url, ADR_PATH), _link("Follow upstream", repo_url, WORKFLOW_PATH)), + "Opened by the %s workflow (%s). The bot made this branch and will not touch it again: push " + "whatever the move needs to it, like any branch." + % (_link("Follow upstream", repo_url, WORKFLOW_PATH), _link("ADR 0005", repo_url, ADR_PATH)), "", - ] - if stable: - lines += [ - "**Where it is.** `next` pins OpenShell %s, a stable release. `main` pins %s. This is " - "ready to merge once its checks pass." % (_code(release), _code(pinned["release"])), - ] - else: - lines += [ - "**Where it is.** `next` pins OpenShell %s, a pre-release. `main` pins %s. Until " - "upstream releases %s this stays a draft and its `pins a stable release` check fails, " - "which is what keeps it from merging; that check says nothing about compatibility. When " - "the release is out, the workflow moves the pin to it and marks this ready for review." - % (_code(release), _code(pinned["release"]), _code(versions.base(release))), - ] - if target.get("skipped") and release == target.get("version"): - lines += [ - "", - "Upstream also released %s since %s. The branch goes straight to the newest release; " - "the ones in between are not walked through." - % (", ".join(_code(version) for version in target["skipped"]), _code(pinned["release"])), - ] - - lines += [ - "", - "**What moved.** One commit, always the first after `main`. It changes %s, `backend/go.mod` " - "and `backend/go.sum`, %sand the generated block of `README.md`." + "**What moved.** One commit: %s, `backend/go.mod` and `backend/go.sum`, %sand the generated " + "block of `README.md`, from OpenShell %s to %s (%s)." % ( _code(PINS_PATH), "the gateway release line compiled into the BFF, " if new_line else "", + _code(pinned["release"]), + _code(version), + _tag_link(target["tag"]), ), "", - "| | Pinned on `next` |", + "| | Pinned here |", "|---|---|", - "| release | %s |" % _code(release), - "| gateway | %s |" % _code(doc["gateway_image"]), - "| supervisor | %s |" % _code(doc["supervisor_image"]), - "| Go SDK | %s |" % _code(doc["sdk"]), + "| release | %s |" % _code(version), + "| gateway | %s |" % _code(target["gateway_image"]), + "| supervisor | %s |" % _code(target["supervisor_image"]), + "| Go SDK | at %s |" % _code(target["commit"][:12]), ] - - if new_line: + if target.get("skipped"): lines += [ "", - "**This starts gateway release line %s.** `main` is on %s, and the line compiled into " - "the BFF moves with the pin." - % (_code(versions.line(release) + ".x"), _code(pinned["line"] + ".x")), + "Upstream also released %s since %s. This goes straight to the newest." + % (", ".join(_code(skipped) for skipped in target["skipped"]), _code(pinned["release"])), ] - if release_branch: - name, sha, created = release_branch - lines += [ - "%s %s from `main` at %s, so the %s line can still get critical and security fixes " - "after this merges. A commit that reaches `main` after that and before this merges " - "is not on it; cherry-pick it there if the old line needs it." - % ( - _code(name), - "was created by the run that wrote this," if created else "exists; it was created", - _code(sha[:12]), - _code(pinned["line"] + ".x"), - ), - ] - - lines += [ - "", - "**Work that needs the upcoming release goes here.** Open a pull request into `next` only " - "for a change that cannot build or pass on `main`; everything else goes to `main`. When " - "`main` moves, the workflow rebases `next` onto it and force-pushes, so rebase a branch " - "cut from `next` after that. Commits on `next` besides the pin move: %d." % len(people), - "", - _ci_note(has_bot_token), - "", - ] - if automerge: - lines += [ - "**Merging.** `UPSTREAM_AUTOMERGE` is on: once this is ready for review, the workflow asks " - "GitHub to merge it by rebase when the checks `main` requires have passed. If `main` " - "requires none, GitHub refuses that, and this is merged by hand with *Rebase and merge*.", - ] - else: + if not target["stable"]: lines += [ - "**Merging.** Nothing merges this automatically. Use *Rebase and merge*, so the pin move " - "and each commit on `next` land on `main` as they are.", + "", + "**A pre-release: this is a prep branch.** Its `pins a stable release` check fails until " + "upstream releases %s, which is what keeps it from merging. When the release is out, the " + "workflow opens a bump to it; move the fixes from here onto that branch and close this one." + % _code(target["base"]), ] - lines += [ - "After the merge, a release is cut by itself once CI has passed on `main`. Nobody chooses its " - "version: it is the console's next patch on the gateway release line `main` then pins.", - ] if new_line: lines += [ "", - "**That release starts the console's %s line.** A line that has no release yet starts " - "at `.0`, so this one is the console's minor release for the new gateway release line " - "(ADR 0005, decision 2). It is cut without anyone being asked." - % _code(versions.line(release) + ".x"), + "**This starts gateway release line %s.** If the %s line still needs patch releases, make " + "its branch from `main` before this merges: `git push origin origin/main:refs/heads/release/%s`." + % (_code(target["line"] + ".x"), _code(pinned["line"] + ".x"), pinned["line"]), ] - return "\n".join(lines) + "\n" - - -def conflict_key(outcome): - """What makes two conflicts the same one, so the comment is left once. - - The commit `next` is on and the files in conflict. `main` moving again - while both stay the same is the same conflict, and says nothing new. - """ - conflict = outcome["conflict"] - return "%s %s %s" % (outcome["next"], conflict["stage"], ",".join(sorted(conflict["files"]))) - - -def conflict_comment(plan, outcome): - conflict = outcome["conflict"] - target = plan.get("target") or {} - commit = conflict.get("commit") - if conflict["stage"] == "rebase": - headline = "**`next` could not be rebased onto `main`.**" - doing = "Replaying" - remedy = "rebase `next` onto `main` by hand, resolve the conflict and force-push" - else: - headline = "**The pin on `next` could not be moved to OpenShell %s.**" % _code(target.get("version", "?")) - doing = "Folding the move into the pin commit and replaying" - remedy = ( - "take the change to these files out of that commit, or drop the commit, and force-push: " - "the pin move writes them, and a commit on top that edits the same lines cannot follow it" - ) - lines = [ - CONFLICT_MARKER % conflict_key(outcome), - "%s %s %s conflicts in:" - % ( - headline, - doing, - "%s (%s)" % (_code(commit["sha"][:12]), commit["subject"]) if commit else "the commits on it", - ), - "", - ] - lines += ["- %s" % _code(path) for path in sorted(conflict["files"])] lines += [ "", - "`next` was left exactly as it was. The workflow regenerates `backend/go.sum` and " - "`frontend/package-lock.json` by itself and stops for anything else. To carry on, %s; the " - "next run takes it from there. This comment is not repeated while `next` and the files in " - "conflict stay the same." % remedy, + "**After it merges,** cut a release by hand: Actions > Publish > Run workflow on `main`.", ] return "\n".join(lines) + "\n" @@ -380,8 +251,8 @@ def issue_text(plan, run_url="", repo_url=""): "", "1. **Report it upstream**, at %s/issues, with the failing test and the request it sends. " "The remedy belongs there: a fix in the next patch release." % UPSTREAM_URL, - "2. Until then the console stays on %s, the last release that passed. Check B, CI on the " - "`next` pull request, shows whether the console built on the new SDK passes." + "2. Until then the console stays on %s, the last release that passed. CI on the bump " + "pull request shows whether the console built on the new SDK passes." % _code(pinned["release"]), ] elif target["same_line"]: @@ -399,8 +270,10 @@ def issue_text(plan, run_url="", repo_url=""): "1. Read the failing tests. If a Stable interface changed (proto, SDK, CLI, config, " "policy), **report it upstream now**, at %s/issues, before %s is released." % (UPSTREAM_URL, _code(target["base"])), - "2. If the change is one the console has to follow, make it on `next`, where check B (CI " - "on the `next` pull request) runs the suite on the pre-release's SDK.", + "2. If the change is one the console has to follow, start a prep branch: run the Follow " + "upstream workflow by hand with `version` %s and the dry-run box cleared. It opens " + "`%s`, where CI runs the suite on the pre-release's SDK. Or wait for the stable release, " + "which the workflow bumps to by itself." % (_code(version), target["branch"]), ] else: lines += [ @@ -414,9 +287,10 @@ def issue_text(plan, run_url="", repo_url=""): "", "### What to do", "", - "Do the work on `next`. Check B, CI on the `next` pull request, runs the suite with the " - "console built on the new line's SDK, and the console's minor release is held until it " - "passes (ADR 0005, decision 2).", + "Wait for the stable release, which the workflow bumps to by itself, or start now: run the " + "Follow upstream workflow by hand with `version` %s and the dry-run box cleared. It opens " + "`%s`, where CI runs the suite with the console built on the new line's SDK. The console's " + "minor release waits until that passes (ADR 0005, decision 2)." % (_code(version), target["branch"]), ] return title, "\n".join(lines) + "\n" @@ -449,8 +323,17 @@ def plan_summary(plan, dry_run=False): ", which starts release line %s" % _code(target["line"] + ".x") if not target["same_line"] else "", ) ) + if plan.get("requested"): + lines.append("- Asked for by hand.") if target["skipped"]: lines.append("- Skipped on the way: %s." % ", ".join(_code(version) for version in target["skipped"])) + if target["bump"]: + lines.append("- Bump branch: %s." % _code(target["branch"])) + else: + lines.append( + "- No bump: a scheduled run bumps only to a stable release. Check A still tests it. To make a " + "prep branch, run this workflow by hand with `version` %s." % _code(target["version"]) + ) if not target["ready"]: lines.append("- **Not yet:** %s. The next run looks again." % target["waiting_for"]) else: @@ -459,59 +342,17 @@ def plan_summary(plan, dry_run=False): return "\n".join(lines) + "\n" -def next_summary(outcome, publish, dry_run=False): - """What the `next` job did, or in a dry run would do.""" +def bump_summary(outcome, publish, dry_run=False): + """What the `bump` job did, or in a dry run would do.""" would = "would be " if dry_run else "" - action = outcome["action"] - lines = ["### `next`", ""] - if action == "create": - lines.append("- `next` %screated from `main` at %s." % (would, _code(outcome["main"][:12]))) - elif action == "update": - lines.append("- `next` %sforce-pushed (with lease): %s." % (would, outcome["reason"])) - elif action == "none": - lines.append("- `next` is left as it is: %s." % outcome["reason"]) - elif action == "conflict": - conflict = outcome["conflict"] - lines.append( - "- **%s.** In conflict: %s. `next` is left exactly as it was." - % (outcome["reason"].capitalize(), ", ".join(_code(path) for path in sorted(conflict["files"]))) - ) - elif action == "delete": - lines.append("- `next` %sdeleted: %s." % (would, outcome["reason"])) - elif action == "leave": - lines.append("- `next` is left as it is: %s." % outcome["reason"]) - if outcome.get("sha"): - lines.append("- Commit: %s." % _code(outcome["sha"])) - if outcome.get("regenerated"): - lines.append( - "- Regenerated after a conflict: %s." % ", ".join(_code(path) for path in sorted(set(outcome["regenerated"]))) - ) - if publish.get("release_branch"): - lines.append( - "- %s %screated from `main` at %s." - % (_code(publish["release_branch"]["name"]), would, _code(publish["release_branch"]["sha"][:12])) - ) - pull = publish.get("pr") - if pull: - if pull["number"] is None: - lines.append( - "- A %spull request `next` -> `main` %sopened: %s." - % ("draft " if pull["draft"] else "", would, _code(pull["title"])) - ) - else: - changes = [name for name in ("title", "body") if pull.get("set_" + name)] - if pull.get("set_draft"): - changes.append("marked %s" % ("a draft" if pull["set_draft"] == "draft" else "ready for review")) - if changes: - lines.append("- Pull request #%d %supdated: %s." % (pull["number"], would, ", ".join(changes))) - else: - lines.append("- Pull request #%d is already right; nothing to change." % pull["number"]) - if publish.get("comment"): - lines.append("- A comment naming the files in conflict %sleft on the pull request." % would) - elif action == "conflict": - lines.append("- The pull request already carries a comment about this conflict; it is not repeated.") - if publish.get("automerge"): - lines.append("- Auto-merge (rebase) %sturned on for the pull request." % would) + lines = ["### Bump", ""] + if publish.get("skip"): + lines.append("- Nothing %sdone: %s." % (would, publish["skip"])) + return "\n".join(lines) + "\n" + lines.append("- %s %spushed: main at %s plus one commit, %s." % ( + _code(publish["branch"]), would, _code(outcome["main"][:12]), _code(outcome["sha"][:12]))) + if publish.get("pr"): + lines.append("- A pull request into `main` %sopened: %s." % (would, _code(publish["pr"]["title"]))) return "\n".join(lines) + "\n" diff --git a/deploy/ci/upstream/tests/support.py b/deploy/ci/upstream/tests/support.py index 002747f3..d1f84a04 100644 --- a/deploy/ci/upstream/tests/support.py +++ b/deploy/ci/upstream/tests/support.py @@ -9,7 +9,7 @@ pins. That is enough for a conflict in one to be a real git conflict and for regenerating it to be a real resolution; * Repo is a real git repository on disk with a bare `origin`, because what - the `next` branch handling does is git, and only git can show it works. + making a bump branch does is git, and only git can show it works. """ import atexit @@ -74,7 +74,7 @@ def pins_of_2026_10_09(): """What `main` pinned on 2026-10-09: OpenShell 0.1.3, with its real digests. A copy, on purpose. A test that read the checked-in pins file for its - values would fail on `next` the moment the workflow moved the pin there. + values would fail on a bump branch the moment the workflow moved the pin there. """ return { "_comment": ["A fixture: main on 2026-10-09."], @@ -159,8 +159,12 @@ def readme_block(doc): class FakeTools(object): """Runs in place of pinmove.run_command, and records what was asked of it.""" - def __init__(self, fail=None, stray=None): + def __init__(self, fail=None, stray=None, tagged=None): self.calls = [] + # {release commit: tag}: `go get` of that commit resolves to the SDK's + # own tag, as it does upstream from 0.1.7 on. + self.tagged = tagged or {} + self.tag_commits = {tag: commit for commit, tag in self.tagged.items()} self.fail = fail # a command prefix that fails, e.g. ("go", "get") self.stray = stray # a path `go mod tidy` also writes, which it must not @@ -172,9 +176,14 @@ def __call__(self, command, cwd): module, _, commit = command[2].partition("@") path = os.path.join(cwd, "go.mod") text = _read(path) - text = re.sub(r"(?m)^\t%s \S+$" % re.escape(module), "\t%s %s" % (module, sdk_version(commit)), text) + version = self.tagged.get(commit) or sdk_version(commit) + text = re.sub(r"(?m)^\t%s \S+$" % re.escape(module), "\t%s %s" % (module, version), text) _write(path, text) return 0, "" + if command[:3] == ["go", "list", "-m"]: + # A tagged SDK: the module metadata names the commit the tag points at. + module, _, version = command[-1].partition("@") + return 0, json.dumps({"Path": module, "Version": version, "Origin": {"Hash": self.tag_commits.get(version, "")}}) if command[:3] == ["go", "mod", "tidy"]: _write(os.path.join(cwd, "go.sum"), go_sum_for(_read(os.path.join(cwd, "go.mod")))) if self.stray: @@ -332,26 +341,14 @@ def on_main(self, message, files): self.git("fetch", "-q", "origin") return sha - def on_next(self, message, files): - """Commit a person's work on top of origin/next and push it.""" - self.git("fetch", "-q", "origin") - self.git("checkout", "-q", "-B", "next", "origin/next") - for path, text in files.items(): - self.write(path, text) - sha = self.commit(message) - self.git("push", "-q", "--force", "origin", "next") - self.git("checkout", "-q", "main") - self.git("fetch", "-q", "origin") - return sha - - def publish(self, sha): - """Put a prepared commit on origin/next, as the publish step would.""" - self.git("push", "-q", "--force", "origin", "%s:refs/heads/next" % sha) + def push_branch(self, sha, name): + """Put a commit on origin as branch `name`, as a person or the publish step would.""" + self.git("push", "-q", "--force", "origin", "%s:refs/heads/%s" % (sha, name)) self.git("fetch", "-q", "origin") - def origin_next(self): + def origin_branch(self, name): self.git("fetch", "-q", "--prune", "origin") try: - return self.git("rev-parse", "--verify", "-q", "refs/remotes/origin/next") + return self.git("rev-parse", "--verify", "-q", "refs/remotes/origin/%s" % name) except subprocess.CalledProcessError: return None diff --git a/deploy/ci/upstream/tests/test_bump.py b/deploy/ci/upstream/tests/test_bump.py new file mode 100644 index 00000000..4e2a9b0c --- /dev/null +++ b/deploy/ci/upstream/tests/test_bump.py @@ -0,0 +1,59 @@ +"""bump.prepare: main plus one commit that moves the pins, made in a scratch worktree.""" + +import unittest + +import bump +import pinmove +import report +from tests import support + + +class Prepare(unittest.TestCase): + def setUp(self): + self.repo = support.Repo("0.1.3") + self.addCleanup(self.repo.close) + + def prepare(self, target, tools=None): + tools = tools or support.FakeTools() + pinned = {"release": "0.1.3", "line": "0.1"} + return bump.prepare( + self.repo.path, + target, + mover=lambda tree, to: pinmove.apply(tree, to, tools="/tools", run=tools), + message=lambda to, moved: report.commit_message(to, pinned), + main_ref="refs/remotes/origin/main", + ) + + def test_the_commit_sits_directly_on_main_and_is_kept_under_a_local_ref(self): + outcome = self.prepare(support.target("0.1.4")) + main = self.repo.rev("origin/main") + self.assertEqual((outcome["action"], outcome["main"]), ("create", main)) + self.assertEqual(self.repo.rev(outcome["sha"] + "^"), main) + self.assertEqual(self.repo.rev(bump.LOCAL_REF), outcome["sha"]) + + def test_it_changes_the_pin_paths_and_nothing_else(self): + outcome = self.prepare(support.target("0.1.4")) + changed = set(self.repo.git("diff", "--name-only", "origin/main", outcome["sha"]).splitlines()) + self.assertTrue(changed) + self.assertLessEqual(changed, set(pinmove.PIN_PATHS)) + + def test_it_is_signed_off_by_the_bot_and_carries_the_trailer(self): + sha = self.prepare(support.target("0.1.4"))["sha"] + self.assertEqual(self.repo.git("log", "-1", "--format=%an", sha), bump.BOT_NAME) + message = self.repo.git("log", "-1", "--format=%B", sha) + self.assertIn("%s: 0.1.4" % bump.TRAILER, message) + self.assertIn("Signed-off-by: %s <%s>" % (bump.BOT_NAME, bump.BOT_EMAIL), message) + + def test_a_tool_that_writes_outside_the_pin_paths_is_refused(self): + with self.assertRaisesRegex(bump.GitError, "changed files it must not"): + self.prepare(support.target("0.1.4"), tools=support.FakeTools(stray="stray.txt")) + + def test_the_working_copy_is_left_as_it_was(self): + before = self.repo.git("status", "--porcelain") + self.prepare(support.target("0.1.4")) + self.assertEqual(self.repo.git("status", "--porcelain"), before) + self.assertEqual(self.repo.git("worktree", "list").count("\n"), 0, "the scratch worktree is gone") + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/ci/upstream/tests/test_cli.py b/deploy/ci/upstream/tests/test_cli.py index 2fbb302a..906d8b6b 100644 --- a/deploy/ci/upstream/tests/test_cli.py +++ b/deploy/ci/upstream/tests/test_cli.py @@ -176,6 +176,22 @@ def test_a_new_line_is_not_the_same_line(self): _, outputs, _ = self.plan(tags=["v0.2.0"], images=images) self.assertEqual((outputs["target"], outputs["same_line"]), ("0.2.0", "false")) + def test_a_version_asked_for_reaches_the_plan_and_the_outputs(self): + pins_path = self.pins_file(support.pins_of_2026_10_09()) + go_mod = self.write("backend/go.mod", "require %s %s\n" % (pins.SDK_MODULE, support.pins_of_2026_10_09()["sdk"])) + images = {"gateway:0.1.4-pre.1": support.digest("g"), "supervisor:0.1.4-pre.1": support.digest("s")} + fake = support.FakeUpstream(support.upstream_data(images=images)) + with mock.patch.object(follow.upstream_module, "Upstream", lambda: fake): + code, _, _, outputs = self.run_cli( + "plan", "--pins", pins_path, "--go-mod", go_mod, "--version", "0.1.4-pre.1", "--out", self.path("plan.json") + ) + self.assertEqual(code, 0) + self.assertEqual((outputs["target"], outputs["bump"], outputs["branch"]), ("0.1.4-pre.1", "true", "bump/openshell-0.1.4-pre.1")) + + def test_a_schedule_does_not_bump_to_a_pre_release(self): + _, outputs, _ = self.plan() + self.assertEqual((outputs["target"], outputs["bump"]), ("0.1.4-pre.2", "false")) + def test_an_outage_fails_the_command(self): pins_path = self.pins_file(support.pins_of_2026_10_09()) go_mod = self.write("backend/go.mod", "require %s %s\n" % (pins.SDK_MODULE, support.pins_of_2026_10_09()["sdk"])) @@ -191,8 +207,10 @@ def release_tags(self): self.assertFalse(os.path.exists(self.path("plan.json"))) -class Next(Cli): - """`next` and `publish` in a dry run, against a throwaway repository.""" +class Bump(Cli): + """`bump` and `publish`, against a throwaway repository.""" + + BRANCH = "bump/openshell-0.1.4" def setUp(self): Cli.setUp(self) @@ -200,167 +218,169 @@ def setUp(self): self.addCleanup(self.repo.close) self.tools = support.FakeTools() - def plan_file(self, target, pinned="0.1.3"): + def plan_file(self, target, pinned="0.1.3", bump=True): + if target is not None: + target = dict(target, same_line=True, skipped=[], bump=bump, branch="bump/openshell-" + target["version"]) made = { "pinned": {"release": pinned, "line": "0.1", "stable": True, "sdk": support.pins_doc(pinned)["sdk"], "sdk_on_tag": True}, "config_schema": "v2", "sandbox_image": support.SANDBOX, "newest_stable": pinned, - "target": target and dict(target, same_line=True, skipped=[], release_branch=None), + "requested": None, + "target": target, } return self.write("plan.json", made) - def next(self, target, *more): + def bump(self, target, *more, **plan): args = follow.build_parser().parse_args( - ["next", "--plan", self.plan_file(target), "--repo", self.repo.path, "--tools", "/tools", - "--main-ref", "refs/remotes/origin/main", "--out", self.path("next"), + ["bump", "--plan", self.plan_file(target, **plan), "--repo", self.repo.path, "--tools", "/tools", + "--main-ref", "refs/remotes/origin/main", "--out", self.path("bump"), "--repo-url", "https://github.com/o/r"] + list(more) ) out = io.StringIO() with contextlib.redirect_stdout(out): - code = follow.cmd_next(args, run=self.tools) - return code, out.getvalue(), json.loads(self.read("next", "outcome.json")), json.loads(self.read("next", "publish.json")) + code = follow.cmd_bump(args, run=self.tools) + return code, out.getvalue(), json.loads(self.read("bump", "outcome.json")), json.loads(self.read("bump", "publish.json")) - def test_the_first_run_would_create_next_and_open_a_draft(self): - code, out, outcome, decided = self.next(support.target("0.1.4-pre.2"), "--dry-run", "true") + def test_a_stable_release_makes_one_signed_off_commit_on_main(self): + code, out, outcome, decided = self.bump(support.target("0.1.4"), "--dry-run", "true") self.assertEqual(code, 0) self.assertEqual(outcome["action"], "create") - self.assertEqual(decided["push"]["expected"], "") - self.assertEqual(decided["pr"]["title"], "fix: move to OpenShell 0.1.4-pre.2") - self.assertTrue(decided["pr"]["draft"]) - self.assertIn("`next` would be created from `main`", out) - self.assertIn("A draft pull request `next` -> `main` would be opened", out) - self.assertEqual(out.strip().splitlines()[-1][:2], "- ", "the summary is the last thing printed") - self.assertIsNone(self.repo.origin_next(), "preparing pushes nothing") + self.assertEqual(outcome["main"], self.repo.rev("origin/main")) + self.assertEqual(self.repo.log("origin/main", outcome["sha"]), ["fix: move to OpenShell 0.1.4"]) + message = self.repo.git("log", "-1", "--format=%B", outcome["sha"]) + self.assertIn("OpenShell-Release: 0.1.4", message) + self.assertIn("Signed-off-by: github-actions[bot]", message) + self.assertEqual(json.loads(self.repo.read(pinmove.PINS, outcome["sha"]))["release"], "0.1.4") + self.assertEqual(decided["branch"], self.BRANCH) + self.assertEqual(decided["push"], {"sha": outcome["sha"]}) + self.assertEqual(decided["pr"]["title"], "fix: move to OpenShell 0.1.4") + self.assertEqual(decided["compare"], "https://github.com/o/r/compare/main...%s?expand=1" % self.BRANCH) + self.assertIn("would be pushed", out) + self.assertIsNone(self.repo.origin_branch(self.BRANCH), "preparing pushes nothing") + + def test_it_says_whether_there_is_a_commit_to_publish(self): + plan_path = self.plan_file(support.target("0.1.4")) + with mock.patch.object(follow.pinmove, "run_command", self.tools): + _, _, _, outputs = self.run_cli( + "bump", "--plan", plan_path, "--repo", self.repo.path, "--main-ref", "refs/remotes/origin/main", "--out", self.path("bump") + ) + self.assertEqual(outputs["made"], "true") + self.assertEqual(len(outputs["sha"]), 40) + self.repo.push_branch(self.repo.rev("origin/main"), self.BRANCH) + _, _, _, outputs = self.run_cli( + "bump", "--plan", plan_path, "--repo", self.repo.path, "--main-ref", "refs/remotes/origin/main", "--out", self.path("bump2") + ) + self.assertEqual((outputs["made"], outputs["sha"]), ("false", "")) def test_publish_in_a_dry_run_prints_the_commands_and_runs_none(self): - self.next(support.target("0.1.4-pre.2"), "--dry-run", "true") + self.bump(support.target("0.1.4"), "--dry-run", "true") with mock.patch.object(follow.publish_module, "run_command", side_effect=AssertionError("ran something")): code, out, err, _ = self.run_cli( - "publish", "--plan", self.path("next", "publish.json"), "--dry-run", "true", "--summary", self.path("published.md") + "publish", "--plan", self.path("bump", "publish.json"), "--dry-run", "true", "--summary", self.path("published.md") ) self.assertEqual((code, err), (0, "")) - self.assertIn("would run: git push --force-with-lease=refs/heads/next: origin ", out) - self.assertIn("would run: gh pr create --head next --base main --title fix: move to OpenShell 0.1.4-pre.2", out) - self.assertEqual(self.read("published.md"), "") - self.assertIsNone(self.repo.origin_next()) - - def test_the_pull_request_listings_and_the_flags_reach_the_decision(self): - first = self.next(support.target("0.1.4-pre.1"))[2] - self.repo.publish(first["sha"]) - merged = self.write("merged.json", []) - opened = self.write( - "open.json", - [{"number": 9, "title": "old", "body": "", "isDraft": False, "headRefName": "next", "headRefOid": first["sha"], - "baseRefName": "main", "isCrossRepository": False, "comments": []}], - ) - _, _, outcome, decided = self.next( - support.target("0.1.4-pre.2"), "--open-prs", opened, "--merged-prs", merged, "--has-bot-token", "true", "--automerge", "true" - ) - self.assertEqual(outcome["action"], "update") - self.assertEqual(decided["push"]["expected"], first["sha"]) - self.assertEqual((decided["pr"]["number"], decided["pr"]["set_title"], decided["pr"]["set_draft"]), (9, True, "draft")) - self.assertNotIn("does not start by itself", decided["pr"]["body"]) - self.assertFalse(decided["automerge"], "a pre-release is never set to merge") - - def test_a_merged_pull_request_at_the_commit_next_is_on_makes_it_spent(self): - first = self.next(support.target("0.1.4"))[2] - self.repo.publish(first["sha"]) - merged = self.write( - "merged.json", - [{"number": 9, "headRefName": "next", "headRefOid": first["sha"], "baseRefName": "main", "isCrossRepository": False}], - ) - _, out, outcome, decided = self.next(None, "--merged-prs", merged) - self.assertEqual(outcome["action"], "delete") - self.assertEqual(decided["delete"]["expected"], first["sha"]) - self.assertIn("its pull request was merged", out) + self.assertIn("would run: git push --force-with-lease=refs/heads/%s: origin " % self.BRANCH, out) + self.assertNotIn("gh workflow run", out) + self.assertIn("would run: gh pr create --head %s --base main --title fix: move to OpenShell 0.1.4" % self.BRANCH, out) + self.assertIsNone(self.repo.origin_branch(self.BRANCH)) + + def test_a_branch_that_exists_is_left_alone_and_nothing_is_moved(self): + self.repo.push_branch(self.repo.rev("origin/main"), self.BRANCH) + _, out, outcome, decided = self.bump(support.target("0.1.4")) + self.assertEqual(outcome["action"], "none") + self.assertEqual(self.tools.calls, [], "the pins are not moved when there is nothing to publish") + self.assertIn("exists already", decided["skip"]) + self.assertIsNone(decided["push"]) + self.assertIn("Nothing done", out) + + def test_any_pull_request_from_the_branch_in_this_repository_stops_it(self): + for state in ("OPEN", "CLOSED", "MERGED"): + pulls = self.write("pulls.json", [{"number": 12, "headRefName": self.BRANCH, "isCrossRepository": False, "state": state}]) + _, _, _, decided = self.bump(support.target("0.1.4"), "--pulls", pulls) + self.assertEqual(decided["skip"], "pull request #12 (%s) already came from `%s`" % (state.lower(), self.BRANCH)) + self.assertEqual(self.tools.calls, []) + + def test_a_forks_pull_request_of_the_same_name_does_not(self): + pulls = self.write("pulls.json", [{"number": 12, "headRefName": self.BRANCH, "isCrossRepository": True, "state": "OPEN"}]) + _, _, outcome, decided = self.bump(support.target("0.1.4"), "--pulls", pulls) + self.assertIsNone(decided["skip"]) + self.assertEqual(outcome["action"], "create") + + def test_no_bump_without_a_bump_target(self): + _, _, outcome, decided = self.bump(support.target("0.1.4-pre.2"), bump=False) + self.assertEqual((outcome["action"], decided["skip"]), ("none", "there is nothing to bump to")) + _, _, _, decided = self.bump(None) + self.assertEqual(decided["skip"], "there is nothing to bump to") + self.assertEqual(self.tools.calls, []) def test_a_target_whose_images_are_not_published_waits_and_touches_nothing(self): waiting = dict(support.target("0.1.4"), ready=False, waiting_for="upstream has tagged v0.1.4 but has not published its gateway image yet", gateway_image=None, supervisor_image=None) - _, out, outcome, decided = self.next(waiting) - self.assertEqual(outcome["action"], "none") + _, out, _, decided = self.bump(waiting) self.assertEqual(self.tools.calls, []) - self.assertEqual([decided[key] for key in ("push", "pr", "comment", "delete")], [None] * 4) + self.assertIsNone(decided["push"]) self.assertIn("has not published its gateway image yet", out) def test_a_failing_tool_fails_the_command_and_leaves_no_decision_to_publish(self): - plan_path = self.plan_file(support.target("0.1.4-pre.2")) + plan_path = self.plan_file(support.target("0.1.4")) with mock.patch.object(follow.pinmove, "run_command", support.FakeTools(fail=("go", "get"))): code, _, err, _ = self.run_cli( - "next", "--plan", plan_path, "--repo", self.repo.path, "--main-ref", "refs/remotes/origin/main", "--out", self.path("next") + "bump", "--plan", plan_path, "--repo", self.repo.path, "--main-ref", "refs/remotes/origin/main", "--out", self.path("bump") ) self.assertEqual(code, 1) self.assertIn("go get", err) - self.assertFalse(os.path.exists(self.path("next", "publish.json"))) + self.assertFalse(os.path.exists(self.path("bump", "publish.json"))) + + +class CheckForward(Cli): + def check(self, pinned_now, target="0.1.4"): + plan_path = self.write("plan.json", {"target": dict(support.target(target))}) + return self.run_cli("check-forward", "--plan", plan_path, "--pins", self.pins_file(support.pins_doc(pinned_now))) + + def test_a_target_still_ahead_of_main_passes(self): + code, out, _, _ = self.check("0.1.3") + self.assertEqual(code, 0) + self.assertIn("still moves it forward", out) + + def test_main_reaching_or_passing_the_target_is_refused(self): + for now in ("0.1.4", "0.1.5"): + code, _, err, _ = self.check(now) + self.assertEqual(code, 1, now) + self.assertIn("not below the bump's target 0.1.4", err) + + def test_a_plan_without_a_target_is_refused(self): + plan_path = self.write("plan.json", {"target": None}) + code, _, err, _ = self.run_cli("check-forward", "--plan", plan_path, "--pins", self.pins_file(support.pins_doc("0.1.3"))) + self.assertEqual(code, 1) + self.assertIn("no target", err) class PublishFailure(Cli): - def test_a_refused_step_fails_the_command_and_says_so_in_the_summary(self): - decided = {"action": "update", "release_branch": None, "push": {"sha": "c" * 40, "expected": "b" * 40, "created": False}, - "pr": None, "comment": None, "automerge": False, "delete": None} - plan_path = self.write("publish.json", decided) + DECIDED = { + "branch": "bump/openshell-0.1.4", "skip": None, "push": {"sha": "c" * 40}, + "pr": {"title": "fix: move to OpenShell 0.1.4", "body": "body\n"}, + "compare": "https://github.com/o/r/compare/main...bump/openshell-0.1.4?expand=1", + } + + def test_a_refused_push_fails_the_command_and_says_so_in_the_summary(self): + plan_path = self.write("publish.json", self.DECIDED) with mock.patch.object(follow.publish_module, "run_command", lambda command, env=None: (1, "", "remote: Permission denied")): code, _, err, _ = self.run_cli("publish", "--plan", plan_path, "--summary", self.path("published.md")) self.assertEqual(code, 1) - self.assertIn("could not push next", err) + self.assertIn("could not push bump/openshell-0.1.4", err) self.assertIn("**Publishing failed.**", self.read("published.md")) + def test_a_refused_pull_request_fails_with_the_link(self): + def run(command, env=None): + if command[:3] == ["gh", "pr", "create"]: + return 1, "", "HTTP 403: Resource not accessible by integration" + return 0, "", "" -class CheckA(Cli): - def record(self, pull, stack, bff, compat): - code, _, _, _ = self.run_cli("record-check", "--pull", pull, "--stack", stack, "--bff", bff, "--compat", compat, "--out", self.path("check-a.json")) - self.assertEqual(code, 0) - return json.loads(self.read("check-a.json"))["outcome"] - - def report(self, made, result="check-a.json", dry_run="false"): - plan_path = self.write("plan.json", made) - return self.run_cli( - "check-report", "--plan", plan_path, "--result", self.path(result), "--run-url", "https://github.com/o/r/actions/runs/7", - "--repo-url", "https://github.com/o/r", "--dry-run", dry_run, "--out", self.path("report"), - ) - - def today(self): - return plan_module.build(support.pins_of_2026_10_09(), support.FakeUpstream(support.upstream_data())) - - def test_step_outcomes_become_one_result(self): - self.assertEqual(self.record("success", "success", "success", "success"), "passed") - self.assertEqual(self.record("success", "success", "success", "failure"), "failed") - self.assertEqual(self.record("success", "failure", "skipped", "skipped"), "stack_failed") - - def test_a_failure_renders_the_issue_and_is_not_incomplete(self): - self.record("success", "success", "success", "failure") - code, _, _, outputs = self.report(self.today()) - self.assertEqual(code, 0) - self.assertEqual(outputs, {"issue_action": "upsert", "incomplete": "0"}) - self.assertEqual(self.read("report", "issue-title.txt"), "OpenShell 0.1.4-pre.2: main fails the compatibility suite\n") - self.assertIn("https://github.com/o/r/actions/runs/7", self.read("report", "issue-body.md")) - self.assertIn("FAILS", self.read("report", "summary.md")) - - def test_a_pass_closes_with_a_comment_that_says_why(self): - self.record("success", "success", "success", "success") - _, _, _, outputs = self.report(self.today()) - self.assertEqual(outputs, {"issue_action": "close", "incomplete": "0"}) - comment = self.read("report", "close-comment.md") - self.assertTrue(comment.startswith("`main` passes the compatibility suite against OpenShell `0.1.4-pre.2`")) - self.assertIn("https://github.com/o/r/actions/runs/7", comment) - self.assertEqual(self.read("report", "issue-body.md"), "") - - def test_a_job_that_never_reported_keeps_the_issue_and_marks_the_run(self): - _, _, _, outputs = self.report(self.today(), result="missing.json") - self.assertEqual(outputs, {"issue_action": "keep", "incomplete": "1"}) - - def test_a_gateway_that_did_not_start_is_not_a_finding(self): - self.record("success", "failure", "skipped", "skipped") - _, out, _, outputs = self.report(self.today()) - self.assertEqual(outputs, {"issue_action": "keep", "incomplete": "1"}) - self.assertIn("the gateway never became healthy", out) - - def test_nothing_ahead_closes_and_needs_no_result(self): - made = plan_module.build( - support.pins_of_2026_10_09(), support.FakeUpstream(support.upstream_data(drop=("v0.1.4-pre.1", "v0.1.4-pre.2"))) - ) - _, _, _, outputs = self.report(made, result="missing.json") - self.assertEqual(outputs, {"issue_action": "close", "incomplete": "0"}) + plan_path = self.write("publish.json", self.DECIDED) + with mock.patch.object(follow.publish_module, "run_command", run): + code, _, err, _ = self.run_cli("publish", "--plan", plan_path) + self.assertEqual(code, 1) + self.assertIn(self.DECIDED["compare"], err) class Errors(Cli): @@ -370,7 +390,7 @@ def test_every_expected_failure_is_exit_1_with_a_message_not_a_traceback(self): plan_module.PlanError("a plan problem"), upstream.UpstreamError("an upstream problem"), pinmove.PinMoveError("a move problem"), - follow.nextbranch.GitError("a git problem"), + follow.bump.GitError("a git problem"), follow.publish_module.PublishError("a publish problem"), follow.versions.VersionError("a version problem"), ): diff --git a/deploy/ci/upstream/tests/test_nextbranch.py b/deploy/ci/upstream/tests/test_nextbranch.py deleted file mode 100644 index b10da5f1..00000000 --- a/deploy/ci/upstream/tests/test_nextbranch.py +++ /dev/null @@ -1,577 +0,0 @@ -"""`next`, against a real git repository on disk. - -Every scenario builds a throwaway clone with a bare `origin`, lets prepare() -work in it, and looks at what came out with git itself. The pin move is the -real one (pinmove.apply) with stand-in tools, so the files it rewrites and the -conflicts they cause are real too. - -A class marked `shared` arranges its scenario once and its tests only look at -the result: git is the slow part, and a scenario costs a few dozen commands. -""" - -import json -import os -import subprocess -import unittest - -import nextbranch -import pinmove -import pins -import report -from tests import support - -PINNED = {"release": "0.1.3", "line": "0.1"} -ON_0_1_4 = {"release": "0.1.4", "line": "0.1"} -PRE1 = support.target("0.1.4-pre.1") -PRE2 = support.target("0.1.4-pre.2") -STABLE = support.target("0.1.4") - -MORE = {"app.txt": "one\ntwo\nthree\nfour\n"} -PIN_FILES = sorted([pinmove.README, pinmove.GO_MOD, pinmove.GO_SUM, pinmove.PINS]) - - -def with_dependency(go_mod, after, new): - """go.mod with one more requirement, placed after an existing one.""" - return go_mod.replace("\t%s v1.0.0\n" % after, "\t%s v1.0.0\n\t%s v1.0.0\n" % (after, new)) - - -class NextBranch(unittest.TestCase): - shared = False - - @classmethod - def fresh(cls): - cls.repo = support.Repo("0.1.3") - cls.tools = support.FakeTools() - - @classmethod - def setUpClass(cls): - if cls.shared: - cls.fresh() - cls.addClassCleanup(cls.repo.close) - cls.arrange() - - def setUp(self): - if not self.shared: - self.fresh() - self.addCleanup(self.repo.close) - self.arrange() - - @classmethod - def arrange(cls): - """The scenario. Runs once for a shared class, before every test otherwise.""" - - @classmethod - def prepare(cls, target, tools=None, spent=False, pinned=PINNED): - tools = tools or cls.tools - return nextbranch.prepare( - cls.repo.path, - target, - mover=lambda tree, to: pinmove.apply(tree, to, tools="/tools", run=tools), - message=lambda to, moved: report.commit_message(to, pinned), - lockfiles=nextbranch.lockfile_regenerators(run=tools), - main_ref="refs/remotes/origin/main", - spent=spent, - ) - - @classmethod - def start(cls, target=PRE1): - """Create `next` for a target and publish it. Returns the outcome.""" - outcome = cls.prepare(target) - cls.repo.publish(outcome["sha"]) - return outcome - - @classmethod - def two_dependencies(cls): - """A commit on `next` and one on main that each add a requirement. - - Far apart in go.mod, so the manifests merge. Next to each other in the - sorted go.sum, so the lock files do not. Returns the commit on next. - """ - ours = with_dependency(cls.repo.read(pinmove.GO_MOD, "origin/next"), "example.com/zzz", "example.com/sss-person") - person = cls.repo.on_next("feat: a dependency on next", {pinmove.GO_MOD: ours, pinmove.GO_SUM: support.go_sum_for(ours)}) - theirs = with_dependency(cls.repo.read(pinmove.GO_MOD, "origin/main"), "example.com/aaa", "example.com/sss-main") - cls.repo.on_main("feat: a dependency on main", {pinmove.GO_MOD: theirs, pinmove.GO_SUM: support.go_sum_for(theirs)}) - return person - - def subjects(self, sha): - return self.repo.log("origin/main", sha) - - def pin_commit(self, sha): - return self.repo.git("rev-list", "--reverse", "origin/main..%s" % sha).splitlines()[0] - - def changed_by(self, sha): - return sorted(self.repo.git("show", "--name-only", "--format=", sha).splitlines()) - - @classmethod - def pins_at(cls, sha): - return json.loads(cls.repo.read(pinmove.PINS, sha)) - - def assert_left_clean(self): - """No scratch worktree, no rebase in progress, and the clone where it was.""" - self.assertEqual(len(self.repo.git("worktree", "list").splitlines()), 1) - self.assertEqual(self.repo.git("status", "--porcelain"), "") - self.assertEqual(self.repo.git("rev-parse", "--abbrev-ref", "HEAD"), "main") - self.assertFalse(os.path.isdir(os.path.join(self.repo.path, ".git", "rebase-merge"))) - - -class Create(NextBranch): - shared = True - - @classmethod - def arrange(cls): - cls.outcome = cls.prepare(PRE1) - cls.sha = cls.outcome["sha"] - - def test_next_is_main_plus_one_commit(self): - self.assertEqual(self.outcome["action"], "create") - self.assertIsNone(self.outcome["next"]) - self.assertEqual(self.repo.rev(self.sha + "~1"), self.repo.rev("origin/main")) - self.assertEqual(self.subjects(self.sha), ["fix: move to OpenShell 0.1.4-pre.1"]) - self.assertTrue(self.outcome["pin_moved"]) - self.assertFalse(self.outcome["rebased"]) - self.assertEqual(self.outcome["people"], []) - - def test_the_commit_is_the_bots_signed_off_and_carries_the_trailer(self): - body = self.repo.git("log", "-1", "--format=%an <%ae>%n%B", self.sha) - self.assertTrue(body.startswith("github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>")) - self.assertIn("\nOpenShell-Release: 0.1.4-pre.1\n", body) - self.assertIn("Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>", body) - self.assertIn("from 0.1.3 to", body) - - def test_it_changes_the_pin_files_and_nothing_else(self): - self.assertEqual(self.changed_by(self.sha), PIN_FILES) - self.assertEqual(self.outcome["pins"]["release"], "0.1.4-pre.1") - self.assertEqual(self.pins_at(self.sha)["gateway_image"], PRE1["gateway_image"]) - - def test_nothing_is_pushed_and_the_commit_is_kept_under_a_local_ref(self): - self.assertIsNone(self.repo.origin_next()) - self.assertEqual(self.repo.rev(nextbranch.LOCAL_REF), self.sha) - self.assert_left_clean() - - -class NothingMoved(NextBranch): - def test_main_did_not_move_and_the_pin_is_there_so_nothing_changes(self): - first = self.start(PRE1) - again = support.FakeTools() - outcome = self.prepare(PRE1, again) - self.assertEqual(outcome["action"], "none") - self.assertEqual(outcome["sha"], first["sha"], "not even the commit id") - self.assertFalse(outcome["rebased"]) - self.assertFalse(outcome["pin_moved"]) - self.assertEqual(again.calls, [], "and no tool was run") - self.assert_left_clean() - - def test_with_a_persons_commit_on_it_too(self): - self.start(PRE1) - person = self.repo.on_next("feat: use the new field", MORE) - outcome = self.prepare(PRE1) - self.assertEqual(outcome["action"], "none") - self.assertEqual(outcome["sha"], person) - self.assertEqual([commit["subject"] for commit in outcome["people"]], ["feat: use the new field"]) - - -class MainMoved(NextBranch): - shared = True - - @classmethod - def arrange(cls): - cls.start(PRE1) - cls.person = cls.repo.on_next("feat: use the new field", MORE) - cls.main = cls.repo.on_main("fix: something on main", {"docs/notes.md": "a note\n"}) - cls.tools.calls[:] = [] - cls.outcome = cls.prepare(PRE1) - cls.sha = cls.outcome["sha"] - - def test_next_is_rebased_onto_it(self): - self.assertEqual(self.outcome["action"], "update") - self.assertTrue(self.outcome["rebased"]) - self.assertFalse(self.outcome["pin_moved"]) - self.assertIn("main has moved", self.outcome["reason"]) - self.assertEqual(self.repo.git("merge-base", self.sha, "origin/main"), self.main) - - def test_it_still_reads_main_the_pin_move_then_peoples_commits(self): - self.assertEqual(self.subjects(self.sha), ["fix: move to OpenShell 0.1.4-pre.1", "feat: use the new field"]) - self.assertEqual(self.repo.read("docs/notes.md", self.sha), "a note\n") - self.assertEqual(self.repo.read("app.txt", self.sha), MORE["app.txt"]) - - def test_a_persons_commit_keeps_its_author_and_the_lease_names_what_was_there(self): - self.assertEqual(self.repo.git("log", "-1", "--format=%an", self.sha), "A Person") - self.assertEqual(self.outcome["next"], self.person, "what origin/next was: the value the push is leased on") - self.assertEqual(self.repo.origin_next(), self.person, "and nothing was pushed") - - def test_no_tool_is_run_for_a_rebase_alone(self): - self.assertEqual(self.tools.calls, []) - - -class SecondPinMove(NextBranch): - """The target changed: the new move is a fixup, folded into the pin commit.""" - - shared = True - - @classmethod - def arrange(cls): - cls.start(PRE1) - cls.repo.on_next("feat: use the new field", MORE) - cls.repo.on_next("test: cover it", {"app_test.txt": "covered\n"}) - cls.outcome = cls.prepare(PRE2) - cls.sha = cls.outcome["sha"] - - def test_there_is_still_one_pin_commit_and_it_is_first(self): - self.assertEqual(self.outcome["action"], "update") - self.assertTrue(self.outcome["pin_moved"]) - self.assertFalse(self.outcome["rebased"], "main did not move") - self.assertEqual( - self.subjects(self.sha), - ["fix: move to OpenShell 0.1.4-pre.2", "feat: use the new field", "test: cover it"], - ) - commits = nextbranch.commits_between(nextbranch.Git(self.repo.path), "origin/main", self.sha) - self.assertEqual([commit["pin"] for commit in commits], ["0.1.4-pre.2", None, None]) - - def test_no_fixup_commit_is_left_behind(self): - for subject in self.subjects(self.sha): - self.assertFalse(subject.startswith(("amend!", "fixup!", "squash!")), subject) - - def test_its_message_names_the_release_it_now_pins(self): - body = self.repo.git("log", "-1", "--format=%B", self.pin_commit(self.sha)) - self.assertTrue(body.startswith("fix: move to OpenShell 0.1.4-pre.2\n")) - self.assertIn("OpenShell-Release: 0.1.4-pre.2", body) - self.assertNotIn("0.1.4-pre.1", body) - self.assertEqual(body.count("Signed-off-by:"), 1) - - def test_the_pin_commit_holds_the_whole_move_from_main(self): - pin = self.pin_commit(self.sha) - self.assertEqual(self.pins_at(pin)["release"], "0.1.4-pre.2") - self.assertEqual(self.pins_at(pin)["gateway_image"], PRE2["gateway_image"]) - self.assertIn(PRE2["commit"][:12], self.repo.read(pinmove.GO_MOD, pin)) - self.assertNotIn(PRE1["commit"][:12], self.repo.read(pinmove.GO_SUM, pin)) - self.assertEqual(self.changed_by(pin), PIN_FILES) - - def test_peoples_commits_are_untouched_in_content_and_author(self): - self.assertEqual(self.repo.read("app.txt", self.sha), MORE["app.txt"]) - self.assertEqual(self.repo.read("app_test.txt", self.sha), "covered\n") - authors = self.repo.git("log", "--format=%an", "origin/main..%s" % self.sha).splitlines() - self.assertEqual(authors, ["A Person", "A Person", "github-actions[bot]"]) - self.assertEqual(len(self.outcome["people"]), 2) - self.assertEqual(self.changed_by(self.sha), ["app_test.txt"]) - - -class StableReleaseAndMainMovedTogether(NextBranch): - def test_one_run_rebases_and_re_pins(self): - self.start(PRE2) - self.repo.on_next("feat: use the new field", MORE) - self.repo.on_main("fix: something on main", {"docs/notes.md": "a note\n"}) - outcome = self.prepare(STABLE) - self.assertEqual(outcome["action"], "update") - self.assertTrue(outcome["rebased"] and outcome["pin_moved"]) - self.assertEqual(self.subjects(outcome["sha"]), ["fix: move to OpenShell 0.1.4", "feat: use the new field"]) - self.assertEqual(outcome["pins"]["release"], "0.1.4") - body = self.repo.git("log", "-1", "--format=%B", self.pin_commit(outcome["sha"])) - self.assertIn("OpenShell-Release: 0.1.4\n", body + "\n") - self.assertNotIn("pre-release", body) - - -class ANewMinorOnNext(NextBranch): - def test_the_built_in_line_moves_in_the_pin_commit(self): - outcome = self.prepare(support.target("0.2.0-pre.1")) - self.assertIn(pinmove.LINE_SOURCE, self.changed_by(outcome["sha"])) - self.assertIn('BuiltInGatewayReleaseLine = "0.2"', self.repo.read(pinmove.LINE_SOURCE, outcome["sha"])) - self.assertIn("starts gateway release line 0.2", self.repo.git("log", "-1", "--format=%B", outcome["sha"])) - - -class LockFileConflict(NextBranch): - """Both sides added a dependency: the manifests merge, the lock files do not.""" - - shared = True - - @classmethod - def arrange(cls): - cls.start(PRE1) - cls.two_dependencies() - cls.outcome = cls.prepare(PRE1) - - def test_the_lock_file_is_regenerated_and_the_rebase_carries_on(self): - self.assertEqual(self.outcome["action"], "update", self.outcome) - self.assertTrue(self.outcome["rebased"]) - self.assertEqual(self.outcome["regenerated"], [pinmove.GO_SUM]) - self.assertIsNone(self.outcome["conflict"]) - - def test_the_result_has_both_dependencies_in_the_manifest_and_in_the_lock_file(self): - sha = self.outcome["sha"] - go_mod, go_sum = self.repo.read(pinmove.GO_MOD, sha), self.repo.read(pinmove.GO_SUM, sha) - for dependency in ("example.com/sss-main", "example.com/sss-person", PRE1["commit"][:12]): - self.assertIn(dependency, go_mod) - self.assertIn(dependency, go_sum) - self.assertEqual(go_sum, support.go_sum_for(go_mod), "the lock file is what its tool derives from the manifest") - self.assertNotIn("<<<<<<<", go_sum) - - def test_the_shape_and_the_authors_are_kept(self): - sha = self.outcome["sha"] - self.assertEqual(self.subjects(sha), ["fix: move to OpenShell 0.1.4-pre.1", "feat: a dependency on next"]) - self.assertEqual(self.repo.git("log", "-1", "--format=%an", sha), "A Person") - self.assert_left_clean() - - def test_go_mod_tidy_is_what_regenerated_it_and_nothing_was_built(self): - self.assertIn(["go", "mod", "tidy"], self.tools.calls) - for command in self.tools.calls: - self.assertNotIn(command[1], ("build", "test", "vet", "run")) - - -class PackageLockConflict(NextBranch): - def test_it_is_regenerated_from_package_json_without_running_scripts(self): - # package.json gains a key at either end, which merges; the lock file - # is rewritten on both sides, which does not. - base = [("mmm-%02d" % index, "1.0.0") for index in range(12)] - - def package(*extra, **where): - items = (list(extra) + base) if where.get("first") else (base + list(extra)) - text = json.dumps({"dependencies": dict(items)}, indent=2) + "\n" - return {"frontend/package.json": text, "frontend/package-lock.json": support.package_lock_for(text)} - - self.repo.on_main("chore: more dependencies", package()) - self.start(PRE1) - self.repo.on_next("feat: one on next", package(("zzz-next", "3.0.0"))) - self.repo.on_main("feat: one on main", package(("aaa-main", "1.0.0"), first=True)) - self.tools.calls[:] = [] - outcome = self.prepare(PRE1) - - self.assertEqual(outcome["action"], "update", outcome) - self.assertEqual(outcome["regenerated"], ["frontend/package-lock.json"]) - merged = self.repo.read("frontend/package.json", outcome["sha"]) - self.assertIn("zzz-next", merged) - self.assertIn("aaa-main", merged) - self.assertEqual(self.repo.read("frontend/package-lock.json", outcome["sha"]), support.package_lock_for(merged)) - self.assertEqual( - self.tools.calls, [["npm", "install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"]] - ) - - -class UnresolvableConflict(NextBranch): - """A person's commit and main changed the same line of an ordinary file.""" - - shared = True - - @classmethod - def arrange(cls): - cls.created = cls.start(PRE1) - cls.person = cls.repo.on_next("feat: change line two", {"app.txt": "one\nTWO from next\nthree\n"}) - cls.repo.on_main("fix: change line two as well", {"app.txt": "one\nTWO from main\nthree\n"}) - cls.outcome = cls.prepare(PRE1) - - def test_it_is_reported_with_the_files_and_the_commit(self): - self.assertEqual(self.outcome["action"], "conflict") - conflict = self.outcome["conflict"] - self.assertEqual(conflict["stage"], "rebase") - self.assertEqual(conflict["files"], ["app.txt"]) - self.assertEqual(conflict["commit"]["sha"], self.person) - self.assertEqual(conflict["commit"]["subject"], "feat: change line two") - self.assertIsNone(self.outcome["sha"]) - - def test_next_is_left_exactly_as_it_was(self): - self.assertEqual(self.repo.origin_next(), self.person) - self.assertEqual(self.outcome["next"], self.person) - # Nothing is kept for publishing either: the local ref still names the - # commit `next` was created at, not a half-rebased one. - self.assertEqual(self.repo.rev(nextbranch.LOCAL_REF), self.created["sha"]) - self.assertEqual(self.outcome["pins"]["release"], "0.1.4-pre.1") - self.assertEqual([commit["subject"] for commit in self.outcome["people"]], ["feat: change line two"]) - - def test_the_rebase_is_aborted_and_nothing_is_left_lying_around(self): - self.assert_left_clean() - self.assertEqual(self.outcome["regenerated"], []) - - -class ResolvedByHand(NextBranch): - def test_once_a_person_has_rebased_next_the_following_run_carries_on(self): - self.start(PRE1) - self.repo.on_next("feat: change line two", {"app.txt": "one\nTWO from next\nthree\n"}) - self.repo.on_main("fix: change line two as well", {"app.txt": "one\nTWO from main\nthree\n"}) - self.assertEqual(self.prepare(PRE1)["action"], "conflict") - - # By hand: rebase, keep both changes, continue, force-push. - self.repo.git("checkout", "-q", "-B", "next", "origin/next") - with self.assertRaises(subprocess.CalledProcessError): - self.repo.git("rebase", "origin/main") - self.repo.write("app.txt", "one\nTWO from main and next\nthree\n") - self.repo.git("add", "app.txt") - self.repo.git("-c", "core.editor=true", "rebase", "--continue") - self.repo.git("push", "-q", "--force", "origin", "next") - self.repo.git("checkout", "-q", "main") - self.repo.git("fetch", "-q", "origin") - - outcome = self.prepare(PRE2) - self.assertEqual(outcome["action"], "update") - self.assertEqual(self.subjects(outcome["sha"]), ["fix: move to OpenShell 0.1.4-pre.2", "feat: change line two"]) - self.assertEqual(self.repo.read("app.txt", outcome["sha"]), "one\nTWO from main and next\nthree\n") - - -class ALockFileAndAnOrdinaryFileInOneStop(NextBranch): - def test_nothing_is_regenerated_for_a_stop_that_cannot_be_finished(self): - self.start(PRE1) - ours = with_dependency(self.repo.read(pinmove.GO_MOD, "origin/next"), "example.com/zzz", "example.com/sss-person") - person = self.repo.on_next( - "feat: both", - {pinmove.GO_MOD: ours, pinmove.GO_SUM: support.go_sum_for(ours), "app.txt": "one\nTWO from next\nthree\n"}, - ) - theirs = with_dependency(self.repo.read(pinmove.GO_MOD, "origin/main"), "example.com/aaa", "example.com/sss-main") - self.repo.on_main( - "feat: both on main", - {pinmove.GO_MOD: theirs, pinmove.GO_SUM: support.go_sum_for(theirs), "app.txt": "one\nTWO from main\nthree\n"}, - ) - self.tools.calls[:] = [] - outcome = self.prepare(PRE1) - self.assertEqual(outcome["action"], "conflict") - self.assertEqual(sorted(outcome["conflict"]["files"]), ["app.txt", pinmove.GO_SUM]) - self.assertEqual(outcome["regenerated"], []) - self.assertEqual(self.tools.calls, [], "no tool was run") - self.assertEqual(self.repo.origin_next(), person) - self.assert_left_clean() - - -class ConflictWhileMovingThePin(NextBranch): - """A person's commit edited the pins file where the next move writes.""" - - shared = True - - @classmethod - def arrange(cls): - cls.start(PRE1) - doc = cls.pins_at("origin/next") - doc["supervisor_image"] = support.image("supervisor", "0.1.4-pre.1", salt="hand-pinned") - cls.person = cls.repo.on_next("fix: pin another supervisor build", {pinmove.PINS: pins.dump(doc)}) - cls.outcome = cls.prepare(PRE2) - - def test_it_stops_and_says_it_was_the_pin_move(self): - self.assertEqual(self.outcome["action"], "conflict") - self.assertEqual(self.outcome["conflict"]["stage"], "pin") - self.assertEqual(self.outcome["conflict"]["files"], [pinmove.PINS]) - self.assertFalse(self.outcome["pin_moved"]) - self.assertIn("could not be moved to OpenShell 0.1.4-pre.2", self.outcome["reason"]) - - def test_next_keeps_the_old_pin_and_the_persons_commit(self): - self.assertEqual(self.repo.origin_next(), self.person) - self.assertEqual(self.outcome["pins"]["release"], "0.1.4-pre.1") - self.assert_left_clean() - - -class MadeByHand(NextBranch): - """Someone pushed `next` themselves, so no pin move starts it.""" - - def test_the_pin_move_is_put_first_and_their_commit_on_top(self): - self.repo.git("checkout", "-q", "-b", "next") - self.repo.write("app.txt", "one\ntwo\nthree\nby hand\n") - self.repo.commit("feat: started early") - self.repo.git("push", "-q", "origin", "next") - self.repo.git("checkout", "-q", "main") - self.repo.git("fetch", "-q", "origin") - - outcome = self.prepare(PRE1) - self.assertEqual(outcome["action"], "update") - self.assertTrue(outcome["pin_moved"]) - sha = outcome["sha"] - self.assertEqual(self.subjects(sha), ["fix: move to OpenShell 0.1.4-pre.1", "feat: started early"]) - self.assertEqual(self.changed_by(self.pin_commit(sha)), PIN_FILES) - self.assertEqual(self.repo.read("app.txt", sha), "one\ntwo\nthree\nby hand\n") - self.assertEqual(outcome["pins"]["release"], "0.1.4-pre.1") - self.assertEqual([commit["subject"] for commit in outcome["people"]], ["feat: started early"]) - self.assert_left_clean() - - -class NothingAhead(NextBranch): - def test_no_branch_and_no_target_is_nothing_to_do(self): - outcome = self.prepare(None) - self.assertEqual(outcome["action"], "none") - self.assertEqual(self.tools.calls, []) - - def test_a_branch_with_only_the_pin_move_is_removed(self): - first = self.start(PRE1) - outcome = self.prepare(None) - self.assertEqual(outcome["action"], "delete") - self.assertEqual(outcome["next"], first["sha"], "the lease for the delete") - self.assertIn("nothing but the pin move is on it", outcome["reason"]) - self.assertEqual(self.repo.origin_next(), first["sha"], "preparing deletes nothing") - - def test_a_branch_with_a_persons_commit_is_left_alone(self): - self.start(PRE1) - person = self.repo.on_next("feat: use the new field", MORE) - outcome = self.prepare(None) - self.assertEqual(outcome["action"], "leave") - self.assertEqual([commit["subject"] for commit in outcome["people"]], ["feat: use the new field"]) - self.assertEqual(self.repo.origin_next(), person) - - def test_a_branch_whose_pull_request_was_merged_is_removed_whatever_is_on_it(self): - self.start(PRE1) - self.repo.on_next("feat: use the new field", MORE) - outcome = self.prepare(None, spent=True) - self.assertEqual(outcome["action"], "delete") - self.assertIn("its pull request was merged", outcome["reason"]) - - -class Spent(NextBranch): - """The pull request from `next` was merged, and upstream already has a newer target.""" - - def test_it_starts_again_from_main_without_the_commits_main_already_has(self): - self.start(STABLE) - person = self.repo.on_next("feat: use the new field", MORE) - # A squash merge: one commit on main with everything `next` had. - self.repo.git("merge", "-q", "--squash", "origin/next") - self.repo.commit("fix: move to OpenShell 0.1.4 (#12)") - self.repo.git("push", "-q", "origin", "main") - self.repo.git("fetch", "-q", "origin") - - outcome = self.prepare(support.target("0.1.5-pre.1"), spent=True, pinned=ON_0_1_4) - self.assertEqual(outcome["action"], "update") - self.assertEqual(outcome["next"], person) - self.assertEqual(self.subjects(outcome["sha"]), ["fix: move to OpenShell 0.1.5-pre.1"]) - self.assertEqual(outcome["people"], []) - self.assertIn("its pull request was merged", outcome["reason"]) - self.assertEqual(self.repo.read("app.txt", outcome["sha"]), MORE["app.txt"], "main has their work") - - def test_after_a_rebase_merge_the_commits_drop_out_without_being_told(self): - self.start(STABLE) - self.repo.on_next("feat: use the new field", MORE) - # Rebase and merge: the same patches on main, under new commit ids. - self.repo.git("checkout", "-q", "-B", "landing", "origin/main") - self.repo.git("-c", "user.name=Merge Button", "-c", "user.email=m@example.com", "cherry-pick", "origin/main..origin/next") - self.repo.git("push", "-q", "origin", "landing:main") - self.repo.git("checkout", "-q", "main") - self.repo.git("fetch", "-q", "origin") - self.repo.git("reset", "-q", "--hard", "origin/main") - self.assertNotEqual(self.repo.rev("origin/main"), self.repo.rev("origin/next")) - - outcome = self.prepare(support.target("0.1.5-pre.1"), pinned=ON_0_1_4) - self.assertEqual(outcome["action"], "update") - self.assertEqual(self.subjects(outcome["sha"]), ["fix: move to OpenShell 0.1.5-pre.1"]) - - -class WhatStopsIt(NextBranch): - def test_a_tool_that_writes_outside_the_pin_files_is_not_committed(self): - with self.assertRaises(nextbranch.GitError) as caught: - self.prepare(PRE1, support.FakeTools(stray="left-behind.txt")) - self.assertIn("backend/left-behind.txt", str(caught.exception)) - self.assertIsNone(self.repo.origin_next()) - self.assert_left_clean() - - def test_a_failing_pin_move_leaves_nothing_behind(self): - with self.assertRaises(pinmove.PinMoveError): - self.prepare(PRE1, support.FakeTools(fail=("go", "get"))) - self.assert_left_clean() - - def test_a_lock_file_that_cannot_be_regenerated_aborts_the_rebase(self): - self.start(PRE1) - person = self.two_dependencies() - with self.assertRaises(pinmove.PinMoveError): - self.prepare(PRE1, support.FakeTools(fail=("go", "mod", "tidy"))) - self.assertEqual(self.repo.origin_next(), person) - self.assert_left_clean() - - def test_git_settings_of_whoever_runs_it_do_not_reach_the_commit(self): - # A signing key that does not exist would fail the commit. - self.repo.git("config", "commit.gpgsign", "true") - self.repo.git("config", "user.signingkey", "no-such-key") - self.repo.git("config", "user.name", "Somebody Else") - outcome = self.prepare(PRE1) - self.assertEqual(self.repo.git("log", "-1", "--format=%an", outcome["sha"]), "github-actions[bot]") - - -if __name__ == "__main__": - unittest.main() diff --git a/deploy/ci/upstream/tests/test_pinmove.py b/deploy/ci/upstream/tests/test_pinmove.py index 8ca1ffe3..5f056366 100644 --- a/deploy/ci/upstream/tests/test_pinmove.py +++ b/deploy/ci/upstream/tests/test_pinmove.py @@ -166,6 +166,24 @@ def elsewhere(command, cwd): self.apply(support.target("0.1.4"), elsewhere) self.assertIn("Nothing is pinned to an SDK other than the one at the release tag", str(caught.exception)) + def test_a_tagged_sdk_is_pinned_when_its_tag_is_the_release_commit(self): + # From OpenShell 0.1.7 upstream tags the SDK module, and `go get` of + # the release commit resolves to that tag. + to = support.target("0.1.4") + tools = support.FakeTools(tagged={to["commit"]: "v0.1.4"}) + result = self.apply(to, tools) + self.assertEqual((result["moved"], result["sdk"]), (True, "v0.1.4")) + self.assertEqual(self.pins()["sdk"], "v0.1.4") + self.assertIn(["go", "list", "-m", "-json", "%s@v0.1.4" % pins.SDK_MODULE], tools.calls) + + def test_a_tagged_sdk_whose_tag_is_another_commit_is_refused(self): + to = support.target("0.1.4") + tools = support.FakeTools(tagged={to["commit"]: "v0.1.4"}) + tools.tag_commits["v0.1.4"] = "e" * 40 + with self.assertRaises(pinmove.PinMoveError) as caught: + self.apply(to, tools) + self.assertIn("Nothing is pinned to an SDK other than the one at the release tag", str(caught.exception)) + def test_a_target_whose_image_is_of_another_release_is_refused(self): to = dict(support.target("0.1.4"), gateway_image=support.image("gateway", "0.1.3")) with self.assertRaises(pins.PinsError): diff --git a/deploy/ci/upstream/tests/test_pins.py b/deploy/ci/upstream/tests/test_pins.py index 66ae0ca4..839c65c9 100644 --- a/deploy/ci/upstream/tests/test_pins.py +++ b/deploy/ci/upstream/tests/test_pins.py @@ -58,9 +58,9 @@ def test_the_stack_has_a_template_for_both_config_schemas(self): def test_the_comment_describes_the_file_as_it_is(self): comment = " ".join(self.doc["_comment"]) - for word in ("release", "sdk", "gateway_image", "supervisor_image", "sandbox_image", "config_schema", "next"): + for word in ("release", "sdk", "gateway_image", "supervisor_image", "sandbox_image", "config_schema", "bump/openshell-"): self.assertIn(word, comment) - for gone in ("lanes", "floor", "ceiling", "sweep", "axis"): + for gone in ("lanes", "floor", "ceiling", "sweep", "axis", "`next`"): self.assertNotIn(gone, comment) diff --git a/deploy/ci/upstream/tests/test_plan.py b/deploy/ci/upstream/tests/test_plan.py index ae6f04c5..4b2914d8 100644 --- a/deploy/ci/upstream/tests/test_plan.py +++ b/deploy/ci/upstream/tests/test_plan.py @@ -17,9 +17,9 @@ def images_for(*versions): return out -def build(doc=None, **data): +def build(doc=None, requested=None, **data): fake = support.FakeUpstream(support.upstream_data(**data)) - return plan.build(doc or support.pins_of_2026_10_09(), fake), fake + return plan.build(doc or support.pins_of_2026_10_09(), fake, requested=requested), fake class UpstreamToday(unittest.TestCase): @@ -40,8 +40,10 @@ def test_the_target_is_the_newest_pre_release_of_0_1_4(self): self.assertEqual(target["commit"], "4c1b16a4a104581fb0afe8675feff34f00cc2ca8") self.assertFalse(target["stable"]) self.assertEqual((target["base"], target["line"], target["same_line"]), ("0.1.4", "0.1", True)) - self.assertIsNone(target["release_branch"]) self.assertEqual(target["skipped"], []) + # A schedule does not bump to a pre-release; check A still tests it. + self.assertFalse(target["bump"]) + self.assertEqual(target["branch"], "bump/openshell-0.1.4-pre.2") def test_its_images_are_resolved_to_digests_once(self): target = self.plan["target"] @@ -60,6 +62,34 @@ def test_the_workload_image_and_the_config_schema_are_carried(self): self.assertEqual(self.plan["config_schema"], doc["config_schema"]) +class Requested(unittest.TestCase): + """A version a person asked for, from workflow_dispatch.""" + + def test_a_pre_release_asked_for_is_bumped_to_as_a_prep_branch(self): + made, _ = build(requested="0.1.4-pre.1", images=images_for("0.1.4-pre.1")) + target = made["target"] + self.assertEqual(made["requested"], "0.1.4-pre.1") + self.assertEqual((target["version"], target["stable"], target["bump"]), ("0.1.4-pre.1", False, True)) + self.assertEqual(target["branch"], "bump/openshell-0.1.4-pre.1") + + def test_it_wins_over_the_newest_release(self): + made, _ = build(requested="0.1.4", tags=["v0.1.4", "v0.1.5"], images=images_for("0.1.4")) + self.assertEqual(made["target"]["version"], "0.1.4") + + def test_a_version_upstream_has_not_tagged_is_refused(self): + with self.assertRaisesRegex(plan.PlanError, "upstream has no tag v0.9.0"): + build(requested="0.9.0") + + def test_a_version_that_is_not_above_the_pin_is_refused(self): + with self.assertRaisesRegex(plan.PlanError, "A bump only moves forward"): + build(requested="0.1.3") + + def test_a_malformed_version_is_refused(self): + for text in ("v0.1.4", "latest", "0.1"): + with self.assertRaisesRegex(plan.PlanError, "without the leading v"): + build(requested=text) + + class Targets(unittest.TestCase): def test_nothing_ahead_is_no_target_and_no_lookup(self): made, fake = build(drop=("v0.1.4-pre.1", "v0.1.4-pre.2")) @@ -70,25 +100,26 @@ def test_a_stable_release_on_the_same_line(self): made, _ = build(tags=["v0.1.4"], images=images_for("0.1.4")) target = made["target"] self.assertEqual((target["version"], target["stable"], target["same_line"]), ("0.1.4", True, True)) - self.assertIsNone(target["release_branch"], "a patch release leaves no line behind") + self.assertTrue(target["bump"]) + self.assertEqual(target["branch"], "bump/openshell-0.1.4") def test_stable_releases_in_between_are_skipped_and_said_so(self): made, _ = build(tags=["v0.1.4", "v0.1.5", "v0.1.6"], images=images_for("0.1.6")) self.assertEqual(made["target"]["version"], "0.1.6") self.assertEqual(made["target"]["skipped"], ["0.1.4", "0.1.5"]) - def test_a_stable_release_that_starts_a_new_line_names_the_branch_for_the_old_one(self): + def test_a_stable_release_that_starts_a_new_line_is_bumped_to(self): made, _ = build(tags=["v0.2.0"], images=images_for("0.2.0")) target = made["target"] self.assertEqual((target["version"], target["line"], target["same_line"]), ("0.2.0", "0.2", False)) - self.assertEqual(target["release_branch"], "release/0.1") + self.assertTrue(target["bump"]) + self.assertNotIn("release_branch", target, "release branches are made by hand") - def test_a_pre_release_of_a_new_line_does_not_yet(self): - # The pull request cannot merge on a pre-release, so nothing has to exist yet. + def test_a_pre_release_of_a_new_line_is_tested_and_not_bumped_on_a_schedule(self): made, _ = build(tags=["v0.2.0-pre.1"], images=images_for("0.2.0-pre.1")) target = made["target"] self.assertEqual((target["version"], target["same_line"]), ("0.2.0-pre.1", False)) - self.assertIsNone(target["release_branch"]) + self.assertFalse(target["bump"]) def test_a_tag_whose_images_are_not_published_yet_is_not_ready(self): made, _ = build(tags=["v0.1.4"]) diff --git a/deploy/ci/upstream/tests/test_publish.py b/deploy/ci/upstream/tests/test_publish.py index 60fbcbe0..548171d7 100644 --- a/deploy/ci/upstream/tests/test_publish.py +++ b/deploy/ci/upstream/tests/test_publish.py @@ -1,286 +1,98 @@ -"""Publishing: what is decided without a token, and what is run with one.""" +"""Publishing a bump: what is decided without a token, and what is run with one.""" import unittest import publish -import report from tests import support MAIN = "a" * 40 -OLD = "b" * 40 NEW = "c" * 40 REPO_URL = "https://github.com/ai-openshell/console" +BRANCH = "bump/openshell-0.1.4" -def plan_for(target, pinned="0.1.3"): +def plan_for(target, pinned="0.1.3", bump=True): made = { "pinned": {"release": pinned, "line": ".".join(pinned.split(".")[:2]), "stable": True, "sdk": "v0.0.0-20261009050449-e1f3c82caa3e", "sdk_on_tag": True}, "config_schema": "v2", "sandbox_image": support.SANDBOX, "newest_stable": pinned, + "requested": None, "target": None, } if target: - new_line = target["line"] != made["pinned"]["line"] made["target"] = dict( target, - same_line=not new_line, + same_line=target["line"] == made["pinned"]["line"], skipped=[], - release_branch="release/%s" % made["pinned"]["line"] if new_line and target["stable"] else None, + bump=bump, + branch="bump/openshell-" + target["version"], ) return made -def pins_on(target): - return { - "release": target["version"], - "sdk": support.sdk_version(target["commit"]), - "gateway_image": target["gateway_image"], - "supervisor_image": target["supervisor_image"], - } - - -def outcome_for(action, target=None, existing=None, **more): - outcome = { - "action": action, - "reason": "because", - "main": MAIN, - "next": existing, - "sha": NEW if action in ("create", "update") else (existing if action == "none" else None), - "rebased": False, - "pin_moved": action in ("create", "update"), - "regenerated": [], - "conflict": None, - "people": [], - "pins": pins_on(target) if target else None, - } - outcome.update(more) - return outcome - - -def pull(number=7, **more): - entry = { - "number": number, - "title": "fix: move to OpenShell 0.1.4-pre.1", - "body": "", - "isDraft": True, - "headRefName": "next", - "headRefOid": OLD, - "baseRefName": "main", - "isCrossRepository": False, - "comments": [], - } - entry.update(more) - return entry - - -PRE1, PRE2, STABLE, MINOR = (support.target(v) for v in ("0.1.4-pre.1", "0.1.4-pre.2", "0.1.4", "0.2.0")) -CONFLICT = {"stage": "rebase", "files": ["app.txt"], "commit": {"sha": "d" * 40, "subject": "feat: change line two"}} - - -class WhichPullRequestIsOurs(unittest.TestCase): - def test_the_one_from_next_in_this_repository_into_main(self): - self.assertEqual(publish.own_pull([pull(7)])["number"], 7) - - def test_a_fork_that_named_its_branch_next_is_not(self): - self.assertIsNone(publish.own_pull([pull(isCrossRepository=True)])) - # Without the field nothing shows it is ours, so it is left alone. - entry = pull() - del entry["isCrossRepository"] - self.assertIsNone(publish.own_pull([entry])) +def created(sha=NEW): + return {"action": "create", "reason": "", "main": MAIN, "sha": sha, "moved": None} - def test_another_branch_or_another_base_is_not(self): - self.assertIsNone(publish.own_pull([pull(headRefName="next-steps")])) - self.assertIsNone(publish.own_pull([pull(baseRefName="release/0.1")])) - self.assertIsNone(publish.own_pull([])) - self.assertIsNone(publish.own_pull(None)) - def test_the_oldest_wins_and_a_fork_beside_it_does_not_count(self): - pulls = [pull(12), pull(3, isCrossRepository=True), pull(9)] - self.assertEqual(publish.own_pull(pulls)["number"], 9) +def pull(number=12, state="OPEN", branch=BRANCH, cross=False): + return {"number": number, "headRefName": branch, "isCrossRepository": cross, "state": state} - def test_spent_means_merged_from_next_at_exactly_this_commit(self): - merged = [pull(5, headRefOid=OLD)] - self.assertTrue(publish.is_spent(merged, OLD)) - self.assertFalse(publish.is_spent(merged, NEW), "next has moved on since that merge") - self.assertFalse(publish.is_spent([pull(5, headRefOid=OLD, isCrossRepository=True)], OLD)) - self.assertFalse(publish.is_spent(merged, None)) - self.assertFalse(publish.is_spent(None, OLD)) - -class Decide(unittest.TestCase): - def build(self, action, target, pulls=None, on_branch=None, plan_target="same", **flags): - outcome = outcome_for(action, on_branch or target, **{key: flags.pop(key) for key in ("existing", "conflict") if key in flags}) - made = plan_for(target if plan_target == "same" else plan_target) - return publish.build(made, outcome, open_pulls=pulls, repo_url=REPO_URL, **flags) - - def test_a_new_branch_is_pushed_and_a_draft_opened_for_a_pre_release(self): - decided = self.build("create", PRE1) - self.assertEqual(decided["push"], {"sha": NEW, "expected": "", "created": True}) - self.assertEqual(decided["pr"]["number"], None) - self.assertEqual(decided["pr"]["title"], "fix: move to OpenShell 0.1.4-pre.1") - self.assertTrue(decided["pr"]["draft"]) - self.assertIsNone(decided["comment"]) - self.assertIsNone(decided["delete"]) - self.assertIsNone(decided["release_branch"]) - - def test_an_update_is_leased_on_what_next_was(self): - decided = self.build("update", PRE2, [pull()], existing=OLD) - self.assertEqual(decided["push"], {"sha": NEW, "expected": OLD, "created": False}) - - def test_nothing_is_pushed_when_nothing_changed_but_the_pull_request_is_still_kept(self): - decided = self.build("none", PRE1, [], existing=OLD) - self.assertIsNone(decided["push"]) - self.assertEqual(decided["pr"]["number"], None, "someone closed it: it is opened again") - - def test_a_pull_request_that_is_already_right_is_not_rewritten(self): - first = self.build("none", PRE1, [], existing=OLD)["pr"] - existing = pull(title=first["title"], body=first["body"].replace("\n", "\r\n"), isDraft=True) - again = self.build("none", PRE1, [existing], existing=OLD)["pr"] - self.assertEqual((again["number"], again["set_title"], again["set_body"], again["set_draft"]), (7, False, False, None)) - - def test_the_body_is_the_same_on_every_run_that_finds_nothing_new(self): - bodies = {self.build("none", PRE1, [], existing=OLD)["pr"]["body"] for _ in range(3)} - self.assertEqual(len(bodies), 1) - self.assertNotIn("/actions/runs/", bodies.pop()) - - def test_a_new_target_retitles_and_rewrites(self): - decided = self.build("update", PRE2, [pull()], existing=OLD)["pr"] - self.assertEqual(decided["title"], "fix: move to OpenShell 0.1.4-pre.2") - self.assertTrue(decided["set_title"] and decided["set_body"]) - self.assertIsNone(decided["set_draft"], "still a pre-release, still a draft") - - def test_the_stable_release_makes_it_ready_for_review(self): - decided = self.build("update", STABLE, [pull()], existing=OLD)["pr"] - self.assertEqual(decided["title"], "fix: move to OpenShell 0.1.4") - self.assertFalse(decided["draft"]) - self.assertEqual(decided["set_draft"], "ready") - - def test_a_pull_request_someone_marked_ready_goes_back_to_draft_on_a_pre_release(self): - decided = self.build("none", PRE1, [pull(isDraft=False)], existing=OLD)["pr"] - self.assertEqual(decided["set_draft"], "draft") - - def test_a_fork_pull_request_from_a_branch_called_next_is_never_touched(self): - decided = self.build("update", PRE2, [pull(isCrossRepository=True)], existing=OLD) - self.assertIsNone(decided["pr"]["number"], "ours does not exist yet, so it is opened; theirs is left alone") - - def test_auto_merge_only_when_asked_and_only_for_a_ready_stable_move(self): - self.assertFalse(self.build("update", STABLE, [pull()], existing=OLD)["automerge"], "off unless the variable says so") - self.assertTrue(self.build("update", STABLE, [pull()], existing=OLD, automerge=True)["automerge"]) - self.assertTrue(self.build("none", STABLE, [pull()], existing=OLD, automerge=True)["automerge"]) - self.assertFalse(self.build("update", PRE2, [pull()], existing=OLD, automerge=True)["automerge"], "never a pre-release") - self.assertFalse( - self.build("conflict", STABLE, [pull()], on_branch=PRE2, existing=OLD, conflict=CONFLICT, automerge=True)["automerge"] - ) - - def test_the_body_says_how_ci_starts_and_how_it_merges(self): - default = self.build("create", PRE1)["pr"]["body"] - self.assertIn("CI does not start by itself", default) - self.assertIn("close this pull request and reopen it", default) - self.assertIn("UPSTREAM_BOT_TOKEN", default) - self.assertIn("Nothing merges this automatically", default) - with_token = self.build("create", PRE1, has_bot_token=True)["pr"]["body"] - self.assertNotIn("does not start by itself", with_token) - self.assertIn("UPSTREAM_AUTOMERGE", self.build("create", STABLE, automerge=True)["pr"]["body"]) +STABLE = support.target("0.1.4") -class Conflicts(unittest.TestCase): - def decide(self, pulls, conflict=CONFLICT): - outcome = outcome_for("conflict", PRE1, existing=OLD, conflict=conflict) - return publish.build(plan_for(PRE2), outcome, open_pulls=pulls), outcome +class Blocked(unittest.TestCase): + def test_nothing_to_bump_to(self): + self.assertEqual(publish.blocked(plan_for(None)), "there is nothing to bump to") + self.assertEqual(publish.blocked(plan_for(STABLE, bump=False)), "there is nothing to bump to") - def test_a_comment_names_the_files_and_nothing_is_pushed(self): - decided, _ = self.decide([pull()]) - self.assertIsNone(decided["push"]) - self.assertIn("`app.txt`", decided["comment"]["body"]) - self.assertIn("feat: change line two", decided["comment"]["body"]) - self.assertIn("left exactly as it was", decided["comment"]["body"]) - - def test_the_pull_request_keeps_describing_what_is_actually_on_next(self): - decided, _ = self.decide([pull()]) - self.assertEqual(decided["pr"]["title"], "fix: move to OpenShell 0.1.4-pre.1", "not the target it could not reach") - self.assertFalse(decided["pr"]["set_title"]) - - def test_the_same_conflict_is_not_commented_on_twice(self): - first, outcome = self.decide([pull()]) - commented = pull(comments=[{"body": "unrelated"}, {"body": first["comment"]["body"]}]) - again, _ = self.decide([commented]) - self.assertIsNone(again["comment"]) - - def test_main_moving_again_is_still_the_same_conflict(self): - first, outcome = self.decide([pull()]) - moved = dict(outcome, main="e" * 40) - self.assertEqual(report.conflict_key(moved), report.conflict_key(outcome)) - - def test_a_different_file_or_a_changed_next_is_a_new_one(self): - first, _ = self.decide([pull()]) - commented = pull(comments=[{"body": first["comment"]["body"]}]) - other_file, _ = self.decide([commented], dict(CONFLICT, files=["app.txt", "other.txt"])) - self.assertIsNotNone(other_file["comment"]) - outcome = outcome_for("conflict", PRE1, existing=NEW, conflict=CONFLICT) - self.assertIsNotNone(publish.build(plan_for(PRE2), outcome, open_pulls=[commented])["comment"]) - - def test_without_a_pull_request_one_is_opened_and_then_commented_on(self): - decided, _ = self.decide([]) - self.assertIsNone(decided["pr"]["number"]) - self.assertIsNotNone(decided["comment"]) - - -class NothingToKeep(unittest.TestCase): - def test_delete_closes_the_pull_request_and_is_leased_on_what_next_is(self): - outcome = outcome_for("delete", existing=OLD) - decided = publish.build(plan_for(None), outcome, open_pulls=[pull(4)]) - self.assertEqual(decided["delete"]["expected"], OLD) - self.assertEqual(decided["delete"]["pr"], 4) - self.assertIn("because", decided["delete"]["comment"]) - self.assertIsNone(decided["push"]) - self.assertIsNone(decided["pr"]) + def test_images_not_out_yet(self): + waiting = dict(STABLE, ready=False, waiting_for="upstream has tagged v0.1.4 but has not published its gateway image yet") + self.assertIn("has not published", publish.blocked(plan_for(waiting))) - def test_leave_and_nothing_ahead_do_nothing(self): - for action in ("leave", "none"): - decided = publish.build(plan_for(None), outcome_for(action, existing=OLD), open_pulls=[pull()]) + def test_any_pull_request_from_the_branch_in_this_repository(self): + for state in ("OPEN", "CLOSED", "MERGED"): self.assertEqual( - [decided[key] for key in ("push", "pr", "comment", "delete", "release_branch")], [None] * 5, action + publish.blocked(plan_for(STABLE), [pull(state=state)]), + "pull request #12 (%s) already came from `%s`" % (state.lower(), BRANCH), ) - def test_a_target_whose_images_are_not_out_yet_does_nothing(self): - waiting = dict(STABLE, ready=False, waiting_for="not published yet", gateway_image=None, supervisor_image=None) - decided = publish.build(plan_for(waiting), outcome_for("none", existing=OLD), open_pulls=[pull()]) - self.assertIsNone(decided["pr"]) - self.assertIsNone(decided["push"]) - - -class ANewMinor(unittest.TestCase): - def test_the_branch_for_the_old_line_is_created_from_main_before_anything_else(self): - decided = publish.build(plan_for(MINOR), outcome_for("update", MINOR, existing=OLD), open_pulls=[pull()]) - self.assertEqual(decided["release_branch"], {"name": "release/0.1", "sha": MAIN}) - body = decided["pr"]["body"] - self.assertIn("This starts gateway release line `0.2.x`", body) - self.assertIn("`release/0.1` was created by the run that wrote this, from `main` at `%s`" % MAIN[:12], body) + def test_the_oldest_is_named(self): + self.assertIn("#7", publish.blocked(plan_for(STABLE), [pull(12), pull(7, "CLOSED")])) - def test_one_that_exists_is_never_moved_and_the_body_still_names_it(self): - there = "f" * 40 - decided = publish.build( - plan_for(MINOR), outcome_for("none", MINOR, existing=OLD), open_pulls=[pull()], release_branch_sha=there - ) - self.assertIsNone(decided["release_branch"]) - self.assertIn("`release/0.1` exists; it was created from `main` at `%s`" % there[:12], decided["pr"]["body"]) - - def test_a_pre_release_of_the_new_line_creates_none_yet(self): - pre = support.target("0.2.0-pre.1") - decided = publish.build(plan_for(pre), outcome_for("create", pre)) - self.assertIsNone(decided["release_branch"]) - self.assertIn("This starts gateway release line `0.2.x`", decided["pr"]["body"]) + def test_a_fork_or_another_branch_does_not_count(self): + self.assertIsNone(publish.blocked(plan_for(STABLE), [pull(cross=True), pull(branch="bump/openshell-0.1.5")])) + # An entry that does not say it is not cross-repository is not shown to be ours. + self.assertIsNone(publish.blocked(plan_for(STABLE), [{"number": 3, "headRefName": BRANCH}])) + def test_a_branch_that_exists_without_a_pull_request(self): + self.assertIn("exists already (at %s)" % ("d" * 12), publish.blocked(plan_for(STABLE), [], "d" * 40)) -class ABranchThatCannotBeRead(unittest.TestCase): - def test_a_next_without_readable_pins_needs_a_person(self): - outcome = outcome_for("none", PRE1, existing=OLD, pins=None) - with self.assertRaises(publish.PublishError): - publish.build(plan_for(PRE1), outcome, open_pulls=[]) + def test_otherwise_it_goes_ahead(self): + self.assertIsNone(publish.blocked(plan_for(STABLE), [], None)) -# --- execute --------------------------------------------------------------- +class Decide(unittest.TestCase): + def test_a_new_bump_is_pushed_and_a_pull_request_opened(self): + decided = publish.build(plan_for(STABLE), created(), [], None, REPO_URL) + self.assertIsNone(decided["skip"]) + self.assertEqual(decided["branch"], BRANCH) + self.assertEqual(decided["push"], {"sha": NEW}) + self.assertEqual(decided["pr"]["title"], "fix: move to OpenShell 0.1.4") + self.assertIn("will not touch it again", decided["pr"]["body"]) + self.assertEqual(decided["compare"], REPO_URL + "/compare/main...%s?expand=1" % BRANCH) + + def test_a_blocked_bump_decides_nothing(self): + decided = publish.build(plan_for(STABLE), created(), [pull()], None, REPO_URL) + self.assertTrue(decided["skip"]) + self.assertEqual((decided["push"], decided["pr"]), (None, None)) + + def test_a_move_that_changed_nothing_decides_nothing(self): + outcome = {"action": "none", "reason": "main already pins OpenShell 0.1.4", "main": MAIN, "sha": None, "moved": None} + decided = publish.build(plan_for(STABLE), outcome, [], None) + self.assertEqual(decided["skip"], "main already pins OpenShell 0.1.4") + self.assertIsNone(decided["push"]) class FakeGitHub(object): @@ -319,57 +131,43 @@ def run_it(self, decided, fail=None, dry_run=False): except publish.PublishError as err: return github, [], log, err - def decided(self, action, target, pulls=None, **more): - flags = {key: more.pop(key) for key in ("automerge", "release_branch_sha") if key in more} - return publish.build(plan_for(target), outcome_for(action, target, **more), open_pulls=pulls, **flags) + def decided(self): + return publish.build(plan_for(STABLE), created(), [], None, REPO_URL) - def test_create_pushes_with_an_empty_lease_then_opens_a_draft(self): - github, notes, _, error = self.run_it(self.decided("create", PRE1)) + def test_push_with_an_empty_lease_then_open_the_pull_request(self): + github, notes, _, error = self.run_it(self.decided()) self.assertIsNone(error) shown = github.shown() - self.assertEqual(shown[0], "git push --force-with-lease=refs/heads/next: origin %s:refs/heads/next" % NEW) - self.assertTrue(shown[1].startswith("gh pr create --head next --base main --title fix: move to OpenShell 0.1.4-pre.1 --body-file ")) - self.assertTrue(shown[1].endswith(" --draft")) self.assertEqual(len(shown), 2) - self.assertEqual(notes, ["Opened %s/pull/41." % REPO_URL]) + self.assertEqual(shown[0], "git push --force-with-lease=refs/heads/%s: origin %s:refs/heads/%s" % (BRANCH, NEW, BRANCH)) + self.assertTrue(shown[1].startswith("gh pr create --head %s --base main --title fix: move to OpenShell 0.1.4 --body-file " % BRANCH)) + self.assertIn("Opened %s/pull/41." % REPO_URL, notes) - def test_update_is_a_force_push_with_a_lease_on_the_commit_that_was_there(self): - github, _, _, _ = self.run_it(self.decided("update", PRE2, [pull()], existing=OLD)) - self.assertEqual(github.shown()[0], "git push --force-with-lease=refs/heads/next:%s origin %s:refs/heads/next" % (OLD, NEW)) - self.assertNotIn("--force ", github.shown()[0]) - - def test_the_title_and_body_are_patched_and_the_draft_state_set(self): - github, _, _, _ = self.run_it(self.decided("update", STABLE, [pull()], existing=OLD)) - shown = github.shown() - self.assertTrue(shown[1].startswith("gh api --method PATCH repos/{owner}/{repo}/pulls/7 --silent -f title=fix: move to OpenShell 0.1.4 -F body=@")) - self.assertEqual(shown[2], "gh pr ready 7") - back = self.run_it(self.decided("none", PRE1, [pull(isDraft=False)], existing=OLD))[0].shown() - self.assertIn("gh pr ready 7 --undo", back) - - def test_nothing_is_run_for_a_pull_request_that_is_already_right(self): - first = self.decided("none", PRE1, [], existing=OLD)["pr"] - decided = self.decided("none", PRE1, [pull(title=first["title"], body=first["body"])], existing=OLD) - github, notes, _, error = self.run_it(decided) - self.assertEqual((github.commands, notes, error), ([], [], None)) + def test_a_skip_runs_nothing(self): + github, notes, _, error = self.run_it(publish.build(plan_for(STABLE), created(), [pull()], None)) + self.assertIsNone(error) + self.assertEqual(github.commands, []) + self.assertTrue(notes[0].startswith("Nothing to publish: pull request #12")) + + def test_nothing_ever_force_pushes_over_a_branch_or_deletes_one(self): + github, _, _, _ = self.run_it(self.decided()) + for command in github.shown(): + if command.startswith("git push"): + self.assertIn("--force-with-lease=refs/heads/%s: " % BRANCH, command, "the lease expects no branch") + self.assertNotIn(" :refs/heads/", command) + self.assertNotIn("--force ", command) + self.assertNotIn("pr merge", command) + self.assertNotIn("pr close", command) def test_only_git_push_and_gh_are_ever_run(self): - for decided in ( - self.decided("create", PRE1), - self.decided("update", STABLE, [pull()], existing=OLD, automerge=True), - self.decided("update", MINOR, [pull()], existing=OLD), - publish.build(plan_for(PRE2), outcome_for("conflict", PRE1, existing=OLD, conflict=CONFLICT), open_pulls=[]), - publish.build(plan_for(None), outcome_for("delete", existing=OLD), open_pulls=[pull()]), - ): - github, _, _, error = self.run_it(decided) - self.assertIsNone(error) - self.assertTrue(github.commands) - for command in github.commands: - self.assertTrue(publish._is_allowed(command), command) - self.assertIn(command[0], ("git", "gh")) - if command[0] == "git": - self.assertIn("push", command) - for word in ("commit", "rebase", "fetch", "config", "clone"): - self.assertNotIn(word, command) + github, _, _, _ = self.run_it(self.decided()) + for command in github.commands: + self.assertTrue(publish._is_allowed(command), command) + self.assertIn(command[0], ("git", "gh")) + if command[0] == "git": + self.assertIn("push", command) + for word in ("commit", "rebase", "fetch", "config", "clone"): + self.assertNotIn(word, command) def test_anything_else_is_refused_before_it_runs(self): for command in (["go", "mod", "tidy"], ["git", "commit", "-m", "x"], ["git", "-c", "a=b", "fetch"], ["python3", "x.py"], ["sh", "-c", "gh"]): @@ -379,7 +177,7 @@ def test_anything_else_is_refused_before_it_runs(self): runner(["node", "script.mjs"]) def test_the_token_reaches_git_through_the_environment_only(self): - github, _, _, _ = self.run_it(self.decided("create", PRE1)) + github, _, _, _ = self.run_it(self.decided()) push = github.commands[0] self.assertEqual(push[:5], ["git", "-c", "credential.helper=", "-c", "credential.helper=" + publish.CREDENTIAL_HELPER]) self.assertIn('echo "password=${GH_TOKEN}"', publish.CREDENTIAL_HELPER) @@ -388,118 +186,32 @@ def test_the_token_reaches_git_through_the_environment_only(self): self.assertNotIn("x-access-token:", word) self.assertNotIn("extraheader", word) - def test_a_refused_pull_request_takes_the_new_branch_away_again_and_names_both_remedies(self): - refusal = (1, "", "pull request create failed: GraphQL: GitHub Actions is not permitted to create or approve pull requests") - github, _, _, error = self.run_it(self.decided("create", PRE1), fail={"gh pr create": refusal}) - self.assertIsNotNone(error) - self.assertEqual(github.shown()[-1], "git push --force-with-lease=refs/heads/next:%s origin :refs/heads/next" % NEW) - message = str(error) - self.assertIn("Allow GitHub Actions to create and approve pull requests", message) - self.assertIn("UPSTREAM_BOT_TOKEN", message) - self.assertIn("The branch was deleted again", message) - self.assertIn("not permitted to create", message, "GitHub's own answer is shown") - - def test_a_refused_pull_request_leaves_a_branch_that_was_already_there(self): - github, _, _, error = self.run_it( - self.decided("update", PRE2, [], existing=OLD), fail={"gh pr create": (1, "", "refused")} - ) - self.assertIsNotNone(error) - self.assertEqual(len([text for text in github.shown() if text.startswith("git push")]), 1) - self.assertNotIn("deleted again", str(error)) - - def test_a_lease_that_no_longer_holds_stands_down_without_an_error(self): - stale = (1, "", " ! [rejected] %s -> next (stale info)\nerror: failed to push some refs" % NEW) - github, notes, _, error = self.run_it(self.decided("update", PRE2, [pull()], existing=OLD), fail={"git": stale}) + def test_a_branch_that_appeared_meanwhile_stands_down_without_an_error(self): + github, notes, _, error = self.run_it(self.decided(), fail={"push --force-with-lease": (1, "", "! [rejected] (stale info)")}) self.assertIsNone(error) - self.assertEqual(len(github.commands), 1, "the pull request is not touched either") - self.assertIn("moved while this run was working", notes[0]) + self.assertEqual(len(github.commands), 1, "no pull request for a branch this run did not push") + self.assertIn("appeared while this run was working", notes[0]) - def test_any_other_push_failure_is_an_error(self): - github, _, _, error = self.run_it( - self.decided("update", PRE2, [pull()], existing=OLD), fail={"git": (1, "", "remote: Permission denied")} - ) - self.assertIn("could not push next", str(error)) + def test_any_other_push_failure_is_an_error_and_nothing_follows(self): + github, _, _, error = self.run_it(self.decided(), fail={"push --force-with-lease": (1, "", "remote: Permission denied")}) + self.assertIn("could not push %s" % BRANCH, str(error)) self.assertEqual(len(github.commands), 1) - def test_a_push_refused_over_a_workflow_file_says_what_the_default_token_cannot_do(self): - refusal = ( - 1, - "", - "! [remote rejected] next (refusing to allow a GitHub App to create or update workflow " - "`.github/workflows/ci.yml` without `workflows` permission)", - ) - _, _, _, error = self.run_it(self.decided("update", PRE2, [pull()], existing=OLD), fail={"git": refusal}) - self.assertIn("UPSTREAM_BOT_TOKEN", str(error)) - self.assertIn("Workflows read/write", str(error)) - - def test_a_conflict_comment_goes_on_the_pull_request_that_was_just_opened(self): - decided = publish.build(plan_for(PRE2), outcome_for("conflict", PRE1, existing=OLD, conflict=CONFLICT), open_pulls=[]) - shown = self.run_it(decided)[0].shown() - self.assertTrue(shown[0].startswith("gh pr create ")) - self.assertTrue(shown[1].startswith("gh pr comment 41 --body-file ")) - self.assertEqual(len(shown), 2, "and nothing was pushed") - - def test_auto_merge_is_turned_on_by_rebase_and_a_refusal_is_a_note_not_a_failure(self): - decided = self.decided("update", STABLE, [pull()], existing=OLD, automerge=True) - github, notes, _, error = self.run_it(decided) - self.assertEqual(github.shown()[-2], "gh pr view 7 --json id --jq .id") - self.assertTrue(github.shown()[-1].startswith("gh api graphql -f query=mutation($id: ID!) { enablePullRequestAutoMerge(")) - self.assertIn("mergeMethod: REBASE", github.shown()[-1]) - self.assertEqual((notes, error), ([], None)) - refusal = (1, "", "GraphQL: Pull request is in clean status (enablePullRequestAutoMerge)") - _, notes, _, error = self.run_it(decided, fail={"enablePullRequestAutoMerge": refusal}) - self.assertIsNone(error) - self.assertIn("Auto-merge could not be turned on", notes[0]) - self.assertIn("Pull request is in clean status", notes[0]) - self.assertIn("Allow auto-merge", notes[0]) - - def test_nothing_ever_merges_a_pull_request_itself(self): - # `gh pr merge --auto` merges on the spot when nothing is required of - # the pull request. Only the mutation that turns auto-merge on is used. - for decided in ( - self.decided("update", STABLE, [pull()], existing=OLD, automerge=True), - self.decided("update", STABLE, [pull()], existing=OLD), - self.decided("create", PRE1, automerge=True), - ): - for text in self.run_it(decided)[0].shown(): - self.assertNotIn("pr merge", text) - self.assertNotIn("mergePullRequest", text) - - def test_nothing_about_merging_is_run_without_the_variable(self): - for decided in (self.decided("update", STABLE, [pull()], existing=OLD), self.decided("create", PRE1)): - for text in self.run_it(decided)[0].shown(): - self.assertNotIn("AutoMerge", text) - - def test_auto_merge_that_is_already_on_is_not_asked_for_again(self): - first = self.decided("none", STABLE, [], existing=OLD)["pr"] - on = pull(title=first["title"], body=first["body"], isDraft=False, autoMergeRequest={"mergeMethod": "REBASE"}) - decided = self.decided("none", STABLE, [on], existing=OLD, automerge=True) - self.assertFalse(decided["automerge"]) - # The body mentions auto-merge once the variable is on, so only that is rewritten. - self.assertFalse([text for text in self.run_it(decided)[0].shown() if "graphql" in text]) - - def test_the_release_branch_is_created_first_and_only_if_it_does_not_exist(self): - shown = self.run_it(self.decided("update", MINOR, [pull()], existing=OLD))[0].shown() - self.assertEqual(shown[0], "git push --force-with-lease=refs/heads/release/0.1: origin %s:refs/heads/release/0.1" % MAIN) - self.assertIn("refs/heads/next", shown[1]) - _, _, _, error = self.run_it( - self.decided("update", MINOR, [pull()], existing=OLD), fail={"release/0.1": (1, "", "stale info")} - ) - self.assertIn("could not create release/0.1", str(error)) - - def test_delete_closes_the_pull_request_then_removes_the_branch_with_a_lease(self): - decided = publish.build(plan_for(None), outcome_for("delete", existing=OLD), open_pulls=[pull(4)]) - shown = self.run_it(decided)[0].shown() - self.assertTrue(shown[0].startswith("gh pr close 4 --comment Closing: because.")) - self.assertEqual(shown[1], "git push --force-with-lease=refs/heads/next:%s origin :refs/heads/next" % OLD) + def test_a_refused_pull_request_is_an_error_that_gives_the_link(self): + github, _, _, error = self.run_it(self.decided(), fail={"gh pr create": (1, "", "HTTP 403: Resource not accessible by integration")}) + text = str(error) + self.assertIn(REPO_URL + "/compare/main...%s?expand=1" % BRANCH, text) + self.assertIn("The branch is pushed", text) + self.assertIn("bump-bot App", text) + # The branch is not taken away again. + self.assertEqual(len(github.commands), 2) def test_a_dry_run_runs_nothing_and_says_what_it_would(self): - github, notes, log, error = self.run_it(self.decided("create", PRE1), dry_run=True) - self.assertEqual(github.commands, []) + github, notes, log, error = self.run_it(self.decided(), dry_run=True) self.assertIsNone(error) - self.assertTrue(log[0].startswith("would run: git push --force-with-lease=refs/heads/next: origin ")) - self.assertTrue(log[1].startswith("would run: gh pr create --head next --base main ")) - self.assertNotIn("credential.helper", "\n".join(log)) + self.assertEqual(github.commands, []) + self.assertEqual(notes, []) + self.assertEqual(len([line for line in log if line.startswith("would run: ")]), 2) if __name__ == "__main__": diff --git a/deploy/ci/upstream/tests/test_report.py b/deploy/ci/upstream/tests/test_report.py index 1724a05b..34699432 100644 --- a/deploy/ci/upstream/tests/test_report.py +++ b/deploy/ci/upstream/tests/test_report.py @@ -3,7 +3,6 @@ import re import unittest -import nextbranch import plan as plan_module import report from tests import support @@ -46,7 +45,7 @@ def test_the_title_is_a_fix_that_names_the_release(self): def test_it_ends_with_the_trailer_the_commit_is_found_by(self): text = self.message(TODAY) self.assertTrue(text.endswith("\n\nOpenShell-Release: 0.1.4-pre.2\n")) - self.assertEqual(nextbranch.TRAILER_RE.search(text).group(1), "0.1.4-pre.2") + self.assertEqual(re.search(r"(?m)^OpenShell-Release: (\S+)$", text).group(1), "0.1.4-pre.2") def test_it_says_where_from_where_to_and_which_commit(self): text = " ".join(self.message(TODAY).split()) @@ -160,7 +159,7 @@ def test_a_new_line_is_not_called_a_broken_promise(self): self.assertIn("is on release line `0.2.x`; `main` is for `0.1.x`", body) self.assertNotIn("Report it upstream", body) self.assertNotIn("report it upstream now", body) - self.assertIn("Do the work on `next`", body) + self.assertIn("Wait for the stable release", body) class Summaries(unittest.TestCase): @@ -202,32 +201,64 @@ def test_check_a_says_what_happens_to_the_issue(self): self.assertIn("left as it is", report.check_summary(TODAY, unknown)) -class NextSummary(unittest.TestCase): - def outcome(self, action, **more): - base = {"action": action, "reason": "the pin moves to OpenShell 0.1.4-pre.2", "main": "a" * 40, "next": None, "sha": None, "regenerated": [], "conflict": None} - base.update(more) - return base - - def test_each_action_reads_as_what_happened(self): - empty = {"pr": None} - self.assertIn("`next` created from `main` at `aaaaaaaaaaaa`", report.next_summary(self.outcome("create", sha="c" * 40), empty)) - self.assertIn("force-pushed (with lease): the pin moves", report.next_summary(self.outcome("update", sha="c" * 40), empty)) - self.assertIn("left as it is", report.next_summary(self.outcome("none"), empty)) - self.assertIn("deleted", report.next_summary(self.outcome("delete"), empty)) - conflict = self.outcome("conflict", reason="next could not be rebased onto main", conflict={"files": ["b.txt", "a.txt"], "stage": "rebase"}) - self.assertIn("In conflict: `a.txt`, `b.txt`. `next` is left exactly as it was.", report.next_summary(conflict, empty)) - - def test_a_dry_run_says_would(self): - publish = {"pr": {"number": None, "title": "fix: move to OpenShell 0.1.4-pre.2", "draft": True}} - text = report.next_summary(self.outcome("create", sha="c" * 40), publish, dry_run=True) - self.assertIn("`next` would be created", text) - self.assertIn("A draft pull request `next` -> `main` would be opened: `fix: move to OpenShell 0.1.4-pre.2`", text) - - def test_regenerated_lock_files_and_the_release_branch_are_listed(self): - publish = {"pr": None, "release_branch": {"name": "release/0.1", "sha": "a" * 40}} - text = report.next_summary(self.outcome("update", sha="c" * 40, regenerated=["backend/go.sum", "backend/go.sum"]), publish) - self.assertIn("Regenerated after a conflict: `backend/go.sum`.", text) - self.assertIn("`release/0.1` created from `main` at `aaaaaaaaaaaa`", text) +class PullRequestBody(unittest.TestCase): + def test_it_says_the_bot_made_it_and_people_finish_it(self): + body = report.pr_body(STABLE, REPO_URL) + self.assertTrue(body.startswith(report.PR_MARKER)) + self.assertIn("will not touch it again", body) + self.assertIn("from OpenShell `0.1.3` to `0.1.4`", body) + self.assertIn(STABLE["target"]["gateway_image"], body) + self.assertIn(REPO_URL + "/blob/main/.github/workflows/follow-upstream.yml", body) + + def test_it_says_merging_releases_nothing(self): + body = " ".join(report.pr_body(STABLE).split()) + self.assertIn("cut a release by hand", body) + self.assertNotIn("next", body.replace("next-version", "")) + + def test_a_pre_release_is_a_prep_branch_that_cannot_merge(self): + body = " ".join(report.pr_body(TODAY).split()) + self.assertIn("A pre-release: this is a prep branch.", body) + self.assertIn("until upstream releases `0.1.4`", body) + self.assertNotIn("prep branch", report.pr_body(STABLE)) + + def test_a_new_line_says_how_to_keep_the_old_one(self): + body = " ".join(report.pr_body(NEW_LINE_STABLE).split()) + self.assertIn("This starts gateway release line `0.2.x`.", body) + self.assertIn("git push origin origin/main:refs/heads/release/0.1", body) + self.assertNotIn("release line", report.pr_body(STABLE)) + + +class BumpSummary(unittest.TestCase): + OUTCOME = {"action": "create", "reason": "", "main": "a" * 40, "sha": "c" * 40} + + def test_a_skip_says_why(self): + text = report.bump_summary(self.OUTCOME, {"skip": "pull request #7 (open) already came from `b`"}) + self.assertIn("Nothing done: pull request #7 (open) already came from `b`.", text) + + def test_a_bump_lists_the_branch_and_the_pull_request(self): + publish = {"skip": None, "branch": "bump/openshell-0.1.4", "pr": {"title": "fix: move to OpenShell 0.1.4"}} + text = report.bump_summary(self.OUTCOME, publish) + self.assertIn("`bump/openshell-0.1.4` pushed: main at `aaaaaaaaaaaa` plus one commit, `cccccccccccc`.", text) + self.assertIn("A pull request into `main` opened: `fix: move to OpenShell 0.1.4`.", text) + self.assertIn("would be pushed", report.bump_summary(self.OUTCOME, publish, dry_run=True)) + + def test_the_plan_summary_names_the_branch_or_says_how_to_make_one(self): + self.assertIn("Bump branch: `bump/openshell-0.1.4`.", report.plan_summary(STABLE)) + self.assertIn("run this workflow by hand with `version` `0.1.4-pre.2`", report.plan_summary(TODAY)) + + +class IssueSaysWhatToDo(unittest.TestCase): + def test_a_pre_release_points_at_a_prep_branch(self): + _, body = report.issue_text(TODAY, RUN_URL, REPO_URL) + flat = " ".join(body.split()) + self.assertIn("run the Follow upstream workflow by hand with `version` `0.1.4-pre.2`", flat) + self.assertIn("bump/openshell-0.1.4-pre.2", flat) + self.assertNotIn("`next`", body) + + def test_a_new_line_says_wait_or_start_now(self): + _, body = report.issue_text(NEW_LINE, RUN_URL, REPO_URL) + self.assertIn("Wait for the stable release", body) + self.assertNotIn("`next`", body) if __name__ == "__main__": diff --git a/deploy/ci/upstream/tests/test_versions.py b/deploy/ci/upstream/tests/test_versions.py index 2b9250f1..54dfc87e 100644 --- a/deploy/ci/upstream/tests/test_versions.py +++ b/deploy/ci/upstream/tests/test_versions.py @@ -150,7 +150,7 @@ def test_the_pin_itself_is_never_the_target(self): self.assertIsNone(target("0.1.3", "v0.1.3")) def test_a_branch_on_a_pre_release_follows_the_same_rules(self): - # `next` is not what the target is chosen for, but the rule holds there. + # A prep branch is not what the target is chosen for, but the rule holds there. self.assertEqual(target("0.1.4-pre.2", "v0.1.4-pre.2", "v0.1.4-pre.3"), "0.1.4-pre.3") self.assertEqual(target("0.1.4-pre.2", "v0.1.4-pre.3", "v0.1.4"), "0.1.4") self.assertIsNone(target("0.1.4-pre.2", "v0.1.4-pre.1", "v0.1.4-pre.2")) diff --git a/deploy/ci/upstream/tests/test_workflows.py b/deploy/ci/upstream/tests/test_workflows.py index 5aa017f7..b196eceb 100644 --- a/deploy/ci/upstream/tests/test_workflows.py +++ b/deploy/ci/upstream/tests/test_workflows.py @@ -7,8 +7,7 @@ There is no YAML parser in the standard library, so this reads the files by indentation. That is enough for the handful of facts it needs, and it only looks at what this directory owns: all of follow-upstream.yml, the triggers -and the compat jobs of ci.yml, and the one step of publish.yml that asks where -a commit came from. +and the compat jobs of ci.yml, and the triggers of publish.yml. """ import os @@ -154,17 +153,26 @@ def commands(script): CI_ALL_JOBS = jobs(CI) CI_JOBS = {name: lines for name, lines in CI_ALL_JOBS.items() if name in ("compat-pins", "stable-release", "compat")} -TOKEN = "GH_TOKEN: ${{ secrets.UPSTREAM_BOT_TOKEN || github.token }}" -LIST = "List the pull requests from next" -BUILD = "Build what next should be" -PUBLISH_STEP = "Push next and keep its pull request" +TOKEN = "GH_TOKEN: ${{ steps.app-token.outputs.token }}" +APP_TOKEN_STEP = "Get a token for the bump-bot App" +PINNED = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[0-9a-f]{40}$") + + +def action(uses): + """The action a `uses:` names, without the ref it is pinned to.""" + return uses.split("@", 1)[0] if uses else None +LIST = "List the pull requests from the bump branch" +BUILD = "Make the bump commit" +PUBLISH_STEP = "Push the branch and open the pull request" +FORWARD = "Refuse a bump main has caught up with" +UNBUNDLE = "Take the bump commit from the bundle" class ScannerSanity(unittest.TestCase): """If the scanner found nothing, every test below would pass for no reason.""" def test_finds_the_jobs(self): - self.assertEqual(sorted(FOLLOW_JOBS), ["check-a", "next", "plan", "report"]) + self.assertEqual(sorted(FOLLOW_JOBS), ["bump", "check-a", "plan", "publish-bump", "report"]) self.assertEqual(sorted(CI_JOBS), ["compat", "compat-pins", "stable-release"]) def test_finds_the_scripts(self): @@ -172,13 +180,15 @@ def test_finds_the_scripts(self): self.assertGreaterEqual(sum(len(run_scripts(lines)) for lines in CI_JOBS.values()), 8) def test_finds_the_steps(self): - found = steps(FOLLOW_JOBS["next"]) - self.assertEqual(found[0]["uses"], "actions/checkout@v4") - named = [step["name"] for step in found] - for name in (LIST, BUILD, PUBLISH_STEP): - self.assertIn(name, named) - self.assertIn("GH_TOKEN", [s for s in found if s["name"] == PUBLISH_STEP][0]["env"]) - self.assertIn("HAS_BOT_TOKEN:", job_env(FOLLOW_JOBS["next"])) + found = steps(FOLLOW_JOBS["bump"]) + self.assertEqual(action(found[0]["uses"]), "actions/checkout") + self.assertIn(LIST, [step["name"] for step in found]) + self.assertIn(BUILD, [step["name"] for step in found]) + self.assertIn("BRANCH:", job_env(FOLLOW_JOBS["bump"])) + published = steps(FOLLOW_JOBS["publish-bump"]) + for name in (FORWARD, UNBUNDLE, APP_TOKEN_STEP, PUBLISH_STEP): + self.assertIn(name, [step["name"] for step in published]) + self.assertIn("GH_TOKEN", [s for s in published if s["name"] == PUBLISH_STEP][0]["env"]) def test_reads_block_and_inline_scripts(self): lines = [ @@ -242,7 +252,10 @@ def test_each_job_holds_exactly_what_it_uses(self): # Changing this table is the point at which a reviewer should ask why. expected = { "plan": {"contents": "read"}, - "next": {"contents": "write", "pull-requests": "write"}, + # Makes the commit; reads which pull requests came from the branch. + "bump": {"contents": "read", "pull-requests": "read"}, + # Writes with the bump-bot App's token, not with this one. + "publish-bump": {"contents": "read"}, "check-a": {"contents": "read"}, "report": {"contents": "read", "issues": "write"}, } @@ -255,147 +268,175 @@ def test_the_job_that_runs_upstreams_image_can_write_nothing_and_sees_no_secret( self.assertNotIn("secrets.", text, name) self.assertNotIn("github.token", text, name) - def test_only_the_next_job_is_handed_the_bot_token(self): + def test_only_the_app_token_step_is_handed_a_secret(self): + holders = [] + for name, lines in FOLLOW_JOBS.items(): + for step in steps(lines): + if "secrets." in step["text"]: + holders.append((name, step["name"])) + self.assertEqual(holders, [("publish-bump", APP_TOKEN_STEP)]) + self.assertNotIn("UPSTREAM_BOT_TOKEN", FOLLOW) + + def test_the_app_secrets_come_from_an_environment_only_main_can_use(self): + self.assertIn(" environment: bump-bot", FOLLOW_JOBS["publish-bump"]) + for name, lines in FOLLOW_JOBS.items(): + if name != "publish-bump": + self.assertNotIn("environment:", "\n".join(lines), name) + step = [s for s in steps(FOLLOW_JOBS["publish-bump"]) if s["name"] == APP_TOKEN_STEP][0] + self.assertIn("client-id: ${{ secrets.BUMP_BOT_CLIENT_ID }}", step["text"]) + self.assertIn("private-key: ${{ secrets.BUMP_BOT_PRIVATE_KEY }}", step["text"]) + # Asked for with what the job does and nothing more. + perms = sorted(re.findall(r"permission-([a-z-]+): (\w+)", step["text"])) + self.assertEqual(perms, [("contents", "write"), ("pull-requests", "write")]) + + +class ActionsArePinned(unittest.TestCase): + """The bump job holds a token that can push, so every action is a commit, not a tag.""" + + def test_every_action_in_follow_upstream_is_pinned_by_sha(self): for name, lines in FOLLOW_JOBS.items(): - if name != "next": - self.assertNotIn("secrets.", "\n".join(lines), name) + for step in steps(lines): + if step["uses"]: + self.assertRegex(step["uses"], PINNED, "%s: %s" % (name, step["uses"])) def test_ci_compat_jobs_are_read_only(self): for name, lines in CI_JOBS.items(): self.assertEqual(permissions(lines), {"contents": "read"}, name) -class TheJobThatCanWrite(unittest.TestCase): - """`next` holds contents: write and pull-requests: write, so it runs nothing foreign. +class TheJobThatMakesTheCommit(unittest.TestCase): + """`bump` runs `go get` and the README script, so it holds no write token and no environment. - check-a may run upstream's gateway image because the worst it can do is - read a public repository. This job could push to main (there is no branch - protection), so the rule is the reverse: it builds nothing, tests nothing - and starts no container, and the token reaches only the steps that list - or publish. + That is also what lets a dry run from any ref preview the bump in full. """ def setUp(self): - self.lines = FOLLOW_JOBS["next"] + self.lines = FOLLOW_JOBS["bump"] self.steps = steps(self.lines) self.text = "\n".join(self.lines) def step(self, name): return [s for s in self.steps if s["name"] == name][0] + def test_it_holds_no_secret_no_environment_and_writes_nothing(self): + self.assertNotIn("secrets.", self.text) + self.assertNotIn("environment:", self.text) + self.assertNotIn("write", (permissions(self.lines) or {}).values()) + + def test_it_runs_whenever_the_plan_says_bump_dry_run_or_not(self): + self.assertIn(" if: needs.plan.outputs.bump == 'true'", self.lines) + def test_it_sets_up_two_toolchains_and_no_container(self): - used = [step["uses"] for step in self.steps if step["uses"]] + used = [action(step["uses"]) for step in self.steps if step["uses"]] self.assertEqual( used, - ["actions/checkout@v4", "actions/setup-go@v5", "actions/setup-node@v4", "actions/download-artifact@v4"], - ) - - def test_its_scripts_call_only_gh_and_this_repositorys_own_command_line(self): - allowed = re.compile( - r"^(set -euo pipefail$|\$FOLLOW (next|publish) |gh pr list |status=0$|" - r"cat /tmp/upstream/next/[a-z]+\.md >> \"\$GITHUB_STEP_SUMMARY\"$|" - r"if \[ -f /tmp/upstream/next/published\.md \]; then$|fi$|exit \"\$status\"$)" + ["actions/checkout", "actions/setup-go", "actions/setup-node", "actions/download-artifact", "actions/upload-artifact"], ) - for step in self.steps: - for script in step["scripts"]: - for line in commands(script): - self.assertRegex(line, allowed, "%s: %r" % (step["name"], line)) - - def test_nothing_is_built_tested_or_started(self): for script in run_scripts(self.lines): - for word in ("docker", "npm ", "npx ", "pip ", "make ", "curl ", "wget ", "e2e-stack", "node "): + for word in ("docker", "npm ", "npx ", "e2e-stack", "curl "): self.assertNotIn(word, script) - # No Go in the workflow itself: `go get` and `go mod tidy` are run - # by pinmove.py, and those two are all it knows how to run. - self.assertIsNone(re.search(r"(^|[\s;&|(])go\s", script)) - for command in pinmove.sdk_commands("0" * 40): - self.assertIn(tuple(command[:2]), (("go", "get"), ("go", "mod"))) - self.assertEqual(pinmove.sdk_commands("0" * 40)[1], ["go", "mod", "tidy"]) def test_the_checkout_keeps_no_credentials_and_fetches_everything(self): checkout = self.steps[0] - self.assertEqual(checkout["uses"], "actions/checkout@v4") self.assertIn("persist-credentials: false", checkout["text"]) self.assertIn("fetch-depth: 0", checkout["text"]) - self.assertNotIn("token:", checkout["text"]) def test_no_cache_is_restored_into_it(self): - setup_go = [s for s in self.steps if s["uses"] == "actions/setup-go@v5"][0] - self.assertIn("cache: false", setup_go["text"]) - self.assertNotIn("cache:", [s for s in self.steps if s["uses"] == "actions/setup-node@v4"][0]["text"]) - - def test_the_token_is_not_in_the_jobs_environment(self): - env = job_env(self.lines) - self.assertNotIn("GH_TOKEN", env) - self.assertNotIn("github.token", env) - # The one mention of the secret at job level is a comparison that - # yields 'true' or 'false', never the value. - mentions = [line.strip() for line in env.splitlines() if "secrets." in line and not line.strip().startswith("#")] - self.assertEqual(mentions, ["HAS_BOT_TOKEN: ${{ secrets.UPSTREAM_BOT_TOKEN != '' }}"]) - - def test_only_the_steps_that_list_and_publish_are_handed_the_token(self): - holders = [step for step in self.steps if "secrets." in step["text"] or "github.token" in step["text"]] - self.assertEqual([step["name"] for step in holders], [LIST, PUBLISH_STEP]) - for step in holders: - self.assertIn(TOKEN, step["env"]) - self.assertEqual(self.text.count("secrets.UPSTREAM_BOT_TOKEN || github.token"), 2) + self.assertIn("cache: false", [s for s in self.steps if action(s["uses"]) == "actions/setup-go"][0]["text"]) def test_the_listing_step_runs_gh_pr_list_and_nothing_else(self): lines = commands(self.step(LIST)["scripts"][0]) - self.assertEqual(lines[0], "set -euo pipefail") - self.assertEqual(len(lines), 3) - for line in lines[1:]: - self.assertTrue(line.startswith("gh pr list --head next --base main --state "), line) - # Which pull request is ours is decided in tested code, from this field. - self.assertIn("isCrossRepository", line) - self.assertIn("headRefOid", line) - self.assertNotIn("--jq", line) - self.assertRegex(line, r"> /tmp/upstream/(open|merged)\.json$") - - def test_the_publishing_step_runs_publish_and_nothing_else(self): - script = self.step(PUBLISH_STEP)["scripts"][0] - self.assertIn('$FOLLOW publish --plan /tmp/upstream/next/publish.json --dry-run "$DRY_RUN"', re.sub(r"\\\n\s*", "", script)) - for word in ("$FOLLOW next", "git ", "gh ", "go ", "node", "python"): - self.assertNotIn(word, script) - - def test_the_step_that_reaches_the_network_for_modules_has_no_token(self): - step = self.step(BUILD) - self.assertNotIn("secrets.", step["text"]) - self.assertNotIn("github.token", step["text"]) - self.assertNotIn("GH_TOKEN", step["env"]) - self.assertEqual([line.strip() for line in step["env"].splitlines() if not line.strip().startswith("#")], ["GOTOOLCHAIN: auto"]) - # And it runs between the two that do. - names = [s["name"] for s in self.steps] - self.assertLess(names.index(LIST), names.index(BUILD)) - self.assertLess(names.index(BUILD), names.index(PUBLISH_STEP)) + self.assertEqual(len(lines), 2) + line = lines[1] + # Every state: a closed or merged pull request stops the bot as well. + self.assertTrue(line.startswith('gh pr list --head "$BRANCH" --state all '), line) + self.assertIn("isCrossRepository", line) + self.assertRegex(line, r"> /tmp/upstream/pulls\.json$") + self.assertIn("GH_TOKEN: ${{ github.token }}", self.step(LIST)["env"]) def test_what_it_is_told_reaches_the_command(self): script = re.sub(r"\\\n\s*", "", self.step(BUILD)["scripts"][0]) - for flag in ( - "--plan /tmp/upstream/plan.json", - "--open-prs /tmp/upstream/open.json", - "--merged-prs /tmp/upstream/merged.json", - '--has-bot-token "$HAS_BOT_TOKEN"', - '--automerge "$AUTOMERGE"', - '--repo-url "$REPO_URL"', - '--dry-run "$DRY_RUN"', - ): + for flag in ("--plan /tmp/upstream/plan.json", "--pulls /tmp/upstream/pulls.json", '--repo-url "$REPO_URL"', '--dry-run "$DRY_RUN"'): self.assertIn(flag, script) + self.assertEqual([l.strip() for l in self.step(BUILD)["env"].splitlines() if not l.strip().startswith("#")], ["GOTOOLCHAIN: auto"]) + + def test_the_commit_is_handed_on_as_a_bundle_only_when_one_was_made(self): + step = self.step("Bundle the bump commit for publish-bump") + self.assertIn("if: steps.bump.outputs.made == 'true'", step["text"]) + self.assertEqual(commands(step["scripts"][0]), ["git bundle create /tmp/upstream/bump/bump.bundle refs/upstream/bump"]) + self.assertIn(" made: ${{ steps.bump.outputs.made }}", self.lines) + + +class TheJobThatCanWrite(unittest.TestCase): + """`publish-bump` holds the App token, so it builds, tests and fetches nothing.""" + + def setUp(self): + self.lines = FOLLOW_JOBS["publish-bump"] + self.steps = steps(self.lines) + self.text = "\n".join(self.lines) + + def step(self, name): + return [s for s in self.steps if s["name"] == name][0] + + def test_it_runs_only_on_main_for_a_real_run_with_a_commit_to_push(self): + condition = [line for line in self.lines if line.startswith(" if:")][0] + for part in ( + "needs.bump.outputs.made == 'true'", + "github.ref == 'refs/heads/main'", + "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)", + ): + self.assertIn(part, condition) + self.assertIn(" needs: [plan, bump]", self.lines) - def test_auto_merge_is_off_unless_the_repository_variable_says_true(self): - self.assertIn("AUTOMERGE: ${{ vars.UPSTREAM_AUTOMERGE == 'true' }}", job_env(self.lines)) - # The workflow never merges by itself. publish.py asks GitHub to turn - # auto-merge on, with a GraphQL mutation and never with `gh pr merge`, - # behind the flag this variable sets. + def test_it_uses_no_toolchain_and_no_container(self): + used = [action(step["uses"]) for step in self.steps if step["uses"]] + self.assertEqual( + used, + ["actions/checkout", "actions/download-artifact", "actions/download-artifact", "actions/create-github-app-token"], + ) for script in run_scripts(self.lines): - self.assertNotIn("pr merge", script) - self.assertNotIn("--auto ", script) + for word in ("docker", "npm ", "node ", "go ", "curl ", "make "): + self.assertNotIn(word, script) + + def test_it_checks_out_main_as_it_is_now_without_credentials(self): + checkout = self.steps[0] + self.assertIn("ref: main", checkout["text"]) + self.assertIn("persist-credentials: false", checkout["text"]) + + def test_main_is_checked_and_the_commit_taken_before_the_token_exists(self): + names = [s["name"] for s in self.steps] + self.assertLess(names.index(FORWARD), names.index(APP_TOKEN_STEP)) + self.assertLess(names.index(UNBUNDLE), names.index(APP_TOKEN_STEP)) + self.assertLess(names.index(APP_TOKEN_STEP), names.index(PUBLISH_STEP)) + self.assertEqual( + commands(self.step(FORWARD)["scripts"][0]), + ["$FOLLOW check-forward --plan /tmp/upstream/plan.json --pins deploy/ci/gateway-pins.json"], + ) + + def test_only_the_publishing_step_is_handed_the_token(self): + holders = [s["name"] for s in self.steps if "app-token.outputs.token" in s["text"] or "github.token" in s["text"]] + self.assertEqual(holders, [PUBLISH_STEP]) + self.assertIn(TOKEN, self.step(PUBLISH_STEP)["env"]) + self.assertNotIn("GH_TOKEN", job_env(self.lines)) + + def test_the_publishing_step_runs_publish_and_nothing_else(self): + script = self.step(PUBLISH_STEP)["scripts"][0] + self.assertIn('$FOLLOW publish --plan /tmp/upstream/bump/publish.json --dry-run "$DRY_RUN"', re.sub(r"\\\n\s*", "", script)) + for word in ("$FOLLOW bump", "git ", "gh ", "go ", "node", "python"): + self.assertNotIn(word, script) def test_a_failed_publish_still_writes_its_summary_and_still_fails(self): script = self.step(PUBLISH_STEP)["scripts"][0] self.assertIn("|| status=$?", script) self.assertLess(script.index("|| status=$?"), script.index("GITHUB_STEP_SUMMARY")) - self.assertTrue(commands(script)[-1] == 'exit "$status"') + self.assertEqual(commands(script)[-1], 'exit "$status"') + + def test_nothing_merges_and_nothing_turns_auto_merge_on(self): + self.assertNotIn("UPSTREAM_AUTOMERGE", FOLLOW) + import publish + source = open(publish.__file__, encoding="utf-8").read() + for word in ("pr merge", "enablePullRequestAutoMerge", "pr close", '"--force"'): + self.assertNotIn(word, source) class CheckA(unittest.TestCase): @@ -478,13 +519,21 @@ def test_a_manual_run_is_a_dry_run_unless_the_box_is_cleared(self): self.assertIn(" type: boolean", dispatch) self.assertIn(" default: true", dispatch) - def test_it_runs_every_hour_off_the_top_of_the_hour(self): + def test_it_runs_once_a_day_off_the_top_of_the_hour(self): crons = re.findall(r'^ - cron: "([^"]+)"$', FOLLOW, re.M) self.assertEqual(len(crons), 1) minute, hour, day, month, weekday = crons[0].split() - self.assertEqual((hour, day, month, weekday), ("*", "*", "*", "*")) + self.assertEqual((day, month, weekday), ("*", "*", "*")) + self.assertTrue(hour.isdigit(), hour) self.assertTrue(minute.isdigit() and 0 < int(minute) < 60, minute) + def test_a_version_can_be_asked_for_and_reaches_the_plan_through_env(self): + dispatch = FOLLOW[FOLLOW.index(" workflow_dispatch:") : FOLLOW.index("\npermissions: {}")] + self.assertIn(" version:", dispatch) + plan = "\n".join(FOLLOW_JOBS["plan"]) + self.assertIn("VERSION: ${{ inputs.version }}", plan) + self.assertIn('--version "$VERSION"', "\n".join(run_scripts(FOLLOW_JOBS["plan"]))) + def test_every_step_that_writes_is_told_about_the_dry_run(self): plan_script = re.sub(r"\\\n\s*", "", "\n".join(run_scripts(FOLLOW_JOBS["plan"]))) self.assertIn('--dry-run "$DRY_RUN"', plan_script) @@ -511,8 +560,9 @@ def test_a_fork_does_not_start_following_upstream_on_a_schedule(self): " if: ${{ github.event_name != 'schedule' || !github.event.repository.fork }}", FOLLOW_JOBS["plan"] ) # Every other job needs the plan, so skipping it skips them all. - for name in ("next", "check-a"): + for name in ("bump", "check-a"): self.assertIn(" needs: plan", FOLLOW_JOBS[name]) + self.assertIn(" needs: [plan, bump]", FOLLOW_JOBS["publish-bump"]) self.assertIn("needs.plan.result == 'success'", "\n".join(FOLLOW_JOBS["report"])) def test_the_issue_is_found_by_the_label_the_report_names(self): @@ -531,15 +581,21 @@ def test_check_a_not_being_run_is_what_turns_the_run_red(self): def test_the_header_says_what_each_token_can_and_cannot_do(self): header = FOLLOW[: FOLLOW.index("\nname: Follow upstream")] - for phrase in ("UPSTREAM_BOT_TOKEN", "UPSTREAM_AUTOMERGE", "create and approve pull requests", "Workflows read/write"): - self.assertIn(phrase, header) + flat = " ".join(header.replace("#", " ").split()) + for phrase in ("bump-bot GitHub App", "`bump-bot` environment", "only `main` can deploy to", "starts CI"): + self.assertIn(phrase, flat) class CiTestsThePinnedGateway(unittest.TestCase): - def test_ci_runs_for_main_next_and_release_branches(self): + def test_ci_runs_for_main_and_release_branches(self): triggers = CI[CI.index("\non:\n") : CI.index("\nconcurrency:")] self.assertIn(' push:\n branches: [main, "release/**"]\n', triggers) - self.assertIn(' pull_request:\n branches: [main, next, "release/**"]\n', triggers) + self.assertIn(' pull_request:\n branches: [main, "release/**"]\n', triggers) + self.assertNotIn("next", triggers) + + def test_latest_and_the_dev_chart_come_from_main_only(self): + for name in ("promote-latest", "publish-dev-chart"): + self.assertIn(" if: github.event_name == 'push' && github.ref == 'refs/heads/main'", CI_ALL_JOBS[name]) def test_validates_the_pins_file_against_go_mod_and_runs_these_tests(self): scripts = run_scripts(CI_JOBS["compat-pins"]) @@ -573,10 +629,8 @@ def test_a_pre_release_is_refused_by_a_check_of_its_own_not_by_compat(self): self.assertNotIn("require-stable", "\n".join(CI_JOBS["compat"] + CI_JOBS["compat-pins"])) self.assertNotIn("needs:", "\n".join(job), "it does not wait for, or hide behind, another job") - def test_it_runs_for_main_and_release_branches_and_not_for_a_pull_request_into_next(self): - self.assertIn( - " if: github.event_name == 'push' || github.base_ref != 'next'", CI_JOBS["stable-release"] - ) + def test_it_runs_on_every_event_with_no_exception(self): + self.assertFalse([line for line in CI_JOBS["stable-release"] if line.startswith(" if:")]) def test_latest_is_promoted_only_from_main_and_only_past_both_checks(self): promote = "\n".join(CI_ALL_JOBS["promote-latest"]) @@ -585,7 +639,7 @@ def test_latest_is_promoted_only_from_main_and_only_past_both_checks(self): for job in ("stable-release", "compat", "release-tooling", "check-backend"): self.assertIn(" %s," % job, needs) - def test_a_pull_request_into_next_still_gets_its_images(self): + def test_a_pull_request_from_this_repository_gets_its_images(self): # The build and its manifest are not limited by base branch; only by # the head being in this repository, which is what has the registry login. for name in ("build", "push-manifest"): @@ -611,15 +665,21 @@ def test_nothing_reads_lanes_or_a_floor_any_more(self): self.assertIsNone(re.search(r"\.lanes\b|\.floor\b|floor_release|matrix\.required", text)) -class AutomaticRelease(unittest.TestCase): - def test_publish_asks_whether_the_commit_was_merged_from_next_and_releases_it(self): - self.assertIn('node scripts/release/next-merge.mjs --sha "$sha" --repo "$REPO"', PUBLISH) - plan_step = PUBLISH[PUBLISH.index("next-merge.mjs --sha") :] - # It decides that the merge is released, not what the release is - # numbered: the version is worked out from the pinned release and the - # tags (scripts/release/next-version.mjs). - self.assertIn('release="true"', plan_step[: plan_step.index("} >> \"$GITHUB_OUTPUT\"")]) - self.assertNotIn("release_type", PUBLISH) +class NoAutomaticRelease(unittest.TestCase): + def test_publish_is_started_by_a_person_only(self): + self.assertNotIn("workflow_run", PUBLISH) + self.assertNotIn("next-merge", PUBLISH) + self.assertIn(" workflow_dispatch:", PUBLISH) + + def test_the_next_machinery_is_gone(self): + for path in ( + ("deploy", "ci", "upstream", "nextbranch.py"), + ("scripts", "release", "next-merge.mjs"), + ): + self.assertFalse(os.path.exists(os.path.join(support.REPO_ROOT, *path)), path) + for name, text in (("ci.yml", CI), ("follow-upstream.yml", FOLLOW), ("publish.yml", PUBLISH)): + for gone in ("UPSTREAM_BOT_TOKEN", "UPSTREAM_AUTOMERGE", "refs/heads/next", "--head next"): + self.assertNotIn(gone, text, "%s still mentions %s" % (name, gone)) class StackScript(unittest.TestCase): diff --git a/docs/adrs/0005-gateway-compatibility-and-releases.md b/docs/adrs/0005-gateway-compatibility-and-releases.md index e8ee700a..67e1463f 100644 --- a/docs/adrs/0005-gateway-compatibility-and-releases.md +++ b/docs/adrs/0005-gateway-compatibility-and-releases.md @@ -1,8 +1,10 @@ # ADR 0005: Gateway compatibility and releases -**Status:** Accepted +**Status:** Accepted; amended 2026-11-06 **Date:** 2026-10-11 +The [amendment of 2026-11-06](#amendment-2026-11-06-bump-branches-instead-of-next) replaces the `next` branch, the automatic release and the automatic release branches. Text it supersedes is struck through below and kept as the record. + Terms follow OpenShell's [RFC 0014](https://github.com/NVIDIA/OpenShell/tree/main/rfc/0014-release-stability): stable release, pre-release, patch and minor release, minor release line, Stable interface. A minor release line is every release that shares a major and a minor number, written `0.1` and shown as `0.1.x`. ## Context @@ -16,12 +18,12 @@ The SDK the BFF is compiled with and the gateway image are the client half and t 3. **On a gateway patch release the dashboard moves to it and releases a patch.** Dashboards already installed keep working without it. If the compatibility suite fails against the new gateway release, the dashboard stays on the last one that passed until it is fixed. 4. **The dashboard's own releases are patch releases.** Between gateway releases it publishes patch releases for fixes and features. It never starts a minor on its own. 5. **The line before the current one gets critical and security fixes, from `release/.`, for as long as the gateway maintains the same line.** Nothing else is backported. -6. **A branch is built on one OpenShell release, and every change is tested against it.** `deploy/ci/gateway-pins.json` names the release: its gateway and supervisor images by digest, and the SDK at the commit of its tag, which is the version `backend/go.mod` requires. `main` and `release/.` pin a stable release; `next` is the only branch that pins a pre-release; no gateway or SDK is pinned to `dev`, `latest` or `@latest`. Each link has its own proof. Gateway and SDK: the compatibility suite in `backend/test/compat`, run against the real gateway with `-count=1`. SDK and BFF: the compiler, `go vet` and the unit tests. BFF and UI: both are built from one commit into one image. A failure is reported as the link it is. -7. **New gateway releases are picked up from the gateway's release tags, and moving to one is one commit.** The gateway images, the SDK, the release line compiled into the BFF when the minor changes, and the README's generated table move together. The Follow upstream workflow makes that commit, on the `next` branch. On `main` and `next` nobody makes it by hand. -8. **Gateway pre-releases are tested before the stable release, in two ways.** Check A runs the dashboard as `main` builds it against the upcoming gateway; a failure is one issue, rewritten in place and closed when the check passes again. Check B is CI on the pull request from `next`: the dashboard built on the upcoming SDK against the upcoming gateway. Work that needs the upcoming release is done on `next`, which merges into `main` when the gateway releases. +6. **A branch is built on one OpenShell release, and every change is tested against it.** `deploy/ci/gateway-pins.json` names the release: its gateway and supervisor images by digest, and the SDK at the commit of its tag, which is the version `backend/go.mod` requires. `main` and `release/.` pin a stable release; ~~`next` is the only branch that pins a pre-release~~ *(superseded: see A3)*; no gateway or SDK is pinned to `dev`, `latest` or `@latest`. Each link has its own proof. Gateway and SDK: the compatibility suite in `backend/test/compat`, run against the real gateway with `-count=1`. SDK and BFF: the compiler, `go vet` and the unit tests. BFF and UI: both are built from one commit into one image. A failure is reported as the link it is. +7. **New gateway releases are picked up from the gateway's release tags, and moving to one is one commit.** The gateway images, the SDK, the release line compiled into the BFF when the minor changes, and the README's generated table move together. ~~The Follow upstream workflow makes that commit, on the `next` branch. On `main` and `next` nobody makes it by hand.~~ *(Superseded: the workflow makes it on a bump branch, A1. Nobody makes it by hand.)* +8. **Gateway pre-releases are tested before the stable release, in two ways.** Check A runs the dashboard as `main` builds it against the upcoming gateway; a failure is one issue, rewritten in place and closed when the check passes again. ~~Check B is CI on the pull request from `next`: the dashboard built on the upcoming SDK against the upcoming gateway. Work that needs the upcoming release is done on `next`, which merges into `main` when the gateway releases.~~ *(Superseded: the second test is CI on the bump pull request, and work that needs the upcoming release is done on its bump branch, A1 to A3.)* 9. **The dashboard says so when the gateway is on another line.** Three verdicts: *supported* when the gateway's major and minor number are the dashboard's line (any patch release of the line, and any pre-release, development build or rebuild of one); *unsupported* for any other line, older or newer; *unknown* when the gateway reports no version that can be read, or the BFF's own line cannot be read. The line is compiled into the BFF, so every image knows it whatever built it. The verdict informs and blocks nothing. 10. **The one requirement on OpenShell: no breaking change to a Stable interface within a minor release line,** as RFC 0014 proposes. The dashboard is tested on one release of its line and claims the whole line; the difference rests on this. -11. **The version of a release is computed, and a release is started by a person.** The version is `.`: the line of the pinned release, then one more than the highest patch among the release tags `v.*`, or `0` when the line has none. Nobody chooses it and no commit title changes it. A person starts the release workflow on `main` or on `release/.`; a dry run is the default; the commit needs a green CI run, and the image released is the one that run built, given more tags. One release is automatic: the merge of `next`, recognised by where the commit came from and not by its title. +11. **The version of a release is computed, and a release is started by a person.** The version is `.`: the line of the pinned release, then one more than the highest patch among the release tags `v.*`, or `0` when the line has none. Nobody chooses it and no commit title changes it. A person starts the release workflow on `main` or on `release/.`; a dry run is the default; the commit needs a green CI run, and the image released is the one that run built, given more tags. ~~One release is automatic: the merge of `next`, recognised by where the commit came from and not by its title.~~ *(Superseded: no release is automatic, A4.)* 12. **Gateway 0.0.116 is served by one release outside the numbering, `gateway-0.0.116`.** No build works with both 0.0.116 and 0.1.x, and gateway 0.0.x releases were not compatible with each other, so a dashboard version 0.0.x would claim too much. No further release is planned for it. ## Alternatives @@ -31,7 +33,7 @@ The SDK the BFF is compiled with and the gateway image are the client half and t - *Promise the last N gateway releases unconditionally.* A wire break makes some promises impossible to keep; what works has to be found by running it. - *Move the SDK and the gateway separately.* Both come from one OpenShell tag; moving them apart doubles the pull requests and proves combinations nobody ships. - *Let commit titles choose the version.* Choosing a title becomes the same act as publishing. -- *No automatic release at all.* The move to a new gateway release is the release most likely to be forgotten. +- ~~*No automatic release at all.* The move to a new gateway release is the release most likely to be forgotten.~~ *(Chosen after all on 2026-11-06, A4.)* ## Consequences @@ -40,5 +42,34 @@ The SDK the BFF is compiled with and the gateway image are the client half and t - The compatibility suite proves only what it varies. A request field the gateway silently drops fails nothing, and the field-by-field diff of request messages that would show it is not run automatically. - The line is written twice, in the pins file and in the BFF's source, and CI holds the two together. - Releases batch: everything merged since the last release goes out together, and someone has to remember to cut one. -- A release branch is created by the workflow and kept up to date by a person. +- ~~A release branch is created by the workflow and kept up to date by a person.~~ A release branch is created and kept up to date by a person (A5). - See [`docs/releasing.md`](../releasing.md) for cutting a release and [`deploy/ci/upstream/README.md`](../../deploy/ci/upstream/README.md) for the Follow upstream workflow. + +## Amendment 2026-11-06: bump branches instead of `next` + +### Why + +The repository moved from a personal account to the `ai-openshell` organization (`ai-openshell/console`). Three of the organization's rules break the `next` design: + +- **Nobody on the project is an admin.** Maintainers have write access, and `main`'s ruleset asks for a code owner's approval given after the last push, and dismisses stale approvals. `next` was rebased and force-pushed every hour, so its approval was gone before anyone could merge it, and no one can exempt the bot. +- **The enterprise refuses fine-grained tokens that live longer than 366 days.** `UPSTREAM_BOT_TOKEN` was one. Without it the bot has only the workflow's default token, which starts no workflow when it opens a pull request and may not push a change to a workflow file, which a rebase of `next` onto `main` can carry. +- **Auto-merge and the release after the merge of `next`** depended on both of the above, and a release that starts itself is one more thing that publishes without a person in the loop. + +### Decisions + +- **A1. No `next`. A bump branch per upstream release.** Follow upstream runs once a day. When upstream has a stable release above the one `main` pins, it creates `bump/openshell-X.Y.Z` from `main` with one commit, the pin move (decision 7, unchanged: titled `fix: move to OpenShell X.Y.Z`, trailer `OpenShell-Release: X.Y.Z`, signed off), and opens a pull request into `main`. A person reviews and merges it like any other. It pushes and opens the pull request with a token of the organization's bump-bot GitHub App, whose secrets are in a `bump-bot` environment only `main` can use. A pull request the App opens runs CI; one opened with the workflow's default token would not, and opening one that way needs a setting the organization keeps off. +- **A2. The bot creates a bump branch once and never touches it again.** If the branch exists, or any pull request has ever come from it, the bot does nothing. It never rebases, updates or force-pushes one. People push fixes to it like any other branch. Closing its pull request means "not this release". +- **A3. A pre-release gets a bump branch only when a person asks.** Running Follow upstream with a `version` makes the bump branch for that version, a pre-release included: a prep branch for work that needs the upcoming release. Its pull request fails `pins a stable release` until upstream ships, which now runs on every pull request. When the stable release lands, the daily run opens its bump branch; the fixes are moved onto it and the prep pull request is closed. Check A is unchanged. What decision 8 called check B is CI on the bump pull request. +- **A4. Every release is started by a person.** `publish.yml` runs only when started by hand, on `main` or `release/.`. Merging a bump pull request releases nothing. +- **A5. Release branches are made by hand.** Before merging a bump that starts a new gateway minor, a person creates `release/` from `main` if that line still needs patch releases. + +- **A6. A tagged SDK is pinned at its tag.** From OpenShell 0.1.7 upstream tags the SDK module itself (`sdk/go/vX.Y.Z`), and `go get` of the release commit resolves to that tag. The pin is then the tag (`v0.1.7`), and the bump checks with `go list -m -json` that the tag is the release commit; before 0.1.7 it is a pseudo-version of that commit. + +Decisions 1 to 5 and 9 to 12, and what decisions 6 to 8 say about the pins file, the pin move and check A, still hold. + +### Consequences + +- The move to a new gateway release can be forgotten: nothing releases it, and a bump pull request can sit unmerged. The daily run and check A's issue are the reminders. +- A bump branch goes stale as `main` moves, and the bot will not refresh it. A person rebases it. +- The repository needs the bump-bot App installed and the `bump-bot` environment with its secrets. *Allow GitHub Actions to create and approve pull requests* stays off. +- See [`deploy/ci/upstream/README.md`](../../deploy/ci/upstream/README.md) for the workflow and [`docs/releasing.md`](../releasing.md) for releases. diff --git a/docs/development.md b/docs/development.md index 4f793a5a..9732461d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -125,4 +125,4 @@ const features = useFeatureFlags(); ## Branches and the pinned OpenShell release -A branch is built on one OpenShell release, which `deploy/ci/gateway-pins.json` names: `main` on a stable release, `next` on the upcoming one. Which branch a change goes to is in [CONTRIBUTING.md](../CONTRIBUTING.md#where-a-pull-request-goes), and how the pinned release moves is in [`deploy/ci/upstream/README.md`](../deploy/ci/upstream/README.md). +A branch is built on one OpenShell release, which `deploy/ci/gateway-pins.json` names: `main` on a stable release, a bump branch (`bump/openshell-X.Y.Z`) on the release it moves to. Which branch a change goes to is in [CONTRIBUTING.md](../CONTRIBUTING.md#where-a-pull-request-goes), and how the pinned release moves is in [`deploy/ci/upstream/README.md`](../deploy/ci/upstream/README.md). diff --git a/docs/releasing.md b/docs/releasing.md index 7906ef43..27052711 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ # Releasing -A release is started by a person. Nobody chooses its version: it is worked out from the OpenShell release the branch is built on and the release tags that exist. Merging to `main` releases nothing, whatever the pull request is titled, with one exception: the merge of `next`, the move to a new OpenShell release ([The one automatic release](#the-one-automatic-release)). +A release is started by a person. Nobody chooses its version: it is worked out from the OpenShell release the branch is built on and the release tags that exist. Merging to `main` releases nothing, whatever the pull request is titled, the move to a new OpenShell release included ([After a bump is merged](#after-a-bump-is-merged)). ## One version, three artifacts @@ -65,19 +65,24 @@ Once `main` has moved to a new gateway minor, the line before it gets critical a gh workflow run publish.yml --ref release/0.1 -f dry_run=true ``` -Nothing is released from a release branch automatically, and three things differ: +Three things differ: - **Only that line's `X.Y` image tag moves.** `latest` and the tag of `main`'s line do not. - **The branch has to pin its own line.** `release/0.1` with a pin on `0.2` is refused. -- **The commit has to contain the newest release of its line.** The [Follow upstream](../.github/workflows/follow-upstream.yml) workflow creates `release/X.Y` from `main` before `next` merges, so for a while both are on one line. A release cut from `main` in the meantime is missing from the branch, and a release from the branch would be numbered above it without containing it. The run says so; merge that release into the branch first. +- **The commit has to contain the newest release of its line.** A person creates `release/X.Y` from `main` before the bump to the next minor merges ([After a bump is merged](#after-a-bump-is-merged)), so for a while both are on one line. A release cut from `main` in the meantime is missing from the branch, and a release from the branch would be numbered above it without containing it. The run says so; merge that release into the branch first. -### The one automatic release +### After a bump is merged -`main` moves to a new OpenShell release by merging `next`, which the [Follow upstream](../.github/workflows/follow-upstream.yml) workflow keeps one commit, the pin move, ahead of `main` ([ADR 0005](adrs/0005-gateway-compatibility-and-releases.md), decisions 7 and 8; [`deploy/ci/upstream/README.md`](../deploy/ci/upstream/README.md)). That merge changes what the next release is built on, and is the release most likely to be forgotten. So once it is merged and CI has passed on `main`, `publish.yml` releases it without being asked. Merge that pull request with *Rebase and merge*, so its commits land on `main` as they are. +`main` moves to a new OpenShell release by merging a bump pull request, `bump/openshell-X.Y.Z`, which the [Follow upstream](../.github/workflows/follow-upstream.yml) workflow opens ([ADR 0005](adrs/0005-gateway-compatibility-and-releases.md), amendment of 2026-11-06; [`deploy/ci/upstream/README.md`](../deploy/ci/upstream/README.md)). The merge releases nothing. It changes what the next release is built on, so cut one once CI has passed on `main`, the same way as any other. -Which commits qualify is decided from where they came from, never from their title ([`scripts/release/next-merge.mjs`](../scripts/release/next-merge.mjs)): the commit was merged into `main` by a pull request whose head was the branch `next` **in this repository**. A fork's branch of that name does not count, and neither does a pull request *into* `next`. Anything else is released by hand. +Before merging a bump that starts a new gateway minor, create the release branch of the line `main` is leaving, if that line still needs patch releases: -Its version is worked out the same way, and it carries every commit since the last release, including work people put on `next` because it needed the new gateway. It follows the merge only if CI passes for the commit `main` ends up on; if a second merge cancels that run, cut the release by hand. +```sh +git fetch origin +git push origin origin/main:refs/heads/release/0.1 +``` + +Nothing creates it for you. ## The pipeline @@ -97,7 +102,7 @@ ci.yml ── build ──► push-manifest ──────────── │ └──► publish-dev-chart ──► chart 0.0.0-dev (appVersion sha-<7>) │ ▼ -publish.yml started by a person, on main or release/X.Y (or: CI passed for the merge of next) +publish.yml started by a person, on main or release/X.Y │ ├── cut-release.mjs ── version = . │ └── GitHub release vX.Y.Z ─────────► git tag vX.Y.Z @@ -138,10 +143,10 @@ A running dashboard shows no version number: the image is built before a release **`Cut the release` refuses.** It says why, and nothing was created: -- *The branch pins a pre-release.* Only `next` does; the release is cut after `next` has moved to the stable release and merged. +- *The branch pins a pre-release.* Only a prep bump branch does, and it is not released; the release is cut after the bump to the stable release has merged. - *The release branch pins another line than its own.* - *The newest release of the line is not in this commit's history.* Either this is a second run for an old commit, and there is nothing to do, or `main` released past this release branch ([The line before `main`'s](#the-line-before-mains)). -- *A counted release tag was not built for the line* (a real run only). Delete the tag and re-run the run; the merge of `next` is stopped and re-run the same way. +- *A counted release tag was not built for the line* (a real run only). Delete the tag and re-run the run. **`Cut the release` failed while creating the release.** `gh`'s message is in the log. The release and its tag are created in one request, so *Re-run all jobs* either cuts the release or finds the tag and carries on. @@ -157,7 +162,7 @@ A running dashboard shows no version number: the image is built before a release **`promote-latest` failed.** `latest` stays on the previous good commit and CI is red, so nothing is published. Re-run the job. If `main` has moved on, the re-run stands down and the newer commit's run promotes its own image. -**An older run of `main` is re-run.** It cannot move `latest`. If its commit was merged from `next` and never released, it is released now, unless the line has a newer release that the commit does not contain; then the run is refused. CI runs on `main` share a concurrency group that cancels the run in progress, so the re-run cancels the newest run if that is still going, and its commit is neither promoted nor released until its own run is re-run. +**An older run of `main` is re-run.** It cannot move `latest`, and it releases nothing. CI runs on `main` share a concurrency group that cancels the run in progress, so the re-run cancels the newest run if that is still going, and its commit is not promoted until its own run is re-run. ## Testing the release tooling diff --git a/scripts/gateway-range.mjs b/scripts/gateway-range.mjs index cc9e1c3b..3e01a7d7 100755 --- a/scripts/gateway-range.mjs +++ b/scripts/gateway-range.mjs @@ -44,8 +44,9 @@ export const LINE_SOURCE_PATH = join(repoRoot, LINE_SOURCE_FILE); export const LINE_CONSTANT = 'BuiltInGatewayReleaseLine'; // What a branch may pin: a stable release X.Y.Z, or a pre-release X.Y.Z-pre.N. -// Only `next` pins a pre-release, and CI refuses one in a pull request into -// any other branch (deploy/ci/upstream/follow.py require-stable). Upstream +// Only a bump branch prepared ahead of an upstream release pins a +// pre-release, and CI refuses one in every pull request +// (deploy/ci/upstream/follow.py require-stable), so it never merges. Upstream // also publishes dev builds (0.1.3-dev.84); a dev gateway pulls a moving // sandbox image at runtime, so nothing is ever declared to be tested on one. const RELEASE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-pre\.(0|[1-9]\d*))?$/; @@ -64,7 +65,7 @@ export function deriveGatewayLine(pins) { if (typeof release !== 'string' || !RELEASE.test(release)) { throw new Error( `"release" is ${JSON.stringify(release)}, which is not an OpenShell release ` + - '(X.Y.Z, or X.Y.Z-pre.N on next). A release line can only be declared from the ' + + '(X.Y.Z, or X.Y.Z-pre.N on a bump branch). A release line can only be declared from the ' + 'release this branch is built on; a dev build is never pinned.', ); } @@ -155,7 +156,7 @@ function check(declared, pinsPath) { ` If the gateway started a new minor release line and this branch is moving to it, set ` + `${LINE_CONSTANT} = ${JSON.stringify(declared.line)} in the same change that moves the pin, ` + 'and run `node scripts/readme-gateway-range.mjs --write`. The Follow upstream workflow does ' + - 'both when it moves `next`. Moving to a new line is a minor release of the dashboard (ADR 0005).\n' + + 'both in the bump it opens. Moving to a new line is a minor release of the dashboard (ADR 0005).\n' + ' If the pin moved by mistake, move it back: a patch release of the gateway never changes the line.', ); } diff --git a/scripts/gateway-range.test.mjs b/scripts/gateway-range.test.mjs index dddedfce..42a13336 100644 --- a/scripts/gateway-range.test.mjs +++ b/scripts/gateway-range.test.mjs @@ -38,8 +38,9 @@ function tempDir(t) { * of a scratch copy. * * Everything is worked out from whatever release is committed, never from a - * release written here: on `next` the committed release is a pre-release, and - * these tests run there too. + * release written here: on a bump branch prepared ahead of an upstream + * release the committed release is a pre-release, and these tests run there + * too. */ function movedPins(t) { const dir = tempDir(t); @@ -60,7 +61,7 @@ function movedPins(t) { nextSdk, // A newer patch release of the same line. patch: write('patch.json', { ...pins, release: nextPatch }), - // A pre-release of it, which is what `next` pins. + // A pre-release of it, which is what a prep bump branch pins. preRelease: write('pre-release.json', { ...pins, release: `${nextPatch}-pre.2` }), // The first release of the next minor line. nextLine: write('next-line.json', { ...pins, release: nextMinor }), diff --git a/scripts/readme-gateway-range.mjs b/scripts/readme-gateway-range.mjs index df169193..dbfd55f9 100755 --- a/scripts/readme-gateway-range.mjs +++ b/scripts/readme-gateway-range.mjs @@ -12,7 +12,7 @@ // node scripts/readme-gateway-range.mjs --check exit 1 when it is stale (CI) // // Whatever moves the pins runs --write in the same change. The Follow upstream -// workflow does, when it moves `next` to a new upstream release: the block +// workflow does, in the bump branch it makes for a new upstream release: the block // restates the release and the SDK, and a branch that left it stale could // never pass --check. It runs this copy of the script, from the commit the // workflow runs at, against the tree it is changing, which is what --readme diff --git a/scripts/release/cut-release.mjs b/scripts/release/cut-release.mjs index c9b1b6e5..4253a9f8 100755 --- a/scripts/release/cut-release.mjs +++ b/scripts/release/cut-release.mjs @@ -2,9 +2,9 @@ // Cuts a dashboard release from the checked-out commit, or says what it would // cut. // -// publish.yml runs this: for a person who started the workflow on main or on -// release/., and for the merge of `next`. Whether to release was -// decided before it runs. This works out which release it is: +// publish.yml runs this when a person starts the workflow on main or on +// release/.. Nothing else starts a release. This works out which +// release it is: // // the version ., from the OpenShell release this branch pins and // the release tags that exist (next-version.mjs). Nobody @@ -25,8 +25,8 @@ // // - The checkout is shallow. The tags and the history are what everything // here is worked out from. -// - The branch pins a pre-release. Only `next` does, and nothing is -// released from one. +// - The branch pins a pre-release. Only a bump branch that prepares for an +// upcoming OpenShell release does, and nothing is released from one. // - The branch is release/X.Y and pins another line than X.Y. // - The newest release of the line is not in this commit's history. Numbers // on a line are given out in order, so a release from here would be @@ -413,9 +413,9 @@ function main() { return; } // A release cannot be renumbered once it is out: its image tag is written - // once and its chart version is never pushed again. The merge of `next` is - // released with no dry run before it, so this is where a number that is - // known to be too high is stopped. + // once and its chart version is never pushed again, and a real run need not + // follow a dry run, so this is where a number that is known to be too high + // is stopped. if (plan.foreign.length > 0) { const tags = plan.foreign.map(({ tag }) => tag).join(', '); throw new Error( diff --git a/scripts/release/cut-release.test.mjs b/scripts/release/cut-release.test.mjs index 1bd92dd6..d0ac8367 100644 --- a/scripts/release/cut-release.test.mjs +++ b/scripts/release/cut-release.test.mjs @@ -229,8 +229,8 @@ test('a tag that was not cut for the line raises the version to 0.1.4, and the r assert.doesNotMatch(stdout, /release line changed/); }); -// The merge of `next` is released with no dry run before it, and a release -// cannot be renumbered afterwards. +// A real run need not follow a dry run, and a release cannot be renumbered +// afterwards. test('a real run does not publish a number that such a tag made too high', (t) => { const repo = withOldTags(t); repo.push('main', '--tags'); @@ -370,8 +370,8 @@ test('a pre-release pin never releases', (t) => { assert.deepEqual(repo.tags(), ['v0.1.0']); }); -// release/0.1 is created from main before `next` merges, so for a while both -// are on the 0.1 line. A release from main in that window is not on the +// release/0.1 is created from main before the move to 0.2 merges, so for a +// while both are on the 0.1 line. A release from main in that window is not on the // release branch. test('a commit that does not contain the newest release of its line is refused', (t) => { const repo = repository(t); diff --git a/scripts/release/next-merge.mjs b/scripts/release/next-merge.mjs deleted file mode 100644 index 434ea387..00000000 --- a/scripts/release/next-merge.mjs +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env node -// Did this commit reach main by merging `next`? -// -// A release is started by a person (docs/releasing.md), with one exception: -// the move to a new OpenShell release. That move is prepared on the `next` -// branch by the Follow upstream workflow and reaches main as the pull request -// from `next` (ADR 0005, decisions 7 and 8). Its whole purpose is to change -// what the next release is built on and declares, and it is the release most -// likely to be forgotten. So publish.yml releases it by itself once it is -// merged and CI has passed on main. -// -// "It came from next" has to be established from where the commit came from, -// not from what it is called. A title can be typed by anyone. This asks the -// one question that cannot be answered by typing: was the commit merged into -// main by a pull request whose head was the branch `next` in THIS repository? -// -// A fork can name a branch `next` too, so the head repository counts, not -// only the branch name. With "Rebase and merge" GitHub associates every -// commit the merge put on main with the pull request, so the tip that CI ran -// for answers for all of them. -// -// Prints `from=next` or `from=` in GITHUB_OUTPUT form. An empty answer is the -// ordinary one and is not an error. -// -// GH_TOKEN=... node scripts/release/next-merge.mjs --sha --repo owner/name -import { execFileSync } from 'node:child_process'; -import { readFileSync, realpathSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { parseArgs } from 'node:util'; - -// The branch deploy/ci/upstream/ keeps, and the one it merges into. -export const NEXT_BRANCH = 'next'; -export const BASE_BRANCH = 'main'; - -/** - * True when the commit was merged into main from `next` in this repository. - * - * @param {object[]} pulls the pull requests GitHub associates with the commit - * (GET /repos/{repo}/commits/{sha}/pulls) - * @param {string} repo owner/name of this repository - */ -export function mergedFromNext(pulls, repo) { - const merged = pulls.filter((pull) => pull?.merged_at && pull.base?.ref === BASE_BRANCH); - // One commit, one pull request. Anything else is not something to guess - // about, and "not sure" must mean "not automatic". - if (merged.length !== 1) { - return false; - } - const [{ head }] = merged; - return head?.repo?.full_name === repo && head.ref === NEXT_BRANCH; -} - -function main() { - const { values } = parseArgs({ - options: { - sha: { type: 'string' }, - repo: { type: 'string' }, - // For tests: the commit's pull requests as a JSON file, instead of asking GitHub. - pulls: { type: 'string' }, - }, - }); - if (!values.sha || !values.repo) { - throw new Error('--sha and --repo are required'); - } - const pulls = JSON.parse( - values.pulls - ? readFileSync(values.pulls, 'utf8') - : execFileSync('gh', ['api', `repos/${values.repo}/commits/${values.sha}/pulls`], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'inherit'], - }), - ); - - if (!mergedFromNext(pulls, values.repo)) { - console.error( - `${values.sha} was not merged from the ${NEXT_BRANCH} branch of ${values.repo}; nothing is released automatically.`, - ); - console.log('from='); - return; - } - console.error(`${values.sha} was merged into ${BASE_BRANCH} from the ${NEXT_BRANCH} branch of ${values.repo}.`); - console.log(`from=${NEXT_BRANCH}`); -} - -if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { - try { - main(); - } catch (error) { - console.error(`next-merge: ${error.message}`); - process.exit(1); - } -} diff --git a/scripts/release/next-merge.test.mjs b/scripts/release/next-merge.test.mjs deleted file mode 100644 index fcbbd7d5..00000000 --- a/scripts/release/next-merge.test.mjs +++ /dev/null @@ -1,101 +0,0 @@ -// Run with: node --test "scripts/**/*.test.mjs" -// -// A release is started by a person. The one automatic release is the merge of -// `next`, the move to a new OpenShell release. These tests cover what decides -// that: next-merge.mjs, which says whether a commit may be released without -// anyone asking. What version it gets is in next-version.test.mjs. -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; - -import { mergedFromNext } from './next-merge.mjs'; - -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); - -// ---- which commit may release without being asked --------------------------- - -const REPO = 'ai-openshell/console'; -const pull = (overrides = {}) => ({ - merged_at: '2026-10-14T06:30:00Z', - base: { ref: 'main' }, - head: { ref: 'next', repo: { full_name: REPO } }, - title: 'fix: move to OpenShell 0.1.4', - ...overrides, -}); - -test('a commit merged from the next branch of this repository is the move to a new release', () => { - assert.equal(mergedFromNext([pull()], REPO), true); - // The title plays no part: it is what a person could type. - assert.equal(mergedFromNext([pull({ title: 'anything at all' })], REPO), true); -}); - -test('anything else is not', () => { - // An ordinary pull request, whatever it is titled. - const ordinary = { head: { ref: 'fix/something', repo: { full_name: REPO } } }; - assert.equal(mergedFromNext([pull(ordinary)], REPO), false); - assert.equal(mergedFromNext([pull({ ...ordinary, title: 'fix: move to OpenShell 0.1.4' })], REPO), false); - // A branch whose name only starts with it, or only contains it. - for (const ref of ['next-steps', 'feature/next', 'nextish', 'bot/next']) { - assert.equal(mergedFromNext([pull({ head: { ref, repo: { full_name: REPO } } })], REPO), false, ref); - } - // A fork that named its branch next. - assert.equal( - mergedFromNext([pull({ head: { ref: 'next', repo: { full_name: 'someone/openshell-dashboard' } } })], REPO), - false, - ); - // A fork that has since been deleted. - assert.equal(mergedFromNext([pull({ head: { ref: 'next', repo: null } })], REPO), false); - // Not merged, or merged somewhere other than main. - assert.equal(mergedFromNext([pull({ merged_at: null })], REPO), false); - assert.equal(mergedFromNext([pull({ base: { ref: 'release/0.1' } })], REPO), false); - assert.equal(mergedFromNext([pull({ base: { ref: '0.2.x' } })], REPO), false); - // A commit pushed straight to main has no pull request. - assert.equal(mergedFromNext([], REPO), false); - // Two merged pull requests for one commit is not something to guess about. - assert.equal(mergedFromNext([pull(), pull()], REPO), false); -}); - -test('a pull request into next is not released when it merges; only next into main is', () => { - const intoNext = pull({ base: { ref: 'next' }, head: { ref: 'feat/needs-0.1.4', repo: { full_name: REPO } } }); - assert.equal(mergedFromNext([intoNext], REPO), false); - // Should GitHub list both for a commit that reached main through next, the - // one into next does not count against the one into main. - assert.equal(mergedFromNext([intoNext, pull()], REPO), true); -}); - -test('the command prints where the commit came from in GITHUB_OUTPUT form, and exits 0 either way', (t) => { - const dir = mkdtempSync(join(tmpdir(), 'next-merge-')); - t.after(() => rmSync(dir, { recursive: true, force: true })); - const pulls = join(dir, 'pulls.json'); - const run = (list) => { - writeFileSync(pulls, JSON.stringify(list)); - return spawnSync( - process.execPath, - [join(repoRoot, 'scripts/release/next-merge.mjs'), '--sha', 'a'.repeat(40), '--repo', REPO, '--pulls', pulls], - { encoding: 'utf8' }, - ); - }; - const ordinary = run([pull({ head: { ref: 'feat/something', repo: { full_name: REPO } } })]); - assert.equal(ordinary.status, 0); - assert.equal(ordinary.stdout, 'from=\n'); - assert.match(ordinary.stderr, /was not merged from the next branch/); - - const fromNext = run([pull()]); - assert.equal(fromNext.status, 0); - assert.equal(fromNext.stdout, 'from=next\n'); - - // publish.yml reads the answer with `sed -n 's/^from=//p'`. - const read = (stdout) => stdout.replace(/^from=/m, '').trim(); - assert.equal(read(ordinary.stdout), ''); - assert.equal(read(fromNext.stdout), 'next'); -}); - -test('the command needs a commit and a repository', () => { - const result = spawnSync(process.execPath, [join(repoRoot, 'scripts/release/next-merge.mjs')], { encoding: 'utf8' }); - assert.equal(result.status, 1); - assert.match(result.stderr, /--sha and --repo are required/); -}); diff --git a/scripts/release/next-version.mjs b/scripts/release/next-version.mjs index c97c992f..9ba6b095 100644 --- a/scripts/release/next-version.mjs +++ b/scripts/release/next-version.mjs @@ -26,7 +26,8 @@ const RELEASE_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; // What a branch can pin (deploy/ci/gateway-pins.json): a stable OpenShell -// release, or on `next` a pre-release. +// release, or, on a bump branch prepared ahead of an upstream release, a +// pre-release. const STABLE_PIN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; const PRE_RELEASE_PIN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-pre\.(0|[1-9]\d*)$/; @@ -63,8 +64,9 @@ export function nextVersion({ pinned, tags }) { if (typeof pinned === 'string' && PRE_RELEASE_PIN.test(pinned)) { throw new Error( `this branch pins OpenShell ${pinned}, a pre-release. Nothing is released from a pre-release pin: ` + - 'a console release is for a gateway release line that exists, and only `next` pins a pre-release. ' + - 'The release is cut after `next` has moved to the stable release and merged.', + 'a console release is for a gateway release line that exists. A pre-release pin is a bump branch ' + + 'prepared ahead of an upstream release, and it cannot be released. Release once the move to the ' + + 'stable OpenShell release has merged.', ); } if (typeof pinned !== 'string' || !STABLE_PIN.test(pinned)) { diff --git a/scripts/release/publish-workflow.test.mjs b/scripts/release/publish-workflow.test.mjs index f2749e6b..e23f359f 100644 --- a/scripts/release/publish-workflow.test.mjs +++ b/scripts/release/publish-workflow.test.mjs @@ -1,11 +1,11 @@ // node --test "scripts/**/*.test.mjs" // -// publish.yml runs for real only on main and on release branches, after a -// merge. Two of its steps are shell written in the workflow file: the one that -// decides WHETHER a run releases, and the one that hands the answer to -// cut-release.mjs. These tests take those scripts out of the file and run -// them, with a stand-in `gh` that answers the two questions the first one -// asks GitHub (did CI pass for this commit, and which pull request merged it). +// publish.yml runs only when a person starts it, on main or on a release +// branch. Two of its steps are shell written in the workflow file: the one that +// checks the run (dry run or not, and whether CI passed), and the one that +// hands the answer to cut-release.mjs. These tests take those scripts out of +// the file and run them, with a stand-in `gh` that answers the one question +// the first one asks GitHub: did CI pass for this commit. import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; @@ -60,18 +60,11 @@ const cleanEnv = () => Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_') && !name.startsWith('GITHUB_')), ); -const fromNext = [ - { merged_at: '2026-10-14T06:30:00Z', base: { ref: 'main' }, head: { ref: 'next', repo: { full_name: REPO } } }, -]; -const ordinary = [ - { merged_at: '2026-10-14T06:30:00Z', base: { ref: 'main' }, head: { ref: 'fix/something', repo: { full_name: REPO } } }, -]; - /** - * Runs the step that decides whether a run releases, in a checkout of one - * commit, and returns what it wrote for the steps after it. + * Runs the step that checks the run, in a checkout of one commit, and returns + * what it wrote for the steps after it. */ -function decide(t, { event, branch = 'main', dryRun = '', greenRuns = 1, pulls = ordinary }) { +function decide(t, { branch = 'main', dryRun = '', greenRuns = 1 }) { const dir = tempDir(t); const work = join(dir, 'work'); const bin = join(dir, 'bin'); @@ -91,8 +84,6 @@ const args = process.argv.slice(2); appendFileSync(process.env.STUB_LOG, args.join(' ') + '\\n'); if (args[0] === 'api' && args[1].includes('/actions/workflows/ci.yml/runs?')) { process.stdout.write(process.env.STUB_GREEN_RUNS + '\\n'); -} else if (args[0] === 'api' && /\\/commits\\/[0-9a-f]{40}\\/pulls$/.test(args[1])) { - process.stdout.write(process.env.STUB_PULLS); } else { process.stderr.write('unexpected gh call: ' + args.join(' ') + '\\n'); process.exit(1); @@ -120,7 +111,6 @@ if (args[0] === 'api' && args[1].includes('/actions/workflows/ci.yml/runs?')) { env: { ...env, PATH: `${bin}${delimiter}${process.env.PATH}`, - EVENT: event, BRANCH: branch, DRY_RUN: dryRun, REPO, @@ -129,7 +119,6 @@ if (args[0] === 'api' && args[1].includes('/actions/workflows/ci.yml/runs?')) { GITHUB_STEP_SUMMARY: summary, STUB_LOG: ghLog, STUB_GREEN_RUNS: String(greenRuns), - STUB_PULLS: JSON.stringify(pulls), }, }); return { @@ -149,7 +138,7 @@ if (args[0] === 'api' && args[1].includes('/actions/workflows/ci.yml/runs?')) { // --- nobody chooses the version ---------------------------------------------- test('the workflow asks for a dry run or not, and for nothing else', () => { - const inputs = workflow.slice(workflow.indexOf(' workflow_dispatch:'), workflow.indexOf(' workflow_run:')); + const inputs = workflow.slice(workflow.indexOf(' workflow_dispatch:'), workflow.indexOf('\nconcurrency:')); assert.deepEqual( inputs.split('\n').filter((line) => /^ {6}[a-z_]+:$/.test(line)).map((line) => line.trim()), ['dry_run:'], @@ -168,19 +157,29 @@ test('the workflow asks for a dry run or not, and for nothing else', () => { test('a person can start it on main or on a release branch, and nowhere else', () => { assert.ok( workflow.includes( - "(github.event_name == 'workflow_dispatch' &&\n" + - " (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))) ||", + " github.event_name == 'workflow_dispatch' &&\n" + + " (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))\n", ), ); - // The automatic path is still for a push to main in this repository only. - assert.ok(workflow.includes("github.event.workflow_run.head_branch == 'main' &&")); - assert.ok(workflow.includes('github.event.workflow_run.head_repository.full_name == github.repository)')); +}); + +// Nothing releases by itself: not a merge, not a CI run that finished, not the +// move to a new OpenShell release. +test('only a person starts it: workflow_dispatch is its one trigger', () => { + const on = workflow.slice(workflow.indexOf('\non:\n') + 1, workflow.indexOf('\nconcurrency:')); + assert.deepEqual( + on.split('\n').filter((line) => /^ {2}[a-z_]+:/.test(line)).map((line) => line.trim()), + ['workflow_dispatch:'], + ); + for (const gone of ['workflow_run', 'next-merge', 'pull-requests:']) { + assert.ok(!workflow.includes(gone), `publish.yml still mentions ${gone}`); + } }); // --- does this run release? -------------------------------------------------- test('asked for by a person: a dry run by default, on the branch it was started on', { skip: !haveBash }, (t) => { - const run = decide(t, { event: 'workflow_dispatch', dryRun: 'true' }); + const run = decide(t, { dryRun: 'true' }); assert.equal(run.status, 0, run.stderr); assert.deepEqual(run.outputs, { release: 'true', dry_run: 'true', branch: 'main' }); assert.equal(run.summary, 'Asked for by a person, on main (dry run).\n'); @@ -189,42 +188,24 @@ test('asked for by a person: a dry run by default, on the branch it was started `api repos/${REPO}/actions/workflows/ci.yml/runs?head_sha=${run.sha}&event=push&status=success&per_page=1 --jq .total_count`, ]); - const real = decide(t, { event: 'workflow_dispatch', dryRun: 'false', branch: 'release/0.1' }); + const real = decide(t, { dryRun: 'false', branch: 'release/0.1' }); assert.equal(real.status, 0, real.stderr); assert.deepEqual(real.outputs, { release: 'true', dry_run: 'false', branch: 'release/0.1' }); assert.equal(real.summary, 'Asked for by a person, on release/0.1.\n'); }); test('a release needs a commit whose CI passed; a dry run only warns', { skip: !haveBash }, (t) => { - const refused = decide(t, { event: 'workflow_dispatch', dryRun: 'false', greenRuns: 0 }); + const refused = decide(t, { dryRun: 'false', greenRuns: 0 }); assert.equal(refused.status, 1); assert.match(refused.stdout, /^::error::CI has not passed for [0-9a-f]{40}, so there is no tested image to release\./m); assert.deepEqual(refused.outputs, {}, 'nothing is handed to the steps after it'); - const dry = decide(t, { event: 'workflow_dispatch', dryRun: 'true', greenRuns: 0 }); + const dry = decide(t, { dryRun: 'true', greenRuns: 0 }); assert.equal(dry.status, 0, dry.stderr); assert.match(dry.stdout, /^::warning::CI has not passed for [0-9a-f]{40}\. This dry run goes ahead/m); assert.deepEqual(dry.outputs, { release: 'true', dry_run: 'true', branch: 'main' }); }); -test('CI finished on main: the merge of next is released, for real, and nothing else is', { skip: !haveBash }, (t) => { - const merged = decide(t, { event: 'workflow_run', pulls: fromNext }); - assert.equal(merged.status, 0, merged.stderr); - assert.deepEqual(merged.outputs, { release: 'true', dry_run: 'false', branch: 'main' }); - assert.equal(merged.summary, 'Merged from next, the move to a new OpenShell release: releasing it.\n'); - assert.deepEqual(merged.gh, [`api repos/${REPO}/commits/${merged.sha}/pulls`]); - - const other = decide(t, { event: 'workflow_run', pulls: ordinary }); - assert.equal(other.status, 0, other.stderr); - assert.deepEqual(other.outputs, { release: 'false', dry_run: 'false', branch: 'main' }); - assert.match(other.summary, /was not merged from next\. Nothing is released automatically/); - - // A commit pushed straight to main, and a fork's branch that is named next. - assert.equal(decide(t, { event: 'workflow_run', pulls: [] }).outputs.release, 'false'); - const fork = [{ ...fromNext[0], head: { ref: 'next', repo: { full_name: 'someone/openshell-dashboard' } } }]; - assert.equal(decide(t, { event: 'workflow_run', pulls: fork }).outputs.release, 'false'); -}); - // A commit that is already released has its tag, and the step that asks the // repository "was a release cut here?" would find it. In a dry run that // answer must not reach the jobs that push image tags and the chart. From bca0fb72ce082aea276f60d5636f3b101d02e71a Mon Sep 17 00:00:00 2001 From: "konflux-internal-p02[bot]" <170854209+konflux-internal-p02[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:05:07 +0000 Subject: [PATCH 4/4] chore(deps): update registry.access.redhat.com/ubi9/ubi-minimal docker tag to v9.8-1794186349 (#62) Signed-off-by: konflux-internal-p02 <170854209+konflux-internal-p02[bot]@users.noreply.github.com> Co-authored-by: konflux-internal-p02[bot] <170854209+konflux-internal-p02[bot]@users.noreply.github.com> --- deploy/Dockerfile.konflux | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/Dockerfile.konflux b/deploy/Dockerfile.konflux index 810a7bcf..96bf8ffa 100644 --- a/deploy/Dockerfile.konflux +++ b/deploy/Dockerfile.konflux @@ -3,7 +3,7 @@ # Base images are digest-pinned and maintained by Renovate/MintMaker. ARG NODE_IMAGE=registry.access.redhat.com/ubi9/nodejs-22:1-1793903405@sha256:18efae02a19cdfbda303ff98d096cfcc2e123f300aff146e7dab1e75b9116dec ARG GO_IMAGE=registry.access.redhat.com/ubi9/go-toolset:1.26.10-1794208260@sha256:0e3894cea4fc077084245e8aa2befc8a7d91a4f60fbf850f0b5b555cbe62ee26 -ARG RUNTIME_IMAGE=registry.access.redhat.com/ubi9/ubi-minimal:9.8-1793862054@sha256:4b79c93106c14ad1c234907f83441ff0e4f8d24e78426a36e159098e9f7e6fa5 +ARG RUNTIME_IMAGE=registry.access.redhat.com/ubi9/ubi-minimal:9.8-1794186349@sha256:f26ef64172ea61f5ac5ba4be8c5c738bf8760b22b3e5148ce7b40f7308ab32db # --- Frontend build --- FROM ${NODE_IMAGE} AS frontend