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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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/<sha7>` git tag, never the registry's
# workflow's commit in the `image/<sha7>` 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.
Expand All @@ -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
Expand All @@ -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.<job_id>.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
Expand Down
85 changes: 82 additions & 3 deletions .github/workflows/release-image.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 `<ref>^{}`; 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/<sha7>` 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 }}
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 13 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `:<sha>` 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 `:<sha>` 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/<short-sha>` 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*
Expand Down
Loading
Loading