diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index cdc84d0..cce3580 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -1,5 +1,5 @@ # Build, publish npm packages, and push Docker images -# Runs only on main branch push/tag +# Runs on main for stable publishes and on next for integration-line prereleases/images variables: # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine + @@ -23,9 +23,21 @@ variables: - '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] + - branch: [main, next] event: [push, manual, tag] steps: @@ -103,6 +115,84 @@ steps: depends_on: - build + 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);' + 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 = []; + + 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)) updatePackage(packagePath); + walk(fullPath); + } + } + } + + function updatePackage(packagePath) { + const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return; + 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'); + updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version); + } + + for (const root of roots) walk(root); + 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); + 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" + depends_on: + - build + # TODO: Uncomment when ready to publish to npmjs.org # publish-npmjs: # image: *node_image @@ -134,8 +224,17 @@ steps: - 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" = "main" ]; then + 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" @@ -146,7 +245,7 @@ steps: build-appservice: image: gcr.io/kaniko-project/executor:debug - when: *image_build_when + when: *main_image_build_when environment: REGISTRY_USER: from_secret: gitea_username @@ -172,7 +271,7 @@ steps: build-web: image: gcr.io/kaniko-project/executor:debug - when: *image_build_when + when: *main_image_build_when environment: REGISTRY_USER: from_secret: gitea_username diff --git a/docs/guides/dev-guide.md b/docs/guides/dev-guide.md index b44766a..936c86a 100644 --- a/docs/guides/dev-guide.md +++ b/docs/guides/dev-guide.md @@ -211,6 +211,17 @@ pnpm format:check && pnpm typecheck && pnpm lint A pre-push hook enforces this mechanically. +### CI Publish Channels + +Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate: + +| Source | npm packages | Gateway image | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-` plus `gateway:latest` on `main`, and the release tag on tag events | +| `next` push/manual | CI-computed prereleases, `-next.`, published with `npm publish --tag next` | `gateway:sha-` only | + +`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass. + --- ## Adding New Agent Tools diff --git a/docs/scratchpads/B1-next-durable-publish-design.md b/docs/scratchpads/B1-next-durable-publish-design.md new file mode 100644 index 0000000..56da745 --- /dev/null +++ b/docs/scratchpads/B1-next-durable-publish-design.md @@ -0,0 +1,82 @@ +# B1 / @next Durable Publish Pipeline — Design + +## Objective + +Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds. + +Every merge to `next` publishes: + +1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`. +2. **Gateway container image** tagged only as `gateway:sha-`. + +The existing stable release behavior remains isolated to `main` / tags. + +## Registry verification + +Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`. + +Pre-implementation checks: + +- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`). +- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`. +- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish. + +Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed. + +## Version scheme + +The prerelease version is computed at publish time only; no `package.json` version changes are committed. + +For each non-private `@mosaicstack/*` package: + +```text +-next. +``` + +Where: + +- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number. +- `target-stable` is the package's current committed stable version with the patch component incremented. + - Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`. + - Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`. + +Rationale: + +- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`. +- The prerelease does not overtake the future stable `0.0.49`. +- The monotonic pipeline number avoids conflicts across repeated `next` merges. + +## Branch and tag guardrails + +| Pipeline path | Branch/event | Publishes | Forbidden | +| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- | +| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag | +| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag | +| gateway image | `main` push/manual or tag | `sha-` + `latest` on main + tag on tag events | next prerelease npm | +| gateway image | `next` push/manual only | `sha-` only | `latest` | +| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication | + +The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses. + +## Implementation plan + +1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges. +2. Keep existing `publish-npm` on `main` / tags only. +3. Add `publish-next-npm` for `next` push/manual only: + - configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`; + - preflight registry dist-tag metadata; + - compute prerelease versions in CI by temporarily editing package manifests in the workspace; + - run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages; + - verify `@mosaicstack/mosaic@next` resolves to the computed version. +4. Split image `when` anchors: + - `image_build_when` includes `next` and is used by `build-gateway`; + - `main_image_build_when` keeps appservice/web on main/tags only. +5. Keep gateway next image destinations to `sha-` only; no `latest` on next. + +## Risk controls + +- Auth/registry failures are fatal. +- No manual image build/push path is introduced. +- No production `latest` tags are touched from `next`. +- No `@latest` npm dist-tags are touched from `next`. +- All changes live in CI config and docs; no runtime source behavior changes.