From 95198f2cc79e897c07c76051f81b55db0b342a00 Mon Sep 17 00:00:00 2001 From: musingfox Date: Tue, 3 Nov 2026 19:09:59 +0800 Subject: [PATCH 1/4] ci(release): run on push to main and deploy the released head The release now runs on every push to main as well as on dispatch, and a release that succeeds deploys the commit it just tagged. A dispatch gets a boolean `deploy` input, default true; unticking it publishes the image without deploying it. Owner decision 2026-11-03 (ADR 0024): every deploy needed the user, because Claude Code's auto mode refuses an agent's deploy dispatch. Shape: deploy.yml gains `on: workflow_call` and release-image.yml calls it as a job after `release`. A local reusable workflow runs "from the same commit as the caller workflow", and under workflow_call GITHUB_SHA is "Same as the caller workflow", so the unchanged resolve step reads image/ of the released commit, never :release. Build and deploy stay separate jobs, so "cannot be made" and "cannot go live" are still two red marks. Secrets are passed one by one, not `secrets: inherit`. The deploy condition is (push || inputs.deploy) && current == 'true' && attempt == run_attempt. On a push the `deploy` input does not exist and evaluates to an empty string, which GitHub's loose comparison equates with false, so `inputs.deploy != false` would skip every push; the tests model the documented comparison rules and pin that pitfall. A `current` job between the two reads `git ls-remote origin $GITHUB_REF` and deploys only while the released commit is still the head of its ref (owner decision 2026-11-03). A re-run keeps its original GITHUB_SHA and GitHub's concurrency queue says ordering is not guaranteed, so either could otherwise put an older commit over a newer one. A moved ref skips the deploy with a notice naming both shas and stays green; a failed read fails red. The attempt term stops "re-run failed jobs" from reusing an answer given while the old commit was still the head. deploy.yml's concurrency moves to its job, same group, not cancelled. That it holds for the call is unverified until a real run. The "timing is chosen by a person" reason is gone: the hourly cron is not avoided. No push filter: every push to main runs. Rejected: workflow_run (GITHUB_SHA is the default branch's head, not the released commit); an ancestry check (an older commit is always an ancestor, so it stops nothing); the guard inside deploy.yml (it would also gate a manual pin by digest). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 46 ++-- .github/workflows/release-image.yml | 85 +++++++- tests/test_deploy_path.py | 31 ++- tests/test_release_image.py | 316 +++++++++++++++++++++++++++- 4 files changed, 450 insertions(+), 28 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 783b7ee9..7087c6e6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,14 +1,15 @@ name: Deploy the container Worker -# Dispatch-only, and separate from the image build on purpose. Building and -# deploying fail in different ways — "the image cannot be made" and "the image -# cannot go live" — and chaining them would collapse both into one red X. -# Separate also means the timing is chosen: `wrangler.toml` runs a cron every -# hour, and a deploy rolls the container, so landing one at 07:59 is a choice -# nobody should make by accident. +# release-image.yml calls this after every release that succeeds, as a job of its +# own, and a person can still dispatch it (ADR 0024). It stays a separate job from +# the image build on purpose: building and deploying fail in different ways — "the +# image cannot be made" and "the image cannot go live" — and steps of one job would +# collapse both into one red X. A deploy lands whatever the hour: the hourly cron +# in `wrangler.toml` is not avoided, and a digest-hour run that the roll kills is +# retried by the next ticks' catch-up (architecture.md §5, CATCH_UP_HOURS). # # With no input it deploys the image release-image.yml recorded for the -# dispatched commit in the `image/` git tag, never the registry's +# workflow's commit in the `image/` git tag, never the registry's # `:release`: on 2026-10-08 the registry went on answering the previous # `:release` for over 50 seconds after the push that moved it, in three # releases of four, while the sha tag always read back right. @@ -22,19 +23,25 @@ name: Deploy the container Worker # digest recorded before then names an image in the previous repository and no # longer resolves, and the resolve step says so rather than deploying something # older. +# +# Called, it takes no input, so the release cannot hand it a tag, and its github +# context is the caller's: GITHUB_SHA is the commit that release just recorded. +# The secrets are declared one by one because a called workflow sees only what it +# declares; none is required, so a missing one fails in the step that names it. on: workflow_dispatch: inputs: image_tag: description: 'Empty: the image the release recorded for this commit. Or a sha256:… digest to pin one exact image' required: false - -# Two dispatches would race each other's `wrangler deploy` and the container -# rollout behind it. Serialise rather than cancel: cancelling mid-deploy is -# what would leave the Worker and its container application disagreeing. -concurrency: - group: deploy-app - cancel-in-progress: false + workflow_call: + secrets: + CLOUDFLARE_CONTAINERS_TOKEN: + required: false + CYRIS_ACCESS_CLIENT_ID: + required: false + CYRIS_ACCESS_CLIENT_SECRET: + required: false env: # Must equal `[[containers]] name` in wrangler.toml: that is the repository a @@ -46,6 +53,17 @@ env: jobs: deploy: runs-on: ubuntu-latest + # Two deploys would race each other's `wrangler deploy` and the container + # rollout behind it. Serialise rather than cancel: cancelling mid-deploy is + # what would leave the Worker and its container application disagreeing. + # On the job, not the workflow. The workflow-syntax docs' jobs..concurrency + # admits one job at a time to a group, whoever runs it; the reusable-workflow page + # lists that key only for the calling job, and only its warning against sharing a + # group with the caller implies a called job's own group applies. That it holds a + # release's call as it holds a dispatch is unverified until a real run shows it. + concurrency: + group: deploy-app + cancel-in-progress: false steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index d9b93d3e..e397aef5 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -1,12 +1,25 @@ name: Release container image +# Every push to main releases, and a release that succeeds deploys the commit it +# released (ADR 0024). A dispatch does the same unless `deploy` is unticked, which +# is how an image is published without going live. There is no path filter: the +# owner has not decided which pushes may skip a release, so none do. on: + push: + branches: + - main workflow_dispatch: + inputs: + deploy: + description: 'Deploy the released commit to production once its image is recorded' + type: boolean + default: true # Two mutable remote tags are written here (`:release` in the registry, and nothing -# else). Overlapping dispatches would interleave their pushes and could leave -# `:release` on the older commit, so the job is serialised rather than cancelled — -# cancelling mid-push is what would leave a half-moved tag. +# else). Overlapping runs would interleave their pushes and could leave +# `:release` on the older commit, so the run is serialised rather than cancelled — +# cancelling mid-push is what would leave a half-moved tag. The group spans the +# deploy job too, so a newer release waits for the older one's deploy to finish. concurrency: group: release-image cancel-in-progress: false @@ -203,3 +216,69 @@ jobs: git tag -a "$tag" "${{ github.sha }}" \ -m "This commit has an image. commit=${{ github.sha }} digest=$digest" git push origin "$tag" + # Only the head of the run's ref deploys on its own. A re-run keeps the run's + # original GITHUB_SHA, and the release queue's order is not guaranteed, so either + # can bring an older commit here after a newer one went live. The test is equality + # with the ref's head as read now, not ancestry: an older commit is always an + # ancestor of the head. A stale commit skips the deploy with a notice and leaves the + # run green. It is a job of its own because `deploy` calls a workflow and has no + # steps, and deploy.yml dispatched by hand (a pin by digest) must not pass through it. + current: + needs: release + if: github.event_name == 'push' || inputs.deploy + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + current: ${{ steps.head.outputs.current }} + attempt: ${{ steps.head.outputs.attempt }} + steps: + # checkout leaves the job's token on `origin`, as git_tag's push and deploy.yml's + # tag fetch already rely on, so ls-remote can read a private repository's refs. + - uses: actions/checkout@v4 + # The assignment is apart from the awk so a failed read fails the job: a head + # that cannot be read is red, not a quiet skip. An annotated tag lists its tag + # object under its name and the commit it points at under `^{}`; ls-remote + # matches a pattern against a ref's tail, so the name is compared exactly. + - id: head + run: | + refs=$(git ls-remote origin "$GITHUB_REF" "$GITHUB_REF^{}") + head=$(printf '%s\n' "$refs" | awk -v ref="$GITHUB_REF" ' + $2 == ref { head = $1 } + $2 == ref "^{}" { peeled = $1 } + END { print (peeled != "" ? peeled : head) }') + echo "attempt=$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + if [ "$head" = "$GITHUB_SHA" ]; then + echo "current=true" >> "$GITHUB_OUTPUT" + echo "$GITHUB_SHA is the head of $GITHUB_REF; deploying it" + else + echo "current=false" >> "$GITHUB_OUTPUT" + message="not deploying $GITHUB_SHA: $GITHUB_REF is now at ${head:-nothing}. Only the head of a ref deploys after its release; dispatch deploy.yml to deploy another image." + echo "::notice::$message" + echo "$message" >> "$GITHUB_STEP_SUMMARY" + fi + # A job of its own, not steps of `release`, so "the image cannot be made" and "the + # image cannot go live" stay two different red jobs. `needs` holds it back unless + # the release and the head guard succeeded; the implicit success() is why the + # condition names no status. No `with`: the called workflow sees this run's github + # context, so its resolve step reads the `image/` that git_tag just recorded + # for this run's commit, never `:release` and never a newer head of main. + # + # `inputs.deploy` does not exist on a push, so it evaluates to an empty string, and + # GitHub compares '' and false as 0 and 0: `inputs.deploy != false` would skip every + # push. The event is named instead, and on a dispatch the boolean input decides. + # The guard's answer counts only from this attempt: re-running just this job would + # otherwise reuse a `current` given when an older commit was still the head. + deploy: + needs: [release, current] + if: >- + (github.event_name == 'push' || inputs.deploy) + && needs.current.outputs.current == 'true' + && needs.current.outputs.attempt == github.run_attempt + permissions: + contents: read + uses: ./.github/workflows/deploy.yml + secrets: + CLOUDFLARE_CONTAINERS_TOKEN: ${{ secrets.CLOUDFLARE_CONTAINERS_TOKEN }} + CYRIS_ACCESS_CLIENT_ID: ${{ secrets.CYRIS_ACCESS_CLIENT_ID }} + CYRIS_ACCESS_CLIENT_SECRET: ${{ secrets.CYRIS_ACCESS_CLIENT_SECRET }} diff --git a/tests/test_deploy_path.py b/tests/test_deploy_path.py index 0ee52843..0a5e7355 100644 --- a/tests/test_deploy_path.py +++ b/tests/test_deploy_path.py @@ -93,22 +93,43 @@ def _deploy_steps() -> dict[str, tuple[int, dict]]: return {s["id"]: (i, s) for i, s in enumerate(steps) if "id" in s} -def test_the_deploy_workflow_is_dispatch_only_and_defaults_to_no_tag() -> None: - """Chaining it to the build would collapse two different failures into one.""" +def test_the_deploy_workflow_is_dispatchable_with_a_tag_and_callable_without_one() -> None: + """Dispatch is how a person pins an image; the release calls it to deploy its own commit. + + The call takes no input, so the release cannot hand it a tag: a called deploy always + resolves the `image/` the release recorded for the caller's commit. + """ workflow = yaml.safe_load((ROOT / ".github/workflows/deploy.yml").read_text()) # PyYAML reads a bare `on:` key as the boolean True. triggers = workflow[True] image_tag = triggers["workflow_dispatch"]["inputs"]["image_tag"] - assert set(triggers) == {"workflow_dispatch"} + assert set(triggers) == {"workflow_dispatch", "workflow_call"} assert image_tag["required"] is False assert image_tag.get("default", "") == "" + assert "inputs" not in (triggers["workflow_call"] or {}) -def test_the_deploy_workflow_serialises_dispatches() -> None: +def test_the_called_deploy_declares_each_secret_its_steps_read() -> None: + """A called workflow sees only the secrets its caller passes and it declares.""" workflow = yaml.safe_load((ROOT / ".github/workflows/deploy.yml").read_text()) + declared = workflow[True]["workflow_call"]["secrets"] + text = (ROOT / ".github/workflows/deploy.yml").read_text() + + assert set(declared) == set(re.findall(r"secrets\.([A-Z_]+)", text)) + # Not required: a missing one fails in the step that names it, legibly. + assert all(spec["required"] is False for spec in declared.values()) + - assert workflow["concurrency"]["cancel-in-progress"] is False +def test_the_deploy_workflow_serialises_dispatches_and_calls() -> None: + """On the job, where it holds when the release calls it as well as on a dispatch.""" + workflow = yaml.safe_load((ROOT / ".github/workflows/deploy.yml").read_text()) + + assert "concurrency" not in workflow + assert workflow["jobs"]["deploy"]["concurrency"] == { + "group": "deploy-app", + "cancel-in-progress": False, + } def test_the_account_id_is_required_before_anything_is_rendered() -> None: diff --git a/tests/test_release_image.py b/tests/test_release_image.py index 64c5d445..cdb93c31 100644 --- a/tests/test_release_image.py +++ b/tests/test_release_image.py @@ -1,6 +1,8 @@ """Release-image invariants that cannot be exercised by the application suite.""" +import math import os +import re import shutil import subprocess from pathlib import Path @@ -108,12 +110,314 @@ def test_release_workflow_runs_checks_before_building() -> None: assert {"actions/checkout@v4", "astral-sh/setup-uv@v5", "oven-sh/setup-bun@v2"} <= actions -def test_release_workflow_is_dispatch_only() -> None: - workflow = yaml.safe_load((ROOT / ".github/workflows/release-image.yml").read_text()) - triggers = workflow.get("on", workflow.get(True)) - assert set(triggers) == {"workflow_dispatch"} - assert "push" not in triggers - assert "pull_request" not in triggers +def _release_workflow() -> dict: + return yaml.safe_load((ROOT / ".github/workflows/release-image.yml").read_text()) + + +def test_release_workflow_runs_on_every_push_to_main_and_on_dispatch() -> None: + """Every push to main releases; no path filter decides which pushes count (ADR 0024).""" + workflow = _release_workflow() + # PyYAML reads a bare `on:` key as the boolean True. + triggers = workflow[True] + assert set(triggers) == {"push", "workflow_dispatch"} + assert triggers["push"] == {"branches": ["main"]} + deploy = triggers["workflow_dispatch"]["inputs"]["deploy"] + assert deploy["type"] == "boolean" + assert deploy["default"] is True + + +def _truthy(value: object) -> bool: + """GitHub's falsy values: false, 0, -0, "", null and NaN.""" + if isinstance(value, float) and math.isnan(value): + return False + return value not in (False, 0, "", None) + + +def _github_expression(expression: str, context: dict) -> object: + """Evaluate the subset of GitHub's expression syntax a job condition uses here. + + The rules are the documented ones + (docs.github.com/en/actions/reference/workflows-and-actions/expressions): a + comparison of two types converts both to numbers, null and false both to 0; + strings compare case-insensitively; `&&` and `||` return an operand, not a + boolean. A property that does not exist, `inputs.deploy` on a push, is an empty + string (docs.github.com/en/actions/reference/workflows-and-actions/contexts). + """ + body = expression.strip() + if body.startswith("${{") and body.endswith("}}"): + body = body[3:-2] + tokens = re.findall(r"'(?:[^']|'')*'|==|!=|&&|\|\||[!()]|[\w.-]+", body) + position = 0 + + def number(value: object) -> float: + if value is None: + return 0.0 + if isinstance(value, bool): + return float(value) + if isinstance(value, str): + if value == "": + return 0.0 + if re.fullmatch(r"-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?", value.strip()): + return float(value) + return math.nan + return float(value) + + def equal(left: object, right: object) -> bool: + if type(left) is type(right) and isinstance(left, str): + return left.lower() == right.lower() + if type(left) is type(right) and not isinstance(left, float): + return left == right + return number(left) == number(right) + + def atom() -> object: + nonlocal position + token = tokens[position] + position += 1 + if token == "(": + value = either() + position += 1 + return value + if token == "!": + return not _truthy(atom()) + if token.startswith("'"): + return token[1:-1].replace("''", "'") + if token in ("true", "false", "null"): + return {"true": True, "false": False, "null": None}[token] + if re.fullmatch(r"-?\d+(\.\d+)?", token): + return float(token) + value: object = context + for part in token.split("."): + value = value.get(part, "") if isinstance(value, dict) else "" + return value + + def comparison() -> object: + nonlocal position + left = atom() + while position < len(tokens) and tokens[position] in ("==", "!="): + operator = tokens[position] + position += 1 + left = equal(left, atom()) == (operator == "==") + return left + + def both() -> object: + nonlocal position + left = comparison() + while position < len(tokens) and tokens[position] == "&&": + position += 1 + right = comparison() + left = right if _truthy(left) else left + return left + + def either() -> object: + nonlocal position + left = both() + while position < len(tokens) and tokens[position] == "||": + position += 1 + right = both() + left = left if _truthy(left) else right + return left + + value = either() + assert position == len(tokens), f"unparsed tail in {expression!r}" + return value + + +RELEASED = "1" * 40 +NEWER = "2" * 40 + + +def _guarded(context: dict, current: str = "true", attempt: str = "1") -> dict: + """`context` once the head guard has answered: run outputs are strings.""" + return { + **context, + "github": {**context["github"], "sha": RELEASED, "run_attempt": "1"}, + "needs": {"current": {"outputs": {"current": current, "attempt": attempt}}}, + } + + +PUSH = {"github": {"event_name": "push"}, "inputs": {}} + + +def _dispatch(deploy: bool) -> dict: + return {"github": {"event_name": "workflow_dispatch"}, "inputs": {"deploy": deploy}} + + +def test_the_expression_model_reproduces_the_null_equals_false_pitfall() -> None: + """Red fixture: the condition that reads naturally skips the deploy on every push.""" + assert not _truthy(_github_expression("inputs.deploy != false", PUSH)) + assert _truthy(_github_expression("inputs.deploy == false", PUSH)) + assert _truthy(_github_expression("${{ github.event_name == 'PUSH' }}", PUSH)) + + +def _deploy_job() -> dict: + return _release_workflow()["jobs"]["deploy"] + + +def test_release_workflow_deploys_only_after_a_successful_release() -> None: + """A separate job, so a red build and a red deploy stay two different red Xs.""" + job = _deploy_job() + assert job["uses"] == "./.github/workflows/deploy.yml" + assert set(job["needs"]) == {"release", "current"} + assert _current_job()["needs"] in ("release", ["release"]) + # A status function would replace the implicit success() that holds the deploy back. + for condition in (job["if"], _current_job()["if"]): + assert not re.search(r"\b(always|failure|cancelled)\(", condition) + + +def test_release_workflow_deploys_on_push_and_by_default_skipping_only_on_false() -> None: + condition = _deploy_job()["if"] + assert _truthy(_github_expression(condition, _guarded(PUSH))) + assert _truthy(_github_expression(condition, _guarded(_dispatch(True)))) + assert not _truthy(_github_expression(condition, _guarded(_dispatch(False)))) + + +def _current_job() -> dict: + return _release_workflow()["jobs"]["current"] + + +def test_release_workflow_asks_for_the_head_only_when_a_deploy_is_wanted() -> None: + """A dispatch that unticks `deploy` has nothing for the guard to hold back.""" + condition = _current_job()["if"] + assert _truthy(_github_expression(condition, PUSH)) + assert _truthy(_github_expression(condition, _dispatch(True))) + assert not _truthy(_github_expression(condition, _dispatch(False))) + + +def test_release_workflow_deploys_only_while_its_commit_is_the_head_of_its_ref() -> None: + """A re-run keeps its GITHUB_SHA, and the release queue's order is not guaranteed. + + Either can bring an older commit to the deploy job after a newer one; the guard's + answer holds it back on a push and on a dispatch alike. + """ + condition = _deploy_job()["if"] + for wanted in (PUSH, _dispatch(True)): + assert _truthy(_github_expression(condition, _guarded(wanted))) + assert not _truthy(_github_expression(condition, _guarded(wanted, current="false"))) + assert not _truthy(_github_expression(condition, _guarded(wanted, current=""))) + + +def test_release_workflow_ignores_a_head_answer_from_an_earlier_attempt() -> None: + """Re-running only `deploy` would leave `current` as an earlier attempt answered it. + + That answer was given when the commit was still the head; it says nothing about now. + """ + condition = _deploy_job()["if"] + assert not _truthy(_github_expression(condition, _guarded(PUSH, attempt="0"))) + assert not _truthy(_github_expression(condition, _guarded(PUSH, attempt=""))) + + +def test_release_workflow_reads_the_head_of_its_ref_from_the_remote() -> None: + job = _current_job() + assert job["permissions"] == {"contents": "read"} + steps = job["steps"] + # checkout leaves the job's token on `origin`, which ls-remote then reads. + assert steps[0]["uses"] == "actions/checkout@v4" + [head] = [step for step in steps if step.get("id") == "head"] + assert 'git ls-remote origin "$GITHUB_REF"' in head["run"] + assert job["outputs"] == { + "current": "${{ steps.head.outputs.current }}", + "attempt": "${{ steps.head.outputs.attempt }}", + } + + +def _run_head_step( + tmp_path: Path, ls_remote: str, ref: str = "refs/heads/main", fails: bool = False +) -> tuple[int, str, str, str]: + """Run the guard's head step with `git` stubbed to answer `ls_remote`.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + answer = tmp_path / "ls-remote" + answer.write_text(ls_remote) + (bin_dir / "git").write_text( + "#!/bin/sh\n" + + ('echo "fatal: unable to access" >&2; exit 128\n' if fails else "") + + '[ "$1 $2" = "ls-remote origin" ] || exit 2\n' + + f'cat "{answer}"\n' + ) + (bin_dir / "git").chmod(0o755) + output, summary = tmp_path / "output", tmp_path / "summary" + output.touch() + summary.touch() + [head] = [step for step in _current_job()["steps"] if step.get("id") == "head"] + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "GITHUB_SHA": RELEASED, + "GITHUB_REF": ref, + "GITHUB_RUN_ATTEMPT": "3", + "GITHUB_OUTPUT": str(output), + "GITHUB_STEP_SUMMARY": str(summary), + } + result = subprocess.run( + ["bash", "-e", "-c", head["run"]], env=env, capture_output=True, text=True + ) + return result.returncode, output.read_text(), summary.read_text(), result.stdout + + +def test_release_workflow_guard_passes_the_head_of_its_ref(tmp_path: Path) -> None: + code, output, summary, stdout = _run_head_step(tmp_path, f"{RELEASED}\trefs/heads/main\n") + assert code == 0 + assert "current=true\n" in output and "attempt=3\n" in output + assert "::notice::" not in stdout + + +def test_release_workflow_guard_skips_a_commit_the_ref_has_moved_past(tmp_path: Path) -> None: + """Skipped, not failed: the newer head deploys from its own run. Visible all the same.""" + code, output, summary, stdout = _run_head_step(tmp_path, f"{NEWER}\trefs/heads/main\n") + assert code == 0 + assert "current=false\n" in output and "current=true" not in output + [notice] = [line for line in stdout.splitlines() if line.startswith("::notice::")] + assert RELEASED in notice and NEWER in notice + assert RELEASED in summary and NEWER in summary + + +def test_release_workflow_guard_skips_when_the_ref_is_gone(tmp_path: Path) -> None: + code, output, _, stdout = _run_head_step(tmp_path, "", ref="refs/heads/gone") + assert code == 0 + assert "current=false\n" in output + assert "::notice::" in stdout + + +def test_release_workflow_guard_matches_the_ref_exactly(tmp_path: Path) -> None: + """ls-remote matches a pattern against a ref's tail, so other refs can answer too.""" + answer = f"{RELEASED}\trefs/heads/feature/refs/heads/main\n{NEWER}\trefs/heads/main\n" + code, output, _, _ = _run_head_step(tmp_path, answer) + assert code == 0 + assert "current=false\n" in output + + +def test_release_workflow_guard_peels_an_annotated_tag(tmp_path: Path) -> None: + """A tag's ls-remote line names the tag object; `^{}` names the commit.""" + answer = f"{NEWER}\trefs/tags/v1\n{RELEASED}\trefs/tags/v1^{{}}\n" + code, output, _, _ = _run_head_step(tmp_path, answer, ref="refs/tags/v1") + assert code == 0 + assert "current=true\n" in output + + +def test_release_workflow_guard_fails_when_the_head_cannot_be_read(tmp_path: Path) -> None: + """An unreadable head is red, not a quiet skip that looks like a newer push.""" + code, output, _, _ = _run_head_step(tmp_path, "", fails=True) + assert code != 0 + assert "current=true" not in output + + +def test_release_workflow_deploys_the_commit_it_released_never_a_pinned_image() -> None: + """No `with`: deploy.yml's resolve then reads `image/` of the caller's commit. + + A called workflow's `github` context is the caller's, so GITHUB_SHA there is the + commit this release just tagged, not whatever main's head has become since. + """ + job = _deploy_job() + assert "with" not in job + assert job["permissions"] == {"contents": "read"} + assert job["secrets"] == { + name: f"${{{{ secrets.{name} }}}}" + for name in ( + "CLOUDFLARE_CONTAINERS_TOKEN", + "CYRIS_ACCESS_CLIENT_ID", + "CYRIS_ACCESS_CLIENT_SECRET", + ) + } def test_release_workflow_pushes_annotated_image_tag() -> None: From 30fa935848b5e7772c4483720aea45bb315d4ec1 Mon Sep 17 00:00:00 2001 From: musingfox Date: Tue, 3 Nov 2026 19:09:59 +0800 Subject: [PATCH 2/4] fix(trial): read production's last deploy from either deploy path Stage 2's note read `gh run list --workflow deploy.yml`. A release now deploys by calling deploy.yml as a job of its own run, and a called workflow has no run of its own, so the note would show only the last deploy started by hand. production_last_deploy reads the last 10 runs of both workflows and keeps a run only if a job named `deploy` or ending in `/ deploy` succeeded, so a release whose deploy was skipped does not count. seed_trial.py, reset_trial.py and deploy_trial.py said a trial is checked before production; a push now deploys production first, and their help text says the trial comes after, as operations.md does. Rejected: asking production's /api/build, the better receipt. The wizard holds only the trial's Access token at that stage and has no production URL. Co-Authored-By: Claude Opus 5.5 --- scripts/deploy_trial.py | 6 +-- scripts/reset_trial.py | 4 +- scripts/seed_trial.py | 4 +- scripts/trial-wizard.sh | 25 +++++++++++- tests/test_trial_wizard.py | 79 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 8 deletions(-) diff --git a/scripts/deploy_trial.py b/scripts/deploy_trial.py index 0f3d9a0e..d7763726 100644 --- a/scripts/deploy_trial.py +++ b/scripts/deploy_trial.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Deploy the image a release recorded to one trial, and check the trial serves it. -The staging step of a release (docs/operations.md): after "Release container -image" has run on a commit, this deploys that commit's image to the trial before -anyone deploys production. The digest comes from the commit's `image/` tag, +The trial step of a release (docs/operations.md): after "Release container +image" has run on a commit, and a push to main has deployed it to production, this +deploys that commit's image to the trial. The digest comes from the commit's `image/` tag, read the way `.github/workflows/deploy.yml` reads it: the tag is fetched by name, and the digest is taken only from a message naming this exact commit. diff --git a/scripts/reset_trial.py b/scripts/reset_trial.py index c3a4c420..1ebfde0d 100644 --- a/scripts/reset_trial.py +++ b/scripts/reset_trial.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Clear one trial deployment back to no sources, waiting for onboarding. -A trial is verified after each release before production gets the image -(docs/operations.md), and a verification that walks /onboarding needs a trial +A trial is verified after each release, once a push to main has deployed it to +production (docs/operations.md), and a verification that walks /onboarding needs a trial that has not been onboarded yet. This empties the trial's reader content and resets the settings the onboarding wizard asks for, so its tester (or the owner) walks the wizard again from the first step. diff --git a/scripts/seed_trial.py b/scripts/seed_trial.py index bc393962..2ad662aa 100644 --- a/scripts/seed_trial.py +++ b/scripts/seed_trial.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Save production's reader content as a seed, and restore a trial to it. -A release is checked on a trial before production gets its image -(docs/operations.md). `reset_trial.py` takes a trial back to no sources; this +A release is checked on a trial after a push to main has deployed it to +production (docs/operations.md). `reset_trial.py` takes a trial back to no sources; this takes it back to a known state with real content instead, so the same check can be run again from the same start. diff --git a/scripts/trial-wizard.sh b/scripts/trial-wizard.sh index 01b6eef9..f96249d9 100755 --- a/scripts/trial-wizard.sh +++ b/scripts/trial-wizard.sh @@ -307,6 +307,29 @@ derive_names() { eval "$out" } +# production_last_deploy prints the commit production last deployed and when, from GitHub's +# run history. A release deploys by calling deploy.yml as a job of its own run, so that deploy +# has no deploy.yml run: both workflows' runs are read, and a run counts only if its deploy job +# succeeded (a release's is skipped by an unticked box or by the head guard). The wizard holds +# only the trial's service token, so it cannot ask production's /api/build itself. +production_last_deploy() { + command -v gh >/dev/null 2>&1 || { echo "gh unavailable"; return 0; } + local found + found=$( + for workflow in release-image.yml deploy.yml; do + gh run list --workflow "$workflow" --limit 10 --json databaseId,headSha \ + -q '.[] | "\(.databaseId) \(.headSha)"' 2>/dev/null || true + done | while read -r id sha; do + at=$(gh run view "$id" --json jobs -q '[.jobs[] + | select(.conclusion == "success" and (.name == "deploy" or (.name | endswith("/ deploy")))) + | .completedAt] | max // empty' 2>/dev/null || true) + [[ -n "$at" ]] && echo "$at $sha" + done | sort -r | head -1 + ) || true + if [[ -n "$found" ]]; then echo "${found#* } (finished ${found%% *})" + else echo "none in the last 10 runs of release-image.yml or deploy.yml"; fi +} + # access_header_file writes the service-token headers to a private temp file and prints its # path, so the secret reaches curl through a file and never through argv. access_header_file() { @@ -470,7 +493,7 @@ stage_2() { IMAGE_DIGEST=$(git tag -l --format='%(contents)' "$IMAGE_TAG" | grep -o 'sha256:[0-9a-f]\{64\}' || true) [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || die "No sha256 digest in tag $IMAGE_TAG." say "Newest image: $IMAGE_TAG ($IMAGE_DIGEST)" - note "Production's last deploy: $(gh run list --workflow deploy.yml --limit 1 --json headSha,conclusion -q '.[0] | "\(.headSha) \(.conclusion)"' 2>/dev/null || echo 'gh unavailable')" + note "Production's last deploy: $(production_last_deploy)" note "The tag's short sha must prefix that commit." confirm "Is $IMAGE_TAG the image production runs?" || die "Pick the image production runs before deploying a trial." write_env IMAGE_TAG "$IMAGE_TAG" diff --git a/tests/test_trial_wizard.py b/tests/test_trial_wizard.py index fca8fb12..b30a8d90 100644 --- a/tests/test_trial_wizard.py +++ b/tests/test_trial_wizard.py @@ -5,6 +5,7 @@ stage that would wait for a key reads end-of-file instead: a "no" at every y/N gate. """ +import json import re import shutil import stat @@ -105,6 +106,7 @@ fi done case " $* " in + *" whoami "*) echo "${FAKE_WHOAMI:-}" ;; *" --dry-run "*) ;; *" deploy "*) case "$*" in @@ -933,3 +935,80 @@ def test_the_hand_over_copies_no_token_to_the_clipboard(rig) -> None: assert "pbcopy" not in run.calls assert "password manager" not in run.out assert "Access one-time PIN" in run.out + + +# gh answers from JSON fixtures through the wizard's own -q filter, so the filter is what is +# tested. Its list key is the workflow file, its view key the run id. +FAKE_GH = r"""#!/bin/bash +echo "gh $*" >> "$CALLS" +q=""; prev="" +for a in "$@"; do [[ "$prev" == -q ]] && q="$a"; prev="$a"; done +case "$1 $2" in + "run list") key="list-$4" ;; + "run view") key="view-$3" ;; + *) exit 0 ;; +esac +jq -r "$q" "$FAKE_DIR/gh-$key.json" +""" + +RELEASED_SHA, MANUAL_SHA, SKIPPED_SHA = "1" * 40, "2" * 40, "3" * 40 + + +def _deploy_history(rig, tmp_path: Path, manual_at: str) -> Run: + """Stage 2 with a release that deployed, a newer one the head guard skipped, and a + deploy.yml dispatch finished at `manual_at`.""" + if shutil.which("jq", path="/usr/bin:/bin") is None: + pytest.skip("jq is not in /usr/bin or /bin") + r = rig("1", state={"IMAGE_TAG": None, "IMAGE_DIGEST": None}, FAKE_WHOAMI=ACCOUNT) + fake = tmp_path / "fake" + + def job(name: str, conclusion: str, at: str) -> dict: + return {"name": name, "conclusion": conclusion, "completedAt": at} + + fixtures = { + "list-release-image.yml": [ + {"databaseId": 102, "headSha": SKIPPED_SHA}, + {"databaseId": 101, "headSha": RELEASED_SHA}, + ], + "list-deploy.yml": [{"databaseId": 201, "headSha": MANUAL_SHA}], + "view-101": { + "jobs": [ + job("release", "success", "2026-11-03T10:00:00Z"), + job("current", "success", "2026-11-03T10:01:00Z"), + job("deploy / deploy", "success", "2026-11-03T10:20:00Z"), + ] + }, + "view-102": { + "jobs": [ + job("release", "success", "2026-11-03T11:00:00Z"), + job("current", "success", "2026-11-03T11:01:00Z"), + job("deploy / deploy", "skipped", "2026-11-03T11:01:00Z"), + ] + }, + "view-201": {"jobs": [job("deploy", "success", manual_at)]}, + } + for key, body in fixtures.items(): + (fake / f"gh-{key}.json").write_text(json.dumps(body)) + bin_dir = tmp_path / "bin" + (bin_dir / "gh").write_text(FAKE_GH) + (bin_dir / "git").write_text( + FAKE_GIT + + 'case "$*" in\n' + + ' "tag -l image/* --sort=-creatordate") echo image/1111111 ;;\n' + + f' "tag -l --format=%(contents) image/1111111") echo "digest=sha256:{"b" * 64}" ;;\n' + + "esac\n" + ) + return r.run(stdin=START) + + +def test_the_last_deploy_note_counts_a_release_that_deployed(rig, tmp_path: Path) -> None: + """A release deploys inside its own run, so deploy.yml's runs alone miss it.""" + run = _deploy_history(rig, tmp_path, manual_at="2026-11-02T09:00:00Z") + assert f"Production's last deploy: {RELEASED_SHA} (finished 2026-11-03T10:20:00Z)" in run.out + assert "Pick the image production runs" in run.out + + +def test_the_last_deploy_note_counts_a_later_deploy_started_by_hand(rig, tmp_path: Path) -> None: + run = _deploy_history(rig, tmp_path, manual_at="2026-11-03T12:00:00Z") + assert f"Production's last deploy: {MANUAL_SHA} (finished 2026-11-03T12:00:00Z)" in run.out + assert SKIPPED_SHA not in run.out From 6479ee324bedd6a68ccb00a3a0c072bf31f50fa1 Mon Sep 17 00:00:00 2001 From: musingfox Date: Tue, 3 Nov 2026 19:09:59 +0800 Subject: [PATCH 3/4] docs(decisions): record ADR 0024, a push to main releases and deploys Records the owner's 2026-11-03 decisions: a push to main releases and deploys, a dispatch may opt out of the deploy, the hourly cron is not avoided, and only the head of its ref deploys automatically. It keeps the surviving half of the old separation (build and deploy fail as separate jobs) and retires the other (a person picks the hour). Rejected: keeping both workflows dispatch-only, holding deploys away from the cron hour, workflow_run, and an ancestry guard. Consequences stated plainly: any push to main, which has no branch protection, deploys production; a digest pin lasts until the next push; a queued dispatch replaces a waiting push run; re-runs of old runs skip their deploy. Co-Authored-By: Claude Opus 5.5 --- ...024-a-push-to-main-releases-and-deploys.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/decisions/0024-a-push-to-main-releases-and-deploys.md diff --git a/docs/decisions/0024-a-push-to-main-releases-and-deploys.md b/docs/decisions/0024-a-push-to-main-releases-and-deploys.md new file mode 100644 index 00000000..fd579c6e --- /dev/null +++ b/docs/decisions/0024-a-push-to-main-releases-and-deploys.md @@ -0,0 +1,166 @@ +--- +status: accepted +date: 2026-11-03 +decision-makers: "" +--- + +# A push to main releases and deploys + +## Context and Problem Statement + +Until 2026-11-03 the app Worker shipped through two workflows, and a person dispatched each: +`.github/workflows/release-image.yml` built and recorded the image, then +`.github/workflows/deploy.yml` deployed it. `deploy.yml`'s header gave two reasons for keeping +them apart. Building and deploying fail differently. And the timing was chosen by a person, +because `wrangler.toml` runs a cron every hour (`crons = ["0 * * * *"]`) and a deploy rolls the +container. + +In practice every deploy waited for the user. The coding agent that lands most changes cannot +dispatch a workflow, so a merged change stayed off production until the user ran two +workflows by hand. The owner decided on 2026-11-03 that a push should reach production +without that step. + +## Decision Drivers + +* Fewer manual steps between a merge and production. Every deploy needed the user. +* "The image cannot be made" and "the image cannot go live" must stay two visible failures. +* A deploy must land the image recorded for the commit that was released. It must not land + `:release`, and it must not land a newer head of main. +* A person must still be able to pin an exact image by digest (`docs/operations.md` + *Going back*). + +## Considered Options + +* The release runs on push to main and on dispatch. A release that succeeds calls `deploy.yml` + as a second job, unless a dispatch unticked its `deploy` input (chosen) +* Keep both workflows dispatch-only +* Deploy after release, but hold the deploy away from the cron hour +* Trigger `deploy.yml` with `workflow_run` when the release completes + +## Decision Outcome + +Chosen option: "release on push, then deploy as a called job". The owner decided three points +on 2026-11-03: + +1. Deploy after release is on by default and optional. A dispatch of the release has a boolean + `deploy` input, default true; false means no deploy. A push has no inputs and deploys. +2. The release also runs on every push to `main`, with no path filter. `workflow_dispatch` + stays. +3. The hourly cron is not avoided. "The timing is chosen by a person" is retired as a reason. + +A fourth point followed the same day's review: only the head of its ref deploys +automatically. A job, `current`, runs between the release and the deploy. It reads the head +of the run's ref (`git ls-remote origin "$GITHUB_REF"`) and lets the deploy run only if that +head equals `GITHUB_SHA`. Otherwise the deploy is skipped, with a notice naming both commits, +and the run stays green. The test is equality, not ancestry: an older commit is always an +ancestor of the head, so ancestry would stop nothing. The guard's answer counts only in the +attempt that gave it, so re-running only the deploy job cannot reuse an answer from when an +older commit was the head. `deploy.yml` dispatched by hand, a pin by digest included, does not +pass through it. + +The other half of the old separation still holds. Build and deploy are separate jobs, so each +fails as its own red X. `deploy.yml` keeps its dispatch and its `image_tag` input for pinning. +Its resolve, derive, deploy and verify steps are unchanged. + +A called workflow runs from the caller's commit and sees the caller's `github` context. Its +`GITHUB_SHA` is therefore the commit the release just tagged `image/`. `workflow_run` +would set `GITHUB_SHA` to the head of the default branch and run `deploy.yml` from there. Its +resolve step would then need `github.event.workflow_run.head_sha` in place of `GITHUB_SHA`, and +the two runs would no longer share a concurrency group. + +Whether a deploy is wanted is `github.event_name == 'push' || inputs.deploy`; the guard's two +outputs are added to it with `&&`. A push has no `deploy` input, and a property that does not +exist evaluates to an empty string. GitHub's loose comparison turns '' and false both into 0, +so the condition that reads more naturally, `inputs.deploy != false`, would skip every push. + +### Consequences + +* Good, because a merge reaches production with no person in the loop, and the agent's work is + live when it lands. +* Good, because the release job runs `scripts/check.sh` before it builds. A push whose checks + fail is never released, so it is never deployed. +* Bad, because any push to `main` deploys production, and `main` has no branch protection. The + check gate inside the release is the only thing between a push and production. +* Bad, because a deploy can land in the minute before a publish hour and roll the container + under that hour's run. The catch-up's retries (`CATCH_UP_HOURS`, `docs/architecture.md` §5) + redo a run the roll kills. A digest can still be late. +* Bad, because a pin is temporary. An image pinned by dispatching `deploy.yml` with a digest stays + live only until the next push to `main` deploys that push's commit. +* Bad, because "staging first, production after" no longer holds for a push. Production gets + the commit before anyone can check it on the trial deployment. +* Neutral, because a dispatch of the release on a branch other than `main` also deploys by + default. That branch's head is what goes live unless `deploy` is unticked. +* Neutral, because releases are serialised by the workflow's `release-image` concurrency + group, which now spans the deploy job too. GitHub's concurrency docs say runs in a group + "are processed in first-in-first-out (FIFO) order according to the time each one started + waiting on the concurrency group", and "Since the actual start time of a job or run may + vary, ordering is not guaranteed." The guard is why that order no longer matters for + production: an older commit whose run reaches the deploy after a newer push skips its + deploy, because it is no longer the head. +* Bad, because only one run waits in a group, and any release that queues replaces it. That + includes a dispatch with `deploy` unticked, or one on another branch. A push whose waiting + run is replaced is never released, so it never deploys; dispatching the release on `main` + recovers it. A `deploy.yml` dispatch queued behind a release's waiting deploy cancels that + deploy in the shared `deploy-app` group, and the other way round, if a called job's own + group holds the call, which is unverified (see below). +* Neutral, because a re-run keeps the run's original `GITHUB_SHA` and `GITHUB_REF`. Without + the guard, re-running an old release run would deploy its old commit. With it, the re-run + skips its deploy unless that commit is still the head. Re-running only a failed deploy job + skips it too, so a deploy is retried by re-running all jobs or by dispatching `deploy.yml`. +* Bad, because a commit deploys only from its own release. When a newer push's release fails, + the older commit has already skipped its deploy, and production keeps what it had. + +### Confirmation + +`tests/test_release_image.py` pins the triggers, the `deploy` input, the deploy job and its +condition. It evaluates the condition under GitHub's documented comparison rules for a push, a +dispatch with `deploy` true, and one with `deploy` false, each with the guard answering current, +stale, or from an earlier attempt. It runs the guard's step against a stubbed `git ls-remote`: +the head, a moved ref, a deleted ref, an annotated tag and a failed read. `tests/test_deploy_path.py` pins +`deploy.yml`'s dispatch with `image_tag`, its `workflow_call` and its job-level concurrency. + +## Pros and Cons of the Options + +### Release on push, then deploy as a called job + +* Good, because the called workflow sees the caller's commit, so the existing resolve step + deploys the released image unchanged. +* Good, because one run shows both jobs, and each fails on its own. +* Bad, because `deploy.yml`'s concurrency had to move from workflow level to its job, and + that it holds for the call is not documented outright. The workflow-syntax docs' + `jobs..concurrency` admits one job at a time to a group. The reusable-workflow page + lists that key only for the calling job. Only its warning against giving the called and + caller workflows the same group implies that a called job's own group applies. It stays + unverified until a real run shows a release's deploy waiting on a dispatched one. + +### Keep both workflows dispatch-only + +* Good, because a person chooses every deploy and its hour. +* Bad, because every deploy needs the user. This is the cost the decision removes. + +### Deploy after release, but hold the deploy away from the cron hour + +* Good, because a roll would not land just before a publish hour. +* Bad, because a merge waits for a safe minute before it goes live, and the owner chose not to + avoid the cron. + +### Trigger `deploy.yml` with `workflow_run` + +* Good, because the two workflows stay fully separate runs. +* Bad, because its `GITHUB_SHA` is the default branch's head, not the released commit. Getting + that wrong deploys the wrong image, or fails to resolve one. +* Bad, because it fires only from the default branch's copy of the workflow, and its chains stop + at three levels. + +## More Information + +* GitHub docs: [Reusing workflow configurations](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations), + [Events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), + [Evaluate expressions](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions), + [Control workflow concurrency](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency), + [Workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax), + [Contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts), + [Pass job outputs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/pass-job-outputs), + [Re-running workflows and jobs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs). +* `docs/spec/release-image-built-in-ci.md` is the release's spec. `docs/operations.md` is the + procedure. From 996037bbfe2bb43217ca0f1131046353192e472a Mon Sep 17 00:00:00 2001 From: musingfox Date: Tue, 3 Nov 2026 19:09:59 +0800 Subject: [PATCH 4/4] docs: say a push to main releases and deploys production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit architecture.md §6, operations.md, trial-deployment.md, the accepted release-image-built-in-ci spec, CLAUDE.md and the CHANGELOG now say the release runs on push and dispatch and deploys the released head unless a dispatch opts out, that the cron is not avoided, and that a digest pin lasts until the next push. operations.md also states what a queued run replaces and how to recover, and that a failed deploy is retried by re-running all jobs. "Staging first, production after" became "The trial deployment, after production". §7 waits for the landed hash. Rejected: rewriting the 2026 CHANGELOG entry that calls both workflows dispatch-only; it records what shipped then. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++ CLAUDE.md | 8 +-- docs/architecture.md | 21 +++++--- docs/operations.md | 69 ++++++++++++++++++++------ docs/spec/release-image-built-in-ci.md | 19 ++++--- docs/trial-deployment.md | 7 +-- 6 files changed, 95 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4defdab0..aeb594cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **A push to `main` deploys production.** The release workflow now runs on + every push to `main`. A release that passes its checks deploys the commit it + released, as a separate job, so a failed build and a failed deploy still show + apart. Only a commit that is still the head of its ref deploys; a re-run + of an old release, or an older release finishing after a newer push, skips + its deploy. A release started by hand deploys too, unless its + `deploy` box is unticked. "Deploy the container Worker" can still be run by hand to pin an + image by digest, until the next push deploys over it. - **Every `read` article gets a summary.** An article scored under **Summary score** used to show the first 80 characters of its text, which for a newsletter was often its subscribe pitch; it is now summarized with the rest diff --git a/CLAUDE.md b/CLAUDE.md index c10e9d91..fe41034e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,11 @@ path, and a fork can run the whole pipeline that way. It fetches articles from R ## Commands Setup, the gate and test subsets are in `CONTRIBUTING.md`. Run `scripts/check.sh` before you -push: `main` has no branch protection, so a red CI run stops nothing from landing. Only the app -Worker deploys through a workflow; the `rss`, `promote` and `newsletter` Workers deploy outside -any workflow, with no gate, so a merged change to them is not live until someone deploys it. +push: `main` has no branch protection, so a red CI run stops nothing from landing. A push to +`main` also deploys the app Worker to production once the release's own `scripts/check.sh` +passes (ADR 0024). Only the app Worker deploys through a workflow; the `rss`, `promote` and +`newsletter` Workers deploy outside any workflow, with no gate, so a merged change to them is not +live until someone deploys it. ## Architecture diff --git a/docs/architecture.md b/docs/architecture.md index ff24fab9..e5a2dc02 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -944,17 +944,22 @@ Cloudflare ``` **The release image is built by CI, not by a workstation.** `.github/workflows/release-image.yml` -is dispatched by hand, bakes the commit into the image and pushes it to the Cloudflare registry -under `:` and `:release`. A plain `wrangler deploy` against the tracked `wrangler.toml` still -builds `./Dockerfile` locally — that config stays fork-neutral by +runs on every push to `main` and on dispatch. It bakes the commit into the image and pushes it to +the Cloudflare registry under `:` and `:release`. A plain `wrangler deploy` against the +tracked `wrangler.toml` still builds `./Dockerfile` locally — that config stays fork-neutral by `docs/spec/wrangler-toml-stays-fork-neutral.md`, so the registry image is deployed through a *derived* config instead: `.github/workflows/deploy.yml` renders it with -`scripts/derive-wrangler-config.sh` and passes it as `--config`. That workflow is dispatch-only -and separate from the build, because building and deploying fail differently and because -pinning is its job. With no input it deploys the digest the release recorded in the dispatched +`scripts/derive-wrangler-config.sh` and passes it as `--config`. A release that succeeds calls +that workflow as a second job, so a push deploys production, as long as the released commit is +still the head of the ref it ran for; an older one skips the deploy. A dispatch of the release deploys +too, unless its `deploy` input is unticked. The deploy lands whatever the hour; the hourly cron is +not avoided ([ADR-0024](decisions/0024-a-push-to-main-releases-and-deploys.md)). It stays a +job of its own because building and deploying fail differently, and it stays dispatchable because +pinning is its job. With no input it deploys the digest the release recorded in the workflow commit's `image/` git tag, never `:release`, which the registry has answered stale for -over 50 seconds after a push; `image_tag` takes a `sha256:…` digest, which is how a deploy goes -back to an image already published. Going back is a reference to an image already in the +over 50 seconds after a push. When the release calls it, that commit is the one just released, +not a newer head of `main`. `image_tag` takes a `sha256:…` digest, which is how a deploy goes +back to an image already published; the next push to `main` deploys over it. Going back is a reference to an image already in the registry, never a rebuild of the old commit — the workflow refuses to republish a commit because the image is not reproducible — and the platform's own rollback does not move that reference: `docs/spec/revert-carries-the-image.md` holds the rule, and `docs/operations.md` *Going back* diff --git a/docs/operations.md b/docs/operations.md index 2aa3512f..340e3c12 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -16,8 +16,10 @@ bunx wrangler deploy --env-file /dev/null It rebuilds the image locally and rolls the container. A deploy replaces running instances, so avoid landing one just before a publish hour. -**The GitHub Actions release path.** Two workflows, both started by hand -(`workflow_dispatch`), never on push: +**The GitHub Actions release path.** Every push to `main` releases and then deploys +production, with no person involved and no path filter +([ADR-0024](decisions/0024-a-push-to-main-releases-and-deploys.md)). The deploy lands +whatever the hour, a publish hour included. Two workflows do it: - `.github/workflows/release-image.yml` ("Release container image") runs `scripts/check.sh`, builds a `linux/amd64` image with the commit baked in @@ -26,12 +28,45 @@ instances, so avoid landing one just before a publish hour. skips the build and push for a commit it has already published, because the image is not reproducible. If the registry still answers the previous `:release` after a minute, the run ends with a warning and records the digest anyway; nothing deploys - from `:release`. + from `:release`. It runs on every push to `main`, and can be started by hand + (`workflow_dispatch`). When the release succeeds, its second job, `deploy`, runs + `deploy.yml` on the same commit. A push always gets that job. A run started by hand + gets it unless its `deploy` box is unticked, which publishes the image without + deploying it. A red check stops the release, so the deploy never runs. - `.github/workflows/deploy.yml` ("Deploy the container Worker") deploys an image that - is already in the registry. With its `image_tag` input left empty, it deploys the - digest recorded in the dispatched commit's `image/` tag, and fails if - that commit has not been released. A `sha256:…` digest pins one exact image instead. - It refuses a tag name such as `release`. + is already in the registry. The release calls it, and it can also be started by hand. + With its `image_tag` input left empty, it deploys the digest recorded in that + commit's `image/` tag, and fails if that commit has not been released. A + `sha256:…` digest pins one exact image instead. It refuses a tag name such as + `release`. + +Releases run one at a time, and each run's deploy finishes before the next release +starts. Only one release waits at a time: any release that queues behind it replaces +it, whether a newer push or a run started by hand, even one with `deploy` unticked or on +another branch. A push whose waiting release is replaced that way is never released, so +it never deploys, and of three quick pushes the middle one may be lost like this. To +recover, start "Release container image" by hand on `main` with `deploy` ticked; it +releases and deploys `main`'s head. + +GitHub's docs say the queue runs first-in-first-out by when each run started waiting, +"Since the actual start time of a job or run may vary, ordering is not guaranteed." That +no longer decides what reaches production. Before its deploy, a release checks that its +commit is still the head of the ref (branch or tag) it ran for. If the ref has moved on, the run +skips its deploy with a notice naming both commits and stays green; the newer commit +deploys from its own release, and if that release fails, neither deploys and production +keeps what it had. Re-running an old release run skips its deploy the same way, because +a re-run keeps the commit it first ran on. + +To retry a deploy that failed, re-run all jobs of the newest release run, or start +"Deploy the container Worker" by hand. Re-running only the failed `deploy` job skips it, +with no notice: the head check counts only from the attempt that made it. + +A deploy started by hand waits for a deploy in progress. Only one deploy waits at a +time, and the release's deploy shares that queue: a deploy started by hand queued behind +a release's waiting deploy cancels it, and the other way round. That the release's +deploy joins this queue is unverified until a real run shows it (ADR-0024). If a release's deploy is +cancelled this way, production gets what the deploy started by hand deploys; to bring +`main`'s head back, start the release on `main` again. On a fork they need, under Settings → Secrets and variables → Actions: @@ -50,22 +85,21 @@ On a fork they need, under Settings → Secrets and variables → Actions: Both paths push to the same registry repository, named by `[[containers]] name` in `wrangler.toml`, so whichever deployed last is what runs. -### Staging first, production after +### The trial deployment, after production -On the release path, a release runs on a trial deployment -([trial-deployment.md](trial-deployment.md)) before it reaches production. This project -stages on `trial1`. +A push to `main` deploys production straight after its release, so a release reaches the +trial deployment ([trial-deployment.md](trial-deployment.md)) after production, not +before it. This project's trial is `trial1`. -1. Merge to `main`. -2. Run "Release container image" on that commit. -3. From the checkout that provisioned the trial, with that commit checked out, run +1. Merge to `main`. The release runs, and its deploy job deploys production. +2. From the checkout that provisioned the trial, with that commit checked out, run `scripts/deploy_trial.py`. It deploys the image the release recorded, and fails unless the trial reports that commit. To check onboarding from its first step, clear the trial with `scripts/reset_trial.py` first. To check it from real content, restore a seed of production's with `scripts/seed_trial.py` first. Each script describes itself under `--help`. -4. Check the release on the trial. -5. The owner runs "Deploy the container Worker" for production. +3. Check the release on the trial. If it is wrong, take production back as *Going back* + describes. The rss and vote Workers deploy outside any workflow. `deploy_trial.py` deploys the trial's copies when their code changed, and production's copies then need the same @@ -144,6 +178,9 @@ gh workflow run "Deploy the container Worker" -f image_tag=sha256: The workflow's `verify` step confirms the rollout; `cyris doctor --deployment` answers the same question by hand, allowing for the warm-instance lag above. +A pin lasts until the next push to `main`, whose release deploys that push's commit over +it. A push made before the fault is fixed on `main` puts the fault back. + **On the `wrangler deploy` path**, there is no recorded digest: check out the older commit and `bunx wrangler deploy --env-file /dev/null` again. That builds a new image from the old source, not the image that ran before. diff --git a/docs/spec/release-image-built-in-ci.md b/docs/spec/release-image-built-in-ci.md index f60c902b..d7123479 100644 --- a/docs/spec/release-image-built-in-ci.md +++ b/docs/spec/release-image-built-in-ci.md @@ -16,8 +16,8 @@ The image a release deploys must be built and pushed by CI, not by a workstation. No step on the deploy path may require a local Docker daemon, a local registry login, or a local keychain. -A `workflow_dispatch` job in `.github/workflows/` builds the image with -`docker build --build-arg GIT_SHA=$GITHUB_SHA` and pushes it to the Cloudflare +A job in `.github/workflows/`, run on every push to `main` and on dispatch, builds +the image with `docker build --build-arg GIT_SHA=$GITHUB_SHA` and pushes it to the Cloudflare registry under two tags: the immutable `:`, and the mutable `:release`. The deployed configuration names neither: a routine deploy reads the digest the release recorded in the commit's `image/` git tag and deploys @@ -26,9 +26,12 @@ moved it. Recording the digest is what makes a deploy reversible — if a deploy has to be pinned, it is a one-string edit to an already-published digest, not another build. -Out of scope: the deploy itself, which stays a separate step and a separate -ticket. Also out of scope: a developer building the image locally to test it — -that is not a release, and it produces an image this entry does not let near +The deploy now follows the release by default +([ADR-0024](../decisions/0024-a-push-to-main-releases-and-deploys.md)). A release that +succeeds runs the deploy as a separate job on the same commit, if that commit is still +the head of its ref, and a dispatch can opt out. This entry still holds only the build. The deploy's own invariants are guarded by +`tests/test_deploy_path.py`. Out of scope: a developer building the image locally to +test it — that is not a release, and it produces an image this entry does not let near production. A violation is silent because a local build produces a working image. The 2026 @@ -40,8 +43,10 @@ release entirely while leaving no trace in the repository, since nothing here observes what a laptop can do. The binding now exists: the `release_workflow` selection of `tests/test_release_image.py` -asserts that the dispatch-only trigger, the check gate, the build, both pushes and the -registry read-back are all in the workflow and in that order. The selection is narrow on +asserts that the push-and-dispatch trigger, the check gate, the build, both pushes and the +registry read-back are all in the workflow and in that order, and that the deploy runs as a +separate job after a release that succeeds, only while the released commit heads its +ref. The selection is narrow on purpose — the same module also guards the Dockerfile invariant and the documentation prose, and binding this entry to all of it would report the spec as violated when an unrelated sentence changes. diff --git a/docs/trial-deployment.md b/docs/trial-deployment.md index c57176ba..f0f6a08d 100644 --- a/docs/trial-deployment.md +++ b/docs/trial-deployment.md @@ -61,7 +61,8 @@ Either works; the first trial (2026-10-06) runs on production's. - **The first image is production's.** The wizard takes the newest `image/*` tag the release workflow writes (see [operations.md](operations.md)) and asks you to confirm it is the one production runs, so the trial tests the code the tester will keep - getting. After that, a trial staged on gets each release before production does. + getting. After that, a push to `main` deploys production first, and a trial staged on + gets each release after it. - **The trial has its own API token**, with the permissions of [install step 3](install-cloudflare.md#3-create-an-api-token) and no more, so it can be revoked alone. Only on an account of its own does a leak reach nothing but the trial. @@ -102,8 +103,8 @@ Either works; the first trial (2026-10-06) runs on production's. ## After each release -A release runs on a trial before production; the flow is in -[operations.md](operations.md#staging-first-production-after). From the checkout that +A push to `main` deploys production first, and the trial gets the release after it; the +flow is in [operations.md](operations.md#the-trial-deployment-after-production). From the checkout that provisioned the trial, `scripts/deploy_trial.py` puts the release on it, and `scripts/reset_trial.py` first clears it back to waiting for onboarding when the release is to be checked from the wizard's first step. `scripts/seed_trial.py` instead restores