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
107 changes: 81 additions & 26 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ jobs:
# converged while leaving the old binary in place.
prime-module-proxy: true

api-publish:
name: Publish the API image and its manifests
publish:
name: Publish the API and web images and their manifests
needs: release
# The version is empty on a push that cut no release.
if: needs.release.outputs.version != ''
Expand All @@ -50,6 +50,7 @@ jobs:
env:
VERSION: ${{ needs.release.outputs.version }}
IMAGE: ghcr.io/${{ github.repository }}
WEB_IMAGE: ghcr.io/${{ github.repository }}-web
MANIFESTS: ghcr.io/${{ github.repository }}/manifests
# A named bash runs with pipefail.
defaults:
Expand All @@ -71,7 +72,8 @@ jobs:
# 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.
# version under a new digest. Each image is decided alone, so a re-run
# after one of the two pushed builds only the other.
- name: Decide what to publish
id: publish
run: |
Expand All @@ -82,20 +84,27 @@ jobs:
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
published() {
local answer
if answer=$(docker buildx imagetools inspect "$1" 2>&1 >/dev/null); then
echo "::notice::$1 is already published"
return 0
elif grep -q ': not found$' <<<"$answer"; then
return 1
fi
echo "$answer" >&2
exit 1
fi
}
build_api=true build_web=true
if published "$IMAGE:$VERSION"; then build_api=false; fi
if published "$WEB_IMAGE:$VERSION"; then build_web=false; fi
echo "api=$build_api" >>"$GITHUB_OUTPUT"
echo "web=$build_web" >>"$GITHUB_OUTPUT"

# 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'
if: steps.publish.outputs.api == 'true'
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
Expand All @@ -109,7 +118,7 @@ jobs:
# 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'
if: steps.publish.outputs.api == 'true'
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
Expand All @@ -124,19 +133,60 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max

# The web image is tagged as the API's is, under the same version.
- name: Resolve the web image's tags
if: steps.publish.outputs.web == 'true'
id: web-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.WEB_IMAGE }}
flavor: latest=false
tags: |
type=raw,value=${{ env.VERSION }}
type=sha,format=short
type=raw,value=latest,enable=${{ steps.publish.outputs.latest == 'true' }}

# Its own cache scope, because two builds writing the default scope
# replace each other's cache on every release.
- name: Build and push the web image
if: steps.publish.outputs.web == 'true'
id: web-build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: web
platforms: linux/amd64
push: true
tags: ${{ steps.web-meta.outputs.tags }}
labels: ${{ steps.web-meta.outputs.labels }}
provenance: mode=max
sbom: true
cache-from: type=gha,scope=web
cache-to: type=gha,mode=max,scope=web

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

- name: Install flux, kustomize and cosign
run: |
Expand All @@ -150,9 +200,10 @@ jobs:
id: manifests
env:
IMAGE_REF: ${{ steps.image.outputs.ref }}
WEB_IMAGE_REF: ${{ steps.image.outputs.web_ref }}
run: |
work=$RUNNER_TEMP/manifests
release/pin-manifests --image "$IMAGE_REF" --version "$VERSION" --out "$work"
release/pin-manifests --image "$IMAGE_REF" --web-image "$WEB_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)
Expand All @@ -162,25 +213,28 @@ jobs:

# 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
# identity before applying it. The verify proves the refs the release
# notes print are the ones a consumer can check. A re-run signs again, and
# either signature verifies.
- name: Sign the image and the manifests
- name: Sign the images and the manifests
env:
IMAGE_REF: ${{ steps.image.outputs.ref }}
WEB_IMAGE_REF: ${{ steps.image.outputs.web_ref }}
MANIFESTS_REF: ${{ steps.manifests.outputs.ref }}
run: |
cosign sign --yes "$IMAGE_REF"
cosign sign --yes "$WEB_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
"$IMAGE_REF" "$WEB_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 }}
WEB_IMAGE_REF: ${{ steps.image.outputs.web_ref }}
MANIFESTS_REF: ${{ steps.manifests.outputs.ref }}
run: |
notes=$RUNNER_TEMP/notes.md
Expand All @@ -190,7 +244,8 @@ jobs:
echo
echo '## Published'
echo
echo "- Image: \`$IMAGE_REF\`"
echo "- API image: \`$IMAGE_REF\`"
echo "- Web image: \`$WEB_IMAGE_REF\`"
echo "- Manifests: \`$MANIFESTS_REF\`, with \`migrate/\` to apply before \`app/\`"
} >>"$notes"
gh release edit "v$VERSION" --notes-file "$notes"
Expand Down
135 changes: 132 additions & 3 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,61 @@ jobs:
go install github.com/go-task/task/v3/cmd/task@v3.52.0
task build

vue-web:
runs-on: ubuntu-26.04
defaults:
run:
working-directory: web
steps:

- uses: actions/checkout@v7

