Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
150 changes: 141 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,29 @@ 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:
name: Build & Release
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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
26 changes: 24 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=<tag>` 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.

Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 <image> 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"]
1 change: 1 addition & 0 deletions docs/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
1 change: 1 addition & 0 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading