diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6663d8f..2ea8c02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,22 +2,34 @@ name: CI on: push: - branches: [ main ] + branches: [ main, 'release/**' ] pull_request: - branches: [ main ] + branches: [ main, 'release/**' ] + workflow_dispatch: + schedule: + - cron: '17 3 * * *' permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build-and-test: + pr-gate: + name: PR gate (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-24.04, macos-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install dependencies (Ubuntu) if: runner.os == 'Linux' @@ -42,11 +54,26 @@ jobs: - name: Run release preflight run: make release-check + extended-linux-runtime: + name: Extended Linux runtime + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y expect libssh-dev valgrind + + - name: Run extended release preflight + run: | + RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check + - name: Check for memory leaks - if: runner.os == 'Linux' run: | set -eu - sudo apt-get install -y valgrind STATE_DIR=$(mktemp -d) SERVER_LOG="$STATE_DIR/server.log" VALGRIND_LOG="$STATE_DIR/valgrind.log" @@ -101,3 +128,60 @@ jobs: cat "$VALGRIND_LOG" exit 1 fi + + portable-container-builds: + name: Portable build (${{ matrix.name }}) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: debian-stable-glibc + image: debian:stable-slim + setup: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential libssh-dev ca-certificates + - name: ubuntu-24.04-glibc + image: ubuntu:24.04 + setup: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential libssh-dev ca-certificates + - name: alpine-musl + image: alpine:3.20 + setup: apk add --no-cache build-base libssh-dev ca-certificates + + steps: + - uses: actions/checkout@v6 + + - name: Build in container + run: | + docker run --rm -v "$PWD:/src:ro" "${{ matrix.image }}" sh -c ' + set -eu + ${{ matrix.setup }} + mkdir /work + cp -R /src/. /work/ + cd /work + make clean + make + ./tnt --version + ./tntctl --version + ' + + package-recipe-gate: + name: Package recipe gate + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + + - name: Install packaging tools + run: | + sudo apt-get update + sudo apt-get install -y ruby cpio + + - name: Validate packaging metadata + run: | + for script in scripts/*.sh; do + sh -n "$script" + done + bash -n packaging/arch/PKGBUILD + ruby -c packaging/homebrew/tnt-chat.rb + scripts/package_debian_source.sh "$RUNNER_TEMP/debian-source" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b467b9a..f5d273f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,11 +8,15 @@ on: permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: name: Build ${{ matrix.target }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: include: - os: ubuntu-24.04 @@ -33,7 +37,7 @@ jobs: ctl_artifact: tntctl-darwin-arm64 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Verify release tag matches source version run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}" @@ -91,40 +95,91 @@ jobs: ${{ matrix.artifact }} ${{ matrix.ctl_artifact }} + source-archive: + name: Source archive + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Verify release tag matches source version + run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}" + + - name: Build source archive + run: | + set -eu + version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h) + commit=$(git rev-list -n 1 "${GITHUB_REF_NAME}") + mkdir -p dist + git archive --format=tar.gz --prefix="TNT-${version}/" \ + -o "dist/tnt-chat-v${version}-source.tar.gz" "$commit" + tar -tzf "dist/tnt-chat-v${version}-source.tar.gz" >/dev/null + tar -tzf "dist/tnt-chat-v${version}-source.tar.gz" | grep -q "^TNT-${version}/LICENSE$" + tar -tzf "dist/tnt-chat-v${version}-source.tar.gz" | grep -q "^TNT-${version}/packaging/README.md$" + sha256sum "dist/tnt-chat-v${version}-source.tar.gz" + + - name: Upload source archive + uses: actions/upload-artifact@v4 + with: + name: tnt-chat-source + path: dist/tnt-chat-v*-source.tar.gz + + artifact-gate: + name: Release artifact gate + needs: [build, source-archive] + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + + - name: Verify release tag matches source version + run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}" + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: ./artifacts + + - name: Verify and package release assets + run: scripts/package_release_assets.sh ./artifacts ./dist/release-assets + + - name: Upload release asset bundle + uses: actions/upload-artifact@v4 + with: + name: release-assets + path: dist/release-assets/* + release: - needs: build + needs: [artifact-gate, source-archive] runs-on: ubuntu-latest permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Verify release tag matches source version run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}" - - name: Download all artifacts + - name: Download release asset bundle uses: actions/download-artifact@v4 with: - path: ./artifacts + name: release-assets + path: ./release-assets - - name: Create checksums + - name: Verify release checksums run: | - cd artifacts - : > checksums.txt - for artifact in */tnt-* */tntctl-*; do - [ -f "$artifact" ] || continue - sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt - done + cd release-assets + sha256sum -c checksums.txt cat checksums.txt - name: Create Release uses: softprops/action-gh-release@v1 with: files: | - artifacts/*/tnt-* - artifacts/*/tntctl-* - artifacts/checksums.txt + release-assets/* body: | ## Installation @@ -190,11 +245,16 @@ jobs: sha256sum -c checksums.txt --ignore-missing # macOS - for f in tnt-* tntctl-*; do + for f in tnt-* tntctl-* tnt-chat-*-source.tar.gz; do grep " $f$" checksums.txt | shasum -a 256 -c - done ``` + The release also includes `tnt-chat-${{ github.ref_name }}-source.tar.gz` + for package-manager recipes. Verify it with the same `checksums.txt` + before updating Arch, Homebrew, Debian, Ubuntu, or container package + metadata. + ## What's Changed See [docs/CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/docs/CHANGELOG.md) draft: true diff --git a/Makefile b/Makefile index 459e158..b948105 100644 --- a/Makefile +++ b/Makefile @@ -134,6 +134,7 @@ script-test: all @cd tests && ./test_docs_help_surface.sh @cd tests && ./test_logrotate.sh @cd tests && ./test_message_log_tool.sh + @cd tests && ./test_release_artifact_gate.sh integration-test: all @echo "Running integration tests..." diff --git a/README.md b/README.md index 7b75563..52b37e4 100644 --- a/README.md +++ b/README.md @@ -398,14 +398,14 @@ make release-check Longer local preflight can opt into runtime soak and slow-client coverage: ```sh -RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check +RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check ``` -Before publishing package recipes, download the final GitHub source archive, +Before publishing package recipes, download the explicit release source archive, replace placeholder checksums, and run: ```sh -SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check +SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check ``` ## Files diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c338cc1..7eca2ff 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,7 +6,12 @@ - Added a release tag/version guard used by the GitHub release workflow, so a `vX.Y.Z` tag must match `TNT_VERSION` before release assets are built. - Added `make package-publish-check` for verifying Arch/Homebrew source - checksums against the final GitHub source archive after a tag exists. + checksums against the explicit release source archive after a tag exists. +- Added a release artifact gate that bundles Linux/macOS binaries, the explicit + release source archive, and `checksums.txt` before opening the draft release. +- Added CI governance layers for fast PR checks, release-branch validation, + extended runtime validation, container portability builds, and package recipe + validation. - Added a `config_defaults` module and unit coverage for runtime default values, env keys, and accepted numeric ranges. - Added a dedicated `tntctl_text` module with unit coverage for local @@ -53,7 +58,7 @@ matches, avoiding the impression that the pager is a complete result set. - Release checks now separate tag/source-archive readiness from package-manager checksum publishing, avoiding self-referential checksum requirements before - the final GitHub source archive exists. + the explicit release source archive exists. - `tntctl --help` now gets its exec command list from `exec_catalog`, reducing duplicate command metadata between the local wrapper and SSH exec mode. - Updated `tnt(1)` to document the current TUI search and pager keys, and diff --git a/docs/CICD.md b/docs/CICD.md index 4808ab2..aa1649d 100644 --- a/docs/CICD.md +++ b/docs/CICD.md @@ -1,171 +1,268 @@ -CI / RELEASE GUIDE -================== - -AUTOMATIC TESTING ------------------ -Every push or PR automatically runs: - - Build on Ubuntu - - AddressSanitizer build - - `make ci-test` (strict integration, anonymous access, connection limits, - and security feature checks) - - Release/package preflight (`make release-check`) - -Check status: - https://github.com/m1ngsama/TNT/actions - -Production deployment is intentionally manual. The CI workflow must not SSH -into production or restart services on push. - - -CREATING RELEASES ------------------ -Release policy: - - Use SemVer-style tags: vMAJOR.MINOR.PATCH. - - Bump PATCH for compatible bug fixes and release hardening. - - Bump MINOR for new commands, new documented flags, JSON field additions, - or visible user-interface behavior changes. - - Bump MAJOR for incompatible command, config, storage, or package behavior. - - Keep GitHub draft release review manual. Do not auto-publish releases. - - Keep production deployment manual. Do not SSH into production from CI. - -1. Update version metadata: - - include/common.h - - tnt.1 - - docs/CHANGELOG.md - - packaging/arch/PKGBUILD - - packaging/homebrew/tnt-chat.rb - - packaging/debian/debian/changelog - - maintainer metadata, when preparing public package recipes - -2. Run the local preflight: - make release-check - - For a longer local runtime gate before publishing or production rollout: - RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check - -3. Commit the release changes and create a local tag. Do not push the tag - until strict checks pass: - git tag vX.Y.Z - -4. Run strict release checks: - make release-check-strict - - Strict mode requires the local `vX.Y.Z` tag to point at HEAD. It also - builds from the tagged source archive, so it catches files that were left - untracked and would be missing from GitHub's source archive. - -5. Push the tag: - git push origin vX.Y.Z - -6. GitHub Actions automatically: - - Builds `tnt` and `tntctl` binaries (Linux/macOS, AMD64/ARM64) - - Creates a draft release - - Uploads binaries - - Generates one `checksums.txt` file - - Verifies that artifact architecture matches the asset name - -7. Review the draft release, smoke-test downloaded assets, then publish it - manually from GitHub. - -8. Release appears at: - https://github.com/m1ngsama/TNT/releases - - -RELEASE REVIEW CHECKLIST ------------------------- -Before publishing a draft release: - - Confirm `git tag` points at the intended commit. - - Download every release asset from GitHub, not from the local workspace. - - Verify downloaded assets against `checksums.txt` (`sha256sum -c - checksums.txt --ignore-missing` on Linux, or `shasum -a 256 -c` for each - downloaded asset on macOS). - - Run downloaded `tnt --version` and `tntctl --version`. - - Start a temporary server and check: - ssh -p 2222 server health - ssh -p 2222 server stats --json - ssh -p 2222 server users --json - ssh -p 2222 operator@server post "release smoke" - ssh -p 2222 server "tail -n 1" - - Check runtime dynamic links (`ldd` on Linux, `otool -L` on macOS) and make - sure `libssh` is documented for the target install path. - - Confirm `make release-check-strict` passed before pushing the tag. - - For package-manager recipes, download the final GitHub source archive, - replace Arch/Homebrew source checksums, then run: - SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check - - -ROLLBACK --------- -Production rollback stays manual: - 1. Keep the previous binary before replacing it. - 2. Stop or restart only the intended `tnt` service. - 3. Restore the previous binary if smoke checks fail. - 4. Re-run `health`, `stats --json`, and one post/tail smoke test. +# CI/CD and Release Governance -Do not overwrite `TNT_STATE_DIR` during rollback. If a future release changes -the message log format, its release notes must include the downgrade behavior. +TNT is a C SSH terminal chat server. The CI/CD system is designed for a public +open-source project: fast feedback on pull requests, broader scheduled +validation across target environments, reproducible release artifacts, and a +manual production deployment boundary. +Production deployment is intentionally manual. Workflows must not SSH into +production, restart services, upload to OSS buckets, publish package-manager +recipes, or mutate running servers. -DEPLOYING TO SERVERS --------------------- -Deployments are operator-driven: - 1. Build and test locally or in a temporary server directory. - 2. Back up the installed binary. - 3. Install the new binary. - 4. Restart the service. - 5. Run black-box checks (`health`, `stats --json`, `users --json`, - and a post/tail smoke test). +## Pipeline Layers -The installer can still be used manually on a server: - curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh +### PR Fast Gate +Workflow: `.github/workflows/ci.yml` -PRODUCTION SETUP (systemd) ---------------------------- -1. Install binary (see above) +Runs on pull requests targeting `main` or `release/**`, and pushes to `main` +or `release/**`: -2. Setup service: - sudo useradd -r -s /bin/false tnt - sudo mkdir -p /var/lib/tnt - sudo chown tnt:tnt /var/lib/tnt - sudo cp tnt.service /etc/systemd/system/ - sudo systemctl daemon-reload - sudo systemctl enable --now tnt +- Ubuntu 24.04 and macOS latest builds. +- Normal build with `make`. +- AddressSanitizer build with `make asan`. +- Integration/security gate with `make ci-test`. +- Local release/package preflight with `make release-check`. -3. Check status: - sudo systemctl status tnt - sudo journalctl -u tnt -f +Purpose: + +- Keep contributor feedback fast enough for normal review. +- Catch build, integration, packaging metadata, and release-preflight regressions + before merge. +- Avoid slow soak, valgrind, and container matrix jobs on every PR. +### Extended and Nightly Validation -UPDATING SERVERS ----------------- -Manual binary replacement pattern: - backup=/usr/local/bin/tnt.bak-$(date +%Y%m%d%H%M%S) - sudo cp -a /usr/local/bin/tnt "$backup" - sudo install -m 755 ./tnt /usr/local/bin/tnt - sudo systemctl restart tnt +Workflow: `.github/workflows/ci.yml` + +Runs on `main` or `release/**` pushes, manual dispatch, and the nightly +schedule: + +- `extended-linux-runtime` + - Runs `RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check`. + - Runs a valgrind smoke test against a temporary server. +- `portable-container-builds` + - Builds in Debian stable glibc. + - Builds in Ubuntu 24.04 glibc. + - Builds in Alpine musl. +- `package-recipe-gate` + - Syntax-checks shell scripts. + - Syntax-checks the Arch `PKGBUILD`. + - Syntax-checks the Homebrew formula. + - Assembles the Debian source tree. + +Purpose: + +- Broaden platform confidence without making every PR wait for the full matrix. +- Detect musl/glibc portability issues early. +- Keep package metadata reviewable before public registry submission. + +### Release Artifact Gates + +Workflow: `.github/workflows/release.yml` + +Runs only for SemVer tags matching `vMAJOR.MINOR.PATCH`: + +- Verifies the tag matches `TNT_VERSION` through `scripts/check_release_ref.sh`. +- Builds Linux glibc AMD64 and ARM64 binaries. +- Builds macOS Intel and Apple Silicon binaries. +- Verifies binary architecture labels. +- Builds an explicit source archive: `tnt-chat-vX.Y.Z-source.tar.gz`. +- Runs `scripts/package_release_assets.sh` to collect release assets, verify + expected asset names, verify binary architecture labels again after artifact + download, verify source archive contents, generate `checksums.txt`, and verify + the checksum file. +- Creates a GitHub draft release only. Publishing stays manual. + +The release workflow does not publish package-manager recipes or deploy +production servers. + +## Platform Policy +Current release assets: -PLATFORMS SUPPORTED -------------------- -✓ Linux AMD64 (x86_64) -✓ Linux ARM64 (aarch64) -✓ macOS Intel (x86_64) -✓ macOS Apple Silicon (arm64) +- Linux glibc AMD64: `tnt-linux-amd64`, `tntctl-linux-amd64` +- Linux glibc ARM64: `tnt-linux-arm64`, `tntctl-linux-arm64` +- macOS Intel: `tnt-darwin-amd64`, `tntctl-darwin-amd64` +- macOS Apple Silicon: `tnt-darwin-arm64`, `tntctl-darwin-arm64` +- Source archive: `tnt-chat-vX.Y.Z-source.tar.gz` +Current CI validation: -EXAMPLE WORKFLOW ----------------- -# Local development -make && make asan && make release-check -./tnt +- Ubuntu 24.04 +- macOS latest +- Debian stable glibc container build +- Ubuntu 24.04 glibc container build +- Alpine musl container build -# Create release +Package-manager routes: + +- Debian/Ubuntu: maintain draft Debian metadata and start with a Launchpad PPA. +- Arch/AUR: maintain `packaging/arch/PKGBUILD` and `.SRCINFO`; submit manually. +- Homebrew/macOS: maintain a tap formula first; Homebrew core can wait for a + stable release cadence and broader adoption. +- Source archive: every public package recipe must pin the final GitHub release + source archive checksum. +- Containers: first stage is Docker-based build validation in CI. Publishing + images should wait until image labels, SBOM, provenance, CVE scanning, and + registry ownership are defined. + +## Release Policy + +- Use SemVer-style tags: `vMAJOR.MINOR.PATCH`. +- Bump PATCH for compatible bug fixes and release hardening. +- Bump MINOR for new commands, new documented flags, JSON field additions, or + visible user-interface behavior changes. +- Bump MAJOR for incompatible command, config, storage, or package behavior. +- Keep GitHub release publishing manual by using draft releases. +- Keep production deployment manual. + +Update version metadata before tagging: + +- `include/common.h` +- `tnt.1` +- `tntctl.1` +- `docs/CHANGELOG.md` +- `packaging/arch/PKGBUILD` +- `packaging/arch/.SRCINFO` +- `packaging/homebrew/tnt-chat.rb` +- `packaging/debian/debian/changelog` + +Local preflight: + +```sh +make release-check +``` + +Longer local runtime gate: + +```sh +RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check +``` + +Strict local release gate before pushing a tag: + +```sh git tag vX.Y.Z +make release-check-strict +``` + +Strict mode requires the local `vX.Y.Z` tag to point at `HEAD` and builds from +the tagged source archive, so it catches files that were left untracked and +would be missing from the release source archive. + +After strict checks pass: + +```sh git push origin vX.Y.Z -# Wait 5 minutes for builds +``` + +GitHub Actions then builds artifacts and opens a draft release. Review and +publish that draft manually. + +## Release Review Checklist + +Before publishing a draft release: + +- Confirm the Git tag points at the intended commit. +- Confirm the release workflow passed. +- Download every release asset from GitHub, not from the local workspace. +- Verify downloaded assets against `checksums.txt`. +- Run downloaded `tnt --version` and `tntctl --version`. +- Start a temporary server and check: + + ```sh + ssh -p 2222 server health + ssh -p 2222 server stats --json + ssh -p 2222 server users --json + ssh -p 2222 operator@server post "release smoke" + ssh -p 2222 server "tail -n 1" + ``` + +- Check runtime dynamic links with `ldd` on Linux or `otool -L` on macOS. +- Confirm `libssh` runtime installation is documented for the target install + path. +- Verify the explicit source archive checksum before updating Arch, Homebrew, + Debian, Ubuntu, or container package metadata. +- Run package publication preflight after package recipes pin final source + checksums: + + ```sh + SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check + ``` + +## Checksums + +Release assets include `checksums.txt`. + +Linux: + +```sh +sha256sum -c checksums.txt --ignore-missing +``` + +macOS: + +```sh +for f in tnt-* tntctl-* tnt-chat-*-source.tar.gz; do + grep " $f$" checksums.txt | shasum -a 256 -c - +done +``` + +## Supply Chain Roadmap + +Stage 1, implemented now: + +- Tag/version gate. +- Draft release, manual publish. +- Binary architecture validation. +- Source archive validation. +- SHA-256 checksums for every release asset. +- Package recipe checksum preflight. + +Stage 2, next: + +- Generate an SBOM for release artifacts, preferably CycloneDX or SPDX. +- Attach SBOM files to draft releases. +- Add package lint jobs for Debian source packages, Arch packages, Homebrew + audit, and container image metadata. + +Stage 3, later: + +- Sign release checksums and/or artifacts with a documented maintainer key or + Sigstore flow. +- Add SLSA provenance for GitHub Actions builds. +- Define container image ownership, tag policy, vulnerability scan policy, and + rollback behavior before publishing images. + +## Manual Production Deployment + +Deployment remains operator-driven: + +1. Build and test locally or in a temporary server directory. +2. Back up the installed binary. +3. Install the new binary. +4. Restart only the intended `tnt` service. +5. Run black-box checks: `health`, `stats --json`, `users --json`, and one + post/tail smoke test. + +Manual binary replacement pattern: + +```sh +backup=/usr/local/bin/tnt.bak-$(date +%Y%m%d%H%M%S) +sudo cp -a /usr/local/bin/tnt "$backup" +sudo install -m 755 ./tnt /usr/local/bin/tnt +sudo systemctl restart tnt +``` + +## Rollback + +Production rollback stays manual: + +1. Keep the previous binary before replacing it. +2. Stop or restart only the intended `tnt` service. +3. Restore the previous binary if smoke checks fail. +4. Re-run `health`, `stats --json`, and one post/tail smoke test. -# Deploy to production manually after validation -ssh server "sudo install -m 755 /tmp/tnt-build/tnt /usr/local/bin/tnt" -ssh server "sudo systemctl restart tnt" -ssh -p 2222 server health +Do not overwrite `TNT_STATE_DIR` during rollback. If a future release changes +the message log format, its release notes must include downgrade behavior. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5aa030c..7060372 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -113,8 +113,8 @@ Goal: make regressions harder to introduce. These are the next changes that should happen before new feature work expands the surface area. -1. Replace remaining source-archive checksum placeholders only after the final - GitHub source archive exists, then run `make package-publish-check`. +1. Replace remaining source-archive checksum placeholders only after the + explicit release source archive exists, then run `make package-publish-check`. 2. Create or move the `vX.Y.Z` tag only when the release commit is final, then run `make release-check-strict` before pushing it. 3. Decide whether admin-only moderation controls belong in 1.0.x or should diff --git a/packaging/README.md b/packaging/README.md index b82fd4a..615d5de 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -13,12 +13,30 @@ any public registry. Package installs include both `tnt` and `tntctl`. `tnt` is the server process; `tntctl` is a thin wrapper around the documented SSH exec interface. +## CI governance + +Package recipes are validated in stages: + +- PR fast gate: `make release-check` verifies package metadata stays aligned + with `TNT_VERSION`. +- Extended CI: package syntax and Debian source-tree assembly run on `main` and + `release/**` pushes, nightly, and manual workflow dispatch. +- Release gate: the workflow builds an explicit release source archive, verifies + it, and includes it in `checksums.txt`. +- Publishing gate: after final source checksums are pinned, run + `SOURCE_TARBALL=... make package-publish-check`. + +All package-manager submissions remain manual. CI must not push to AUR, open or +merge Homebrew tap updates, upload Debian/PPA packages, publish container +images, or deploy production servers. + ## Release checklist 1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match. Also update package versions in Arch, Homebrew, and Debian drafts. 2. Create a GitHub release tag such as `vX.Y.Z`. -3. Build and upload release tarballs or rely on GitHub source archives. +3. Let the release workflow build the explicit release source archive and draft + release assets. 4. Replace placeholder checksums in package drafts. 5. Verify package contents in an isolated directory: @@ -35,11 +53,11 @@ Package installs include both `tnt` and `tntctl`. `tnt` is the server process; Use `scripts/package_debian_source.sh --build` on a Debian/Ubuntu system with `dpkg-buildpackage` installed to build the unsigned source package. -7. Before submitting package recipes, download the final GitHub source archive, +7. Before submitting package recipes, download the explicit release source archive, replace checksum placeholders, and run: ```sh - SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check + SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check ``` 8. Submit packages manually: diff --git a/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO index 981cea4..22c0c06 100644 --- a/packaging/arch/.SRCINFO +++ b/packaging/arch/.SRCINFO @@ -9,7 +9,7 @@ pkgbase = tnt-chat makedepends = gcc makedepends = make depends = libssh - source = tnt-chat-1.0.1.tar.gz::https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz + source = tnt-chat-v1.0.1-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.0.1/tnt-chat-v1.0.1-source.tar.gz source = tnt-chat.sysusers sha256sums = SKIP sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 868a1cc..dae9f15 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -9,7 +9,7 @@ url='https://github.com/m1ngsama/TNT' license=('MIT') depends=('libssh') makedepends=('gcc' 'make') -source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz" +source=("${pkgname}-v${pkgver}-source.tar.gz::${url}/releases/download/v${pkgver}/${pkgname}-v${pkgver}-source.tar.gz" "${pkgname}.sysusers") sha256sums=('SKIP' '8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed') diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 334b165..dac5850 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -26,12 +26,12 @@ After editing `PKGBUILD`, regenerate `.SRCINFO`: makepkg --printsrcinfo > .SRCINFO ``` -Before AUR submission, replace `sha256sums=('SKIP')` with the real GitHub +Before AUR submission, replace `sha256sums=('SKIP')` with the real release source archive checksum, regenerate `.SRCINFO`, then run the package publish check: ```sh -SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check +SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check ``` ## Manual AUR submission diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md index 4c1fcd2..792f95b 100644 --- a/packaging/homebrew/README.md +++ b/packaging/homebrew/README.md @@ -34,16 +34,16 @@ ruby -c packaging/homebrew/tnt-chat.rb 2. Download or hash the release source archive: ```sh - curl -L -o dist/tnt-chat-vX.Y.Z.tar.gz \ - https://github.com/m1ngsama/TNT/archive/refs/tags/vX.Y.Z.tar.gz - shasum -a 256 dist/tnt-chat-vX.Y.Z.tar.gz + curl -L -o dist/tnt-chat-vX.Y.Z-source.tar.gz \ + https://github.com/m1ngsama/TNT/releases/download/vX.Y.Z/tnt-chat-vX.Y.Z-source.tar.gz + shasum -a 256 dist/tnt-chat-vX.Y.Z-source.tar.gz ``` 3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`. 4. Run: ```sh - SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check + SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check ``` 5. Copy the formula into the tap repository and open a normal review PR. diff --git a/packaging/homebrew/tnt-chat.rb b/packaging/homebrew/tnt-chat.rb index 967373c..0b06bd8 100644 --- a/packaging/homebrew/tnt-chat.rb +++ b/packaging/homebrew/tnt-chat.rb @@ -1,7 +1,7 @@ class TntChat < Formula desc "SSH-native terminal chat server with a Vim-style interface" homepage "https://github.com/m1ngsama/TNT" - url "https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz" + url "https://github.com/m1ngsama/TNT/releases/download/v1.0.1/tnt-chat-v1.0.1-source.tar.gz" sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256" license "MIT" diff --git a/scripts/package_publish_check.sh b/scripts/package_publish_check.sh index 921afd2..da9a0cc 100755 --- a/scripts/package_publish_check.sh +++ b/scripts/package_publish_check.sh @@ -23,12 +23,21 @@ sha256_of() { version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h) [ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h" +release_source="tnt-chat-v${version}-source.tar.gz" source_tarball=${SOURCE_TARBALL:-${RELEASE_SOURCE_TARBALL:-}} [ -n "$source_tarball" ] || - fail "set SOURCE_TARBALL to the final GitHub source archive" + fail "set SOURCE_TARBALL to the explicit release source archive" [ -f "$source_tarball" ] || fail "SOURCE_TARBALL does not exist: $source_tarball" +tar -tzf "$source_tarball" >/dev/null || + fail "SOURCE_TARBALL is not a readable tar.gz archive" +tar -tzf "$source_tarball" | grep -q "^TNT-$version/LICENSE$" || + fail "SOURCE_TARBALL is missing LICENSE" +tar -tzf "$source_tarball" | grep -q "^TNT-$version/packaging/README.md$" || + fail "SOURCE_TARBALL is missing packaging/README.md" +tar -tzf "$source_tarball" | grep -q "^TNT-$version/src/tntctl.c$" || + fail "SOURCE_TARBALL is missing src/tntctl.c" ! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null || fail "replace maintainer email placeholders before package publishing" @@ -60,8 +69,12 @@ grep -q "^pkgver=$version$" packaging/arch/PKGBUILD || fail "PKGBUILD pkgver does not match $version" grep -q "pkgver = $version" packaging/arch/.SRCINFO || fail ".SRCINFO pkgver does not match $version" -grep -q "v${version}.tar.gz" packaging/homebrew/tnt-chat.rb || - fail "Homebrew URL does not match v$version" +grep -q '${pkgname}-v${pkgver}-source.tar.gz' packaging/arch/PKGBUILD || + fail "PKGBUILD source must use the release source archive" +grep -q "$release_source" packaging/arch/.SRCINFO || + fail ".SRCINFO source does not match $release_source" +grep -q "$release_source" packaging/homebrew/tnt-chat.rb || + fail "Homebrew URL does not match $release_source" grep -q "^tnt-chat (${version}-1)" packaging/debian/debian/changelog || fail "Debian changelog version does not match $version" diff --git a/scripts/package_release_assets.sh b/scripts/package_release_assets.sh new file mode 100755 index 0000000..26c0753 --- /dev/null +++ b/scripts/package_release_assets.sh @@ -0,0 +1,150 @@ +#!/bin/sh +# Collect release workflow artifacts into one flat, checksum-verified bundle. + +set -eu + +usage() { + cat <<'USAGE' +Usage: scripts/package_release_assets.sh ARTIFACT_ROOT [OUT_DIR] + +ARTIFACT_ROOT is the directory produced by actions/download-artifact. +OUT_DIR defaults to dist/release-assets. + +The script validates the expected release asset names, verifies binary +architecture labels, verifies the source archive shape, writes checksums.txt, +and verifies that checksums.txt matches the assembled bundle. It never +publishes a release. +USAGE +} + +fail() { + echo "release-artifact-gate: $*" >&2 + exit 1 +} + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + fail "sha256sum or shasum is required" + fi +} + +find_unique() { + name=$1 + matches=$(find "$ARTIFACT_ROOT" -type f -name "$name" -print) + count=$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l | tr -d ' ') + [ "$count" = "1" ] || + fail "expected exactly one artifact named $name, found $count" + printf '%s\n' "$matches" +} + +require_file_label() { + path=$1 + pattern=$2 + label=$(file "$path") + printf '%s\n' "$label" | grep -E "$pattern" >/dev/null || + fail "unexpected file type for $(basename "$path"): $label" +} + +verify_asset() { + name=$1 + path=$2 + + [ -s "$path" ] || fail "empty artifact: $name" + + case "$name" in + tnt-linux-amd64|tntctl-linux-amd64) + require_file_label "$path" 'ELF 64-bit.*x86-64' + ;; + tnt-linux-arm64|tntctl-linux-arm64) + require_file_label "$path" 'ELF 64-bit.*(aarch64|ARM aarch64)' + ;; + tnt-darwin-amd64|tntctl-darwin-amd64) + require_file_label "$path" 'Mach-O 64-bit.*x86_64' + ;; + tnt-darwin-arm64|tntctl-darwin-arm64) + require_file_label "$path" 'Mach-O 64-bit.*arm64' + ;; + tnt-chat-v*-source.tar.gz) + tar -tzf "$path" >/dev/null + tar -tzf "$path" | grep -q "^TNT-$VERSION/LICENSE$" || + fail "source archive is missing LICENSE" + tar -tzf "$path" | grep -q "^TNT-$VERSION/src/tntctl.c$" || + fail "source archive is missing src/tntctl.c" + tar -tzf "$path" | grep -q "^TNT-$VERSION/packaging/README.md$" || + fail "source archive is missing packaging/README.md" + ;; + *) + fail "unexpected release artifact: $name" + ;; + esac +} + +[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] || { + usage + exit 0 +} + +ARTIFACT_ROOT=${1:-} +OUT_DIR=${2:-dist/release-assets} + +[ -n "$ARTIFACT_ROOT" ] || { + usage >&2 + exit 2 +} +[ -d "$ARTIFACT_ROOT" ] || fail "ARTIFACT_ROOT does not exist: $ARTIFACT_ROOT" +ARTIFACT_ROOT=$(CDPATH= cd -- "$ARTIFACT_ROOT" && pwd) + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +case "$OUT_DIR" in + /*) ;; + *) OUT_DIR="$ROOT/$OUT_DIR" ;; +esac + +VERSION=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h) +[ -n "$VERSION" ] || fail "could not read TNT_VERSION" + +SOURCE_ASSET="tnt-chat-v$VERSION-source.tar.gz" +EXPECTED_ASSETS=" +tnt-linux-amd64 +tntctl-linux-amd64 +tnt-linux-arm64 +tntctl-linux-arm64 +tnt-darwin-amd64 +tntctl-darwin-amd64 +tnt-darwin-arm64 +tntctl-darwin-arm64 +$SOURCE_ASSET +" + +mkdir -p "$OUT_DIR" + +for name in $EXPECTED_ASSETS; do + dst="$OUT_DIR/$name" + [ ! -e "$dst" ] || fail "output already exists: $dst" + src=$(find_unique "$name") + verify_asset "$name" "$src" + cp "$src" "$dst" +done + +( + cd "$OUT_DIR" + : > checksums.txt + for name in $EXPECTED_ASSETS; do + printf '%s %s\n' "$(sha256_of "$name")" "$name" >> checksums.txt + done + + while read -r expected name; do + [ -n "$expected" ] || continue + actual=$(sha256_of "$name") + [ "$actual" = "$expected" ] || + fail "checksum mismatch for $name" + done < checksums.txt +) + +echo "release artifact bundle ready: $OUT_DIR" diff --git a/scripts/release_check.sh b/scripts/release_check.sh index 2c52e64..b255fb8 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -28,7 +28,7 @@ Environment: Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a matching changelog release section, non-placeholder maintainer metadata, and a build from the tagged source archive. Run `make package-publish-check` after -the final GitHub source archive exists to verify package checksums. +the explicit release source archive exists to verify package checksums. USAGE } @@ -78,8 +78,12 @@ grep -q "^pkgname=tnt-chat$" packaging/arch/PKGBUILD || fail "packaging/arch/PKGBUILD pkgname is not tnt-chat" grep -q "^pkgname = tnt-chat$" packaging/arch/.SRCINFO || fail "packaging/arch/.SRCINFO pkgname is not tnt-chat" -grep -q "v${version}.tar.gz" packaging/homebrew/tnt-chat.rb || - fail "packaging/homebrew/tnt-chat.rb URL does not match v$version" +grep -q '${pkgname}-v${pkgver}-source.tar.gz' packaging/arch/PKGBUILD || + fail "packaging/arch/PKGBUILD source must use the release source archive" +grep -q "tnt-chat-v${version}-source.tar.gz" packaging/arch/.SRCINFO || + fail "packaging/arch/.SRCINFO source does not match v$version release archive" +grep -q "tnt-chat-v${version}-source.tar.gz" packaging/homebrew/tnt-chat.rb || + fail "packaging/homebrew/tnt-chat.rb URL does not match v$version release archive" grep -q "^class TntChat < Formula$" packaging/homebrew/tnt-chat.rb || fail "packaging/homebrew/tnt-chat.rb formula class is not TntChat" grep -q 'depends_on "libssh"' packaging/homebrew/tnt-chat.rb || @@ -193,6 +197,7 @@ step "checking installer syntax" sh -n install.sh sh -n scripts/check_release_ref.sh sh -n scripts/package_publish_check.sh +sh -n scripts/package_release_assets.sh scripts/check_release_ref.sh "v$version" bad_ref=v0.0.0 [ "$version" != "0.0.0" ] || bad_ref=v9.9.9 diff --git a/tests/test_release_artifact_gate.sh b/tests/test_release_artifact_gate.sh new file mode 100755 index 0000000..ea3a496 --- /dev/null +++ b/tests/test_release_artifact_gate.sh @@ -0,0 +1,158 @@ +#!/bin/sh +# Release-artifact gate regression tests. + +set -u + +PASS=0 +FAIL=0 +SCRIPT="../scripts/package_release_assets.sh" +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-artifact-test.XXXXXX") + +cleanup() { + rm -rf "$STATE_DIR" +} +trap cleanup EXIT + +pass() { + echo "✓ $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "✗ $1" + if [ "${2:-}" ]; then + printf '%s\n' "$2" + fi + FAIL=$((FAIL + 1)) +} + +version() { + sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' ../include/common.h +} + +write_elf_x86_64() { + printf '\177ELF\002\001\001\000\000\000\000\000\000\000\000\000\002\000\076\000\001\000\000\000' > "$1" +} + +write_elf_aarch64() { + printf '\177ELF\002\001\001\000\000\000\000\000\000\000\000\000\002\000\267\000\001\000\000\000' > "$1" +} + +write_macho_x86_64() { + printf '\317\372\355\376\007\000\000\001\003\000\000\000\002\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000' > "$1" +} + +write_macho_arm64() { + printf '\317\372\355\376\014\000\000\001\000\000\000\000\002\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000' > "$1" +} + +build_artifact_tree() { + artifact_root=$1 + include_source=$2 + ver=$(version) + + mkdir -p \ + "$artifact_root/linux-amd64" \ + "$artifact_root/linux-arm64" \ + "$artifact_root/darwin-amd64" \ + "$artifact_root/darwin-arm64" + + write_elf_x86_64 "$artifact_root/linux-amd64/tnt-linux-amd64" + write_elf_x86_64 "$artifact_root/linux-amd64/tntctl-linux-amd64" + write_elf_aarch64 "$artifact_root/linux-arm64/tnt-linux-arm64" + write_elf_aarch64 "$artifact_root/linux-arm64/tntctl-linux-arm64" + write_macho_x86_64 "$artifact_root/darwin-amd64/tnt-darwin-amd64" + write_macho_x86_64 "$artifact_root/darwin-amd64/tntctl-darwin-amd64" + write_macho_arm64 "$artifact_root/darwin-arm64/tnt-darwin-arm64" + write_macho_arm64 "$artifact_root/darwin-arm64/tntctl-darwin-arm64" + + if [ "$include_source" = "yes" ]; then + source_root="$STATE_DIR/source-$ver/TNT-$ver" + mkdir -p "$source_root/src" "$source_root/packaging" "$artifact_root/source" + printf 'MIT\n' > "$source_root/LICENSE" + printf 'int main(void) { return 0; }\n' > "$source_root/src/tntctl.c" + printf '# Packaging\n' > "$source_root/packaging/README.md" + (cd "$STATE_DIR/source-$ver" && tar -czf "$artifact_root/source/tnt-chat-v$ver-source.tar.gz" "TNT-$ver") + fi +} + +expect_file() { + path=$1 + name=$2 + [ -f "$path" ] && pass "$name" || fail "$name missing" +} + +echo "=== TNT Release Artifact Gate Tests ===" + +if [ ! -x "$SCRIPT" ]; then + echo "Error: script $SCRIPT not found or not executable." + exit 1 +fi + +VER=$(version) +ARTIFACT_ROOT="$STATE_DIR/artifacts" +OUT_DIR="$STATE_DIR/out" +build_artifact_tree "$ARTIFACT_ROOT" yes + +OUTPUT=$("$SCRIPT" "$ARTIFACT_ROOT" "$OUT_DIR" 2>&1) +STATUS=$? +if [ "$STATUS" -eq 0 ] && + printf '%s\n' "$OUTPUT" | grep -q 'release artifact bundle ready'; then + pass "complete artifact set is accepted" +else + fail "complete artifact set failed" "$OUTPUT" +fi + +for asset in \ + tnt-linux-amd64 \ + tntctl-linux-amd64 \ + tnt-linux-arm64 \ + tntctl-linux-arm64 \ + tnt-darwin-amd64 \ + tntctl-darwin-amd64 \ + tnt-darwin-arm64 \ + tntctl-darwin-arm64 \ + "tnt-chat-v$VER-source.tar.gz" \ + checksums.txt +do + expect_file "$OUT_DIR/$asset" "bundles $asset" +done + +if grep -q " tnt-linux-amd64$" "$OUT_DIR/checksums.txt" && + grep -q " tnt-chat-v$VER-source.tar.gz$" "$OUT_DIR/checksums.txt"; then + pass "checksums include binaries and source archive" +else + fail "checksums are incomplete" "$(cat "$OUT_DIR/checksums.txt" 2>/dev/null)" +fi + +DUP_ROOT="$STATE_DIR/duplicate" +DUP_OUT="$STATE_DIR/duplicate-out" +build_artifact_tree "$DUP_ROOT" yes +mkdir -p "$DUP_ROOT/duplicate" +cp "$DUP_ROOT/linux-amd64/tnt-linux-amd64" "$DUP_ROOT/duplicate/tnt-linux-amd64" +DUP_OUTPUT=$("$SCRIPT" "$DUP_ROOT" "$DUP_OUT" 2>&1) +DUP_STATUS=$? +if [ "$DUP_STATUS" -ne 0 ] && + printf '%s\n' "$DUP_OUTPUT" | grep -q 'expected exactly one artifact named tnt-linux-amd64'; then + pass "duplicate artifact is rejected" +else + fail "duplicate artifact handling" "$DUP_OUTPUT" +fi + +MISSING_ROOT="$STATE_DIR/missing" +MISSING_OUT="$STATE_DIR/missing-out" +build_artifact_tree "$MISSING_ROOT" no +MISSING_OUTPUT=$("$SCRIPT" "$MISSING_ROOT" "$MISSING_OUT" 2>&1) +MISSING_STATUS=$? +if [ "$MISSING_STATUS" -ne 0 ] && + printf '%s\n' "$MISSING_OUTPUT" | grep -q "expected exactly one artifact named tnt-chat-v$VER-source.tar.gz"; then + pass "missing source archive is rejected" +else + fail "missing source archive handling" "$MISSING_OUTPUT" +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/test_soak.sh b/tests/test_soak.sh index 4becc0c..406b1cc 100755 --- a/tests/test_soak.sh +++ b/tests/test_soak.sh @@ -106,7 +106,7 @@ else fi IDLE_READY="$STATE_DIR/idle.ready" -IDLE_HOLD=$((DURATION + 2)) +IDLE_HOLD=$((DURATION + 20)) cat >"$STATE_DIR/idle.expect" <