name: Release on: push: tags: ['v*'] env: NODE_VERSION: '22' DOCKER_PLATFORMS: linux/amd64,linux/arm64 permissions: contents: write packages: write jobs: # ──── Release Gate (CI-strength) ────────────────────────────────── # Mirror the CI workflow's gate jobs so a tag can never publish an image or GitHub Release that # lint, the spec type-check, unit + e2e tests, the Postgres migration smoke, or the dashboard # build/lint would have rejected. Each job below matches its CI counterpart; only the tag-match # guard and the publish jobs (docker, release) are release-specific. lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: npm ci # Fail fast (before tests, image build, or the GitHub Release) if the release is inconsistent: # the tag must match package.json, and the docs/CHANGELOG version guard must pass. This stops a # mis-tagged or under-documented release from ever publishing an image or a GitHub Release. - name: Verify tag matches package.json version env: TAG: ${{ github.ref_name }} run: | PKG_VERSION="$(node -p "require('./package.json').version")" TAG_VERSION="${TAG#v}" if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then echo "::error::Tag '$TAG' (version '$TAG_VERSION') does not match package.json version '$PKG_VERSION'. Bump package.json or fix the tag before releasing." exit 1 fi echo "Tag $TAG matches package.json $PKG_VERSION" - name: Security audit run: npm audit --audit-level=critical - name: Run ESLint run: npm run lint # Same gap as CI: `nest build` excludes specs, so a spec-only type regression slips past every # existing release gate. tsconfig.json includes both src and test, so this is the full-program # check that also covers spec files. - name: Type-check full program (including specs) run: npx tsc --noEmit -p tsconfig.json - name: Check formatting run: npm run format -- --check - name: Check version consistency (docs track package.json) run: npm run check:versions test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run unit tests run: npm test -- --coverage - name: Run e2e smoke tests run: npm run test:e2e test-postgres: name: Test (PostgreSQL migrations) runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_USER: openwa POSTGRES_PASSWORD: openwa POSTGRES_DB: openwa ports: - 5432:5432 options: >- --health-cmd "pg_isready -U openwa" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: npm ci - name: Build (compiles the data migrations to dist/) run: npm run build # Applies the full data-migration chain to a real Postgres and asserts every generated-uuid PK # has a DB DEFAULT — the dialect gap SQLite-only tests can't see. - name: Migrate + uuid-default smoke against PostgreSQL run: npm run test:pg-smoke env: DATABASE_TYPE: postgres DATABASE_HOST: localhost DATABASE_PORT: '5432' DATABASE_USERNAME: openwa DATABASE_PASSWORD: openwa DATABASE_NAME: openwa # Runtime-proves BuiltInFtsProvider on Postgres (websearch_to_tsquery + ts_headline against the # STORED body_ts tsvector). The spec self-skips unless DATABASE_TYPE=postgres, so it is a no-op # in the default test job and only executes here against the postgres:16 service. - name: Postgres FTS provider spec run: npx jest src/database/migrations/__tests__/1782400000000-AddMessagesFts.pg.spec.ts env: DATABASE_TYPE: postgres DATABASE_HOST: localhost DATABASE_PORT: '5432' DATABASE_USERNAME: openwa DATABASE_PASSWORD: openwa DATABASE_NAME: openwa dashboard: name: Dashboard runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' cache-dependency-path: dashboard/package-lock.json - name: Install dependencies run: cd dashboard && npm ci - name: Run ESLint run: cd dashboard && npm run lint - name: Check i18n parity run: cd dashboard && npm run i18n:check - name: Build dashboard run: cd dashboard && npm run build - name: Run dashboard unit tests run: cd dashboard && npm run test:unit build: name: Build runs-on: ubuntu-latest needs: [lint, test, dashboard] steps: - uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: npm ci - name: Build project run: npm run build # ──── Create GitHub Release ─────────────────────────────────────── release: name: GitHub Release runs-on: ubuntu-latest # Gate the public Release on every CI-strength job AND a successfully built+pushed image that # also boots, so a tag never produces a GitHub Release without passing the same gate CI enforces, # a matching container image, and a runtime boot check on both architectures (boot-smoke runs the # published image on amd64 + arm64). Trades a few minutes of buildx/boot latency for release # safety. needs: [lint, test, test-postgres, dashboard, build, docker, boot-smoke] steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Extract version from tag id: version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - name: Extract release notes from CHANGELOG id: changelog run: | # Extract the section for this version from CHANGELOG.md VERSION="${{ steps.version.outputs.VERSION }}" NOTES=$(awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md) if [ -z "$NOTES" ]; then NOTES="Release v${VERSION}" fi # Use EOF delimiter for multi-line output echo "NOTES<> $GITHUB_OUTPUT echo "$NOTES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: # Author the release as a real user (the PAT owner) instead of github-actions[bot]. # Add a repo secret RELEASE_PAT — a fine-grained PAT scoped to this repo with # "Contents: read and write". Falls back to GITHUB_TOKEN (bot-authored) when the secret # is absent, so the job never breaks before the token is configured. token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }} tag_name: ${{ github.ref_name }} name: v${{ steps.version.outputs.VERSION }} body: ${{ steps.changelog.outputs.NOTES }} draft: false prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} # ──── Docker Image with Version Tag ─────────────────────────────── docker: name: Docker Release runs-on: ubuntu-latest needs: [lint, test, test-postgres, dashboard, build] steps: - uses: actions/checkout@v7 - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} - name: Build and push uses: docker/build-push-action@v7 with: context: . push: true platforms: ${{ env.DOCKER_PLATFORMS }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # provenance is generated by default (build-push-action v7); pin it explicitly and opt into # an SBOM attestation so each release image carries an in-toto SLSA provenance + SBOM pair, # verifiable via `docker buildx imagetools inspect`. provenance: true sbom: true cache-from: type=gha cache-to: type=gha,mode=max # ──── Boot Smoke (amd64 + arm64) ────────────────────────────────── # The docker job proves the image BUILDS for both architectures; this proves the just-pushed image # actually BOOTS and serves the liveness endpoint on both. A boot regression that only surfaces at # runtime on one architecture (e.g. a native-dep/Chromium SIGTRAP on arm64 that a clean build still # produces) cannot reach a public Release. Runs the published image — not a rebuild — against the # dependency-free /api/health/live (NOT /ready, which needs a DB and would conflate boot failure # with database setup). Both architectures are tried even if the first fails, so logs name both. boot-smoke: name: Boot smoke (amd64 + arm64) runs-on: ubuntu-latest needs: [docker] steps: - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Resolve published image ref id: image # metadata-action publishes the {{version}} tag (e.g. 0.8.17) from the v-prefixed git tag, so # strip the leading "v" to match it. ${REPO,,} lowercases github.repository — GHCR normalizes # the repo name to lowercase on push, and `docker run` rejects a mixed-case reference, so the # pull target must be lowercase too (this repo is rmyndharis/OpenWA). env: REPO: ${{ github.repository }} run: echo "ref=ghcr.io/${REPO,,}:${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" - name: Boot + liveness check on each architecture env: IMAGE: ${{ steps.image.outputs.ref }} run: | exit_code=0 for platform in linux/amd64 linux/arm64; do echo "::group::boot-smoke $platform" docker rm -f openwa-smoke >/dev/null 2>&1 || true if ! docker run --rm --platform "$platform" -d -p 2785:2785 --name openwa-smoke "$IMAGE" >/dev/null; then echo "FAIL: $platform — could not start container (image pull/run error)" exit_code=1 echo "::endgroup::" continue fi ok="" for i in $(seq 1 40); do if curl -sf http://127.0.0.1:2785/api/health/live >/dev/null 2>&1; then ok=1; break; fi sleep 5 done if [ -n "$ok" ]; then echo "OK: $platform — /api/health/live returned 200" else echo "FAIL: $platform — /api/health/live did not return 200 within ~200s; container logs follow:" docker logs openwa-smoke 2>&1 | tail -60 || true exit_code=1 fi docker stop openwa-smoke >/dev/null 2>&1 || true docker rm -f openwa-smoke >/dev/null 2>&1 || true echo "::endgroup::" done exit "$exit_code"