diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5b7fa..5ba18ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,13 +5,18 @@ on: branches: - main - 'fix/**' + - 'feat/**' tags-ignore: - 'v*' pull_request: workflow_dispatch: +permissions: + contents: read + jobs: lint-and-check: + name: Lint and check runs-on: ubuntu-latest steps: @@ -21,11 +26,131 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24.19.0 cache: npm - - name: Install dependencies - run: npm ci + - name: Install dependencies without lifecycle scripts + run: npm ci --ignore-scripts - name: Lint and validate run: npm run check + + npm-audit: + name: Audit production dependencies + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.19.0 + cache: npm + + - name: Install dependencies without lifecycle scripts + run: npm ci --ignore-scripts + + - name: Check production dependency vulnerabilities + run: npm audit --omit=dev --audit-level=high + + node-compatibility: + name: Node.js runtime compatibility + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - '18.19.1' + - '24.19.0' + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies without lifecycle scripts + run: npm ci --ignore-scripts + + - name: Check runtime compatibility + run: npm run check + + codeql: + name: CodeQL + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + security-events: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + + - name: Analyze with CodeQL + uses: github/codeql-action/analyze@v3 + with: + category: /language:javascript-typescript + + dependency-review: + name: Dependency review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + steps: + - name: Review dependency changes + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + + container-build: + name: Build and smoke-test Ubuntu 26.04 ${{ matrix.platform }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build container + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + push: false + load: true + tags: bzo:ci-ubuntu26.04-${{ strategy.job-index }} + provenance: false + sbom: false + + - name: Smoke-test container + shell: bash + run: | + set -euo pipefail + image="bzo:ci-ubuntu26.04-${{ strategy.job-index }}" + actual_node="$(docker run --rm --platform "${{ matrix.platform }}" "$image" node --version)" + test "$actual_node" = "v24.19.0" + docker run --rm --platform "${{ matrix.platform }}" "$image" node --check server.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13296fe..799e328 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,20 @@ on: tags: - 'v*' +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + permissions: - contents: write - packages: write + contents: read jobs: - release: + gate: + name: Validate release runs-on: ubuntu-latest + outputs: + image: ${{ steps.image.outputs.name }} + version: ${{ steps.version.outputs.value }} steps: - name: Check out repository @@ -19,28 +26,50 @@ jobs: with: fetch-depth: 0 + - name: Verify tag commit is on main + shell: bash + run: | + set -euo pipefail + git fetch origin main --no-tags --quiet + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "Release tags must point to a commit reachable from main." >&2 + exit 1 + fi + - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24.19.0 cache: npm - - name: Install dependencies - run: npm ci + - name: Install dependencies without lifecycle scripts + run: npm ci --ignore-scripts - name: Lint and validate run: npm run check + - name: Check production dependency vulnerabilities + run: npm audit --omit=dev --audit-level=high + - name: Validate release metadata run: npm run release:check -- "${GITHUB_REF_NAME}" + - name: Validate tag increment + run: npm run release:check:increment -- "${GITHUB_REF_NAME}" + - name: Extract release notes run: npm run changelog:extract -- "${GITHUB_REF_NAME}" > release-notes.md - name: Compute release version id: version + shell: bash run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + - name: Compute image name + id: image + shell: bash + run: echo "name=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + - name: Create release archive run: | git archive \ @@ -49,9 +78,135 @@ jobs: -o "bzo-${{ steps.version.outputs.value }}.tar.gz" \ "$GITHUB_SHA" - - name: Compute image name - id: image - run: echo "name=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + - name: Upload release assets + uses: actions/upload-artifact@v4 + with: + name: release-assets + path: | + release-notes.md + bzo-${{ steps.version.outputs.value }}.tar.gz + retention-days: 7 + + codeql: + name: CodeQL release scan + needs: gate + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + + - name: Analyze with CodeQL + uses: github/codeql-action/analyze@v3 + with: + category: /language:javascript-typescript + + node-compatibility: + name: Release Node.js compatibility + needs: gate + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - '18.19.1' + - '24.19.0' + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies without lifecycle scripts + run: npm ci --ignore-scripts + + - name: Check runtime compatibility + run: npm run check + + build: + name: Build Ubuntu 26.04 images + needs: + - gate + - codeql + - node-compatibility + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + actions: read + attestations: write + id-token: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push staging image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ needs.gate.outputs.image }}:ci-${{ needs.gate.outputs.version }}-ubuntu26.04-${{ github.run_id }} + labels: | + org.opencontainers.image.title=BZFlag Battlezone Online + org.opencontainers.image.version=${{ needs.gate.outputs.version }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=release-ubuntu-26.04 + provenance: mode=max + sbom: true + + verify: + name: Verify Ubuntu 26.04 manifest and smoke test + needs: + - gate + - build + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 @@ -60,21 +215,100 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build Docker image + - name: Verify published manifest + shell: bash + env: + IMAGE: ${{ needs.gate.outputs.image }} + STAGING_TAG: ci-${{ needs.gate.outputs.version }}-ubuntu26.04-${{ github.run_id }} run: | - docker build --no-cache --progress=plain \ - -t ${{ steps.image.outputs.name }}:${{ steps.version.outputs.value }} \ - -t ${{ steps.image.outputs.name }}:latest \ - . + set -euo pipefail + manifest="$(docker buildx imagetools inspect "${IMAGE}:${STAGING_TAG}")" + printf '%s\n' "$manifest" + grep -q 'linux/amd64' <<<"$manifest" + grep -q 'linux/arm64' <<<"$manifest" - - name: Push Docker image + - name: Smoke test both architectures + shell: bash + env: + IMAGE: ${{ needs.gate.outputs.image }} + STAGING_TAG: ci-${{ needs.gate.outputs.version }}-ubuntu26.04-${{ github.run_id }} run: | - docker push ${{ steps.image.outputs.name }}:${{ steps.version.outputs.value }} - docker push ${{ steps.image.outputs.name }}:latest + set -euo pipefail + for platform in linux/amd64 linux/arm64; do + actual_node="$(docker run --rm --pull=always --platform "$platform" "${IMAGE}:${STAGING_TAG}" node --version)" + test "$actual_node" = "v24.19.0" + docker run --rm --pull=always --platform "$platform" "${IMAGE}:${STAGING_TAG}" node --check server.js + done + + promote: + name: Promote verified images + needs: + - gate + - codeql + - verify + if: ${{ needs.verify.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Promote verified tags + shell: bash + env: + IMAGE: ${{ needs.gate.outputs.image }} + VERSION: ${{ needs.gate.outputs.version }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + source="${IMAGE}:ci-${VERSION}-ubuntu26.04-${RUN_ID}" + docker buildx imagetools create --tag "${IMAGE}:${VERSION}-ubuntu26.04" "$source" + docker buildx imagetools create --tag "${IMAGE}:ubuntu26.04" "$source" + docker buildx imagetools create --tag "${IMAGE}:${VERSION}" "${IMAGE}:${VERSION}-ubuntu26.04" + docker buildx imagetools create --tag "${IMAGE}:latest" "${IMAGE}:ubuntu26.04" + + - name: Verify promoted default tag + shell: bash + env: + IMAGE: ${{ needs.gate.outputs.image }} + run: | + set -euo pipefail + manifest="$(docker buildx imagetools inspect "${IMAGE}:latest")" + printf '%s\n' "$manifest" + grep -q 'linux/amd64' <<<"$manifest" + grep -q 'linux/arm64' <<<"$manifest" + + release: + name: Create GitHub release + needs: + - gate + - promote + if: ${{ needs.promote.result == 'success' }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + + steps: + - name: Download release assets + uses: actions/download-artifact@v4 + with: + name: release-assets + path: . - name: Create GitHub release uses: softprops/action-gh-release@v2 with: body_path: release-notes.md files: | - bzo-${{ steps.version.outputs.value }}.tar.gz + bzo-${{ needs.gate.outputs.version }}.tar.gz diff --git a/Dockerfile b/Dockerfile index 2b48e53..e0e345f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,23 +3,44 @@ # Source: https://github.com/BZFlag-Dev/bzo # See LICENSE or https://www.gnu.org/licenses/agpl-3.0.html -FROM ubuntu:24.04 +FROM ubuntu:26.04 ENV NODE_ENV=production \ PORT=3000 \ SERVER_CONFIG_PATH=/data/server-config.json \ DEBIAN_FRONTEND=noninteractive -RUN apt-get update \ - && apt-get install -y --no-install-recommends nodejs npm \ - && rm -rf /var/lib/apt/lists/* +ARG NODE_VERSION=24.19.0 +ARG NODE_SHA256_AMD64=f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4 +ARG NODE_SHA256_ARM64=d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f + +RUN set -eu; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl libstdc++6; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) node_arch="x64"; node_checksum="$NODE_SHA256_AMD64" ;; \ + arm64) node_arch="arm64"; node_checksum="$NODE_SHA256_ARM64" ;; \ + *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \ + esac; \ + node_archive="node-v${NODE_VERSION}-linux-${node_arch}.tar.gz"; \ + curl --fail --silent --show-error --location --retry 3 \ + --output "/tmp/${node_archive}" \ + "https://nodejs.org/dist/v${NODE_VERSION}/${node_archive}"; \ + echo "${node_checksum} /tmp/${node_archive}" | sha256sum --check --strict; \ + tar --extract --gzip --file "/tmp/${node_archive}" \ + --strip-components=1 --directory /usr/local; \ + test "$(node --version)" = "v${NODE_VERSION}"; \ + rm -f "/tmp/${node_archive}"; \ + apt-get purge -y --auto-remove curl; \ + rm -rf /var/lib/apt/lists/* RUN useradd --system --create-home --shell /bin/bash node WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci --omit=dev && npm cache clean --force +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force COPY public ./public COPY maps ./maps diff --git a/README.md b/README.md index 0e17bc7..22d7b1c 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,16 @@ Each tagged release publishes: - a GitHub release with notes generated from [CHANGELOG.md](CHANGELOG.md) - a source tarball -- a versioned Docker image at `ghcr.io/bzflag-dev/bzo:` -- a moving Docker tag at `ghcr.io/bzflag-dev/bzo:latest` +- a versioned Ubuntu 26.04 image at `ghcr.io/bzflag-dev/bzo:-ubuntu26.04` +- a moving `ubuntu26.04` tag +- `ghcr.io/bzflag-dev/bzo:` and `ghcr.io/bzflag-dev/bzo:latest`, both using Ubuntu 26.04 + +Every published image contains `linux/amd64` and `linux/arm64` variants. Release +tags use stable `vX.Y.Z` SemVer only; prerelease and build-metadata tags are not +published. Ubuntu 26.04 images use the pinned Node.js `24.19.0` runtime. + +Docker images are built on Ubuntu 26.04 with pinned Node.js `24.19.0`. +Runtime compatibility is validated in CI on Node.js `18.19.1` and `24.19.0`. ## Install with Docker @@ -54,7 +62,7 @@ The image defaults to `SERVER_CONFIG_PATH=/data/server-config.json`. ### Prerequisites -- Node.js 20+ +- Node.js 18.19.1 or Node.js 24.19.0 - npm ### Setup @@ -190,6 +198,7 @@ Validate locally: ```bash npm run check npm run release:check -- v1.0.1 +npm run release:check:increment -- v1.0.1 ``` Then commit, tag, and push: @@ -204,13 +213,13 @@ git push origin v1.0.1 The release workflow will: -1. install dependencies -2. run lint and validation +1. verify that the stable tag is newer than the previous release and points to `main` +2. install dependencies and run lint, validation, audit, and CodeQL checks 3. fail if `package.json` does not match the pushed tag 4. fail if [CHANGELOG.md](CHANGELOG.md) does not contain a matching non-placeholder section -5. publish a GitHub release -6. attach a source tarball -7. build and publish a multi-arch Docker image to GHCR +5. build and smoke-test Ubuntu 26.04 images with pinned Node.js `24.19.0` for `linux/amd64` and `linux/arm64` +6. promote the verified versioned and moving Docker tags to GHCR +7. publish a GitHub release and attach a source tarball ## AGPL source availability diff --git a/package.json b/package.json index 3a1bf4f..cb1b3ac 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "check": "npm run check:server && npm run lint", "release:prepare": "node scripts/prepare-release.mjs", "release:check": "node scripts/check-release.mjs", + "release:check:increment": "node scripts/check-tag-increment.mjs", "changelog:extract": "node scripts/extract-changelog.mjs", "prepare": "node -e \"const { existsSync } = require('node:fs'); process.exit(existsSync('node_modules/.bin/husky') ? 0 : 1)\" && husky || true" }, diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 4099978..a93525b 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -55,8 +55,8 @@ if (!tagInput) { } const tagVersion = normalizeVersion(tagInput); -if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(tagVersion)) { - fail(`tag "${tagInput}" is not a valid release version`); +if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tagVersion)) { + fail(`tag "${tagInput}" must be a stable SemVer release version in the form X.Y.Z (prerelease and build metadata are not published)`); } const packageJson = readJson(packageJsonPath); diff --git a/scripts/check-tag-increment.mjs b/scripts/check-tag-increment.mjs new file mode 100644 index 0000000..de341d8 --- /dev/null +++ b/scripts/check-tag-increment.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/* + * Copyright (C) 2025-2026 Tim Riker + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3). + * Source: https://github.com/BZFlag-Dev/bzo + * See LICENSE or https://www.gnu.org/licenses/agpl-3.0.html + */ + +import { execFileSync } from 'child_process'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(__dirname, '..'); +const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +function fail(message) { + console.error(`Tag increment check failed: ${message}`); + process.exit(1); +} + +function normalizeTag(input) { + if (!input || typeof input !== 'string') return ''; + return input.trim().replace(/^refs\/tags\//, ''); +} + +function parseVersion(input) { + const tag = normalizeTag(input); + const version = tag.replace(/^v/, ''); + const match = semverPattern.exec(version); + if (!match) return null; + + return { + tag, + version, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: [], + }; +} + +function compareIdentifiers(left, right) { + const leftIsNumeric = /^\d+$/.test(left); + const rightIsNumeric = /^\d+$/.test(right); + + if (leftIsNumeric && rightIsNumeric) return Number(left) - Number(right); + if (leftIsNumeric) return -1; + if (rightIsNumeric) return 1; + return left < right ? -1 : left > right ? 1 : 0; +} + +function compareVersions(left, right) { + for (const field of ['major', 'minor', 'patch']) { + if (left[field] !== right[field]) return left[field] - right[field]; + } + + if (!left.prerelease.length && !right.prerelease.length) return 0; + if (!left.prerelease.length) return 1; + if (!right.prerelease.length) return -1; + + const length = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < length; index += 1) { + if (index >= left.prerelease.length) return -1; + if (index >= right.prerelease.length) return 1; + + const comparison = compareIdentifiers(left.prerelease[index], right.prerelease[index]); + if (comparison !== 0) return comparison; + } + + return 0; +} + +function readReleaseTags() { + try { + return execFileSync('git', ['tag', '--list', 'v*'], { + cwd: rootDir, + encoding: 'utf8', + }) + .split(/\r?\n/) + .map((tag) => tag.trim()) + .filter(Boolean); + } catch (error) { + fail(`unable to read release tags from Git: ${error.message}`); + } +} + +const tagInput = process.argv[2] || process.env.GITHUB_REF_NAME; +if (!tagInput) { + fail('missing tag argument'); +} + +const candidate = parseVersion(tagInput); +if (!candidate) { + fail(`tag "${tagInput}" must be a stable SemVer release tag in the form vX.Y.Z (prerelease and build metadata are not published)`); +} + +const releaseTags = readReleaseTags(); +const parsedTags = []; +for (const tag of releaseTags) { + const parsed = parseVersion(tag); + if (parsed) { + parsedTags.push(parsed); + } else { + console.warn(`Ignoring non-SemVer release tag "${tag}"`); + } +} + +function readTagCommit(tag) { + try { + return execFileSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}^{}`], { + cwd: rootDir, + encoding: 'utf8', + }).trim(); + } catch (error) { + fail(`unable to resolve release tag "${tag}": ${error.message}`); + } +} + +const currentRef = normalizeTag(process.env.GITHUB_REF_NAME || process.env.GITHUB_REF); +const currentSha = process.env.GITHUB_SHA || ''; +const candidateTagExists = parsedTags.some((tag) => tag.tag === candidate.tag); +const isCurrentWorkflowTag = currentRef === candidate.tag && Boolean(currentSha); + +if (candidateTagExists && !isCurrentWorkflowTag) { + fail(`tag "${candidate.tag}" already exists in the release history`); +} + +if (candidateTagExists && isCurrentWorkflowTag && readTagCommit(candidate.tag) !== currentSha) { + fail(`tag "${candidate.tag}" does not resolve to the workflow commit`); +} + +if (parsedTags.some((tag) => tag.version === candidate.version && tag.tag !== candidate.tag)) { + fail(`release version "${candidate.version}" already exists under another tag`); +} + +const previous = parsedTags + .filter((tag) => tag.tag !== candidate.tag) + .sort((left, right) => compareVersions(right, left))[0]; + +if (!previous) { + console.log(`Tag increment check passed: ${candidate.tag} is the first release tag`); + process.exit(0); +} + +if (compareVersions(candidate, previous) <= 0) { + fail(`tag "${candidate.tag}" must be greater than the previous release tag "${previous.tag}"`); +} + +console.log(`Tag increment check passed: ${candidate.tag} is greater than ${previous.tag}`); diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 85d5de4..4355159 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -56,8 +56,8 @@ function getSectionRange(content, headingPrefix) { } const version = normalizeVersion(process.argv[2] || process.env.npm_config_version); -if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { - fail('usage: npm run release:prepare -- '); +if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) { + fail('usage: npm run release:prepare -- (stable SemVer only)'); } const today = new Date().toISOString().slice(0, 10);