# Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images
#
# SDLC-D-034 publish gate: every publish effect (publish-npm, publish-next-npm,
# and every image build/push step) depends DIRECTLY on the `verify` step below.
# `verify` (a) asserts the provider's commit identity matches the actual
# checkout (CI_COMMIT_SHA == git rev-parse HEAD, fail closed on mismatch or
# emptiness) and (b) runs the canonical terminal verification command
# (`pnpm verify:release`), which mirrors the PR CI pipeline's complete
# mandatory set (sanitization, upgrade-guard, preflight+typecheck, lint,
# format:check, test, build) — see scripts/verify-release.mjs. A missing,
# failed, skipped, cancelled, or inconclusive verification therefore skips the
# dependent publish effects (fail closed). Path-filtered short-circuits may
# skip publish EFFECTS (e.g. docs-only merges) but never bypass `verify` for a
# publish that does run: `verify` itself carries no path filter.
# scripts/verify-release.test.mjs enforces this DAG invariant at checkout time.

variables:
  # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
  # toolchain + warm pnpm store. Kills the second cold install publish pays.
  # PINNED to the immutable lock-tag, not :latest (#1328, brain D27): a mutable
  # tag resolves per-pod at pull time on the k8s backend and made CI verdicts
  # non-reproducible (#1324). Byte-identical to :latest at pin time (pushed
  # atomically by the same kaniko run, main 712c770, 2026-07-26). Bump only via
  # reviewed PR, per the procedure in .woodpecker/ci.yml's header comment.
  - &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-9cb7ffcd8828'
  - &enable_pnpm 'corepack enable'
  # Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
  # the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
  # images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
  # (tags) always build everything. Exclude-list keeps the default SAFE: any
  # non-excluded change still builds, so no transitive dep can silently go stale.
  # (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
  # the separate `event: tag` entry.)
  # #1407: ONE shared anchor for all three image steps. A second main-only
  # anchor previously gated build-web/build-appservice, so next-lane pushes
  # published gateway sha images with no web/appservice counterpart — no
  # sha-parity set existed for next-lane containerized deploys. Every image
  # step now builds on next too (sha-only destinations, enforced per step).
  - &image_build_when
    - event: tag
    - event: [push, manual]
      branch: main
      path:
        exclude:
          - 'packages/mosaic/**'
          - 'docs/**'
          - '**/*.md'
          - '.woodpecker/**'
    - event: [push, manual]
      branch: next

when:
  - branch: [main, next]
    event: [push, manual, tag]

