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/.github/workflows/release.yml b/.github/workflows/release.yml index 607defb51..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: @@ -32,6 +33,33 @@ 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. 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=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 with: 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..0ba5efce6 100644 --- a/docs/CI-MIGRATION.md +++ b/docs/CI-MIGRATION.md @@ -1,27 +1,28 @@ # 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 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 -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: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 204769509..7f017b65a 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,16 +11,43 @@ 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 - → (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. +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 | @@ -43,7 +70,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 +111,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 +196,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 +215,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..4b37a701c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,19 +1,25 @@ # 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. +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 the final stage of the CI dependency chain: +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 -changes → lint and build-check → unit and integration tests → post-tests → publish-container-image +release: published (nostream@X.Y.Z) + → build-and-push-image + → ghcr.io/cameri/nostream:{nostream@X.Y.Z, X.Y.Z, latest} ``` -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..fc6bd6901 100644 --- a/test/unit/ci/publish-container-image.spec.ts +++ b/test/unit/ci/publish-container-image.spec.ts @@ -1,36 +1,111 @@ -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 + 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 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'] - - 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'`) + 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 + + 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(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 tags = metaTags(releaseWorkflow) + expect(tags, 'release.yml must declare its tags, not inherit the defaults').to.include('type=raw,value=latest') + }) + + // 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') + }) }) 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', '',