# generated:vue-web
# Shared by the vue job and the hooks job, whose vue-eslint, vue-prettier
# and vue-typecheck hooks run npm scripts in the component.
- uses: actions/setup-node@v7
with:
# The repo's own file, at the workspace root, because the Node major
# also lives in each Dockerfile's FROM line and a pin here would be a
# third copy. The ci die refuses to write this job without the file,
# since setup-node fails before installing anything.
node-version-file: .nvmrc
cache: npm
# A glob rather than web/package-lock.json, because the two
# layouts in use keep the lock in different places: one lock per
# component beside its package.json, or an npm workspace with a
# single lock at the repo root. setup-node fails outright when a
# named path does not resolve, so a workspace component with no lock
# of its own kills the job before npm ci.
#
# Action inputs resolve from the workspace root, not the job's
# working-directory, so this is not relative to the component.
cache-dependency-path: "**/package-lock.json"

# An npm workspace has to install from the root: the lock covers every
# workspace, and npm ci inside one refuses to run without a lock beside
# it. Falling back keeps the per-component layout installing in place.
#
# The directory is named on the step because the hooks job has no
# working-directory of its own, and installs for every component.
- working-directory: "web"
run: |
if [ -f package-lock.json ]; then
npm ci
else
cd "$GITHUB_WORKSPACE" && npm ci
fi

- run: npm run build

# test:unit where the component has one, because test is where a
# frontend hangs its browser run or a second build; otherwise test. A
# component with neither has no suite, which is not a failure.
- name: Test
run: |
script=$(node -p "const s = require('./package.json').scripts ?? {}; s['test:unit'] ? 'test:unit' : s.test ? 'test' : ''")
if [ -n "$script" ]; then npm run "$script"; fi

hooks:
runs-on: ubuntu-26.04
steps:
Expand All @@ -88,6 +143,41 @@ jobs:
cache: true
cache-dependency-path: "./go.sum"

# Shared by the vue job and the hooks job, whose vue-eslint, vue-prettier
# and vue-typecheck hooks run npm scripts in the component.
- uses: actions/setup-node@v7
with:
# The repo's own file, at the workspace root, because the Node major
# also lives in each Dockerfile's FROM line and a pin here would be a
# third copy. The ci die refuses to write this job without the file,
# since setup-node fails before installing anything.
node-version-file: .nvmrc
cache: npm
# A glob rather than web/package-lock.json, because the two
# layouts in use keep the lock in different places: one lock per
# component beside its package.json, or an npm workspace with a
# single lock at the repo root. setup-node fails outright when a
# named path does not resolve, so a workspace component with no lock
# of its own kills the job before npm ci.
#
# Action inputs resolve from the workspace root, not the job's
# working-directory, so this is not relative to the component.
cache-dependency-path: "**/package-lock.json"

# An npm workspace has to install from the root: the lock covers every
# workspace, and npm ci inside one refuses to run without a lock beside
# it. Falling back keeps the per-component layout installing in place.
#
# The directory is named on the step because the hooks job has no
# working-directory of its own, and installs for every component.
- working-directory: "web"
run: |
if [ -f package-lock.json ]; then
npm ci
else
cd "$GITHUB_WORKSPACE" && npm ci
fi

# Every hook in the repo's committed pre-commit config. A push from a
# checkout that never ran `pre-commit
# install`, or a merge in the web UI, passes every hook by not running it,
Expand Down Expand Up @@ -301,6 +391,9 @@ jobs:
go-build-repo-mod
go-mod-tidy-repo
go-test-repo-mod
vue-eslint
vue-prettier
vue-typecheck
hadolint
jactionlint
sqlfluff-lint
Expand All @@ -317,7 +410,7 @@ jobs:
done
exit "$status"

# > custom:after:all - Kubernetes manifests
# > custom:after:all - Kubernetes manifests, the images, and the web smoke
#
# 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.
Expand All @@ -338,7 +431,7 @@ jobs:
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 each 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.
#
Expand All @@ -352,7 +445,8 @@ jobs:
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 \
release/pin-manifests --image "ghcr.io/$GITHUB_REPOSITORY@sha256:$zeros" \
--web-image "ghcr.io/$GITHUB_REPOSITORY-web@sha256:$zeros" --version 0.0.0 \
--out "$RUNNER_TEMP/manifests" >"$RUNNER_TEMP/paths"
status=0
while IFS= read -r path; do
Expand All @@ -376,3 +470,38 @@ jobs:

- name: Build the image without pushing it
run: docker build --build-arg PROJECTS_VERSION=0.0.0 .

# Builds the web image as the release will, then drives one roundtrip through
# the stack it ships in: Caddy's routes, the browser login against a stand-in
# identity provider, and a project named with a slash reaching the API. The
# vue-web job's unit tests reach none of those.
web-stack:
name: Web image and its smoke
runs-on: ubuntu-26.04
timeout-minutes: 20
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: web/package-lock.json

- run: npm ci

- name: Build and start the stack
working-directory: .
run: docker compose -f docker-compose.dev.yml up --build --wait

- run: npx playwright install --with-deps chromium

- run: npx playwright test

- name: Print the stack's logs
if: failure()
working-directory: .
run: docker compose -f docker-compose.dev.yml logs --no-color
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ vendor/
# Task
.task/

# The web app's dependencies, build and Playwright output
node_modules/
dist/
*.tsbuildinfo
test-results/
playwright-report/

# SQLite
*.db
*.db-journal
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
Loading
Loading