diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9d83fd..0d97bbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,3 +123,15 @@ jobs: docker logs semibase-initdb exit 1 fi + + # The published image, built and smoke-checked but never pushed - a broken + # Dockerfile has to fail here rather than at tag time. It reuses the binary + # the socket step already proved statically linked, which is what lets it + # run at all: scratch has no loader, no shell and no libc. + - name: Build the container image + run: | + set -euo pipefail + mkdir -p image + cp semibase-linux image/semibase + docker build --platform linux/amd64 -f Dockerfile -t semibase:ci image + docker run --rm semibase:ci version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fdc1bf..7e329d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,9 +5,14 @@ on: tags: - "v*" +# The group is the workflow alone, deliberately: with github.ref in it, two tags +# pushed together (git push --tags) run as separate concurrent jobs and whichever +# `docker push :latest` lands last wins. Cancelling is off for the same reason - +# a job killed between the GitHub release and the image push leaves the pair +# half-published. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }} + cancel-in-progress: false jobs: release: @@ -15,12 +20,14 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + packages: write steps: - # fetch-depth: 0 is load-bearing. At a finite depth actions/checkout - # fetches the tag by SHA and leaves a LIGHTWEIGHT tag behind, which - # throws the annotation away and drops every release to the fallback - # text without failing anything. + # fetch-depth: 0 is load-bearing twice over. At a finite depth + # actions/checkout fetches the tag by SHA and leaves a LIGHTWEIGHT tag + # behind, which throws the annotation away and drops every release to the + # fallback text without failing anything; and the :latest guard below + # needs every other tag present to know whether this one is the newest. - uses: actions/checkout@v7 with: persist-credentials: false @@ -42,10 +49,56 @@ jobs: id: tag shell: bash run: | + set -euo pipefail TAG="${GITHUB_REF_NAME}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + # ghcr.io accepts lowercase names only, and the owner is not lowercase + IMAGE="ghcr.io/$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')" + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + + # semver: everything after the first hyphen is a prerelease. This test + # and the newest-tag one below settle every "latest" in the release - + # the GitHub release's prerelease and make_latest fields and the + # image's :latest tag all read the same two answers, so the release + # page and the registry cannot name different versions. + case "$TAG" in + *-*) PRERELEASE=true ;; + *) PRERELEASE=false ;; + esac + echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" + + # "Latest" may only ever move forward, and two ordinary operator moves + # walk it backwards without this test: re-running an old tag's + # workflow from the Actions UI, and pushing two tags at once. So the + # tag being released has to BE the newest release tag in the + # repository, not merely a non-prerelease one. Every tag has to be + # present for that to mean anything - checkout's fetch-depth: 0 is + # what puts them there, and this fails loudly if they are missing. + # + # The comparison is git's version ordering, not a string compare: + # -v:refname reads the numeric fields as numbers, so v0.10.0 outranks + # v0.9.0, which "v0.10.0" > "v0.9.0" as text does not. Candidates are + # narrowed to plain vN.N[.N...] tags, so no prerelease and no oddly + # named tag can be the newest - which also keeps git's own ordering of + # suffixed tags out of it, since it puts v1.0.0-rc1 AFTER v1.0.0 + # unless versionsort.suffix is configured. + if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::tag $TAG is not in the checkout, so it cannot be compared against the others" + exit 1 + fi + NEWEST=$(git tag -l 'v*' --sort=-v:refname | grep -E '^v[0-9]+(\.[0-9]+)+$' | head -n 1 || true) + echo "newest=$NEWEST" >> "$GITHUB_OUTPUT" + + if [ "$PRERELEASE" = false ] && [ "$NEWEST" = "$TAG" ]; then + MAKE_LATEST=true + else + MAKE_LATEST=false + fi + echo "make_latest=$MAKE_LATEST" >> "$GITHUB_OUTPUT" + echo "$TAG: prerelease=$PRERELEASE, newest release tag=${NEWEST:-none}, latest moves=$MAKE_LATEST" + # objecttype is the only reliable test. A lightweight tag is a bare # ref onto the commit, so %(contents) silently resolves to the COMMIT # message - trailers and all - instead of returning nothing. @@ -89,7 +142,44 @@ jobs: # no extension on linux - an ELF is executable by its mode bit build windows amd64 .exe build linux amd64 "" - ls -l dist + + # The image gets a copy of the linux artifact under the plain name the + # Dockerfile expects, in a directory of its own: the release attaches + # dist/*, and an unversioned duplicate in there would be attached too. + # A copy rather than a second build, so the bytes in the image and the + # bytes on the release page are provably the same binary. + mkdir -p image + cp "dist/semibase_${VERSION}_linux_amd64" image/semibase + ls -l dist image + + # Built before the release exists, pushed after it. The build is the half + # that fails on a bad copy or a bad Dockerfile, and it has to fail while + # there is still nothing published to withdraw. + - name: Build the container image + env: + TAG: ${{ steps.tag.outputs.tag }} + IMAGE: ${{ steps.tag.outputs.image }} + shell: bash + run: | + set -euo pipefail + docker build --platform linux/amd64 -f Dockerfile -t "$IMAGE:$TAG" image + + # The only run of the shipped bytes anywhere. CI's smoke check runs a + # binary built without -trimpath and without -s -w, so it exercises a + # different artifact than the one consumers pull. + docker run --rm "$IMAGE:$TAG" version + + # provisioning.md states the image is linux/amd64, and FROM scratch + # otherwise takes its manifest from whatever built it. --platform is + # the only thing that sets it - a FROM --platform= pin does not, since + # scratch carries no config for the output to inherit - so the claim + # is read back off the built image rather than trusted. + PLATFORM=$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$IMAGE:$TAG") + echo "image platform: $PLATFORM" + if [ "$PLATFORM" != linux/amd64 ]; then + echo "::error::image manifest says $PLATFORM, not linux/amd64" + exit 1 + fi # Pinned to a commit, not a tag: this is the one third-party action that # runs with the job's contents: write token, so a moved tag in its @@ -101,6 +191,48 @@ jobs: name: ${{ steps.tag.outputs.tag }} body: ${{ steps.tag.outputs.body }} draft: false - prerelease: false - make_latest: true + prerelease: ${{ steps.tag.outputs.prerelease }} + make_latest: ${{ steps.tag.outputs.make_latest }} files: dist/* + + # After the release, never before: SemiPlot's bench tracks :latest and its + # CI still downloads the release assets, so an image published first would + # leave a window where :latest names a version whose assets do not exist. + # + # docker login rather than docker/login-action: the action would be a + # third-party step holding a write-capable token, and the CLI needs no + # such trust for one command. + - name: Publish the container image + env: + TAG: ${{ steps.tag.outputs.tag }} + PRERELEASE: ${{ steps.tag.outputs.prerelease }} + NEWEST: ${{ steps.tag.outputs.newest }} + MOVE_LATEST: ${{ steps.tag.outputs.make_latest }} + IMAGE: ${{ steps.tag.outputs.image }} + REGISTRY_USER: ${{ github.actor }} + REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + # on a trap, so the credential leaves ~/.docker/config.json on the + # skip and failure paths too, not only on the one that runs to the end + trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT + printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin + + docker push "$IMAGE:$TAG" + + # The same answer the GitHub release's make_latest was set from, so + # :latest and /releases/latest move together or not at all. A skipped + # push says why; it is never silent. + if [ "$MOVE_LATEST" != true ]; then + if [ "$PRERELEASE" = true ]; then + echo ":latest not moved: $TAG is a prerelease" + else + echo ":latest not moved: $TAG is not the newest release tag (${NEWEST:-none} is)" + fi + exit 0 + fi + + docker tag "$IMAGE:$TAG" "$IMAGE:latest" + docker push "$IMAGE:latest" + echo ":latest now names $TAG" diff --git a/.gitignore b/.gitignore index 7d11a8f..8159d90 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,9 @@ Thumbs.db # Local secrets .env + +# Container image build context +/image/ + +# Archive dumps taken from a customer installation. The repository is public. +/samples/ diff --git a/CLAUDE.md b/CLAUDE.md index 35474c4..81a253b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,8 @@ SemiBase is the deployable PostgreSQL service for the semiconductor-tools installation. It provisions and configures the instance that hosts the Simple-Scada 2 archive: the SCADA writes `trends`/`messages` into it, SemiPlot and future tools read from it. The deliverable is one -CLI binary, `semibase.exe`, plus the instance's architecture docs. +CLI binary, `semibase.exe`, a container image carrying its Linux build, plus the instance's +architecture docs. Deployment target: Windows. Language: Go (module `github.com/Semiteq/SemiBase`), driver `pgx/v5`, `sql/semiplot_tags.sql` embedded via `go:embed`. Entry point: `cmd/semibase`. The module is pure `pgx` and compiles for any GOOS; Linux builds serve the containerised @@ -28,6 +29,26 @@ cross-compiles `windows/amd64` and `linux/amd64` with `CGO_ENABLED=0` — the mo artifact, an ELF is executable by its mode bit. `-X main.revision=` is embedded, so `semibase version` prints the tag. +The same job then pushes `ghcr.io/semiteq/semibase:latest` and `:vX.Y.Z` — the root +`Dockerfile`, `FROM scratch`, a copy of the Linux artifact as the only file. The image is built +and smoke-run before the release is created, so a bad `Dockerfile` aborts with nothing +published, and pushed after it, since consumers track `:latest` and still download the release +assets. "Latest" is one decision made once in the `Read the tag` step — the tag has no `-` +suffix **and** it is the newest release tag in the repository (`git tag -l --sort=-v:refname` +over plain `vN.N.N` tags) — and both the release page's `prerelease`/`make_latest` and the +`:latest` push read it, so they cannot disagree. A prerelease publishes its version tag alone, +and re-running an old tag's workflow cannot walk either "latest" backwards. Build it by hand the +way both workflows do — `--platform linux/amd64`, since `FROM scratch` otherwise stamps the +builder's architecture into the manifest, and a context of one file, so the Dockerfile's +`COPY semibase` finds it and nothing else reaches the daemon: + +```powershell +$env:CGO_ENABLED = "0"; $env:GOOS = "linux"; $env:GOARCH = "amd64" +go build -trimpath -ldflags "-s -w -X main.revision=v0.0.0-dev" -o image/semibase ./cmd/semibase +docker build --platform linux/amd64 -f Dockerfile -t semibase:dev image +docker run --rm semibase:dev version +``` + The release is named after the tag and nothing else. The whole annotated tag message becomes the body, verbatim; blank lines survive, so write it in sections and give it no title line. @@ -60,7 +81,8 @@ golangci-lint is pinned to 2.12.2 (`winget install GolangCI.golangci-lint --vers config in `.golangci.yml`. CI (`.github/workflows/ci.yml`) runs `go build`, `go test -race`, and the same lint on `windows-latest` and `ubuntu-latest` for every push and pull request; the Linux job also provisions a `postgres:17-alpine` service container by running `all` -twice — the bench path and the idempotency check in one step. +twice — the bench path and the idempotency check in one step — and builds the container image, +so a broken `Dockerfile` fails on the pull request rather than at tag time. CI pushes nothing. Unit tests live beside the source (`cmd/semibase/*_test.go`, `internal/provision/*_test.go`), table-driven. There are no database-touching tests; the `verify` command is the integration diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5931539 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# The image carries the linux/amd64 binary and nothing else. Its only consumer +# copies the binary out (COPY --from=ghcr.io/semiteq/semibase:latest /semibase), +# so a base with a shell, CA certificates or a libc would ship weight nobody +# runs: the module is pure pgx, builds CGO_ENABLED=0 and speaks no TLS on the +# bench. The ENTRYPOINT costs one line and makes `docker run version` +# a valid smoke check. +# +# Build it with `--platform linux/amd64`, the way both workflows do. FROM +# scratch takes the manifest from the builder's own platform, so without the +# flag the manifest states whatever machine ran the build while the payload is +# always the linux/amd64 artifact. A `FROM --platform=` pin does NOT fix that - +# it names the platform of the base stage, and scratch carries no config for +# the output to inherit, so the output platform stays the builder's. The +# release job reads the manifest back and fails if it says anything else. +FROM scratch + +# The documented mechanism that links the GHCR package back to this repository. +LABEL org.opencontainers.image.source="https://github.com/Semiteq/SemiBase" + +COPY semibase /semibase +ENTRYPOINT ["/semibase"] diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 20a6518..1ba3ceb 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -24,6 +24,7 @@ Declarative architecture docs (English, present tense). These describe the syste | Reader access | `ALTER DEFAULT PRIVILEGES FOR ROLE scada_writer` set **before** the writer first runs | | Objects we add | `semiplot_tags` only — no triggers, functions, scheduled jobs, or extensions | | Archive schema | Owned by Simple-Scada 2; documented in the SemiPlot repository, never created or altered here | +| Distribution | Binaries as GitHub release assets; the Linux binary also as `ghcr.io/semiteq/semibase`, `FROM scratch`, one file, pushed after the release exists; benches track `:latest` and a prerelease never moves it | | Backup method | `UNDECIDED` — commissioning-time, needs the customer's archive size | | Retention depth / disk sizing | `UNDECIDED` — needs a measured write rate from a working installation | diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 17e8ef6..f969860 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -30,6 +30,7 @@ plaintext password in a client configuration file an acceptable risk. | --- | --- | | `cmd/semibase`, `internal/provision` | `semibase.exe` — idempotent provisioning: instance configuration, database, roles, grants, `semiplot_tags`, post-writer verification | | `sql/semiplot_tags.sql` | DDL for the one object we add to the archive database, embedded into the binary | +| `Dockerfile` | `ghcr.io/semiteq/semibase` — the Linux binary alone on `scratch`, for consumers that layer it into a bench image | | `docs/architecture/` | The instance as it is: configuration deltas, provisioning order, ownership | Nothing else is added to the database. Specifically, and deliberately: no summary tables, no diff --git a/docs/architecture/provisioning.md b/docs/architecture/provisioning.md index 75ce4c5..9e6556b 100644 --- a/docs/architecture/provisioning.md +++ b/docs/architecture/provisioning.md @@ -10,6 +10,63 @@ socket, so neither `psql.exe` nor any runtime needs to be present on the target `sql/semiplot_tags.sql` is embedded at build time. Installing the PostgreSQL engine itself is one documented `winget` line, deliberately outside the tool — OS package management is not database provisioning. +## Distribution + +A tagged release carries three artifacts, all built from one commit by +`.github/workflows/release.yml`: + +| Artifact | Form | Consumer | +| --- | --- | --- | +| `semibase__windows_amd64.exe` | GitHub release asset | Commissioning on the installation machine | +| `semibase__linux_amd64` | GitHub release asset | Provisioning a bench from a Linux host or CI runner | +| `ghcr.io/semiteq/semibase:latest` and `:vX.Y.Z` | Container image | Bench images that layer the binary onto `postgres:17-alpine` | + +The image is `FROM scratch` and holds one file, `/semibase` — the same bytes as the Linux +release asset, copied rather than built a second time. It carries no shell, no libc and no CA +certificates, because nothing runs it as a base: the consumer copies the binary out +(`COPY --from=ghcr.io/semiteq/semibase:latest /semibase /semibase`) onto its own postgres image +and runs it as an init script over the unix socket. The init script belongs to that consumer, +not to the image. The `ENTRYPOINT` is there so +`docker run ghcr.io/semiteq/semibase:latest version` answers, and it is what the release job +runs against the built image before pushing it — the one execution of the shipped bytes. + +The manifest says `linux/amd64` because every build passes `--platform linux/amd64`. `FROM +scratch` otherwise takes the platform of whatever machine built it, so the manifest would state +the runner rather than the payload. A `FROM --platform=` pin does not substitute for the flag — +it sets the base stage's platform, and scratch carries no config for the output to inherit — so +the release job reads the built image back and fails if the manifest says anything else. + +`latest` is the tag consumers track, and that is deliberate. A delivered installation updates +neither the database service nor the viewer on its own, so the only pair ever newly deployed is +the newest SemiBase with the reader as it stands; a bench pinned to an old image would test a +pair nobody runs. The `vX.Y.Z` tag is immutable and exists to place blame and to reproduce a +failing pair. + +"Latest" only ever moves forward, and that is enforced rather than assumed. One decision, made +once in the release job, drives all of it: the tag is a release tag (no `-` suffix) **and** it is +the newest release tag in the repository. That answer sets the GitHub release's `prerelease` and +`make_latest` fields and gates the `:latest` push alike, so `/releases/latest` and `:latest` +cannot name different versions. Without the newest test, re-running an old tag's workflow from +the Actions UI, or pushing two tags at once, walks "latest" backwards with no signal to anyone +tracking it. A skipped `:latest` push says so in the job log; it is never silent. + +The newest tag is found with git's version ordering (`git tag -l --sort=-v:refname`) over plain +`vN.N[.N…]` tags. That ordering compares the numeric fields as numbers, which a string compare +does not — `v0.10.0` outranks `v0.9.0`. Narrowing the candidates to unsuffixed tags keeps +prereleases out of the comparison, and with them git's own placement of `v1.0.0-rc1` *after* +`v1.0.0` unless `versionsort.suffix` is configured. All tags have to be in the checkout for any +of it to mean anything; `fetch-depth: 0` is what puts them there, and the job fails loudly if +the tag being released is not among them. + +The image is pushed after the GitHub release exists, never before: consumers still download the +release assets, so `latest` must never name a version whose assets are missing. The image is +*built* before it, because a bad copy or a bad `Dockerfile` has to abort while there is still +nothing published to withdraw. + +The GHCR package is public, because consumers pull it anonymously from their own CI. The first +push creates it private; that is switched once, in the package's settings, and the workflow has +no say in it. + ## Commands | Command | What it does | When | diff --git a/docs/deployment.md b/docs/deployment.md index b458aa0..5570914 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -21,6 +21,10 @@ SemiBase разворачивается одной утилитой — `semibas Утилита — один исполняемый файл. Готовые сборки для Windows и Linux (x86-64) находятся на странице [Releases](https://github.com/Semiteq/SemiBase/releases/latest). +Каждый выпуск дополнительно публикует образ контейнера `ghcr.io/semiteq/semibase`. Он нужен +только тестовым стендам смежных проектов, которые встраивают эту утилиту в свои сборки. При +пусконаладке образ не используется — берите `semibase.exe` со страницы Releases. + Сборка из исходников: `go build -o semibase.exe ./cmd/semibase`. ## Команды