diff --git a/.dockerignore b/.dockerignore index 06b6abd8d..2f5f061a7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ node_modules **/.next **/.turbo **/dist +**/*.tsbuildinfo .env .env.local .env*.local diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..f2b22cf27 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,64 @@ +name: Docker + +on: + workflow_dispatch: + inputs: + version: + description: "Existing editor release version (without the package prefix)" + required: true + type: string + publish: + description: "Publish to GHCR (otherwise build only)" + default: false + type: boolean + +concurrency: + group: docker-publication + cancel-in-progress: false + +jobs: + image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Validate version + env: + VERSION: ${{ inputs.version }} + run: | + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] || [[ ${#VERSION} -gt 128 ]]; then + echo 'Expected an editor release version suitable for a Docker tag.' >&2 + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: refs/tags/@pascal-app/editor@${{ inputs.version }} + persist-credentials: false + + - name: Verify release version + env: + VERSION: ${{ inputs.version }} + run: jq -e --arg version "$VERSION" '.version == $version' packages/editor/package.json + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: inputs.publish + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build versioned image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ inputs.publish }} + tags: ghcr.io/${{ github.repository }}:${{ inputs.version }} + labels: org.opencontainers.image.source=https://github.com/${{ github.repository }} diff --git a/Dockerfile b/Dockerfile index 11741c831..9583f072a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # Matches `packageManager` in package.json and the version CI installs — a skew # here is what makes `--frozen-lockfile` fail inside the image but not locally. FROM oven/bun:1.3.14-alpine +LABEL org.opencontainers.image.source="https://github.com/pascalorg/editor" WORKDIR /app # `next build` runs under `node`, and this image's `node` is a shim that re-execs diff --git a/SETUP.md b/SETUP.md index 837ed17e7..7ea612055 100644 --- a/SETUP.md +++ b/SETUP.md @@ -39,8 +39,9 @@ Local development and the official hosted editor work without any environment va docker compose up -d ``` -The editor will be running at **http://localhost:3000**. Saved scenes live in -the `pascal-data` volume, so they survive `docker compose down`. +The editor will be running at **http://localhost:3000**. Compose builds from source; +it does not depend on a GHCR package being available. Saved scenes live in the +`pascal-data` volume, so they survive `docker compose down`. Docker defaults `MINT_PASCAL_HOST_ORIGIN` to `http://localhost:3000`. Override it when hosting Pascal at another origin: @@ -54,6 +55,39 @@ a base URL that only `NEXT_PUBLIC_APP_URL` can override, and Next inlines that value at build time, so remapping the port to something else makes the page return 500. +### GHCR bootstrap (maintainers) + +Docker publication is separate from the npm release workflow. After a successful +editor release, run the **Docker** workflow with the existing version (for example, +`1.0.0-beta.5`). It checks out `@pascal-app/editor@` and builds amd64 and +arm64 images. Leave `publish` disabled for a build-only check, then enable it to +publish `ghcr.io//:`. It never updates `latest`. + +Before switching the default Compose configuration in a follow-up PR: + +1. Publish a version from a completed editor release. +2. In the organization's Packages settings, connect the package to `pascalorg/editor` + and set its visibility to **Public** (a one-time maintainer action). +3. Using a Docker configuration without registry credentials, inspect the manifest + and pull both platforms. Replace `` with the published version: + + ```bash + docker buildx imagetools inspect ghcr.io/pascalorg/editor: + docker pull --platform linux/amd64 ghcr.io/pascalorg/editor: + docker pull --platform linux/arm64 ghcr.io/pascalorg/editor: + ``` + +4. Smoke-test startup and scene persistence on both architectures and record the + version, digest, and results in the follow-up PR. The maintainer must approve + promotion to `latest` and the default-Compose transition separately. + +If a Docker build or push fails, npm releases and source-based Compose remain +unchanged. Inspect GHCR for a partially uploaded version before retrying the same +release tag; do not rerun npm publishing or move the Git tag. For a bad image, keep +users on the source build or a previously verified image digest, and publish a +corrected release version rather than silently replacing an image users may have +pinned. Registry cleanup and any future `latest` rollback require maintainer approval. + ## CLI-managed editor Node.js 22.13 or newer can install a persistent local runtime, start it in the diff --git a/scripts/docker-workflow.test.rb b/scripts/docker-workflow.test.rb new file mode 100644 index 000000000..83715e6f0 --- /dev/null +++ b/scripts/docker-workflow.test.rb @@ -0,0 +1,31 @@ +require 'yaml' +require 'open3' + +Dir.chdir(File.expand_path('..', __dir__)) do + workflow = YAML.load_file('.github/workflows/docker.yml') + steps = workflow.fetch('jobs').fetch('image').fetch('steps') + validation = steps.find { |step| step['name'] == 'Validate version' }.fetch('run') + { + '1.0.0' => true, + '1.0.0-beta.5' => true, + 'main' => false, + '../main' => false, + '1.0.0;echo bad' => false, + '' => false, + "1.0.0-#{'a' * 128}" => false + }.each do |version, expected| + _, status = Open3.capture2e({'VERSION' => version}, 'bash', '-e', '-c', validation) + raise "Incorrect version validation: #{version}" unless status.success? == expected + end + + build = steps.find { |step| step['uses'] == 'docker/build-push-action@v6' }.fetch('with') + raise 'Missing architecture' unless build['platforms'] == 'linux/amd64,linux/arm64' + raise 'Publication must be opt-in' unless build['push'] == '${{ inputs.publish }}' + raise 'Do not promote latest during bootstrap' if build['tags'].include?('latest') + checkout = steps.find { |step| step['uses'] == 'actions/checkout@v4' }.fetch('with') + raise 'Build a release tag' unless checkout['ref'] == 'refs/tags/@pascal-app/editor@${{ inputs.version }}' + compose = YAML.load_file('docker-compose.yml').fetch('services').fetch('editor') + raise 'Keep source builds working' unless compose['build'] == '.' && !compose.key?('image') + raise 'Keep npm releases independent' if File.read('.github/workflows/release.yml').include?('docker/') + puts 'PASS: Docker bootstrap workflow checks' +end