From 78661a8ba42b2655ffc9cacc45e59fd47573f5b1 Mon Sep 17 00:00:00 2001 From: Avanti Date: Thu, 22 Oct 2026 19:14:06 +0530 Subject: [PATCH 1/3] fix(ci): stop building and pushing container images outside a release (#881) --- ...op-publishing-container-outside-release.md | 11 ++++ .github/workflows/checks.yml | 57 ------------------- deploy/README.md | 12 ++-- deploy/bootstrap.sh | 2 +- deploy/docker-compose.haproxy.yml | 4 +- deploy/docker-compose.prod.yml | 4 +- docs/CI-MIGRATION.md | 25 ++++---- docs/DEPLOY-RUNBOOK.md | 16 +++--- docs/DEPLOYMENT.md | 17 +++--- test/unit/ci/publish-container-image.spec.ts | 52 ++++++++++------- .../src/components/docs/DeployPaths.astro | 2 +- 11 files changed, 82 insertions(+), 120 deletions(-) create mode 100644 .changeset/stop-publishing-container-outside-release.md diff --git a/.changeset/stop-publishing-container-outside-release.md b/.changeset/stop-publishing-container-outside-release.md new file mode 100644 index 000000000..8b71e57cf --- /dev/null +++ b/.changeset/stop-publishing-container-outside-release.md @@ -0,0 +1,11 @@ +--- +"nostream": patch +"nostream-website": patch +--- + +fix(ci): stop building and pushing container images outside a release + +Remove the container publish job from checks.yml so pushes to main no longer publish +images to GHCR. Container image builds and pushes are now restricted exclusively to +release.yml on published releases. Update deployment documentation and reference compose +stacks to target the released image tag (:latest) instead of :main. diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 82809889d..da817fc3b 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -227,60 +227,3 @@ jobs: run: pnpm install --frozen-lockfile - name: Check for changeset run: pnpm exec changeset status --since origin/${{ github.base_ref }} - - publish-container-image: - name: Build and push container image - runs-on: ubuntu-latest - needs: - - changes - - lint - - build-check - - test-units-and-cover - - test-integrations-and-cover - - post-tests - if: >- - always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && - needs.changes.result == 'success' && - ( - needs.changes.outputs.src != 'true' || - ( - needs.lint.result == 'success' && - needs.build-check.result == 'success' && - needs.test-units-and-cover.result == 'success' && - needs.test-integrations-and-cover.result == 'success' - ) - ) && - needs.post-tests.result == 'success' - env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Log in to the Container registry - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=raw,value=main - type=sha,prefix=sha- - - - name: Build and push Docker image - uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/deploy/README.md b/deploy/README.md index d690a6855..95005e09b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -5,8 +5,8 @@ uses a pre-built image from GHCR; migrations and default settings ship inside that image. This guide assumes a Linux host with Docker Engine and the Compose plugin -installed. Container images are published automatically after CI succeeds on pushes to -`main`. See [`docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md) for the CI/CD flow and +installed. Container images are published automatically when releases are published. +See [`docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md) for the CI/CD flow and [`docs/DEPLOY-RUNBOOK.md`](../docs/DEPLOY-RUNBOOK.md) for the step-by-step update procedure after a new image lands on the host. @@ -33,7 +33,7 @@ chmod +x deploy/bootstrap.sh ./deploy/bootstrap.sh /opt/nostream ``` -Edit `/opt/nostream/.env`, load `ghcr.io/cameri/nostream:main`, then: +Edit `/opt/nostream/.env`, load `ghcr.io/cameri/nostream:latest`, then: ```bash cd /opt/nostream @@ -46,7 +46,7 @@ from this repository. You only maintain `.env` and optional settings overrides. ## Prerequisites 1. [Docker Engine](https://docs.docker.com/engine/install/) and the Compose plugin -2. `ghcr.io/cameri/nostream:main` loaded on the host (see +2. `ghcr.io/cameri/nostream:latest` loaded on the host (see [Image delivery](#image-delivery-on-restricted-networks) if `docker pull` fails) ## Server layout after bootstrap @@ -65,10 +65,10 @@ from this repository. You only maintain `.env` and optional settings overrides. | Service | Image | Notes | |-------------------|--------------------------------|---------------------------------------------------------| -| nostream | ghcr.io/cameri/nostream:main | `pull_policy: never` when the image is pre-loaded | +| nostream | ghcr.io/cameri/nostream:latest | `pull_policy: never` when the image is pre-loaded | | nostream-db | postgres:15 | | | nostream-cache | redis:7.0.5-alpine3.16 | | -| nostream-migrate | ghcr.io/cameri/nostream:main | one-shot `knex migrate:latest`; same image as the relay | +| nostream-migrate | ghcr.io/cameri/nostream:latest | one-shot `knex migrate:latest`; same image as the relay | The relay listens on `127.0.0.1:8008`. Expose it with a reverse proxy or tunnel (for example Cloudflare Tunnel). diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 764ab609e..5f29b3b07 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -59,7 +59,7 @@ Bootstrap complete: $TARGET Next steps: 1. Edit $TARGET/.env (SECRET, DB_PASSWORD, REDIS_PASSWORD) - 2. Load ghcr.io/cameri/nostream:main on this host + 2. Load ghcr.io/cameri/nostream:latest on this host 3. cd $TARGET && docker compose up -d Optional relay overrides: diff --git a/deploy/docker-compose.haproxy.yml b/deploy/docker-compose.haproxy.yml index c448aaa83..ca7ca668a 100644 --- a/deploy/docker-compose.haproxy.yml +++ b/deploy/docker-compose.haproxy.yml @@ -10,7 +10,7 @@ networks: - subnet: 172.28.0.0/24 x-nostream-relay: &nostream-relay - image: ghcr.io/cameri/nostream:main + image: ghcr.io/cameri/nostream:latest pull_policy: never env_file: .env environment: @@ -125,7 +125,7 @@ services: retries: 10 nostream-migrate: - image: ghcr.io/cameri/nostream:main + image: ghcr.io/cameri/nostream:latest pull_policy: never container_name: nostream-migrate networks: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 16d135a2b..f86501c6e 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: nostream: - image: ghcr.io/cameri/nostream:main + image: ghcr.io/cameri/nostream:latest pull_policy: never container_name: nostream env_file: .env @@ -72,7 +72,7 @@ services: retries: 10 nostream-migrate: - image: ghcr.io/cameri/nostream:main + image: ghcr.io/cameri/nostream:latest pull_policy: never container_name: nostream-migrate user: node:node diff --git a/docs/CI-MIGRATION.md b/docs/CI-MIGRATION.md index c675d6b4f..d6de592c9 100644 --- a/docs/CI-MIGRATION.md +++ b/docs/CI-MIGRATION.md @@ -1,27 +1,26 @@ # CI container publishing migration -Container publishing is now part of `.github/workflows/checks.yml`. +Container publishing is now restricted exclusively to official releases via `.github/workflows/release.yml`. ## What changed -The standalone `publish-container-image.yml` workflow, which listened for a -completed `workflow_run`, was removed. `checks.yml` now contains -`publish-container-image` as its final job. +The `publish-container-image` job was removed from `.github/workflows/checks.yml`. Container images +are no longer built or published on pushes to `main`. ## Why -Keeping publishing in the workflow that performed the checks makes its -dependencies explicit. It avoids the timing and event-context ambiguity of a -separate `workflow_run`, which could be triggered by an external pull-request -run. +Publishing images from unreleased commits on `main` caused the `main` tag (and per-commit SHA tags) +to serve as moving targets of unreleased code, creating drift between published releases and images +pulled in production. Publishing is now aligned strictly with official GitHub releases. ## New behavior -On successful pushes to `main`, CI runs linting, the build check, unit tests, -integration tests, and post-test reporting before publishing the container -image. The publish job cannot run for pull requests or manual workflow runs. +Container images are published only by `.github/workflows/release.yml` when an official GitHub release +is published with a `v*` tag. Pushes to `main` and pull requests run verification checks without +publishing any container images. ## Action required -None. Developers and deployment users receive the same `main` and per-commit -container image tags automatically after CI succeeds. +Deployments tracking `ghcr.io/cameri/nostream:main` should be updated to track released versions, +such as `ghcr.io/cameri/nostream:latest` or pinned release tags (e.g. `ghcr.io/cameri/nostream:v3.6.2`). +See [`deploy/README.md`](../deploy/README.md) and [`docs/DEPLOY-RUNBOOK.md`](DEPLOY-RUNBOOK.md). diff --git a/docs/DEPLOY-RUNBOOK.md b/docs/DEPLOY-RUNBOOK.md index 204769509..86b388578 100644 --- a/docs/DEPLOY-RUNBOOK.md +++ b/docs/DEPLOY-RUNBOOK.md @@ -1,7 +1,7 @@ # Deploy runbook (sandbox / production relay) Step-by-step procedure for updating a running nostream host after a new -`ghcr.io/cameri/nostream:main` image is available. +`ghcr.io/cameri/nostream:latest` (or release tag) image is available. | Doc | Purpose | |-----|---------| @@ -11,8 +11,8 @@ Step-by-step procedure for updating a running nostream host after a new ## Image delivery vs relay recreate ```text -GitHub push to main - → CI publishes ghcr.io/cameri/nostream:main +GitHub Release published + → CI publishes ghcr.io/cameri/nostream:latest (and versioned tags) → (optional) webhook pull stack loads the image on the host → operator runs this runbook (migrate + recreate relay(s)) → Cloudflare Tunnel / reverse proxy keeps pointing at 127.0.0.1:8008 @@ -43,7 +43,7 @@ are documented under - New relay image on the host (`docker pull` or `docker load`) - Shell access (`cd /opt/nostream`) - Before loading a new image or recreating containers, record the **running** - relay image (not `docker image inspect :main` after pull — that tag may already + relay image (not `docker image inspect :latest` after pull — that tag may already point at the new image) ```bash @@ -84,7 +84,7 @@ Existing `.env` and `.nostr/settings.yaml` are preserved. ### 2. Load the new image (skip if webhook pull already did) ```bash -docker pull ghcr.io/cameri/nostream:main +docker pull ghcr.io/cameri/nostream:latest ``` On hosts that cannot reach GHCR over IPv4, use `docker save` / `docker load`. See @@ -169,10 +169,10 @@ HAProxy stack: stop the relay you just replaced; leave the healthy peer running. ```bash PREVIOUS_IMAGE="$(cat /tmp/nostream-pre-deploy-image-id)" -docker tag "$PREVIOUS_IMAGE" ghcr.io/cameri/nostream:main +docker tag "$PREVIOUS_IMAGE" ghcr.io/cameri/nostream:latest ``` -Or `docker load -i /path/to/nostream-main-backup.tar.gz`. +Or `docker load -i /path/to/nostream-latest-backup.tar.gz`. ### 3. Recreate on the old image (skip migrations) @@ -188,7 +188,7 @@ SKIP_MIGRATE=1 /opt/nostream/recreate-relay.sh /opt/nostream ``` HAProxy: run migrate only when moving **forward** on a new image. For rollback, -retag the old image to `:main`, then recreate relays **without** migrations. +retag the old image to `:latest`, then recreate relays **without** migrations. `rolling-relay-recreate.sh` will **not** replace a running relay unless its peer is up and `/readyz` healthy. If step 1 left one backend stopped, running the diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7d7648fab..b00e19efc 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,19 +1,20 @@ # Deployment and container publishing The `ghcr.io/cameri/nostream` container image is published automatically by the -CI Checks workflow after a successful push to `main`. The workflow publishes -both the `main` tag and a `sha-` tag. +Release workflow (`release.yml`) whenever an official GitHub release is published. +The workflow publishes both the `latest` tag and semver tags (e.g. `v3.6.2`). -Publishing is the final stage of the CI dependency chain: +Publishing is triggered only on published releases whose tag starts with `v`: ```text -changes → lint and build-check → unit and integration tests → post-tests → publish-container-image +release: published (tag v*) → build-and-push-image → ghcr.io/cameri/nostream:latest and : ``` -The publish job runs only for `refs/heads/main` and only when `post-tests` -succeeds. Pull requests and manual workflow runs do not publish images. +CI Checks (`checks.yml`) does not build or publish container images on push to +`main` or pull requests, ensuring that published images only correspond to +released versions. -Deployment hosts can pull the `main` image as described in -[`deploy/README.md`](../deploy/README.md). After the image is on the host, follow +Deployment hosts can pull the released image (`latest` or a specific release tag) +as described in [`deploy/README.md`](../deploy/README.md). After the image is on the host, follow [`docs/DEPLOY-RUNBOOK.md`](DEPLOY-RUNBOOK.md) to migrate and recreate the relay. There is no separate manual image publishing step. diff --git a/test/unit/ci/publish-container-image.spec.ts b/test/unit/ci/publish-container-image.spec.ts index bd3529a98..dba045b6a 100644 --- a/test/unit/ci/publish-container-image.spec.ts +++ b/test/unit/ci/publish-container-image.spec.ts @@ -1,36 +1,44 @@ -import { existsSync, readFileSync } from 'fs' -import { join } from 'path' - import chai from 'chai' +import { existsSync, readFileSync } from 'fs' import { load } from 'js-yaml' +import { join } from 'path' const { expect } = chai type Workflow = { - jobs: Record + jobs: Record< + string, + { + if?: string + needs?: string[] + permissions?: Record + } + > } describe('container image publishing workflow', () => { - const workflow = load(readFileSync(join(process.cwd(), '.github', 'workflows', 'checks.yml'), 'utf-8')) as Workflow - - it('publishes only on main after all CI checks complete', () => { - const publish = workflow.jobs['publish-container-image'] + const checksWorkflow = load( + readFileSync(join(process.cwd(), '.github', 'workflows', 'checks.yml'), 'utf-8'), + ) as Workflow + const releaseWorkflow = load( + readFileSync(join(process.cwd(), '.github', 'workflows', 'release.yml'), 'utf-8'), + ) as Workflow - expect(publish.needs).to.deep.equal([ - 'changes', - 'lint', - 'build-check', - 'test-units-and-cover', - 'test-integrations-and-cover', - 'post-tests', - ]) - expect(publish.if).to.include("always() && github.event_name == 'push' && github.ref == 'refs/heads/main'") - expect(publish.if).to.include("needs.changes.result == 'success'") - expect(publish.if).to.include("needs.changes.outputs.src != 'true'") - for (const job of publish.needs.filter((job) => !['changes', 'post-tests'].includes(job))) { - expect(publish.if).to.include(`needs.${job}.result == 'success'`) + it('does not publish container images in checks.yml', () => { + expect(checksWorkflow.jobs['publish-container-image']).to.be.undefined + for (const [jobName, job] of Object.entries(checksWorkflow.jobs)) { + expect( + job.permissions?.packages, + `job ${jobName} in checks.yml should not have packages write permission`, + ).to.not.equal('write') } - expect(publish.if).to.include("needs.post-tests.result == 'success'") + }) + + it('publishes container image only in release.yml on published releases', () => { + const buildAndPush = releaseWorkflow.jobs['build-and-push-image'] + expect(buildAndPush).to.exist + expect(buildAndPush.permissions?.packages).to.equal('write') + expect(buildAndPush.if).to.include("startsWith(github.event.release.tag_name, 'v')") expect(existsSync(join(process.cwd(), '.github', 'workflows', 'publish-container-image.yml'))).to.equal(false) }) }) diff --git a/website/site/src/components/docs/DeployPaths.astro b/website/site/src/components/docs/DeployPaths.astro index a058b50cd..37c9b9850 100644 --- a/website/site/src/components/docs/DeployPaths.astro +++ b/website/site/src/components/docs/DeployPaths.astro @@ -32,7 +32,7 @@ const paths = [ 'chmod +x deploy/bootstrap.sh', './deploy/bootstrap.sh /opt/nostream', '', - '# edit /opt/nostream/.env, load ghcr.io/cameri/nostream:main, then', + '# edit /opt/nostream/.env, load ghcr.io/cameri/nostream:latest, then', 'cd /opt/nostream', 'docker compose up -d', '', From 0ebddd65080ccb980036d01dc02e35b363131167 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 17:24:41 -0400 Subject: [PATCH 2/3] ci(release): make the deploy path follow releases, not main The container-publish removal left two acceptance criteria open. The relay host pulls its image through a GitHub webhook, and that stack both pulled tag `main` and ignored every event except a push to the watched branch. With pushes no longer publishing, that path could never see a new image. - release.yml names its tags instead of inheriting metadata-action's defaults: the version and `latest`, which is the tag deploys pull. The defaults already produce these for a vX.Y.Z release, but a default is not a contract, and the deploy path now depends on it. - DEPLOY-RUNBOOK records the two host-side steps the change requires, and says plainly that deployments are manual until both are done. - the spec asserts the `latest` tag, so a silent action change cannot strand a host on its last pulled image. --- .github/workflows/release.yml | 8 ++++++++ docs/DEPLOY-RUNBOOK.md | 18 +++++++++++++++--- test/unit/ci/publish-container-image.spec.ts | 13 +++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 607defb51..f83b18af1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,14 @@ jobs: uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # Stated rather than inherited from the action's defaults: the deploy + # path pulls `:latest`, so a released image must carry it. The action's + # default already emits `` and `latest` for a release whose + # tag is vX.Y.Z, but that is a default and not a contract - naming it + # here is what makes `latest` something this repository promises. + tags: | + type=semver,pattern={{version}} + type=raw,value=latest - name: Build and push Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc with: diff --git a/docs/DEPLOY-RUNBOOK.md b/docs/DEPLOY-RUNBOOK.md index 86b388578..44defec4f 100644 --- a/docs/DEPLOY-RUNBOOK.md +++ b/docs/DEPLOY-RUNBOOK.md @@ -11,13 +11,25 @@ Step-by-step procedure for updating a running nostream host after a new ## Image delivery vs relay recreate ```text -GitHub Release published - → CI publishes ghcr.io/cameri/nostream:latest (and versioned tags) - → (optional) webhook pull stack loads the image on the host +GitHub Release published (tag v*) + → CI publishes ghcr.io/cameri/nostream:latest (and the version tag) + → the release webhook pulls :latest onto the host (see below) → operator runs this runbook (migrate + recreate relay(s)) → Cloudflare Tunnel / reverse proxy keeps pointing at 127.0.0.1:8008 ``` +Nothing publishes an image on a push to `main` any more, so a host that still +pulls `:main` will never see a new image. The on-host pull stack must track the +release instead: + +1. `NOSTREAM_TAG` in `/opt/nostream/webhook/.env` set to `latest`. +2. The GitHub webhook subscribed to **releases** as well as **push**, because + the receiver ignores anything that is not a push to the watched branch. + +Both live in `containers/nostream-dev-webhook/` (the versioned source) with its +own README. Until both are done, deployments are manual: pull the tag by hand +and continue at step 2 below. + The webhook pull stack **loads images only**. It does **not** run migrations or recreate relay containers. This runbook closes that gap. diff --git a/test/unit/ci/publish-container-image.spec.ts b/test/unit/ci/publish-container-image.spec.ts index dba045b6a..12e3fd1b3 100644 --- a/test/unit/ci/publish-container-image.spec.ts +++ b/test/unit/ci/publish-container-image.spec.ts @@ -41,4 +41,17 @@ describe('container image publishing workflow', () => { expect(buildAndPush.if).to.include("startsWith(github.event.release.tag_name, 'v')") expect(existsSync(join(process.cwd(), '.github', 'workflows', 'publish-container-image.yml'))).to.equal(false) }) + + // The deploy path pulls `:latest`, so the release must actually push it. + // Without this the metadata action's defaults are the only thing producing + // the tag, and a silent change in that action would strand every host on the + // last image it managed to pull. + it('tags the released image latest, which is what deploys pull', () => { + const steps = (releaseWorkflow.jobs['build-and-push-image'] as { steps?: Array<{ id?: string; with?: Record }> }).steps ?? [] + const meta = steps.find((step) => step.id === 'meta') + expect(meta, 'the metadata step must exist and be named `meta`').to.exist + const tags = meta?.with?.tags ?? '' + expect(tags, 'release.yml must declare its tags, not inherit the defaults').to.include('type=raw,value=latest') + expect(tags).to.include('type=semver,pattern={{version}}') + }) }) From d55166ddaa998d930a961f9e8131772bbcc06527 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 17:48:18 -0400 Subject: [PATCH 3/3] ci(release): publish tags for the releases that actually exist (#881) The release gate only matched tags starting with `v`, but changesets has tagged every relay release `nostream@X.Y.Z` since 3.5.0. Every release from that point on was skipped by the job-level `if:`, so the workflow has not published an image since v3.4.1 - and with the publisher now removed from `checks.yml`, nothing does. Three faults, all in the tag path: - the `if:` did not match the changesets tag; - a semver rule derives its input from the git ref and emits nothing for a non-semver tag, and with no version the `latest` rule is skipped as well, so even a matched release would have pushed no tag. Neither `prefix=` nor `value=` can put the prefix back: one is rendered after parsing, the other replaces the whole tag; - the docs told operators to pull a tag the workflow never produced. The tags now come from a `match` rule for the semver form, the ref for the release tag itself, and a literal `latest`. Verified by running this action revision's own `Meta` class over the workflow's extracted `tags:` input, against its pristine source: a relay release yields `3.6.2`, `nostream@3.6.2` and `latest`; a legacy `v3.4.1` yields `3.4.1`, `v3.4.1` and `latest`; a website release is stopped by the job gate. The spec's gate test evaluates the expression instead of substring-matching it, so a typo that still contains `nostream@` now fails rather than passing. --- .github/workflows/release.yml | 36 ++++++++--- docs/CI-MIGRATION.md | 8 ++- docs/DEPLOY-RUNBOOK.md | 15 +++++ docs/DEPLOYMENT.md | 11 +++- test/unit/ci/publish-container-image.spec.ts | 66 ++++++++++++++++++-- 5 files changed, 116 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f83b18af1..4eb4afb9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,10 @@ on: jobs: build-and-push-image: # Changesets tags each workspace package on publish: relay releases tag - # vX.Y.Z, website releases tag nostream-website@X.Y.Z. Only a relay tag - # should build the relay container image. - if: startsWith(github.event.release.tag_name, 'v') + # nostream@X.Y.Z, website releases tag nostream-website@X.Y.Z. Only a relay + # tag should build the relay container image. The v* branch is kept for the + # releases cut before the repo moved to changesets tagging. + if: startsWith(github.event.release.tag_name, 'nostream@') || startsWith(github.event.release.tag_name, 'v') name: Build and push container image runs-on: ubuntu-latest env: @@ -33,12 +34,31 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} # Stated rather than inherited from the action's defaults: the deploy - # path pulls `:latest`, so a released image must carry it. The action's - # default already emits `` and `latest` for a release whose - # tag is vX.Y.Z, but that is a default and not a contract - naming it - # here is what makes `latest` something this repository promises. + # path pulls `:latest`, so a released image must carry it. Naming the + # tags here is what makes `latest` something this repository promises + # rather than a behaviour of a third-party action. + # + # Changesets tags a relay release `nostream@3.6.2`, which is not a + # semver. A semver rule derives its input from the git ref and emits + # NOTHING for it, and with no version the `latest` rule is skipped as + # well - so a relay release published no tag at all. `prefix=` cannot + # help (it is rendered after parsing), and `value=` cannot help either + # (it replaces the whole tag, leaving no room for the prefix). + # + # So the semver-shaped tag comes from a `match` rule that extracts the + # version out of the relay tag, and the release tag itself is resolved + # from the git ref, unchanged. The semver rule runs first so the bare + # version is the primary tag; the release tag is kept beside it, which + # is what a host sees on the GitHub release and pins. + # + # Verified by running this action revision's own Meta class over this + # block: a relay release yields `3.6.2`, `nostream@3.6.2` and `latest`; + # a legacy `v3.4.1` yields `3.4.1`, `v3.4.1` and `latest`; a website + # release matches neither semver nor `match`, and the job's `if:` + # never lets that job run. tags: | - type=semver,pattern={{version}} + type=match,pattern=nostream@(.+),group=1 + type=ref,event=tag type=raw,value=latest - name: Build and push Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc diff --git a/docs/CI-MIGRATION.md b/docs/CI-MIGRATION.md index d6de592c9..0ba5efce6 100644 --- a/docs/CI-MIGRATION.md +++ b/docs/CI-MIGRATION.md @@ -15,12 +15,14 @@ pulled in production. Publishing is now aligned strictly with official GitHub re ## New behavior -Container images are published only by `.github/workflows/release.yml` when an official GitHub release -is published with a `v*` tag. Pushes to `main` and pull requests run verification checks without +Container images are published only by `.github/workflows/release.yml` when an official relay release +is published. Changesets tags a relay release `nostream@X.Y.Z`, so that prefix is what the workflow +matches; the `vX.Y.Z` form from before the changesets migration is still accepted. Pushes to `main` and pull requests run verification checks without publishing any container images. ## Action required Deployments tracking `ghcr.io/cameri/nostream:main` should be updated to track released versions, -such as `ghcr.io/cameri/nostream:latest` or pinned release tags (e.g. `ghcr.io/cameri/nostream:v3.6.2`). +such as `ghcr.io/cameri/nostream:latest` or pinned release tags (e.g. `ghcr.io/cameri/nostream:nostream@3.6.2`, +published as-is from the release tag, or the bare `ghcr.io/cameri/nostream:3.6.2`). See [`deploy/README.md`](../deploy/README.md) and [`docs/DEPLOY-RUNBOOK.md`](DEPLOY-RUNBOOK.md). diff --git a/docs/DEPLOY-RUNBOOK.md b/docs/DEPLOY-RUNBOOK.md index 44defec4f..7f017b65a 100644 --- a/docs/DEPLOY-RUNBOOK.md +++ b/docs/DEPLOY-RUNBOOK.md @@ -33,6 +33,21 @@ and continue at step 2 below. The webhook pull stack **loads images only**. It does **not** run migrations or recreate relay containers. This runbook closes that gap. +The release event reaches the webhook at the same moment the image build starts, +so the pull fires before the tag exists. The puller retries on a growing backoff +and re-queues a message it exhausts, but **there is no signal that says the pull +landed**. Before you recreate, confirm the image is on the host: + +```bash +docker images --filter=reference='ghcr.io/cameri/nostream' --format '{{.Tag}} {{.ID}}' +# expect the tag the release carried, and a digest that is not the previous one +``` + +A recreate with a stale `latest` starts the previous release from a brand-new +build: the migrate step runs the old schema, nothing fails, and the deploy looks +successful. When in doubt, `docker pull ghcr.io/cameri/nostream:latest` and re-run +the check above. + ## Choose your stack | Stack | Compose file | Update script | Downtime | diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b00e19efc..4b37a701c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,12 +2,17 @@ The `ghcr.io/cameri/nostream` container image is published automatically by the Release workflow (`release.yml`) whenever an official GitHub release is published. -The workflow publishes both the `latest` tag and semver tags (e.g. `v3.6.2`). +For release `nostream@3.6.2` the workflow publishes three tags: `latest`, the release tag itself +(`nostream@3.6.2`, which is what a host pins), and the bare version (`3.6.2`). -Publishing is triggered only on published releases whose tag starts with `v`: +Publishing is triggered only on published relay releases — the changesets tag `nostream@X.Y.Z`, or the +`vX.Y.Z` form used before the changesets migration. A `nostream-website@X.Y.Z` release publishes no +image: ```text -release: published (tag v*) → build-and-push-image → ghcr.io/cameri/nostream:latest and : +release: published (nostream@X.Y.Z) + → build-and-push-image + → ghcr.io/cameri/nostream:{nostream@X.Y.Z, X.Y.Z, latest} ``` CI Checks (`checks.yml`) does not build or publish container images on push to diff --git a/test/unit/ci/publish-container-image.spec.ts b/test/unit/ci/publish-container-image.spec.ts index 12e3fd1b3..fc6bd6901 100644 --- a/test/unit/ci/publish-container-image.spec.ts +++ b/test/unit/ci/publish-container-image.spec.ts @@ -12,10 +12,42 @@ type Workflow = { if?: string needs?: string[] permissions?: Record + steps?: Array<{ id?: string; with?: Record }> } > } +// Changesets tags each workspace package on publish, so a relay release is +// `nostream@X.Y.Z` while a website release is `nostream-website@X.Y.Z`. The +// `if:` expression below is the only thing standing between a website release +// and a relay image built from the wrong commit, so it is exercised rather than +// string-matched: a typo that still contains the right substring would pass a +// `to.include` and skip every relay release. +const evaluateReleaseGate = (tagName: string): boolean => { + const expression = + (load( + readFileSync(join(process.cwd(), '.github', 'workflows', 'release.yml'), 'utf-8'), + ) as Workflow).jobs['build-and-push-image'].if ?? '' + // Only the `startsWith(github.event.release.tag_name, '…')` clauses are used + // in this expression; anything else is a workflow change that needs review. + const clauses = String(expression) + .split('||') + .map((clause) => clause.trim()) + const startsWith = (value: string, prefix: string) => value.startsWith(prefix) + // Translate the two GitHub expression forms into JavaScript. + const js = clauses + .map((clause) => + clause.replace(/^startsWith\(github\.event\.release\.tag_name, '([^']*)'\)$/, + (_, prefix: string) => `startsWith(tagName, ${JSON.stringify(prefix)})`), + ) + .join(' || ') + // eslint-disable-next-line no-new-func + return new Function('tagName', 'startsWith', `return ${js}`)(tagName, startsWith) as boolean +} + +const metaTags = (workflow: Workflow): string => + ((workflow.jobs['build-and-push-image'].steps ?? []).find((step) => step.id === 'meta')?.with?.tags ?? '') + describe('container image publishing workflow', () => { const checksWorkflow = load( readFileSync(join(process.cwd(), '.github', 'workflows', 'checks.yml'), 'utf-8'), @@ -38,20 +70,42 @@ describe('container image publishing workflow', () => { const buildAndPush = releaseWorkflow.jobs['build-and-push-image'] expect(buildAndPush).to.exist expect(buildAndPush.permissions?.packages).to.equal('write') - expect(buildAndPush.if).to.include("startsWith(github.event.release.tag_name, 'v')") expect(existsSync(join(process.cwd(), '.github', 'workflows', 'publish-container-image.yml'))).to.equal(false) }) + it('builds the image for the tag format changesets actually creates', () => { + // The last six relay releases (v3.0.0 … v3.4.1) were tagged vX.Y.Z and + // published, but every release since the repo moved to changesets tagging + // (nostream@3.5.0 onward) was skipped - no image was published for it. + expect(evaluateReleaseGate('nostream@3.6.2'), 'a relay release must build').to.equal(true) + expect(evaluateReleaseGate('v3.4.1'), 'a pre-changesets relay release must still build').to.equal(true) + }) + + it('does not build the image for a website release', () => { + expect(evaluateReleaseGate('nostream-website@1.3.1')).to.equal(false) + }) + // The deploy path pulls `:latest`, so the release must actually push it. // Without this the metadata action's defaults are the only thing producing // the tag, and a silent change in that action would strand every host on the // last image it managed to pull. it('tags the released image latest, which is what deploys pull', () => { - const steps = (releaseWorkflow.jobs['build-and-push-image'] as { steps?: Array<{ id?: string; with?: Record }> }).steps ?? [] - const meta = steps.find((step) => step.id === 'meta') - expect(meta, 'the metadata step must exist and be named `meta`').to.exist - const tags = meta?.with?.tags ?? '' + const tags = metaTags(releaseWorkflow) expect(tags, 'release.yml must declare its tags, not inherit the defaults').to.include('type=raw,value=latest') - expect(tags).to.include('type=semver,pattern={{version}}') + }) + + // A semver rule's input comes from the git ref, so a `nostream@X.Y.Z` tag + // makes it emit nothing - and with no version, the `latest` rule is skipped + // too. A `match` rule is what produces the version tag for that shape. + it('derives a version tag from the changesets release tag', () => { + expect(metaTags(releaseWorkflow)).to.include('type=match,pattern=nostream@(.+),group=1') + }) + + // The release tag is what a host sees on the GitHub release and pins, and no + // semver rule can reproduce its `nostream@` prefix (a `prefix=` is rendered + // after parsing, a `value=` replaces the whole tag). The plain ref rule + // publishes it unchanged. + it('publishes the release tag name itself, which the deploy docs pull', () => { + expect(metaTags(releaseWorkflow)).to.include('type=ref,event=tag') }) })