# 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.) - &image_build_when - event: tag - event: [push, manual] branch: main path: exclude: - 'packages/mosaic/**' - 'docs/**' - '**/*.md' - '.woodpecker/**' - event: [push, manual] branch: next - &main_image_build_when - event: tag - event: [push, manual] branch: main path: exclude: - 'packages/mosaic/**' - 'docs/**' - '**/*.md' - '.woodpecker/**' 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 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 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 // -next. 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: *main_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" = "main" ]; then DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest" 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: *main_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" = "main" ]; then DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest" 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