From f2d33faec60d0baf2033c80d7328f26d782b6bcb Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sun, 30 Aug 2026 20:29:29 +0530 Subject: [PATCH] fix(ci): publish container image after CI passes on main Move publish into checks.yml with always() gating, add workflow test and CI docs, and fix deploy README link. --- .../fix-publish-container-image-trigger.md | 9 +++ .github/workflows/checks.yml | 57 +++++++++++++++++++ .github/workflows/publish-container-image.yml | 57 ------------------- deploy/README.md | 4 +- docs/CI-MIGRATION.md | 27 +++++++++ docs/DEPLOYMENT.md | 18 ++++++ test/unit/ci/publish-container-image.spec.ts | 36 ++++++++++++ 7 files changed, 149 insertions(+), 59 deletions(-) create mode 100644 .changeset/fix-publish-container-image-trigger.md delete mode 100644 .github/workflows/publish-container-image.yml create mode 100644 docs/CI-MIGRATION.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 test/unit/ci/publish-container-image.spec.ts diff --git a/.changeset/fix-publish-container-image-trigger.md b/.changeset/fix-publish-container-image-trigger.md new file mode 100644 index 00000000..46bd4e35 --- /dev/null +++ b/.changeset/fix-publish-container-image-trigger.md @@ -0,0 +1,9 @@ +--- +"nostream": patch +--- + +fix(ci): publish container image after CI passes on main + +Removes the standalone publish workflow and its unsafe workflow_run trigger. +Image publish now runs as the final job in checks.yml on pushes to main, after +lint, build, and tests succeed. diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 99e89404..b1cf0ae5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -225,3 +225,60 @@ 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/publish-container-image.yml b/.github/workflows/publish-container-image.yml deleted file mode 100644 index 434183ab..00000000 --- a/.github/workflows/publish-container-image.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Publish Container Image - -on: - workflow_run: - workflows: - - CI Checks - types: - - completed - branches: - - main - workflow_dispatch: - -jobs: - build-and-push-image: - name: Build and push container image - if: > - github.event_name == 'workflow_dispatch' || - ( - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' - ) - runs-on: ubuntu-latest - env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.event.workflow_run.head_sha }} - - - 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 0af20865..1054040b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -4,8 +4,8 @@ Minimal Docker Compose stack for running nostream in production. The relay container uses a pre-built image from GHCR instead of building on the server. This guide assumes a Linux host with Docker Engine and the Compose plugin -installed. For image publishing on merge to `main`, see -`.github/workflows/publish-container-image.yml`. +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. Migrations ship inside that image (`migrations/` and `knexfile.js`). The `nostream-migrate` service is a one-shot container of the same image that diff --git a/docs/CI-MIGRATION.md b/docs/CI-MIGRATION.md new file mode 100644 index 00000000..c675d6b4 --- /dev/null +++ b/docs/CI-MIGRATION.md @@ -0,0 +1,27 @@ +# CI container publishing migration + +Container publishing is now part of `.github/workflows/checks.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. + +## 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. + +## 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. + +## Action required + +None. Developers and deployment users receive the same `main` and per-commit +container image tags automatically after CI succeeds. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 00000000..e90e7659 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,18 @@ +# 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. + +Publishing is the final stage of the CI dependency chain: + +```text +changes → lint and build-check → unit and integration tests → post-tests → publish-container-image +``` + +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. + +Deployment hosts can pull the `main` image as described in +[`deploy/README.md`](../deploy/README.md). 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 new file mode 100644 index 00000000..bd3529a9 --- /dev/null +++ b/test/unit/ci/publish-container-image.spec.ts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' + +import chai from 'chai' +import { load } from 'js-yaml' + +const { expect } = chai + +type Workflow = { + jobs: 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'] + + 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'`) + } + expect(publish.if).to.include("needs.post-tests.result == 'success'") + expect(existsSync(join(process.cwd(), '.github', 'workflows', 'publish-container-image.yml'))).to.equal(false) + }) +})