From 389da8e68201b640246c2c07fe5829533787eb99 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:34:03 +0300 Subject: [PATCH 1/2] build(image): ship semibase as a container image Consumers resolve the binary from PATH today, so whichever build happens to be installed is the one that provisions their bench. A published image gives them a version they can name: SemiPlot copies /semibase out of it onto its own postgres image and runs it as an init script over the unix socket. The image is FROM scratch and holds one file. Nothing runs it as a base, so a shell, a libc and CA certificates would be weight nobody executes; the module is pure pgx and builds CGO_ENABLED=0. The binary is copied from the linux release artifact rather than built again, so the bytes in the image and the bytes on the release page are the same. The release job pushes :latest and an immutable :vX.Y.Z after creating the GitHub release, never before - consumers still download the release assets. A prerelease tag publishes its version tag and leaves :latest where it is. It logs in with docker login rather than docker/login-action: no third-party action needs the write-capable token for one command. CI builds the image and runs it, so a broken Dockerfile fails on the pull request instead of at tag time. CI pushes nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 12 +++++++++ .github/workflows/release.yml | 44 ++++++++++++++++++++++++++++++- .gitignore | 3 +++ CLAUDE.md | 20 ++++++++++++-- Dockerfile | 9 +++++++ docs/architecture/README.md | 1 + docs/architecture/overview.md | 1 + docs/architecture/provisioning.md | 33 +++++++++++++++++++++++ 8 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9d83fd..cce7336 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 -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..4884b0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + packages: write steps: # fetch-depth: 0 is load-bearing. At a finite depth actions/checkout @@ -89,7 +90,15 @@ 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 # 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 @@ -104,3 +113,36 @@ jobs: prerelease: false make_latest: true 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 }} + REPOSITORY: ${{ github.repository }} + REGISTRY_USER: ${{ github.actor }} + REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + # ghcr.io accepts lowercase names only, and the owner is not lowercase + IMAGE="ghcr.io/$(printf '%s' "$REPOSITORY" | tr '[:upper:]' '[:lower:]')" + printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin + + docker build -f Dockerfile -t "$IMAGE:$TAG" image + docker push "$IMAGE:$TAG" + + # semver: everything after the first hyphen is a prerelease, so + # v1.2.3-rc1 publishes its own tag and leaves :latest where it is + case "$TAG" in + *-*) echo "prerelease $TAG: :latest not moved" ;; + *) + docker tag "$IMAGE:$TAG" "$IMAGE:latest" + docker push "$IMAGE:latest" + ;; + esac diff --git a/.gitignore b/.gitignore index 7d11a8f..48e9368 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ Thumbs.db # Local secrets .env + +# Container image build context +/image/ diff --git a/CLAUDE.md b/CLAUDE.md index 35474c4..7c26cfc 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,20 @@ 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. It runs after the +release is created, since consumers track `:latest` and still download the release assets; a +prerelease tag (`v1.2.3-rc1`) publishes its version tag and leaves `latest` alone. Build it by +hand the way both workflows do — 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 -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 +75,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..c6105fe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +# 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. +FROM scratch +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..d1361f1 100644 --- a/docs/architecture/provisioning.md +++ b/docs/architecture/provisioning.md @@ -10,6 +10,39 @@ 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. + +`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. A prerelease tag (`v1.2.3-rc1`) publishes its version tag only and leaves `latest` +where it is. + +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 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 | From d48f8af8645103e327ebc5eb5f3ba61f7f992a78 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:55:53 +0300 Subject: [PATCH 2/2] fix(release): keep latest forward-only, harden publish The prerelease policy reached the image and stopped there: the GitHub release was created with prerelease: false and make_latest: true hardcoded, so a v1.2.3-rc1 became the Latest release the deployment doc points commissioning engineers at, while the image :latest correctly stayed put. Worse, nothing held :latest to moving forward at all. Re-running an old tag's workflow from the Actions UI republished that old version as :latest, and two tags pushed together raced because the concurrency group carried github.ref. A consumer tracking :latest downgraded with no signal. So "latest" is now one decision, made once in the Read the tag step: the tag carries no - suffix AND it is the newest release tag in the repository. The release's prerelease and make_latest fields and the image's :latest push all read that answer, so the release page and the registry cannot disagree, and neither can walk backwards. The newest tag comes from git's version ordering over unsuffixed tags, which compares numeric fields as numbers - v0.10.0 outranks v0.9.0 - and keeps git's placement of v1.0.0-rc1 after v1.0.0 out of it. A skipped :latest push prints why. The concurrency group drops github.ref so two tag pushes serialise rather than race. The image build moves ahead of release creation: the build is the half that fails on a bad copy or a bad Dockerfile, and it now fails with nothing published to withdraw. Only login and push stay after it. The built image runs `version` before being pushed - CI's smoke check exercises a binary built without -trimpath and -s -w, so these are the only bytes proved. docker logout runs on a trap, covering the skip and failure paths. Both builds pass --platform linux/amd64, and the release job reads the manifest back. A FROM --platform= pin was tried and does not do this: with the pin set to arm64 and no build flag, an amd64 host still produced an amd64 image, since scratch carries no config for the output to inherit. The image also gains org.opencontainers.image.source, which is what links the GHCR package to this repository. /samples/ is ignored. It holds archive dumps from a customer installation, was untracked and unignored in a public repository, and the image/ rule added with the Dockerfile only kept it away from the Docker daemon, not from git add -A. Nothing inside it is touched. The Russian deployment doc gains the third artifact as what it is: an image for consumers' test benches, never part of commissioning. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 132 +++++++++++++++++++++++++----- .gitignore | 3 + CLAUDE.md | 18 ++-- Dockerfile | 12 +++ docs/architecture/provisioning.md | 32 +++++++- docs/deployment.md | 4 + 7 files changed, 171 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cce7336..0d97bbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,5 +133,5 @@ jobs: set -euo pipefail mkdir -p image cp semibase-linux image/semibase - docker build -f Dockerfile -t semibase:ci image + 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 4884b0a..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: @@ -18,10 +23,11 @@ jobs: 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 @@ -43,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. @@ -100,6 +152,35 @@ jobs: 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 # repository would be a write-capable supply chain change. @@ -110,8 +191,8 @@ 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 @@ -124,25 +205,34 @@ jobs: - name: Publish the container image env: TAG: ${{ steps.tag.outputs.tag }} - REPOSITORY: ${{ github.repository }} + 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 - # ghcr.io accepts lowercase names only, and the owner is not lowercase - IMAGE="ghcr.io/$(printf '%s' "$REPOSITORY" | tr '[:upper:]' '[:lower:]')" + # 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 build -f Dockerfile -t "$IMAGE:$TAG" image docker push "$IMAGE:$TAG" - # semver: everything after the first hyphen is a prerelease, so - # v1.2.3-rc1 publishes its own tag and leaves :latest where it is - case "$TAG" in - *-*) echo "prerelease $TAG: :latest not moved" ;; - *) - docker tag "$IMAGE:$TAG" "$IMAGE:latest" - docker push "$IMAGE:latest" - ;; - esac + # 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 48e9368..8159d90 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ Thumbs.db # 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 7c26cfc..81a253b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,16 +30,22 @@ artifact, an ELF is executable by its mode bit. `-X main.revision=` is embe `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. It runs after the -release is created, since consumers track `:latest` and still download the release assets; a -prerelease tag (`v1.2.3-rc1`) publishes its version tag and leaves `latest` alone. Build it by -hand the way both workflows do — a context of one file, so the Dockerfile's `COPY semibase` -finds it and nothing else reaches the daemon: +`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 -f Dockerfile -t semibase:dev image +docker build --platform linux/amd64 -f Dockerfile -t semibase:dev image docker run --rm semibase:dev version ``` diff --git a/Dockerfile b/Dockerfile index c6105fe..5931539 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,18 @@ # 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/provisioning.md b/docs/architecture/provisioning.md index d1361f1..9e6556b 100644 --- a/docs/architecture/provisioning.md +++ b/docs/architecture/provisioning.md @@ -27,17 +27,41 @@ certificates, because nothing runs it as a base: the consumer copies the binary (`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. +`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. A prerelease tag (`v1.2.3-rc1`) publishes its version tag only and leaves `latest` -where it is. +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. +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 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`. ## Команды