diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml new file mode 100644 index 00000000..9fa8dddd --- /dev/null +++ b/.github/workflows/image.yaml @@ -0,0 +1,148 @@ +name: Publish web image + +# Publishes the built web client as ghcr.io/soliplex/frontend-web: an image +# holding only the cache-busted 'build/web' tree (the Dockerfile's 'web' +# target), for images that serve the client with their own web server: +# +# COPY --from=ghcr.io/soliplex/frontend-web: /build/web +# +# Release tags like 'v0.109.0+95' are not valid OCI tags ('+'), so each +# release is tagged as: +# +# 0.109.0 (fixed) +# 0.109.0-95 (fixed; the full release, build number included) +# 0.109 (moves to the newest release of that minor version) +# latest (moves to the newest release) +# +# The full release tag is kept in the 'org.opencontainers.image.version' +# label. A manual run rebuilds and republishes an existing release tag; it +# moves the floating tags ('0.109', 'latest') only when asked to, so +# republishing an old release cannot pull them backwards. +# +# The package must be public for downstream pulls to need no credentials. +# GITHUB_TOKEN cannot change package visibility, so an org admin sets it once +# after the first publish. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to (re)publish, e.g. v0.109.0+95" + required: true + type: string + floating: + description: "Also move the minor-version tag and 'latest'" + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/frontend-web + SLACK_NOTIFY_URL: ${{ secrets.SLACK_NOTIFY_URL }} + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + # Pass the tag through the environment, not by interpolating it into + # the script: a dispatch input is free text. + - name: Compute image tags + id: version + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + # A published release moves the floating tags unless it is a + # prerelease; a manual run only when 'floating' is set. + FLOATING: ${{ github.event_name == 'release' && !github.event.release.prerelease || github.event_name == 'workflow_dispatch' && inputs.floating }} + run: | + if [[ ! "$RELEASE_TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)(\+([0-9]+))?$ ]]; then + echo "::error::Release tag '$RELEASE_TAG' is not of the form v..[+]" + exit 1 + fi + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + build="${BASH_REMATCH[5]}" + { + echo "release_tag=$RELEASE_TAG" + echo "version=$major.$minor.$patch" + echo "minor=$major.$minor" + echo "build=$build" + echo "floating=$FLOATING" + } >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v7 + with: + ref: ${{ steps.version.outputs.release_tag }} + + - name: Image metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.IMAGE }} + flavor: | + latest=${{ steps.version.outputs.floating }} + tags: | + type=raw,value=${{ steps.version.outputs.version }} + type=raw,value=${{ steps.version.outputs.version }}-${{ steps.version.outputs.build }},enable=${{ steps.version.outputs.build != '' }} + type=raw,value=${{ steps.version.outputs.minor }},enable=${{ steps.version.outputs.floating }} + labels: | + org.opencontainers.image.version=${{ steps.version.outputs.release_tag }} + org.opencontainers.image.title=soliplex-frontend-web + org.opencontainers.image.description=Built Soliplex web client (build/web), for COPY --from= + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # The builder stage runs on the runner's own platform, so publishing + # both platforms builds Flutter once. Both are needed: 'COPY --from=' + # on an arm64 host fails if the image only has an amd64 manifest. + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + target: web + platforms: linux/amd64,linux/arm64 + build-args: | + RELEASE_HASH=${{ steps.version.outputs.release_tag }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Notify Slack on failure + if: failure() && env.SLACK_NOTIFY_URL != '' + uses: slackapi/slack-github-action@v3.0.3 + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + webhook: ${{ env.SLACK_NOTIFY_URL }} + webhook-type: incoming-webhook + payload: | + { + "channel": "#soliplex", + "username": "flutter-ci", + "text": ${{ toJSON(format(':x: Publishing the web image failed for {0}:{1}{2}', env.RELEASE_TAG, fromJSON('"\n"'), env.RUN_URL)) }}, + "icon_emoji": ":flutter:" + } diff --git a/AGENTS.md b/AGENTS.md index 38d3716f..c05096a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,12 @@ GitHub Actions (`.github/workflows/flutter.yaml`) runs three jobs: threshold enforced. Slack notification on failure. - **build-web** -- Web release build with artifact upload. +On each published release, `.github/workflows/image.yaml` publishes the +cache-busted web build (the `Dockerfile`'s `web` target) to GHCR as +`ghcr.io/soliplex/frontend-web`, for `linux/amd64` and `linux/arm64`. A +release tag like `v0.109.0+95` becomes the image tags `0.109.0`, +`0.109.0-95`, `0.109`, and `latest`. + ## Pre-commit Hooks Configured via `.pre-commit-config.yaml`: diff --git a/Dockerfile b/Dockerfile index 37a3e241..04d5e815 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,12 +17,21 @@ # $ docker build . -t soliplex-frontend:latest \ # --build-arg RELEASE_HASH=$(git rev-parse --short HEAD) # +# To build only the web tree, as an image for other images to 'COPY --from=' +# (what .github/workflows/image.yaml publishes on each release): +# +# $ docker build . --target web -t soliplex-frontend-web:latest \ +# --build-arg RELEASE_HASH=$(git rev-parse --short HEAD) +# ############################################################################### ############################################################################### # Build stage ############################################################################### -FROM ubuntu:focal AS builder +# The web build is platform-independent static files, so build it on the +# builder's own platform: a multi-platform build then runs Flutter once, not +# under emulation for each target. +FROM --platform=$BUILDPLATFORM ubuntu:focal AS builder #------------------------------------------------------------------------------ # Install system utilities / prereqs. @@ -59,15 +68,19 @@ RUN export FLUTTER=flutter_linux_$(jq -r '.flutter' /tmp/.fvmrc)-stable.tar.xz & COPY . /app #------------------------------------------------------------------------------ -# Build flutter web app +# Build flutter web app. '--no-web-resources-cdn' bundles CanvasKit rather +# than loading it from Google's CDN. Text fonts still come from Google Fonts +# (the engine's 'fontFallbackBaseUrl'): the build bundles only MaterialIcons. #------------------------------------------------------------------------------ +ARG FLUTTER_BUILD_ARGS="--release --no-tree-shake-icons --no-web-resources-cdn" + RUN cd /app && \ export FLUTTER=/opt/flutter/bin/flutter && \ git config --global --add safe.directory /opt/flutter && \ $FLUTTER --disable-analytics && \ $FLUTTER clean && \ $FLUTTER pub get && \ - $FLUTTER build web --release --no-tree-shake-icons + $FLUTTER build web $FLUTTER_BUILD_ARGS #------------------------------------------------------------------------------ # Optionally cache-bust the web assets. The hash has to come in as a build arg: @@ -80,6 +93,18 @@ RUN if [ -n "$RELEASE_HASH" ]; then \ /app/scripts/post-build-cache-bust.sh /app/build/web; \ fi +############################################################################### +# Web stage — only the built web tree, at /build/web +############################################################################### +# Published as ghcr.io/soliplex/frontend-web, for images that serve the client +# with their own web server: +# +# COPY --from=ghcr.io/soliplex/frontend-web: /build/web +# +FROM scratch AS web + +COPY --from=builder /app/build/web /build/web + ############################################################################### # Dev stage — flutter web dev server with hot reload ###############################################################################