From c1f60d9507f275297d1d98e2c2b4607032e0c703 Mon Sep 17 00:00:00 2001 From: luisalrp Date: Mon, 7 Sep 2026 15:18:19 +0200 Subject: [PATCH 1/4] ci(docker): publish editor image to ghcr --- .github/workflows/docker.yml | 36 ++++++++++++++++++++++++++++++++++++ SETUP.md | 10 ++++++++-- docker-compose.yml | 2 +- 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000..90259ab84b --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,36 @@ +name: Docker + +on: + push: + tags: + - '@pascal-app/editor@*' + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract release version + id: version + run: echo "version=${GITHUB_REF_NAME##*@}" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/pascalorg/editor:latest + ghcr.io/pascalorg/editor:${{ steps.version.outputs.version }} diff --git a/SETUP.md b/SETUP.md index 80c33bc024..a2343c22ba 100644 --- a/SETUP.md +++ b/SETUP.md @@ -34,8 +34,14 @@ 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 pulls the official +image from GHCR; set `IMAGE_TAG` to use a release instead of `latest`: + +```bash +IMAGE_TAG=1.0.0 docker compose up -d +``` + +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: diff --git a/docker-compose.yml b/docker-compose.yml index 55a169220a..9ae0eef885 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: editor: - build: . + image: ghcr.io/pascalorg/editor:${IMAGE_TAG:-latest} ports: # Keep the container port at 3000. `/scenes` fetches its own API through a # base URL that only `NEXT_PUBLIC_APP_URL` can override, and Next inlines From b10b01de045c1e1b2fa7c7cc19eea1d3cfcaa59f Mon Sep 17 00:00:00 2001 From: luisalrp Date: Mon, 7 Sep 2026 15:33:22 +0200 Subject: [PATCH 2/4] fix(ci): publish Docker image with releases --- .github/workflows/docker.yml | 36 ----------------------------------- .github/workflows/release.yml | 32 +++++++++++++++++++++++++++++++ Dockerfile | 1 + SETUP.md | 5 +++++ 4 files changed, 38 insertions(+), 36 deletions(-) delete mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 90259ab84b..0000000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Docker - -on: - push: - tags: - - '@pascal-app/editor@*' - -permissions: - contents: read - packages: write - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Extract release version - id: version - run: echo "version=${GITHUB_REF_NAME##*@}" >> "$GITHUB_OUTPUT" - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and publish image - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: | - ghcr.io/pascalorg/editor:latest - ghcr.io/pascalorg/editor:${{ steps.version.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 560eaeda86..4e8fb00ab0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,7 @@ jobs: environment: npm permissions: contents: write + packages: write steps: - uses: actions/checkout@v4 with: @@ -292,6 +293,37 @@ jobs: echo "📦 Published @pascal-app/editor@$EDITOR_VERSION" fi + - name: Prepare Docker image tags + if: inputs.package == 'editor' || inputs.package == 'all' + id: docker-tags + env: + BUMP: ${{ inputs.bump }} + run: | + { + echo 'tags<> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + if: (inputs.package == 'editor' || inputs.package == 'all') && inputs.dry-run == false + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & publish Docker image + if: inputs.package == 'editor' || inputs.package == 'all' + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ inputs.dry-run == false }} + tags: ${{ steps.docker-tags.outputs.tags }} + - name: Build & publish nodes if: inputs.package == 'nodes' || inputs.package == 'all' working-directory: packages/nodes diff --git a/Dockerfile b/Dockerfile index 11741c8318..9583f072a4 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 a2343c22ba..070eee0c3b 100644 --- a/SETUP.md +++ b/SETUP.md @@ -41,6 +41,11 @@ image from GHCR; set `IMAGE_TAG` to use a release instead of `latest`: IMAGE_TAG=1.0.0 docker compose up -d ``` +The `editor` package must be public in the `pascalorg` organization's GHCR packages +for unauthenticated deployments. After the first release, open the package settings +under the organization's Packages page, set its visibility to **Public**, and connect +it to `pascalorg/editor`. Maintainers only need to configure this once. + 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 From fdd0475720fc681853b3c59c6b36a0d758b56565 Mon Sep 17 00:00:00 2001 From: luisalrp Date: Mon, 7 Sep 2026 15:47:49 +0200 Subject: [PATCH 3/4] fix(ci): publish stable Docker tags and ARM images --- .github/workflows/release.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e8fb00ab0..595340770f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -302,9 +302,11 @@ jobs: { echo 'tags<> "$GITHUB_OUTPUT" @@ -316,11 +318,20 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + if: inputs.package == 'editor' || inputs.package == 'all' + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + if: inputs.package == 'editor' || inputs.package == 'all' + uses: docker/setup-buildx-action@v3 + - name: Build & publish Docker image if: inputs.package == 'editor' || inputs.package == 'all' uses: docker/build-push-action@v6 with: context: . + platforms: linux/amd64,linux/arm64 push: ${{ inputs.dry-run == false }} tags: ${{ steps.docker-tags.outputs.tags }} From bdc886a5b0cfa372f5e8c5ba04c6deedb60001bd Mon Sep 17 00:00:00 2001 From: luisalrp Date: Sat, 12 Sep 2026 19:02:44 +0200 Subject: [PATCH 4/4] fix(docker): preserve source builds during registry bootstrap --- .dockerignore | 1 + .github/workflows/docker.yml | 64 +++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 43 ---------------------- SETUP.md | 49 ++++++++++++++++++------- docker-compose.yml | 2 +- scripts/docker-workflow.test.rb | 31 ++++++++++++++++ 6 files changed, 133 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/docker.yml create mode 100644 scripts/docker-workflow.test.rb diff --git a/.dockerignore b/.dockerignore index 06b6abd8d5..2f5f061a77 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 0000000000..f2b22cf278 --- /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/.github/workflows/release.yml b/.github/workflows/release.yml index 1055dea41a..3242c33854 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,6 @@ jobs: permissions: contents: write id-token: write - packages: write env: # Verbose npm logs show the OIDC token exchange and the registry's # rejection reason when trusted publishing is misconfigured; tokens are @@ -280,48 +279,6 @@ jobs: echo "📦 Published @pascal-app/editor@$EDITOR_VERSION" fi - - name: Prepare Docker image tags - if: inputs.package == 'editor' || inputs.package == 'all' - id: docker-tags - env: - BUMP: ${{ inputs.bump }} - run: | - { - echo 'tags<> "$GITHUB_OUTPUT" - - - name: Log in to GHCR - if: (inputs.package == 'editor' || inputs.package == 'all') && inputs.dry-run == false - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up QEMU - if: inputs.package == 'editor' || inputs.package == 'all' - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - if: inputs.package == 'editor' || inputs.package == 'all' - uses: docker/setup-buildx-action@v3 - - - name: Build & publish Docker image - if: inputs.package == 'editor' || inputs.package == 'all' - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ inputs.dry-run == false }} - tags: ${{ steps.docker-tags.outputs.tags }} - - name: Build & publish nodes if: inputs.package == 'nodes' || inputs.package == 'all' working-directory: packages/nodes diff --git a/SETUP.md b/SETUP.md index c45b95acf6..7ea612055a 100644 --- a/SETUP.md +++ b/SETUP.md @@ -39,19 +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**. Compose pulls the official -image from GHCR; set `IMAGE_TAG` to use a release instead of `latest`: - -```bash -IMAGE_TAG=1.0.0 docker compose up -d -``` - -The `editor` package must be public in the `pascalorg` organization's GHCR packages -for unauthenticated deployments. After the first release, open the package settings -under the organization's Packages page, set its visibility to **Public**, and connect -it to `pascalorg/editor`. Maintainers only need to configure this once. - -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: @@ -65,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/docker-compose.yml b/docker-compose.yml index 9ae0eef885..55a169220a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: editor: - image: ghcr.io/pascalorg/editor:${IMAGE_TAG:-latest} + build: . ports: # Keep the container port at 3000. `/scenes` fetches its own API through a # base URL that only `NEXT_PUBLIC_APP_URL` can override, and Next inlines diff --git a/scripts/docker-workflow.test.rb b/scripts/docker-workflow.test.rb new file mode 100644 index 0000000000..83715e6f09 --- /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