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
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
*
!go.mod
!go.sum
!api/
!backend/
!config/
!graph/
!model/
!repos/
!service/
!store/
**/*_test.go
store/sqlite/
store/postgres/postgrestest/
163 changes: 163 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,166 @@ jobs:
# the previous version and exits 0, so the installer reports the machine
# converged while leaving the old binary in place.
prime-module-proxy: true

api-publish:
name: Publish the API image and its manifests
needs: release
# The version is empty on a push that cut no release.
if: needs.release.outputs.version != ''
runs-on: ubuntu-26.04
# A stalled download fails the job here rather than at Actions' six-hour default.
timeout-minutes: 30
permissions:
# The release's notes gain what a deployment pins.
contents: write
packages: write
# The OIDC token cosign trades for a signing certificate naming this workflow.
id-token: write
env:
VERSION: ${{ needs.release.outputs.version }}
IMAGE: ghcr.io/${{ github.repository }}
MANIFESTS: ghcr.io/${{ github.repository }}/manifests
# A named bash runs with pipefail.
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7

# SHA-pinned because they are third-party, the major tag moves, and the
# login action is handed a token with packages: write.
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# A version already on ghcr is never rebuilt, so one tag names one image,
# and :latest moves only to the newest version. Only "not found" builds:
# any other error fails the step, so a transient one never republishes a
# version under a new digest.
- name: Decide what to publish
id: publish
run: |
newest=$(git ls-remote --tags --refs origin 'refs/tags/v*' | sed 's|.*refs/tags/v||' | sort -V | tail -n 1)
[ -n "$newest" ] || { echo "origin lists no v* tag, though this run tagged v$VERSION" >&2; exit 1; }
if [ "$newest" = "$VERSION" ]; then
echo "latest=true" >>"$GITHUB_OUTPUT"
else
echo "latest=false" >>"$GITHUB_OUTPUT"
fi
if answer=$(docker buildx imagetools inspect "$IMAGE:$VERSION" 2>&1 >/dev/null); then
echo "::notice::$IMAGE:$VERSION is already published"
echo "build=false" >>"$GITHUB_OUTPUT"
elif grep -q ': not found$' <<<"$answer"; then
echo "build=true" >>"$GITHUB_OUTPUT"
else
echo "$answer" >&2
exit 1
fi

# The version for a deployment to pin, :sha-<short> for a person to
# trace, and :latest for `docker run`. A manifest names the digest.
- name: Resolve the tags
if: steps.publish.outputs.build == 'true'
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
flavor: latest=false
tags: |
type=raw,value=${{ env.VERSION }}
type=sha,format=short
type=raw,value=latest,enable=${{ steps.publish.outputs.latest == 'true' }}

# The provenance and SBOM attestations ride in the image's index, so the
# digest a manifest pins carries them.
- name: Build and push
if: steps.publish.outputs.build == 'true'
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: PROJECTS_VERSION=${{ env.VERSION }}
provenance: mode=max
sbom: true
cache-from: type=gha
cache-to: type=gha,mode=max

# A re-run that built nothing reads the published version's digest.
- name: Name the image by its digest
id: image
env:
BUILT: ${{ steps.build.outputs.digest }}
run: |
pinned=$BUILT
if [ -z "$pinned" ]; then
pinned=$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{json .Manifest}}' | jq -r .digest)
fi
[[ $pinned == sha256:* ]] || { echo "no digest for $IMAGE:$VERSION: '$pinned'" >&2; exit 1; }
echo "ref=$IMAGE@$pinned" >>"$GITHUB_OUTPUT"
echo "Image \`$IMAGE@$pinned\`" >>"$GITHUB_STEP_SUMMARY"

- name: Install flux, kustomize and cosign
run: |
release/install-tools "$RUNNER_TEMP/bin" flux kustomize cosign
echo "$RUNNER_TEMP/bin" >>"$GITHUB_PATH"

# Pushed as manifests:<version>. --reproducible makes a re-run send the
# same bytes for the same digest. The source and revision travel in the
# artifact, and Flux reports them beside what it applied.
- name: Push the manifests
id: manifests
env:
IMAGE_REF: ${{ steps.image.outputs.ref }}
run: |
work=$RUNNER_TEMP/manifests
release/pin-manifests --image "$IMAGE_REF" --version "$VERSION" --out "$work"
pushed=$(flux push artifact "oci://$MANIFESTS:$VERSION" --path="$work" \
--source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" --revision="$VERSION@sha1:$GITHUB_SHA" \
--reproducible --output=json | jq -r .digest)
[[ $pushed == sha256:* ]] || { echo "flux push named no digest: '$pushed'" >&2; exit 1; }
echo "ref=$MANIFESTS@$pushed" >>"$GITHUB_OUTPUT"
echo "Manifests \`$MANIFESTS:$VERSION\` as \`$MANIFESTS@$pushed\`" >>"$GITHUB_STEP_SUMMARY"

# Keyless, so the signing certificate names this workflow on this ref and
# no key exists to keep. A deployment verifies the artifact against that
# identity before applying it. The verify proves the pair the release
# notes print is the one a consumer can check. A re-run signs again, and
# either signature verifies.
- name: Sign the image and the manifests
env:
IMAGE_REF: ${{ steps.image.outputs.ref }}
MANIFESTS_REF: ${{ steps.manifests.outputs.ref }}
run: |
cosign sign --yes "$IMAGE_REF"
cosign sign --yes "$MANIFESTS_REF"
cosign verify --certificate-identity "$GITHUB_SERVER_URL/$GITHUB_WORKFLOW_REF" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$IMAGE_REF" "$MANIFESTS_REF" >/dev/null

# A re-run finds the section already written, and leaves it.
- name: Name what was published in the release notes
env:
GH_TOKEN: ${{ github.token }}
IMAGE_REF: ${{ steps.image.outputs.ref }}
MANIFESTS_REF: ${{ steps.manifests.outputs.ref }}
run: |
notes=$RUNNER_TEMP/notes.md
gh release view "v$VERSION" --json body --jq .body >"$notes"
if ! grep -q '^## Published$' "$notes"; then
{
echo
echo '## Published'
echo
echo "- Image: \`$IMAGE_REF\`"
echo "- Manifests: \`$MANIFESTS_REF\`, with \`migrate/\` to apply before \`app/\`"
} >>"$notes"
gh release edit "v$VERSION" --notes-file "$notes"
fi
71 changes: 71 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ jobs:
- name: Check sqlc generated code is current
run: cd store/sqlite && go tool sqlc diff

- name: Check sqlc generated code for Postgres is current
run: cd store/postgres && go tool sqlc diff

# One suite, every rule, on the second store. The go job above ran it on
# SQLite, so a rule that only one store honors fails here.
- name: Run the service suite on Postgres
run: go test ./service/
env:
PROJECTS_TEST_STORE: postgres

# Executes the Taskfile so its build paths cannot silently rot again, as
# they did for 109 days after the package layout flatten.
- name: Build via Taskfile
Expand Down Expand Up @@ -291,6 +301,7 @@ jobs:
go-build-repo-mod
go-mod-tidy-repo
go-test-repo-mod
hadolint
jactionlint
sqlfluff-lint
)
Expand All @@ -305,3 +316,63 @@ jobs:
uvx --python 3.13 pre-commit run "$hook" "${scope[@]}" --show-diff-on-failure || status=1
done
exit "$status"

# > custom:after:all - Kubernetes manifests
#
# The release publishes deploy/ as the base a cluster applies, and calls this
# workflow as its gate, so a base that fails here is never pushed.

deploy-manifests:
name: Kubernetes manifests
runs-on: ubuntu-26.04
# A named bash runs with pipefail.
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7

- name: Install kustomize, kubeconform and kube-linter
run: |
release/install-tools "$RUNNER_TEMP/bin" kustomize kubeconform kube-linter
echo "$RUNNER_TEMP/bin" >>"$GITHUB_PATH"

# release/pin-manifests pins and labels every path as the release does,
# with the image named by a digest of zeros. kube-linter refuses an image
# with no tag or digest, which the committed base carries until the
# release pins it.
#
# kubeconform -strict also refuses a field the schema does not know. Its
# schemas are read at a fixed commit of kubeconform's own schema
# repository, for the Kubernetes release named. Given that commit's root,
# kubeconform appends the path of each kind's strict schema itself.
- name: Render and check each path
env:
KUBERNETES_VERSION: 1.36.5
SCHEMAS: https://raw.githubusercontent.com/yannh/kubernetes-json-schema/f7d855e49c34b039c9ef2196444910275e31e09e
run: |
zeros=$(printf '0%.0s' {1..64})
release/pin-manifests --image "ghcr.io/$GITHUB_REPOSITORY@sha256:$zeros" --version 0.0.0 \
--out "$RUNNER_TEMP/manifests" >"$RUNNER_TEMP/paths"
status=0
while IFS= read -r path; do
echo "::group::${path#"$RUNNER_TEMP"/manifests/}"
kustomize build "$path" >"$RUNNER_TEMP/rendered.yaml"
kubeconform -strict -summary -kubernetes-version "$KUBERNETES_VERSION" \
-schema-location "$SCHEMAS" "$RUNNER_TEMP/rendered.yaml" || status=1
kube-linter lint "$RUNNER_TEMP/rendered.yaml" || status=1
echo "::endgroup::"
done <"$RUNNER_TEMP/paths"
exit "$status"

# The release builds the image only after it has tagged, and .dockerignore is an
# allowlist of what the binary imports, so a missing directory would otherwise
# surface with the version already cut.
api-image:
name: API image
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7

- name: Build the image without pushing it
run: docker build --build-arg PROJECTS_VERSION=0.0.0 .
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ run:
modules-download-mode: readonly
linters:
# A linter joins by name after the sweep that makes every Go module pass it,
# never as a preset. The last four catch a defect rather than a style.
# never as a preset.
enable:
- gocritic
- misspell
Expand Down
49 changes: 46 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ repos:
hooks:
# --unsafe parses instead of safe_load'ing. Every mkdocs-material repo
# carries `!!python/name:material.extensions.emoji.twemoji`, which
# safe_load can never construct — five repos fail this hook without it.
# safe_load can never construct — a repo carrying that tag fails this hook without it.
# The alternative is excluding mkdocs.yml, which checks strictly less.
- id: check-yaml
args: [--unsafe]
Expand Down Expand Up @@ -114,8 +114,8 @@ repos:
# The guard distinguishes "this repo has no tests" from "this repo has tests
# and bats is missing". Only the first is allowed to pass: a bare `bats
# tests/` under language: system would be the second hook here to report
# success because its tool was absent. The first hid seven shellcheck
# findings behind a green run.
# success because its tool was absent. The first hid shellcheck findings
# behind a green run.
#
# The unset clears every variable git reads to find a repository. A commit
# from a linked worktree exports GIT_DIR and GIT_INDEX_FILE into each hook,
Expand Down Expand Up @@ -286,6 +286,35 @@ repos:
files: ^store/sqlite/.*\.(sql|yaml)$
pass_filenames: false
stages: [pre-commit]
- id: sqlc-diff-postgres
name: sqlc generated code for Postgres is current
entry: bash -c 'cd store/postgres && go tool sqlc diff'
language: system
files: ^store/postgres/.*\.(sql|yaml)$
pass_filenames: false
stages: [pre-commit]

# generated:docker - Docker
#
# hadolint-py installs hadolint's own release binary through pip and checks
# its sha256, so the hook needs no Docker daemon and no system package. Its
# tag is the hadolint release it installs.
- repo: https://github.com/AleksaC/hadolint-py
rev: v2.14.0
hooks:
# DL3018 is DL3008's apk counterpart — the same "pin every package
# version" rule, for an Alpine base rather than a Debian one. Ignoring one
# and enforcing the other only means the Debian images pass and the Alpine
# ones do not; pinning a package version in either breaks on the next base
# image bump.
#
# The exclude is not about rules. hadolint matches on the Dockerfile name,
# and BuildKit's per-file ignore list is named <dockerfile>.dockerignore,
# so a repo using one gets a file whose name hadolint reads as a
# Dockerfile. It then parses an ignore list and fails on its first glob.
- id: hadolint
args: ["--ignore", "DL3008", "--ignore", "DL3018", "--ignore", "DL4006"]
exclude: '\.dockerignore$'

# generated:github-actions - GitHub Actions
- repo: https://github.com/jdx/jactionlint
Expand All @@ -302,3 +331,17 @@ repos:
hooks:
- id: sqlfluff-lint
args: [--dialect, "sqlite", --config, .sqlfluff]

# > custom:after:sql - Postgres SQL lints in its own dialect
# .sqlfluffignore keeps the generated SQLite hook off store/postgres. This one
# reads only that directory, in the dialect it is written in. It is a local hook so
# its id differs from the generated hook's, which a second hook of that id would
# replace.
- repo: local
hooks:
- id: sqlfluff-lint-postgres
name: sqlfluff-lint (Postgres)
language: python
additional_dependencies: ["sqlfluff==4.2.2"]
entry: sqlfluff lint --dialect postgres --config .sqlfluff --disregard-sqlfluffignores --disable-progress-bar
files: ^store/postgres/.*\.sql$
1 change: 1 addition & 0 deletions .sqlfluffignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
store/postgres/
Loading
Loading