steps:
  install:
    image: *node_image
    commands:
      - corepack enable
      # Resolve from the baked pnpm store instead of a cold network fetch.
      - pnpm install --frozen-lockfile --prefer-offline

  # SDLC-D-034 exact-commit publish gate. No `when`/path filter on purpose: it
  # runs for every event this pipeline serves so no publish effect can ever
  # start without it. Fails closed on commit-identity mismatch (or either SHA
  # being empty) and on any incomplete verification.
  verify:
    image: *node_image
    environment:
      # Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
      # secret, exposed only on trusted events (push/tag/cron/manual/deployment).
      TURBO_API: https://turbo.mosaicstack.dev
      TURBO_TEAM: mosaic
      TURBO_TOKEN:
        from_secret: turbo_token
    commands:
      - *enable_pnpm
      # (a) Commit identity: the provider's claimed SHA must equal the actual
      # checkout HEAD — verification of anything else must never authorize a
      # publish of this commit.
      - |
        if [ -z "$CI_COMMIT_SHA" ]; then
          echo "[verify] FATAL: CI_COMMIT_SHA is empty — cannot certify commit identity" >&2
          exit 1
        fi
        CHECKOUT_SHA="$(git rev-parse HEAD 2>/dev/null || true)"
        if [ -z "$CHECKOUT_SHA" ]; then
          echo "[verify] FATAL: git rev-parse HEAD returned nothing — cannot certify commit identity" >&2
          exit 1
        fi
        if [ "$CI_COMMIT_SHA" != "$CHECKOUT_SHA" ]; then
          echo "[verify] FATAL: provider commit ($CI_COMMIT_SHA) != checkout HEAD ($CHECKOUT_SHA)" >&2
          exit 1
        fi
        echo "[verify] commit identity confirmed: $CHECKOUT_SHA"
      # (b) Canonical terminal verification. Caller-provided prerequisites the
      # runner expects (see .woodpecker/ci.yml comments): bash/rsync for the
      # guard stages, openssl + the pinned pi binary for the test stage. git is
      # baked into ci-base but re-asserted here so the identity check above can
      # never silently depend on a stale baked image. DATABASE_URL is
      # deliberately NOT set: the canonical command must hold on the PGlite
      # path too and never sets or requires a database itself.
      - apk add --no-cache bash rsync openssl git
      - npm install -g @earendil-works/pi-coding-agent@0.84.1
      - pnpm verify:release
    depends_on:
      - install

  build:
    image: *node_image
    environment:
      # Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
      # secret, exposed only on trusted events (push/tag/cron/manual/deployment).
      TURBO_API: https://turbo.mosaicstack.dev
      TURBO_TEAM: mosaic
      TURBO_TOKEN:
        from_secret: turbo_token
    commands:
      - *enable_pnpm
      - pnpm build
    depends_on:
      - install
      - verify

  publish-npm:
    image: *node_image
    # Publish only when a publishable package changed (or on a release tag); a
    # pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
    when:
      - event: tag
      - event: [push, manual]
        branch: main
        path:
          include:
            - 'packages/**'
    environment:
      NPM_TOKEN:
        from_secret: gitea_token
    commands:
      - *enable_pnpm
      # Configure auth for Gitea npm registry
      - |
        echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
        echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
      # Publish non-private packages to Gitea.
      #
      # The only publish failure we tolerate is "version already exists" —
      # that legitimately happens when only some packages were bumped in
      # the merge. Any other failure (registry 404, auth error, network
      # error) MUST fail the pipeline loudly: the previous
      # `|| echo "... continuing"` fallback silently hid a 404 from the
      # Gitea org rename and caused every @mosaicstack/* publish to fall
      # on the floor while CI still reported green.
      - |
        # Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
        set +e
        pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
        EXIT=$?
        set -e
        cat /tmp/publish.log
        if [ "$EXIT" -eq 0 ]; then
          echo "[publish] all packages published successfully"
          exit 0
        fi
        # Hard registry / auth / network errors → fatal. Match npm's own
        # error lines specifically to avoid false positives on arbitrary
        # log text that happens to contain "E404" etc.
        if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
          echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
          exit 1
        fi
        # Only tolerate the explicit "version already published" case.
        # npm returns this as E403 with body "You cannot publish over..."
        # or EPUBLISHCONFLICT depending on version.
        if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
          echo "[publish] some packages already at this version — continuing (non-fatal)"
          exit 0
        fi
        echo "[publish] FATAL: publish failed with unrecognized error — failing pipeline" >&2
        exit 1
    depends_on:
      - build
      - verify

  publish-next-npm:
    image: *node_image
    # Durable @next integration-line publish. Runs only on next; never writes
    # the latest dist-tag and never commits the computed prerelease versions.
    when:
      - event: [push, manual]
        branch: next
    environment:
      NPM_TOKEN:
        from_secret: gitea_token
      CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
      CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
    commands:
      - *enable_pnpm
      - |
        if [ "$CI_COMMIT_BRANCH" != "next" ]; then
          echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
          exit 1
        fi
        if [ -z "$CI_PIPELINE_NUMBER" ]; then
          echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
          exit 1
        fi
        echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
        echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
        DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
        DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
        # #1404: snapshot every publishable manifest BEFORE the transform so the
        # workspace can be restored byte-exact after publish. The transform
        # rewrites package.json in place (needed: pnpm publish reads the
        # workspace manifests); without restore, later steps in this pipeline
        # (build-gateway kaniko COPY + pnpm install --frozen-lockfile) see
        # manifests that no longer match pnpm-lock.yaml and fail
        # ERR_PNPM_OUTDATED_LOCKFILE. Snapshot dir is step-local tmp.
        SNAPSHOT_DIR="$(mktemp -d /tmp/publish-next-manifests.XXXXXX)"
        export SNAPSHOT_DIR
        find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*" | while read -r mf; do
          mkdir -p "$SNAPSHOT_DIR/$(dirname "$mf")"
          cp -p "$mf" "$SNAPSHOT_DIR/$mf"
        done
        echo "[publish-next] snapshotted $(find "$SNAPSHOT_DIR" -name package.json | wc -l) manifests to $SNAPSHOT_DIR"
        node <<'NODE'
        const fs = require('node:fs');
        const path = require('node:path');

        const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
        const roots = ['apps', 'packages', 'plugins'];
        const updated = [];
        const exactVersions = new Map(); // name -> bumped next version

        function walk(dir, visit) {
          if (!fs.existsSync(dir)) return;
          for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
            if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
            const fullPath = path.join(dir, entry.name);
            if (entry.isDirectory()) {
              const packagePath = path.join(fullPath, 'package.json');
              if (fs.existsSync(packagePath)) {
                const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
                if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
                  visit(manifest, packagePath);
                }
              }
              walk(fullPath, visit);
            }
          }
        }

        // #1389: two passes. Pass 1 bumps every publishable manifest to
        // <stable+1>-next.<pipeline> exactly as before, recording name ->
        // bumped version. Pass 2 rewrites every published manifest's
        // @mosaicstack/* dependency entries (dependencies, devDependencies,
        // peerDependencies, optionalDependencies) to the EXACT same-pipeline
        // build. A caret range like ^0.0.3-next.2636 leaves the resolver free
        // to pick any later build — and on a host with a stale cache, an
        // installer-side scaffold pinned at stable, or a registry hiccup, that
        // freedom is how a "next" install ends up executing stable-era code
        // (web1 evidence: old tier validator, missing migrations). Exact pins
        // make the defect class unrepresentable regardless of resolver path.
        function bump(manifest, packagePath) {
          const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
          if (!stableMatch) {
            throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
          }
          const [, major, minor, patch] = stableMatch;
          const oldVersion = manifest.version;
          manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
          fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
          exactVersions.set(manifest.name, manifest.version);
          updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
        }

        const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
        let pinnedEntries = 0;
        function pin(manifest, packagePath) {
          let changed = false;
          for (const field of DEP_FIELDS) {
            const deps = manifest[field];
            if (!deps || typeof deps !== 'object') continue;
            for (const [name, range] of Object.entries(deps)) {
              if (!name.startsWith('@mosaicstack/')) continue;
              const exact = exactVersions.get(name);
              if (!exact) {
                throw new Error(
                  manifest.name + ' depends on ' + name +
                  ' which has no bumped version in this publish set — cannot pin');
              }
              if (range === exact) continue;
              deps[name] = exact;
              pinnedEntries++;
              changed = true;
            }
          }
          if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
        }

        for (const root of roots) walk(root, bump);
        for (const root of roots) walk(root, pin);
        if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
        console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
        for (const line of updated) console.log('[publish-next] ' + line);
        console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests');
        NODE
        pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
        EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
        RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
        if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
          echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
          exit 1
        fi
        echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
        # #1389 post-publish guard: every freshly published manifest must carry
        # EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable
        # fallback). A leak here fails the pipeline instead of shipping.
        node <<'GUARD'
        const { execFileSync } = require('node:child_process');
        const fs = require('node:fs');
        const path = require('node:path');
        const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
        const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
        const roots = ['apps', 'packages', 'plugins'];
        const published = [];
        function walk(dir) {
          if (!fs.existsSync(dir)) return;
          for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
            if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
            const fullPath = path.join(dir, entry.name);
            if (entry.isDirectory()) {
              const packagePath = path.join(fullPath, 'package.json');
              if (fs.existsSync(packagePath)) {
                const m = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
                if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name);
              }
              walk(fullPath);
            }
          }
        }
        for (const root of roots) walk(root);
        let failures = 0;
        for (const name of published) {
          let manifest;
          try {
            const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry],
                                     { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
            const arr = JSON.parse(out);
            manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr;
          } catch (e) {
            console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message);
            failures++;
            continue;
          }
          const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
          for (const field of fields) {
            const deps = manifest[field];
            if (!deps || typeof deps !== 'object') continue;
            for (const [dep, range] of Object.entries(deps)) {
              if (!dep.startsWith('@mosaicstack/')) continue;
              const expected = dep === name ? manifest.version : null;
              const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range);
              const samePipeline = range.endsWith('-next.' + pipelineNumber);
              if (!isExactPin) {
                console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)');
                failures++;
              } else if (!samePipeline) {
                console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)');
                failures++;
              }
            }
          }
        }
        if (failures > 0) {
          console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)');
          process.exit(1);
        }
        console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins');
        GUARD
        # #1404 restore: put the workspace manifests back byte-exact so later
        # steps (build-gateway frozen-lockfile install) see the committed tree.
        RESTORE_FAIL=0
        while read -r mf; do
          if [ -f "$SNAPSHOT_DIR/$mf" ]; then
            cp -p "$SNAPSHOT_DIR/$mf" "$mf"
          else
            echo "[publish-next] FATAL: no snapshot for $mf — cannot restore (snapshot incomplete?)" >&2
            RESTORE_FAIL=1
          fi
        done < <(find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*")
        # Pristine guard (#1404 red-first control): the publish step must leave
        # the workspace byte-identical to the checkout for every manifest.
        # git diff is the arbiter — any residual mutation fails THIS step
        # instead of surfacing as ERR_PNPM_OUTDATED_LOCKFILE in build-gateway.
        if ! git diff --exit-code -- '**/package.json' >/dev/null 2>&1; then
          echo "[publish-next] FATAL: workspace package.json files still differ from HEAD after restore (#1404 class)" >&2
          git diff --stat -- '**/package.json' >&2 || true
          RESTORE_FAIL=1
        fi
        rm -rf "$SNAPSHOT_DIR"
        if [ "$RESTORE_FAIL" -ne 0 ]; then exit 1; fi
        echo "[publish-next] workspace manifests restored byte-exact (git diff clean); later steps see the committed tree"
    depends_on:
      - build
      - verify

  # TODO: Uncomment when ready to publish to npmjs.org
  # publish-npmjs:
  #   image: *node_image
  #   environment:
  #     NPM_TOKEN:
  #       from_secret: npmjs_token
  #   commands:
  #     - *enable_pnpm
  #     - apk add --no-cache jq bash
  #     - bash scripts/publish-npmjs.sh
  #   depends_on:
  #     - build
  #     - verify
  #   when:
  #     - event: [tag]

  build-gateway:
    image: gcr.io/kaniko-project/executor:debug
    when: *image_build_when
    environment:
      REGISTRY_USER:
        from_secret: REGISTRY_USERNAME
      REGISTRY_PASS:
        from_secret: REGISTRY_PASSWORD
      CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
      CI_COMMIT_TAG: ${CI_COMMIT_TAG}
      CI_COMMIT_SHA: ${CI_COMMIT_SHA}
    commands:
      - mkdir -p /kaniko/.docker
      - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
      - |
        DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
        if [ "$CI_COMMIT_BRANCH" = "next" ]; then
          if [ -n "$CI_COMMIT_TAG" ]; then
            echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
            exit 1
          fi
          echo "[publish] next gateway publish is sha-only"
        elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
          DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
        elif [ -z "$CI_COMMIT_TAG" ]; then
          echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
          exit 1
        fi
        if [ -n "$CI_COMMIT_TAG" ]; then
          DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
        fi
        /kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
    depends_on:
      - build
      - verify
      # #1411: publish-next-npm mutates workspace manifests in place during
      # its transform window and restores them at step end. Any step that
      # reads the pipeline workspace (kaniko COPY of manifests, later
      # installs) must run AFTER publish-next-npm, never concurrently —
      # pipeline 2648 raced a COPY inside the window and failed
      # ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
      # serialization invariant; add it to every new workspace consumer.
      - publish-next-npm

  build-appservice:
    image: gcr.io/kaniko-project/executor:debug
    when: *image_build_when
    environment:
      REGISTRY_USER:
        from_secret: REGISTRY_USERNAME
      REGISTRY_PASS:
        from_secret: REGISTRY_PASSWORD
      CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
      CI_COMMIT_TAG: ${CI_COMMIT_TAG}
      CI_COMMIT_SHA: ${CI_COMMIT_SHA}
    commands:
      - mkdir -p /kaniko/.docker
      - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
      - |
        DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
        if [ "$CI_COMMIT_BRANCH" = "next" ]; then
          if [ -n "$CI_COMMIT_TAG" ]; then
            echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
            exit 1
          fi
          echo "[publish] next appservice publish is sha-only"
        elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
          DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
        elif [ -z "$CI_COMMIT_TAG" ]; then
          echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
          exit 1
        fi
        if [ -n "$CI_COMMIT_TAG" ]; then
          DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
        fi
        /kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
    depends_on:
      - build
      - verify
      # #1411: publish-next-npm mutates workspace manifests in place during
      # its transform window and restores them at step end. Any step that
      # reads the pipeline workspace (kaniko COPY of manifests, later
      # installs) must run AFTER publish-next-npm, never concurrently —
      # pipeline 2648 raced a COPY inside the window and failed
      # ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
      # serialization invariant; add it to every new workspace consumer.
      - publish-next-npm

  build-web:
    image: gcr.io/kaniko-project/executor:debug
    when: *image_build_when
    environment:
      REGISTRY_USER:
        from_secret: REGISTRY_USERNAME
      REGISTRY_PASS:
        from_secret: REGISTRY_PASSWORD
      CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
      CI_COMMIT_TAG: ${CI_COMMIT_TAG}
      CI_COMMIT_SHA: ${CI_COMMIT_SHA}
    commands:
      - mkdir -p /kaniko/.docker
      - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
      - |
        DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
        if [ "$CI_COMMIT_BRANCH" = "next" ]; then
          if [ -n "$CI_COMMIT_TAG" ]; then
            echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
            exit 1
          fi
          echo "[publish] next web publish is sha-only"
        elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
          DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
        elif [ -z "$CI_COMMIT_TAG" ]; then
          echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
          exit 1
        fi
        if [ -n "$CI_COMMIT_TAG" ]; then
          DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
        fi
        /kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
    depends_on:
      - build
      - verify
      # #1411: publish-next-npm mutates workspace manifests in place during
      # its transform window and restores them at step end. Any step that
      # reads the pipeline workspace (kaniko COPY of manifests, later
      # installs) must run AFTER publish-next-npm, never concurrently —
      # pipeline 2648 raced a COPY inside the window and failed
      # ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
      # serialization invariant; add it to every new workspace consumer.
      - publish-next-npm
