diff --git a/.changeset/version-aware-image-tags.md b/.changeset/version-aware-image-tags.md new file mode 100644 index 000000000..debf885a8 --- /dev/null +++ b/.changeset/version-aware-image-tags.md @@ -0,0 +1,5 @@ +--- +"everything-dev": minor +--- + +Version-aware runtime image tags: `bos deploy` now pushes the exact `v` tag (and a floating `v` tag on stable releases) alongside `sha-`. `:latest` is held while the workspace version is a prerelease — the Docker analog of the npm `rc` dist-tag — so deployments pulling `:latest` keep serving the last stable image until a stable release moves it. diff --git a/.github/templates/workflows/ci.yml b/.github/templates/workflows/ci.yml index 29b218e7c..5cad1d5fd 100644 --- a/.github/templates/workflows/ci.yml +++ b/.github/templates/workflows/ci.yml @@ -1,5 +1,9 @@ name: CI +# Thin wrapper — the shared implementation lives in the base runtime repo +# (NEARBuilders/everything-dev). This repo owns triggers, secret policy, and +# working directory. The @main ref matches the sync trust model: bos sync +# already tracks the parent's main for all framework-owned files. on: push: branches: [main] @@ -10,70 +14,13 @@ on: pull_request: workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: - lint-and-typecheck: - name: Lint and Type Check - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) - - - name: Guard — no stray bun artifacts - run: | - if [ -f bun.lock ]; then - echo "::error::bun.lock present — bun re-migrated the workspace (ADR 0026). Delete bun.lock and re-run pnpm install." - exit 1 - fi - - - name: Setup pnpm - uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d # v4.1.0 - - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "24" - - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - - name: Security audit - id: audit - timeout-minutes: 5 - env: - AUDIT_STRICT: ${{ secrets.AUDIT_STRICT }} - run: | - set +e -o pipefail - timeout 240 pnpm audit --json 2>&1 | tee audit-output.txt - audit_exit=$? - if [ "$audit_exit" -eq 124 ]; then - echo "::warning::pnpm audit timed out after 240s — skipping vulnerability check" - exit 0 - fi - if [ "$audit_exit" -gt 1 ]; then - echo "::warning::pnpm audit exited with code $audit_exit — see output above" - exit 0 - fi - if grep -qiE '(critical|high)' audit-output.txt; then - if [ "$AUDIT_STRICT" = "true" ]; then - echo "::error::Critical or high vulnerabilities found — failing the audit" - exit 1 - else - echo "::warning::Critical or high vulnerabilities found — set AUDIT_STRICT=true secret to fail on these" - fi - fi - - - name: Run Biome lint and format check - run: pnpm exec biome ci . - - - name: Run typecheck - run: pnpm run typecheck - - - name: Run tests - run: pnpm run test + ci: + name: CI + uses: NEARBuilders/everything-dev/.github/workflows/consumer-ci.yml@main + with: + working_directory: . + secrets: inherit diff --git a/.github/templates/workflows/deploy.yml b/.github/templates/workflows/deploy.yml index 90e18dc81..f563931d0 100644 --- a/.github/templates/workflows/deploy.yml +++ b/.github/templates/workflows/deploy.yml @@ -1,5 +1,9 @@ name: Deploy +# Thin wrapper — the shared implementation lives in the base runtime repo +# (NEARBuilders/everything-dev). This repo owns triggers, secret policy, and +# working directory. The @main ref matches the sync trust model: bos sync +# already tracks the parent's main for all framework-owned files. on: workflow_run: workflows: [CI] @@ -12,47 +16,15 @@ on: type: boolean default: false -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - permissions: contents: read + packages: write jobs: deploy: - name: Deploy Production - if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} - BOS_STORAGE_API_KEY: ${{ secrets.BOS_STORAGE_API_KEY }} - RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) - with: - ref: ${{ github.event.workflow_run.head_sha || github.sha }} - - - name: Guard — no stray bun artifacts - run: | - if [ -f bun.lock ]; then - echo "::error::bun.lock present — bun re-migrated the workspace (ADR 0026). Delete bun.lock and re-run pnpm install." - exit 1 - fi - - - name: Setup pnpm - uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d # v4.1.0 - - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "24" - - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - - name: Deploy (build → upload → publish → image → Railway) - timeout-minutes: 25 - run: pnpm run bos deploy${{ inputs.verbose == true && ' --verbose' || '' }} + name: Deploy + uses: NEARBuilders/everything-dev/.github/workflows/consumer-deploy.yml@main + with: + working_directory: . + verbose: ${{ inputs.verbose == true }} + secrets: inherit diff --git a/.github/templates/workflows/staging.yml b/.github/templates/workflows/staging.yml index 217dd1b62..94874ce83 100644 --- a/.github/templates/workflows/staging.yml +++ b/.github/templates/workflows/staging.yml @@ -1,5 +1,9 @@ name: Staging +# Thin wrapper — the shared implementation lives in the base runtime repo +# (NEARBuilders/everything-dev). This repo owns triggers, secret policy, and +# working directory. The @main ref matches the sync trust model: bos sync +# already tracks the parent's main for all framework-owned files. on: push: branches: @@ -11,46 +15,15 @@ on: type: boolean default: false -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - permissions: contents: read + packages: write jobs: deploy-staging: name: Deploy Staging - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} - BOS_STORAGE_API_KEY: ${{ secrets.BOS_STORAGE_API_KEY }} - RAILWAY_TOKEN: ${{ secrets.RAILWAY_STAGING_TOKEN }} - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) - with: - ref: ${{ github.ref }} - - - name: Guard — no stray bun artifacts - run: | - if [ -f bun.lock ]; then - echo "::error::bun.lock present — bun re-migrated the workspace (ADR 0026). Delete bun.lock and re-run pnpm install." - exit 1 - fi - - - name: Setup pnpm - uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d # v4.1.0 - - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "24" - - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - - name: Deploy staging (build → upload → publish → image → Railway) - timeout-minutes: 25 - run: pnpm run bos deploy --env staging${{ inputs.verbose == true && ' --verbose' || '' }} + uses: NEARBuilders/everything-dev/.github/workflows/consumer-staging.yml@main + with: + working_directory: . + verbose: ${{ inputs.verbose == true }} + secrets: inherit diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ec799443b..657c3e1b5 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -2,16 +2,17 @@ ## Overview -This is the **base everything.dev runtime** — the source of truth for the published framework packages (`everything-dev`, `every-plugin`, `better-near-auth`), the universal runtime image, and the everything.dev app itself. +This repository is the base everything.dev runtime — the source of truth for the published framework packages (`everything-dev`, `every-plugin`, `better-near-auth`), the universal runtime image, and the everything.dev app itself. The reusable child-repo workflows (`consumer-*.yml`) live here too. -This repository uses the following workflows: +Workflows: -- `CI` — lint, audit, typecheck, framework tests, and regression -- `Deploy` — the full deploy train via `bos deploy` (build → bundle upload → FastKV publish → GHCR image push → Railway, production, triggered directly by CI) -- `Staging` — the same train against the staging env (`--env staging`, triggered by push to the `staging` branch) +- `CI` — lint, audit, typecheck, framework tests, and regression suites +- `Deploy` — production deployment via `bos deploy` (build → bundle upload → FastKV publish → GHCR image push → Railway; runs after CI succeeds on `main`) +- `Staging` — the same deployment against the staging environment (`--env staging`, triggered by push to the `staging` branch) - `Release` — changeset versioning and npm publish (runs on push to `main`, canonical changesets flow) +- `consumer-ci` / `consumer-deploy` / `consumer-staging` — reusable workflows for generated child repos (see [Consumer workflows](#consumer-workflows)) -The key design: `CI` is the validation workflow. On a successful push to `main`, the `Deploy` workflow triggers automatically via `workflow_run` — no dispatch token, no notify job, and it checks out the exact SHA that CI validated (`workflow_run.head_sha`). +Design: `CI` is the validation workflow. On a successful push to `main`, the `Deploy` workflow triggers via `workflow_run` — it deploys only fresh push-triggered CI runs whose commit is still the tip of `main`, and it checks out the exact SHA that CI validated (`workflow_run.head_sha`). `Staging` runs independently on push to the `staging` branch, deploying with the config's staging env (`staging.account`/`staging.domain` in `bos.app.ts`). @@ -21,27 +22,28 @@ The key design: `CI` is the validation workflow. On a successful push to `main`, **Trigger:** Push to `main` (with `paths-ignore` for markdown and changesets) or pull requests. Also `workflow_dispatch`. -**Purpose:** Lint, typecheck, security audit, framework tests, regression, and downstream notification. +**Purpose:** Lint, typecheck, security audit, framework tests, and regression suites. **Jobs:** -1. `detect-changes` — diffs against base to determine if `packages/everything-dev/` or `packages/every-plugin/` changed +1. `detect-changes` — diffs against the base to gate the test jobs by changed paths (includes the authored config files `bos.app.ts`/`bos.dev.ts` and `tsconfig.base.json`) 2. `lint-and-typecheck` — install, build, audit, lint, typecheck -3. `framework-tests` — runs `everything-dev` tests (only if `everything-dev` or `every-plugin` changed) -4. `plugin-tests` — runs `every-plugin` tests (only if `every-plugin` changed) -5. `host-tests` — runs the host test suite (only if `host/`, `ui/`, or packages changed) -6. `regression-unit` / `regression-http` / `regression-framework` / `regression-browser` — the regression stack legs (path-gated) +3. `framework-tests` — `everything-dev` tests (only if framework packages changed) +4. `plugin-tests` — `every-plugin` tests (only if `every-plugin` changed) +5. `host-tests` — host test suite (only if `host/`, `ui/`, or packages changed) +6. `regression-unit` / `regression-http` / `regression-framework` / `regression-browser` — the regression stack (path-gated) +7. `image-smoke` — builds the runtime image and boots it (path-gated) **Key design decisions:** - Generated types (`types:gen`) are produced on demand: `pnpm run typecheck` chains `types:gen` first, `bos dev`/`bos build`/`bos publish` regenerate via `generateCodeArtifacts` — no postinstall hook exists (it was dead code under `--ignore-scripts` installs). - `detect-changes` uses native `git diff` (no third-party action). For `workflow_dispatch`, all tests run unconditionally. - Playwright browsers are cached by `pnpm-lock.yaml` hash — cache hit only installs system deps (~10s), miss does full install (~60-90s). -- `cancel-in-progress: true` is safe for CI — cancelled runs never trigger Deploy (the `workflow_run` gate requires `conclusion == 'success'`). +- `cancel-in-progress: true` is safe for CI — cancelled runs never trigger Deploy (the `workflow_run` gate requires a fresh `push` event and a `success` conclusion). - Skipped jobs in `needs` are non-blocking for the workflow result: `framework-tests`/`plugin-tests` may be skipped (no relevant changes) without failing CI. - Deploy reads its config from FastKV at runtime (`BOS_ACCOUNT`/`BOS_GATEWAY` on Railway), so nothing needs to be committed back after a deploy. ### Release (`release.yml`) -**Trigger:** Push to `main`, or `workflow_dispatch` (manual retry). +**Trigger:** Push to `main`, or `workflow_dispatch` (manual retry — guarded to `main`). **Purpose:** Consume changesets, create version PRs, and publish framework packages to npm. @@ -66,40 +68,62 @@ The key design: `CI` is the validation workflow. On a successful push to `main`, no --prerelease flag) ``` -This is the canonical [changesets/action](https://github.com/changesets/changesets) setup: the same workflow both opens the version PR (when changesets are pending) and publishes (when they are not) — merging the version PR *is* the release. The publish logic lives in `scripts/publish-release-packages.ts` via `pnpm run release`. Generated child repos use the separate template at `.github/templates/workflows/release.yml` (`workflow_run`-triggered on CI success, version-PR + workspace GitHub Releases, no npm publish). +This is the canonical [changesets/action](https://github.com/changesets/changesets) setup: the same workflow both opens the version PR (when changesets are pending) and publishes (when they are not) — merging the version PR *is* the release. The publish logic lives in `scripts/publish-release-packages.ts` via `pnpm run release`; it builds every publishable workspace itself before staging and publishing. Generated child repos use the separate template at `.github/templates/workflows/release.yml` (`workflow_run`-triggered on CI success, version-PR + workspace GitHub Releases, no npm publish). **npm publishing uses OIDC trusted publishing** — no `NPM_TOKEN` secret needed. `NODE_AUTH_TOKEN` is set to empty string, and `npm publish --provenance` authenticates via the OIDC token provisioned by `id-token: write` permission and `actions/setup-node` with `registry-url`. ### Deploy (`deploy.yml`) -**Trigger:** `workflow_run` (CI completed successfully on `main`), or `workflow_dispatch`. +**Trigger:** `workflow_run` (CI completed successfully on `main` from a `push` event), or `workflow_dispatch` (guarded to `main`). -**Purpose:** Run the full deploy train with one command (`pnpm run bos deploy`): preflight (fail fast on config/signing/storage credentials before any build), staleness-checked prerequisite builds + workspace builds, bundle upload to the R2-backed storage at `cdn.everything.dev`, FastKV publish with read-back confirmation, `runtime`-stage image build pushed to GHCR by SHA + `latest` tags, and a pull-only Railway deploy pinned to the pushed digest (generated thin `FROM @sha256:` Dockerfile — Railway never rebuilds, ADR 0021). +**Purpose:** Run the full deployment with one command (`pnpm run bos deploy`): preflight (fails fast on config/signing/storage credentials before any build), staleness-checked prerequisite builds + workspace builds, bundle upload to the R2-backed storage at `cdn.everything.dev`, FastKV publish with read-back confirmation, `runtime`-stage image build pushed to GHCR by SHA + version tags (`latest` held during prereleases), and a pull-only Railway deploy pinned to the pushed digest (generated thin `FROM @sha256:` Dockerfile — Railway never rebuilds, ADR 0021). **Behavior:** -- Runs `pnpm run bos deploy` — the CLI handles every leg; missing legs (no `ci.image`, no docker, no `RAILWAY_TOKEN`) degrade gracefully with a notice +- A `gate` job first verifies the CI-validated commit is still the tip of `main` — if `main` moved on, this run is skipped and the run queued for the new tip deploys instead +- Runs `pnpm run bos deploy` — the CLI handles every stage; missing pieces (no `ci.image`, no docker, no `RAILWAY_TOKEN`) degrade gracefully with a notice - Checks out the exact commit CI validated (`github.event.workflow_run.head_sha`) -- Keeps the mf-check retry loop and the remote smoke test as workflow-level verification +- Keeps the bundle compatibility check (`bos mf check`) and the remote smoke test as workflow-level verification - Does **not** commit anything back — the runtime fetches the published config from FastKV (`bos start` resolves `BOS_ACCOUNT`/`BOS_GATEWAY`); the authored `bos.app.ts` is the publish *input*, not the deploy output **Secrets:** `NEAR_PRIVATE_KEY` (FastKV config publish), `BOS_STORAGE_API_KEY` (bundle upload — mint once with `bos login --key`), `RAILWAY_TOKEN` (Railway deploy). GHCR push needs `packages: write`. -**`cancel-in-progress: false`** — interrupting the deploy mid-flight could leave the FastKV config and the live image on different release trains. Queued deploys pick up the latest main when they run. +**`cancel-in-progress: false`** — interrupting the deploy mid-flight could leave the FastKV config and the live image from different versions. Queued deploys pick up the latest main when they run. ### Staging (`staging.yml`) -**Trigger:** Push to the `staging` branch, or `workflow_dispatch`. +**Trigger:** Push to the `staging` branch, or `workflow_dispatch` (guarded to the `staging` branch). -**Purpose:** Run the same deploy train against the staging env (`bos deploy --env staging`): account/gateway switch to the config's `staging.*` values, bundles and config publish under the staging namespace, and the Railway deploy targets the staging service (`RAILWAY_STAGING_TOKEN`). +**Purpose:** Run the same deployment against the staging env (`bos deploy --env staging`): account/gateway switch to the config's `staging.*` values, bundles and config publish under the staging namespace, and the Railway deploy targets the staging service (`RAILWAY_STAGING_TOKEN`). + +**Differences from production (intentional):** +- The image is pushed under its own namespace — `BOS_IMAGE: ghcr.io/nearbuilders/everything-dev-staging` — so staging runs never move the production image tags +- No post-deploy verification parity yet: the bundle compatibility check and the remote smoke test assert production URLs and are production-only today **Required GitHub secrets for staging:** +- `NEAR_TESTNET_PRIVATE_KEY` — signs the staging FastKV config publish +- `BOS_STORAGE_API_KEY` — staging bundle upload - `RAILWAY_STAGING_TOKEN` — the staging Railway service token +## Consumer workflows + +The `consumer-*.yml` workflows are `workflow_call`-only — they never run in this repo. Generated child repos call them from thin wrapper workflows under `.github/templates/workflows/`: + +- `consumer-ci.yml` — install, audit, biome, typecheck, test (from the child's standard scripts) +- `consumer-deploy.yml` — checkout the CI-validated commit, install, `pnpm run bos deploy` +- `consumer-staging.yml` — checkout the pushed commit, install, `pnpm run bos deploy --env staging` + +All three take a `working_directory` input (default `.`): the directory from which the child repo's standard scripts are valid. The workflows never assume `packages/`, `host/`, or any fixed layout — where the app root lives is the child repo's business. + +**Prerequisite:** the parent repo's Actions settings must allow these workflows to be accessed from other repositories (Settings → Actions → General → Access). + +**Pinning:** child wrappers currently reference `@main` — the same trust model `bos sync` already applies to all framework-owned files (children track the parent's `main`, snapshot conflict detection guards local edits). Upgrading to a maintained version tag is recorded as a follow-up in `docs/plans/049-workflow-hardening-and-consumer-reusable-workflows.md`. + ## Required secrets | Secret | Workflow | Purpose | |--------|----------|---------| | `NEAR_PRIVATE_KEY` | Deploy | Signs the FastKV config publish | -| `BOS_STORAGE_API_KEY` | Deploy | Bundle upload to the R2-backed storage | +| `NEAR_TESTNET_PRIVATE_KEY` | Staging | Signs the staging FastKV config publish | +| `BOS_STORAGE_API_KEY` | Deploy, Staging | Bundle upload to the R2-backed storage | | `RAILWAY_TOKEN` | Deploy | Production Railway deploy | | `RAILWAY_STAGING_TOKEN` | Staging | Staging Railway deploy | diff --git a/.github/workflows/check-skills.yml b/.github/workflows/check-skills.yml index be13ef00e..23900a039 100644 --- a/.github/workflows/check-skills.yml +++ b/.github/workflows/check-skills.yml @@ -1,5 +1,3 @@ -# check-skills.yml — Drop this into your library repo's .github/workflows/ -# # Validates intent skills on PRs. After a release or manual run, opens or # updates one review PR when existing skills, artifact coverage, or workspace # package coverage need review. @@ -11,9 +9,6 @@ # # intent-workflow-version: 3 (locally extended — reconcile on the next # `intent setup` regeneration; keep the validate/stale additions and path list) -# -# Template variables (replaced by `intent setup`): -# everything-dev — e.g. @tanstack/query or my-workspace workspace name: Check Skills @@ -33,15 +28,13 @@ on: types: [published] workflow_dispatch: {} -permissions: - contents: write - pull-requests: write - jobs: validate: name: Validate intent skills if: github.event_name == 'pull_request' runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) @@ -70,9 +63,13 @@ jobs: # stale does not verify source existence. - name: Check skill drift run: | - set -o pipefail for pkg in packages/everything-dev packages/every-plugin packages/better-near-auth; do - if intent stale "$pkg" --json 2>/dev/null | grep -q '"needsReview": true'; then + if ! intent stale "$pkg" --json > "$RUNNER_TEMP/stale.json" 2> "$RUNNER_TEMP/stale.err"; then + echo "::error::intent stale failed for $pkg:" + cat "$RUNNER_TEMP/stale.err" + exit 1 + fi + if grep -q '"needsReview": true' "$RUNNER_TEMP/stale.json"; then echo "::error::Skill drift detected in $pkg — run 'pnpm dlx @tanstack/intent@latest stale $pkg' locally and review" exit 1 fi @@ -82,6 +79,9 @@ jobs: name: Check intent skill coverage if: github.event_name != 'pull_request' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35e78fd2..79dc37cf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: regression-browser: ${{ steps.changes.outputs.regression-browser }} regression-unit: ${{ steps.changes.outputs.regression-unit }} image-smoke: ${{ steps.changes.outputs.image-smoke }} + workflows: ${{ steps.changes.outputs.workflows }} steps: - name: Checkout repository uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 (Node 24) @@ -43,14 +44,17 @@ jobs: git fetch origin "${{ github.base_ref }}" --depth=1 BASE="origin/${{ github.base_ref }}" elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "everything-dev=true" >> "$GITHUB_OUTPUT" - echo "every-plugin=true" >> "$GITHUB_OUTPUT" - echo "host=true" >> "$GITHUB_OUTPUT" - echo "regression-http=true" >> "$GITHUB_OUTPUT" - echo "regression-framework=true" >> "$GITHUB_OUTPUT" - echo "regression-browser=true" >> "$GITHUB_OUTPUT" - echo "regression-unit=true" >> "$GITHUB_OUTPUT" - echo "image-smoke=true" >> "$GITHUB_OUTPUT" + { + echo "everything-dev=true" + echo "every-plugin=true" + echo "host=true" + echo "regression-http=true" + echo "regression-framework=true" + echo "regression-browser=true" + echo "regression-unit=true" + echo "image-smoke=true" + echo "workflows=true" + } >> "$GITHUB_OUTPUT" exit 0 else BEFORE="${{ github.event.before }}" @@ -63,52 +67,60 @@ jobs: fi if ! DIFF=$(git diff --name-only "$BASE" HEAD); then - echo "everything-dev=true" >> "$GITHUB_OUTPUT" - echo "every-plugin=true" >> "$GITHUB_OUTPUT" - echo "host=true" >> "$GITHUB_OUTPUT" - echo "regression-http=true" >> "$GITHUB_OUTPUT" - echo "regression-framework=true" >> "$GITHUB_OUTPUT" - echo "regression-browser=true" >> "$GITHUB_OUTPUT" - echo "regression-unit=true" >> "$GITHUB_OUTPUT" - echo "image-smoke=true" >> "$GITHUB_OUTPUT" + { + echo "everything-dev=true" + echo "every-plugin=true" + echo "host=true" + echo "regression-http=true" + echo "regression-framework=true" + echo "regression-browser=true" + echo "regression-unit=true" + echo "image-smoke=true" + echo "workflows=true" + } >> "$GITHUB_OUTPUT" exit 0 fi - if echo "$DIFF" | grep -qE '^tests/regression/lib/|^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig\.json)$|^\.github/workflows/ci\.yml$'; then + if echo "$DIFF" | grep -qE '^tests/regression/lib/|^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig\.json|tsconfig\.base\.json|bos\.app\.ts|bos\.dev\.ts)$|^\.github/workflows/'; then echo "regression-unit=true" >> "$GITHUB_OUTPUT" else echo "regression-unit=false" >> "$GITHUB_OUTPUT" fi - if echo "$DIFF" | grep -qE '^Dockerfile$|^scripts/|^host/|^api/|^ui/|^plugins/|^packages/|^tests/|^bos\.config\.json$|^\.env\.test$|^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig\.json)$|^\.github/workflows/ci\.yml$'; then + if echo "$DIFF" | grep -qE '^\.github/workflows/|^\.github/templates/workflows/'; then + echo "workflows=true" >> "$GITHUB_OUTPUT" + else + echo "workflows=false" >> "$GITHUB_OUTPUT" + fi + if echo "$DIFF" | grep -qE '^Dockerfile$|^scripts/|^host/|^api/|^ui/|^plugins/|^packages/|^tests/|^bos\.config\.json$|^\.env\.test$|^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig\.json|tsconfig\.base\.json|bos\.app\.ts)$|^\.github/workflows/ci\.yml$'; then echo "image-smoke=true" >> "$GITHUB_OUTPUT" else echo "image-smoke=false" >> "$GITHUB_OUTPUT" fi - if echo "$DIFF" | grep -qE '^packages/everything-dev/|^packages/every-plugin/|^packages/better-near-auth/|^api/|^plugins/|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json)$|^\.github/workflows/ci.yml$'; then + if echo "$DIFF" | grep -qE '^packages/everything-dev/|^packages/every-plugin/|^packages/better-near-auth/|^api/|^plugins/|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json|tsconfig.base.json|bos.app.ts|bos.dev.ts)$|^\.github/workflows/ci.yml$'; then echo "everything-dev=true" >> "$GITHUB_OUTPUT" else echo "everything-dev=false" >> "$GITHUB_OUTPUT" fi - if echo "$DIFF" | grep -qE '^packages/every-plugin/|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json)$|^\.github/workflows/ci.yml$'; then + if echo "$DIFF" | grep -qE '^packages/every-plugin/|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json|tsconfig.base.json)$|^\.github/workflows/ci.yml$'; then echo "every-plugin=true" >> "$GITHUB_OUTPUT" else echo "every-plugin=false" >> "$GITHUB_OUTPUT" fi - if echo "$DIFF" | grep -qE '^host/|^ui/|^packages/|^bos\.config\.json$|^\.env\.test$|^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig\.json)$|^\.github/workflows/ci\.yml$'; then + if echo "$DIFF" | grep -qE '^host/|^ui/|^packages/|^bos\.config\.json$|^\.env\.test$|^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig\.json|tsconfig\.base\.json|bos\.app\.ts|bos\.dev\.ts)$|^\.github/workflows/ci\.yml$'; then echo "host=true" >> "$GITHUB_OUTPUT" else echo "host=false" >> "$GITHUB_OUTPUT" fi - if echo "$DIFF" | grep -qE '^tests/regression/(http|lib)/|^host/|^api/|^plugins/|^packages/|^scripts/|^Dockerfile$|^bos\.config\.json$|^\.env\.test$|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json)$|^\.github/workflows/ci\.yml$'; then + if echo "$DIFF" | grep -qE '^tests/regression/(http|lib)/|^host/|^api/|^plugins/|^packages/|^scripts/|^Dockerfile$|^bos\.config\.json$|^\.env\.test$|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json|tsconfig.base.json|bos.app.ts|bos.dev.ts)$|^\.github/workflows/ci\.yml$'; then echo "regression-http=true" >> "$GITHUB_OUTPUT" else echo "regression-http=false" >> "$GITHUB_OUTPUT" fi - if echo "$DIFF" | grep -qE '^tests/regression/(framework|lib)/|^packages/everything-dev/src/|^packages/every-plugin/src/|^\.env\.test$|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json)$|^\.github/workflows/ci\.yml$'; then + if echo "$DIFF" | grep -qE '^tests/regression/(framework|lib)/|^packages/everything-dev/src/|^packages/every-plugin/src/|^\.env\.test$|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json|tsconfig.base.json|bos.app.ts|bos.dev.ts)$|^\.github/workflows/ci\.yml$'; then echo "regression-framework=true" >> "$GITHUB_OUTPUT" else echo "regression-framework=false" >> "$GITHUB_OUTPUT" fi - if echo "$DIFF" | grep -qE '^tests/regression/(browser|lib)/|^host/|^ui/|^api/|^plugins/|^packages/|^scripts/|^Dockerfile$|^bos\.config\.json$|^\.env\.test$|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json)$|^\.github/workflows/ci\.yml$'; then + if echo "$DIFF" | grep -qE '^tests/regression/(browser|lib)/|^host/|^ui/|^api/|^plugins/|^packages/|^scripts/|^Dockerfile$|^bos\.config\.json$|^\.env\.test$|^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.json|tsconfig.base.json|bos.app.ts|bos.dev.ts)$|^\.github/workflows/ci\.yml$'; then echo "regression-browser=true" >> "$GITHUB_OUTPUT" else echo "regression-browser=false" >> "$GITHUB_OUTPUT" @@ -183,12 +195,34 @@ jobs: - name: Run typecheck run: pnpm run typecheck - # The build train: staleness-checked quiet prerequisites - # (every-plugin / everything-dev / better-near-auth) + the template - # plugin target — contract types + manifest pipeline in one call. - - name: Build template plugin via the train (contract types + manifest pipeline) + # Staleness-checked prerequisite builds + the template plugin target — + # contract types + manifest pipeline in one command. + - name: Build template plugin (contract types + manifest pipeline) run: pnpm run build template + workflows-lint: + name: Workflow lint + runs-on: ubuntu-latest + needs: [detect-changes] + if: needs.detect-changes.outputs.workflows == 'true' + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 (Node 24) + + - name: Setup Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 (Node 24) + with: + go-version: "1.22" + + # Guards the consumer reusable workflows: child repos call them by ref, + # so a syntax or schema error here breaks children the moment they sync + # the wrapper templates. + - name: Lint workflow files (actionlint) + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + "$(go env GOPATH)/bin/actionlint" -color .github/workflows/*.yml .github/templates/workflows/*.yml + framework-tests: name: Framework tests needs: [detect-changes] @@ -210,7 +244,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts - - name: Build framework dists via the train + - name: Build framework dists run: pnpm run build template - name: Test everything-dev @@ -239,7 +273,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts - - name: Build every-plugin dist via the train + - name: Build every-plugin dist run: pnpm run build template - name: Test every-plugin @@ -268,7 +302,7 @@ jobs: - name: Patch Effect TypeScript-Go (tsgo) and Oxlint run: pnpm run prepare - - name: Build framework dists via the train + - name: Build framework dists run: pnpm run build template - name: Test host @@ -303,8 +337,9 @@ jobs: fail-fast: false matrix: # dev = the dev server; start = the deployment image (ADR 0009). - # http is render-mode-blind, so it runs on :ssr only. The start leg - # pays the docker build, hence the longer timeout. + # The HTTP suite is identical for SSR and CSR, so it runs on :ssr + # only. The start variant pays the docker build, hence the longer + # timeout. include: - command: dev timeout: 15 @@ -366,7 +401,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts - - name: Build every-plugin dist via the train + - name: Build every-plugin dist if: matrix.command == 'dev' run: pnpm run build template @@ -437,7 +472,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts - - name: Build every-plugin dist via the train + - name: Build every-plugin dist run: pnpm run build template - name: Run framework regression suite @@ -456,8 +491,8 @@ jobs: fail-fast: false matrix: # start:* = the deployment image via `bos start --config` (full suite); - # dev:* = the dev server, smoke-only (ADR 0009). The start legs pay - # the docker build, hence the longer timeout. + # dev:* = the dev server, smoke-only (ADR 0009). The start variants + # pay the docker build, hence the longer timeout. include: - mode: start:ssr slug: start-ssr @@ -522,7 +557,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts - - name: Build every-plugin dist via the train + - name: Build every-plugin dist if: startsWith(matrix.mode, 'dev:') run: pnpm run build template @@ -543,6 +578,9 @@ jobs: - name: Resource watchdog run: | + # $(date) must expand inside the inner bash on every loop iteration — + # single quotes are intentional. + # shellcheck disable=SC2016 nohup bash -c 'while true; do echo "=== watchdog $(date -u +%H:%M:%S) ===" free -m diff --git a/.github/workflows/consumer-ci.yml b/.github/workflows/consumer-ci.yml new file mode 100644 index 000000000..3deb084e6 --- /dev/null +++ b/.github/workflows/consumer-ci.yml @@ -0,0 +1,84 @@ +name: Consumer CI + +# Reusable CI for generated child repos — never runs in this repo. +# The caller owns triggers, permissions policy, and secrets. This workflow +# only assumes a directory (`working_directory`) from which the child repo's +# standard scripts are valid: pnpm install, pnpm audit, biome, typecheck, test. + +on: + workflow_call: + inputs: + working_directory: + description: 'Directory exposing the repo''s standard scripts (default: repo root)' + required: false + type: string + default: . + +permissions: + contents: read + +jobs: + ci: + name: Lint, typecheck, and test + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: ${{ inputs.working_directory }} + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) + + - name: Guard — no stray bun artifacts + run: | + if [ -f bun.lock ]; then + echo "::error::bun.lock present — bun re-migrated the workspace (ADR 0026). Delete bun.lock and re-run pnpm install." + exit 1 + fi + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Security audit + id: audit + timeout-minutes: 5 + env: + AUDIT_STRICT: ${{ secrets.AUDIT_STRICT }} + run: | + set +e -o pipefail + timeout 240 pnpm audit --json 2>&1 | tee audit-output.txt + audit_exit=$? + if [ "$audit_exit" -eq 124 ]; then + echo "::warning::pnpm audit timed out after 240s — skipping vulnerability check" + exit 0 + fi + if [ "$audit_exit" -gt 1 ]; then + echo "::warning::pnpm audit exited with code $audit_exit — see output above" + exit 0 + fi + if grep -qiE '(critical|high)' audit-output.txt; then + if [ "$AUDIT_STRICT" = "true" ]; then + echo "::error::Critical or high vulnerabilities found — failing the audit" + exit 1 + else + echo "::warning::Critical or high vulnerabilities found — set AUDIT_STRICT=true secret to fail on these" + fi + fi + + - name: Run Biome lint and format check + run: pnpm exec biome ci . + + - name: Run typecheck + run: pnpm run typecheck + + - name: Run tests + run: pnpm run test diff --git a/.github/workflows/consumer-deploy.yml b/.github/workflows/consumer-deploy.yml new file mode 100644 index 000000000..93a3fb159 --- /dev/null +++ b/.github/workflows/consumer-deploy.yml @@ -0,0 +1,69 @@ +name: Consumer Deploy + +# Reusable production deploy for generated child repos — never runs in this +# repo. The caller owns triggers, permissions policy, and secrets (pass +# `secrets: inherit` or map them explicitly). Runs the child's deploy command +# (`pnpm run bos deploy`) from `working_directory`; the CLI degrades +# gracefully when no image or Railway service is configured. + +on: + workflow_call: + inputs: + working_directory: + description: 'Directory exposing the repo''s standard scripts (default: repo root)' + required: false + type: string + default: . + verbose: + description: Show full build output instead of clean summary + required: false + type: boolean + default: false + +permissions: + contents: read + packages: write + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 45 + defaults: + run: + working-directory: ${{ inputs.working_directory }} + shell: bash + env: + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + BOS_STORAGE_API_KEY: ${{ secrets.BOS_STORAGE_API_KEY }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Guard — no stray bun artifacts + run: | + if [ -f bun.lock ]; then + echo "::error::bun.lock present — bun re-migrated the workspace (ADR 0026). Delete bun.lock and re-run pnpm install." + exit 1 + fi + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Deploy (preflight → build → upload → publish → image → Railway) + timeout-minutes: 35 + run: pnpm run bos deploy${{ inputs.verbose == true && ' --verbose' || '' }} diff --git a/.github/workflows/consumer-staging.yml b/.github/workflows/consumer-staging.yml new file mode 100644 index 000000000..8d526f097 --- /dev/null +++ b/.github/workflows/consumer-staging.yml @@ -0,0 +1,68 @@ +name: Consumer Staging + +# Reusable staging deploy for generated child repos — never runs in this +# repo. The caller owns triggers, permissions policy, and secrets (pass +# `secrets: inherit` or map them explicitly). Runs the child's staging deploy +# command (`pnpm run bos deploy --env staging`) from `working_directory`. + +on: + workflow_call: + inputs: + working_directory: + description: 'Directory exposing the repo''s standard scripts (default: repo root)' + required: false + type: string + default: . + verbose: + description: Show full build output instead of clean summary + required: false + type: boolean + default: false + +permissions: + contents: read + packages: write + +jobs: + deploy-staging: + name: Deploy Staging + runs-on: ubuntu-latest + timeout-minutes: 45 + defaults: + run: + working-directory: ${{ inputs.working_directory }} + shell: bash + env: + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + BOS_STORAGE_API_KEY: ${{ secrets.BOS_STORAGE_API_KEY }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) + with: + ref: ${{ github.ref }} + + - name: Guard — no stray bun artifacts + run: | + if [ -f bun.lock ]; then + echo "::error::bun.lock present — bun re-migrated the workspace (ADR 0026). Delete bun.lock and re-run pnpm install." + exit 1 + fi + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Deploy staging (preflight → build → upload → publish → image → Railway) + timeout-minutes: 35 + run: pnpm run bos deploy --env staging${{ inputs.verbose == true && ' --verbose' || '' }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4d0523288..d1fd71822 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,9 +21,42 @@ permissions: packages: write jobs: + # Deploy gate: only fresh CI runs of a push to main, and only while the + # validated commit is still the tip of main. Manual dispatches must run + # against main; stale runs are skipped (the queued run for the new tip + # deploys instead). + gate: + name: Verify deploy is current + if: >- + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + deploy: ${{ steps.tip.outputs.current }} + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Verify the CI-validated commit is still the main tip + id: tip + run: | + git fetch --no-tags origin main + TIP="$(git rev-parse origin/main)" + SHA="$(git rev-parse HEAD)" + if [ "$SHA" = "$TIP" ]; then + echo "current=true" >> "$GITHUB_OUTPUT" + else + echo "current=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping deploy — main advanced past the CI-validated commit ($SHA → $TIP). The queued run for the new tip deploys instead." + fi + deploy: name: Deploy Production - if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + needs: [gate] + if: needs.gate.outputs.deploy == 'true' runs-on: ubuntu-latest timeout-minutes: 60 env: @@ -58,28 +91,30 @@ jobs: - name: Deploy (preflight → build → upload → publish → image → Railway) timeout-minutes: 50 - # One command runs the whole train (ADR 0020/0021): staleness-checked - # prerequisite builds + workspace dists, bundle upload to the R2-backed - # storage, FastKV publish with read-back confirmation, `runtime`-stage - # image build pushed by SHA + latest tags, then a pull-only Railway - # deploy pinned to the pushed digest via a generated thin Dockerfile. + # One command runs the whole deployment (ADR 0020/0021): + # staleness-checked prerequisite builds + workspace dists, bundle + # upload to the R2-backed storage, FastKV publish with read-back + # confirmation, `runtime`-stage image build pushed by SHA + version + # tags (latest held on prereleases), then a pull-only Railway deploy + # pinned to the pushed digest via a generated thin Dockerfile. run: pnpm run bos deploy${{ inputs.verbose == true && ' --verbose' || '' }} - - name: Verify release-train identity (mf check) + - name: Verify bundle compatibility (bos mf check) timeout-minutes: 5 run: | - # Gate the release train: all host/plugin bundles must agree on shared-dep - # versions (exact for effect-critical deps) and pluginVersion. Runs AFTER - # the deploy — CDN-mode bundles serve from cdn.everything.dev, so the - # check passes once the config is published and the new bytes are live. + # Gate the deployment: all host/plugin bundles must agree on + # shared-dep versions (exact for effect-critical deps) and + # pluginVersion. Runs AFTER the deploy — CDN-mode bundles serve + # from cdn.everything.dev, so the check passes once the config is + # published and the new bytes are live. attempts=10 until pnpm run bos mf check; do attempts=$((attempts - 1)) if [ "$attempts" -le 0 ]; then - echo "::error::Release-train identity check failed — bundles are inconsistent (deployment did not come up healthy or skew). See bos mf check output." + echo "::error::Bundle compatibility check failed — bundles are inconsistent (deployment did not come up healthy or has a version mismatch). See bos mf check output." exit 1 fi - echo "mf check failed; retrying in 20s ($attempts attempt(s) left)…" + echo "bos mf check failed; retrying in 20s ($attempts attempt(s) left)…" sleep 20 done @@ -88,6 +123,6 @@ jobs: # ADR 0009 §4: published-config coverage lives in the Deploy workflow. # Boots the real host program against the live production URLs from the # deployed SHA's bos.config.json — SSR compose from the deployed ui, - # doc-asset proxy, api ping, root document contract. mf check has - # already gated the site being up and consistent above. + # doc-asset proxy, api ping, root document contract. bos mf check has + # already verified the site is up and consistent above. run: pnpm --filter host test:integration:remote diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af6ac24fd..2d1e81c5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,10 @@ permissions: jobs: release: - name: Publish Packages + name: Process Changesets + # Dispatches must run against main — a manual run on a feature branch + # would open version PRs (or publish) from the wrong source. + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -37,8 +40,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts - - name: Build every-plugin - run: pnpm --filter every-plugin build + # No pre-build here: `pnpm run release` + # (scripts/publish-release-packages.ts) builds every publishable + # workspace itself before staging and publishing. - name: Create Release PR or Publish to npm uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0 diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 6d29e6c5a..c5b0ee353 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -17,10 +17,14 @@ concurrency: permissions: contents: read + packages: write jobs: deploy-staging: name: Deploy Staging + # Dispatches must run against the staging branch — a manual run on another + # ref would deploy unreviewed code to the staging environment. + if: github.ref == 'refs/heads/staging' runs-on: ubuntu-latest timeout-minutes: 60 env: @@ -28,12 +32,21 @@ jobs: BOS_STORAGE_API_KEY: ${{ secrets.BOS_STORAGE_API_KEY }} BOS_BUNDLE_CDN_ORIGIN: https://cdn.everything.dev RAILWAY_TOKEN: ${{ secrets.RAILWAY_STAGING_TOKEN }} + # Staging pushes its own image namespace — staging runs never move the + # production image tags (sha-*/latest on ghcr.io/nearbuilders/everything-dev). + BOS_IMAGE: ghcr.io/nearbuilders/everything-dev-staging steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Node 24) with: ref: ${{ github.ref }} + - name: Guard — no stray bun artifacts + run: | + if [ -f bun.lock ]; then + echo "::error::bun.lock present — bun re-migrated the workspace (ADR 0026). Delete bun.lock and re-run pnpm install." + exit 1 + fi - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 @@ -44,6 +57,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + - name: Deploy staging (preflight → build → upload → publish → image → Railway) timeout-minutes: 50 run: pnpm run bos deploy --env staging${{ inputs.verbose == true && ' --verbose' || '' }} + + # Note: staging intentionally has no post-deploy verification parity with + # production today — the bundle compatibility check (bos mf check) and the + # remote smoke test are production-only because they assert production + # URLs. Adding staging-scoped verification is a follow-up. diff --git a/AGENTS.md b/AGENTS.md index 0f7ced8c9..e04961a33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -488,7 +488,7 @@ This repo is the parent platform, not a generated child project. **Release flow:** - CI is the validation workflow. On successful push to `main`, the Deploy workflow triggers automatically via `workflow_run` and checks out the exact SHA CI validated. - `release.yml` runs on every push to `main` (canonical changesets flow): pending changesets → it opens/updates the `chore: version packages` PR; none → it publishes the packages to npm (under the `rc` dist-tag while `.changeset/pre.json` pre mode is active) and creates GitHub Releases. `workflow_dispatch` remains for manual retries. -- `deploy.yml` runs `pnpm run bos deploy` — the single command runs the whole train: preflight (config/signing/storage credentials, fail fast before any build), workspace builds, bundle upload to the R2-backed storage, FastKV publish with read-back confirmation, the `runtime` image stage pushed to GHCR by SHA + `latest` tags (`ci.image` in the authored config), and a pull-only Railway deploy pinned to the pushed digest (generated thin `FROM @sha256:` Dockerfile — ADR 0021). Nothing is committed back — the runtime fetches the published config from FastKV. +- `deploy.yml` runs `pnpm run bos deploy` — the single command runs the whole train: preflight (config/signing/storage credentials, fail fast before any build), workspace builds, bundle upload to the R2-backed storage, FastKV publish with read-back confirmation, the `runtime` image stage pushed to GHCR by SHA + version tags — `latest` is held during prereleases so `:latest` keeps serving the last stable image (`ci.image` in the authored config), and a pull-only Railway deploy pinned to the pushed digest (generated thin `FROM @sha256:` Dockerfile — ADR 0021). Nothing is committed back — the runtime fetches the published config from FastKV. - Generated child repos use a simpler flow: both Release and Deploy trigger directly from CI success via `workflow_run` (no npm publish, no Docker). **Create changeset:** diff --git a/docs/plans/049-workflow-hardening-and-consumer-reusable-workflows.md b/docs/plans/049-workflow-hardening-and-consumer-reusable-workflows.md new file mode 100644 index 000000000..5a485537f --- /dev/null +++ b/docs/plans/049-workflow-hardening-and-consumer-reusable-workflows.md @@ -0,0 +1,323 @@ +# Plan 049: Workflow hardening and consumer reusable workflows + +> Executor instructions: follow the steps in order, verify each gate before +> moving on, and stop if a step requires changing the child-repo contract in a +> way this plan did not call out. Update the status row for this plan in +> `docs/plans/README.md` when the work lands. + +## Status + +- Priority: P1 +- Effort: M +- Risk: MED +- Depends on: none +- Category: ci/cd / architecture +- Planned at: commit `fa549f4f`, 2026-10-09 + +## Why this matters + +The current workflow story has two separate problems: + +1. The parent repo workflows have a few objective reliability gaps: manual + dispatches are too permissive, staging does not match production's image + behavior, CI path gating is stale, and `check-skills` can pass when its core + command fails. +2. Child repos still consume synced workflow copies from + `.github/templates/workflows/*`, so fixes have to be copied forward instead + of reused centrally. + +The architecture constraint is real: child repos may not have `packages/`, +`host/`, or a flat root where `bos.app.ts`, `ui/`, and `api/` all live at the +repository root. The reusable seam therefore cannot be directory conventions. +It has to be a command contract. + +## Current state + +- Parent workflows live in `.github/workflows/*` and are repo-specific. +- Child repos receive synced workflow copies from `.github/templates/workflows/*` + via `packages/everything-dev/src/cli/sync.ts`. +- `findConfigPath()` discovers the app root by walking upward to the nearest + `bos.app.ts` or `bos.config.json`; it does not search downward. +- ADR 0022 defines two roots: app root and workspace root. A reusable workflow + cannot assume they are the same. +- The current child templates are already simpler than the parent workflows and + rely on repo-root scripts like `pnpm run typecheck`, `pnpm run test`, and + `pnpm run bos deploy`. + +## Scope + +In scope: + +- Parent workflows in `.github/workflows/*` +- Child workflow templates in `.github/templates/workflows/*` +- Shared workflow docs in `.github/workflows/README.md` +- Sync coverage for workflow templates and framework-owned workflow files +- New reusable workflows named: + - `.github/workflows/consumer-ci.yml` + - `.github/workflows/consumer-deploy.yml` + - `.github/workflows/consumer-staging.yml` + +Out of scope: + +- Replacing the parent repo's own CI with the consumer workflows +- Centralizing `release.yml` for child repos in this pass +- Inventing path discovery inside GitHub Actions beyond one explicit input +- Adding parent-only test jobs to child repos + +## Contract decision + +The consumer reusable workflows take a single path input: + +- `working_directory` - the directory from which the child repo exposes its + standard scripts + +This input is intentionally not named `app_root`. The reusable workflow does not +need to know where the authored config lives, where the workspace root lives, or +whether they differ. It only needs one directory from which these commands are +valid: + +- `pnpm install --frozen-lockfile --ignore-scripts` +- `pnpm run typecheck` +- `pnpm run test` +- `pnpm run bos deploy` + +If a child repo keeps its app under `app/` or another nested path, the child +wrapper chooses the correct `working_directory`. If a child repo wants to keep +workflow entrypoints at repository root, its own scripts bridge from the chosen +directory to the actual app root. + +## Commands + +Run from repository root unless a step says otherwise. + +- `pnpm run typecheck` +- `pnpm run lint` +- `pnpm --filter everything-dev exec vitest run tests/integration/init.structure.test.ts tests/integration/sync.structure.test.ts tests/integration/sync.template.test.ts` +- `git diff --check` + +If the reusable workflows land in this plan's implementation, also validate one +generated child fixture or smoke repo by exercising the wrapper workflows with +`working_directory: .` and one nested-path fixture before merging. + +## Steps + +### Step 1: Harden the parent workflows + +Make the existing parent workflows correct before introducing reuse. + +Changes: + +- In `deploy.yml`, `release.yml`, and `staging.yml`, restrict + `workflow_dispatch` so a manual run cannot act on an arbitrary branch by + accident. +- In `deploy.yml`, only deploy from successful CI runs whose triggering event + was `push`, and verify the checked-out SHA is still the current tip of + `main` before deploying. +- In `staging.yml`, choose one explicit behavior and document it in code: + - either match production's image leg with GHCR auth and `packages: write` + - or explicitly skip image publishing in staging and remove any wording that + claims parity with production +- Bring `staging.yml` back into parity with the production safety guards it is + supposed to share, such as the bun-lock check. + +Verify: + +- Workflow YAML stays valid. +- `pnpm run lint` and `git diff --check` pass. + +### Step 2: Fix CI path gating and skill-check failure handling + +Refresh change detection so workflow and config changes exercise the right jobs. + +Changes: + +- In `.github/workflows/ci.yml`, replace the hardcoded references to only + `.github/workflows/ci.yml` with patterns that cover all managed workflow + files and workflow templates where appropriate. +- Add current canonical root files to the relevant change detectors: + - `bos.app.ts` + - `bos.dev.ts` + - `tsconfig.base.json` +- In `check-skills.yml`, make `intent stale` fail loudly if the command itself + fails or emits unusable output. +- Scope write permissions in `check-skills.yml` to only the job that actually + opens or edits PRs. + +Verify: + +- `pnpm run lint` +- `pnpm run typecheck` + +### Step 3: Clean up workflow naming and wording + +Remove repo jargon where it obscures the actual behavior. + +Changes: + +- Replace `deploy train`, `build train`, `release train`, and `leg` with plain + language like `deploy workflow`, `build pipeline`, `released artifacts`, and + `job` or `matrix entry`. +- Expand `mf check` once to `Module Federation compatibility check + (bos mf check)`. +- Replace `skew` with `version mismatch`. +- Remove stale README wording like `downstream notification`. +- Fix the staging secrets list in `.github/workflows/README.md` so it matches + the actual workflow inputs. +- Rename misleading workflow job titles such as `Publish Packages` when the job + sometimes only opens a version PR. + +Verify: + +- README and workflow comments describe the YAML truthfully. + +### Step 4: Introduce reusable consumer workflows + +Add three new reusable workflows in the parent repo: + +- `.github/workflows/consumer-ci.yml` +- `.github/workflows/consumer-deploy.yml` +- `.github/workflows/consumer-staging.yml` + +Design rules: + +- They are child-oriented, not parent-oriented. +- They do not assume `packages/`, `host/`, or fixed directory names. +- They run only the child contract commands from `working_directory`. +- They keep secrets and environment mapping explicit. +- They do not own child trigger policy. + +Initial contract: + +- `consumer-ci.yml` + - input: `working_directory` (default `.`) + - behavior: checkout, node/pnpm setup, bun-lock guard, install, audit, + biome, typecheck, test +- `consumer-deploy.yml` + - inputs: `working_directory` (default `.`), `verbose` (default `false`) + - behavior: checkout exact CI SHA, node/pnpm setup, bun-lock guard, install, + run `pnpm run bos deploy` +- `consumer-staging.yml` + - inputs: `working_directory` (default `.`), `verbose` (default `false`) + - behavior: checkout target ref, node/pnpm setup, bun-lock guard, install, + run `pnpm run bos deploy --env staging` + +Non-goals for this step: + +- No parent regression matrix reuse +- No parent npm release reuse +- No automatic path discovery inside the reusable workflows + +Verify: + +- Reusable workflows validate in GitHub syntax. +- They can be called without parent-only directories existing. + +### Step 5: Convert child templates into thin wrappers + +Change `.github/templates/workflows/{ci,deploy,staging}.yml` from copied logic +to thin wrappers that call the new reusable workflows. + +Wrapper responsibilities: + +- Own triggers +- Own repo-specific permissions +- Own environment protection rules +- Own secret mapping +- Pass `working_directory` + +Central workflow responsibilities: + +- Shared execution logic +- Shared guards +- Shared install / lint / deploy command sequence + +Do not centralize `.github/templates/workflows/release.yml` in this pass. Keep +release policy local until the child release contract is stable enough to reuse. + +Ref pinning decision (executed): + +- Wrappers reference `@main`, matching the sync trust model: `bos sync` + already tracks the parent's `main` for every framework-owned file, and the + tarball source (`downloadTarball`) has no pinned-commit protocol to stamp + from. The published config would need to carry the source commit for + sync-time SHA stamping — recorded as a follow-up. +- Mitigation: `ci.yml` gains a path-gated `workflows-lint` job (actionlint, + version-pinned via `go install`) covering `.github/workflows/**` and + `.github/templates/workflows/**`, so a broken consumer workflow fails the + parent's CI before children sync it. +- Prerequisite outside this repo: the parent repo's Actions settings must + allow these workflows to be accessed from other repositories (Settings → + Actions → General → Access). + +Verify: + +- `sync.template.test.ts` still proves children receive the expected wrappers. +- `sync.structure.test.ts` and `init.structure.test.ts` still pass. + +### Step 6: Decide whether shared setup belongs in a composite action + +Decision (executed): **deferred.** After steps 1-5 the duplicated bootstrap is +~5 steps per consumer workflow; a composite action would add an indirection +layer for that small a bundle while hiding nothing that changes often. If a +fourth consumer workflow appears (or the setup grows a prepare/patch step), +extract `checkout + node + pnpm + install + bun-guard` into a composite action +then. Do not use a composite action to hide triggers, permissions, concurrency, +or service containers. Those belong in workflows. + +## Done criteria + +- [x] Parent workflows are hardened against accidental manual deploy/release on + the wrong ref (deploy/release guarded to `main`, staging to the `staging` + branch; deploy additionally gated on fresh `push` CI runs whose commit is + still the main tip via a `gate` job). +- [x] Staging behavior is explicit and documented: its own image namespace + (`BOS_IMAGE: ghcr.io/nearbuilders/everything-dev-staging`), GHCR auth + + bun guard restored; no-post-deploy-verification parity documented in the + workflow and the workflows README. +- [x] CI path gating includes `bos.app.ts`, `bos.dev.ts`, and + `tsconfig.base.json`; `regression-unit` broadened to all workflow files; + new `workflows` detector feeds the `workflows-lint` job. +- [x] `check-skills` fails when its core command fails and limits write + permissions to the PR-writing job. +- [x] Workflow wording is plain and operationally accurate (`train`/`leg`/ + `skew`/`render-mode-blind` removed from workflow files and the workflows + README; stale "downstream notification" claim removed; staging secrets + table completed; release job renamed to `Process Changesets`). +- [x] `consumer-ci`, `consumer-deploy`, and `consumer-staging` exist as + reusable workflows with a `working_directory` input. +- [x] Child workflow templates (ci/deploy/staging) are thin wrappers; + `release.yml` stays local per the plan. +- [x] `pnpm run typecheck`, `pnpm run lint`, the sync/init integration tests + (30 tests), actionlint, and `git diff --check` all pass. + +Post-merge follow-ups (tracked here, not blocking): + +- Live smoke of the wrappers from a real consumer repo (flat and a nested + `working_directory`) once the workflows exist on the pinned ref — actionlint + plus the sync/init suites are the pre-merge coverage. +- Versioned-ref upgrade path for the wrappers (maintained tag or + published-config commit stamping) — see the executed pinning decision above. +- AGENTS.md and the framework skills still use `deploy train`/`build train` + phrasing; the wording pass here covered `.github/` only. +- Staging post-deploy verification parity (staging-scoped `bos mf check` / + smoke) when staging URLs can be asserted safely. + +## Stop conditions + +- A child repo cannot reliably expose `typecheck`, `test`, and `bos deploy` + from one chosen `working_directory`. If that contract does not hold, stop and + define the missing script contract before implementing reusable workflows. +- A required child behavior depends on parent-only directories like `host/` or + `packages/`. Stop and split parent-only logic from child-safe logic first. +- GitHub reusable workflow limitations force secrets, permissions, or + `workflow_run` semantics that child repos cannot safely adopt. Stop and keep + that concern local in the wrapper. + +## Maintenance notes + +- Parent workflows and child reusable workflows are different products. Keep + them separate even if they share some setup steps. +- `working_directory` is the long-term seam. Do not reintroduce assumptions that + app root, workspace root, and repository root are the same directory. +- Future child workflow reuse should prefer stable root commands over path-based + commands. diff --git a/docs/plans/README.md b/docs/plans/README.md index c1c7faa11..cd0414790 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -27,6 +27,7 @@ docs/plans/ | Plan | Title | Priority | Effort | Depends on | Status | |------|-------|----------|--------|------------|--------| +| 049 | Workflow hardening + consumer reusable workflows | P1 | M | — | DONE (PR #316 — post-merge consumer-repo smoke + versioned-ref upgrade tracked in the plan) | | 016 | Shared Effect test helpers + port 0 | P2 | S-M | [done/001](./done/001-buildscoped-helper.md) | TODO | | 004 | oRPC boundary typing (as-any chain, config generics, api inline type) | P1 | M | [done/001](./done/001-buildscoped-helper.md) | TODO | | 007 | createAuthMiddleware consolidation | P2 | M | [done/002](./done/002-effect-bridge-dedupe.md) | TODO | diff --git a/packages/everything-dev/src/image-deploy.ts b/packages/everything-dev/src/image-deploy.ts index 714386da4..5f539380b 100644 --- a/packages/everything-dev/src/image-deploy.ts +++ b/packages/everything-dev/src/image-deploy.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { run } from "./utils/run"; import { colors, icons } from "./utils/theme"; @@ -46,16 +46,78 @@ function parseDigest(pushOutput: string): string | undefined { return match?.[1]; } +const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; + +/** + * The runtime framework version baked into the image is the workspace source's + * `everything-dev` package version — the image builds from `configDir`, so that + * file is the truth. Missing (child repos without the workspace) → undefined. + */ +function readFrameworkVersion(configDir: string): string | undefined { + try { + const raw = readFileSync( + join(configDir, "packages", "everything-dev", "package.json"), + "utf-8", + ); + const pkg = JSON.parse(raw) as { version?: unknown }; + return typeof pkg.version === "string" && SEMVER_PATTERN.test(pkg.version) + ? pkg.version + : undefined; + } catch { + return undefined; + } +} + +export interface ImageTags { + /** Exact refs to build and push, in push order (latest excluded). */ + tags: string[]; + /** Push `:latest` after the pinned tags. */ + pushLatest: boolean; + /** Human-readable reason when `latest` is held. */ + latestHoldReason?: string; +} + +/** + * Tag scheme (ADR 0021): `sha-` always; the exact `v` tag; a + * floating major tag (`v2`) on stable releases only. `latest` is held during + * prereleases (npm's `rc` dist-tag analog) so existing deployments pinned to + * `:latest` keep serving the last stable image. + */ +export function computeImageTags(input: { shortSha: string; version?: string }): ImageTags { + const tags = [`sha-${input.shortSha}`]; + const match = input.version?.trim().match(SEMVER_PATTERN); + if (!match) { + return { tags, pushLatest: true }; + } + const [, major, minor, patch, prerelease] = match; + const exact = prerelease + ? `v${major}.${minor}.${patch}-${prerelease}` + : `v${major}.${minor}.${patch}`; + if (prerelease) { + return { + tags: [...tags, exact], + pushLatest: false, + latestHoldReason: `${exact} is a prerelease — :latest still serves the last stable image`, + }; + } + return { tags: [...tags, exact, `v${major}`], pushLatest: true }; +} + export interface ImageDeployResult { image: string; + /** Primary pinned tag (the sha tag). */ tag: string; + /** All tags pushed, including the primary. */ + tags: string[]; + latestPushed: boolean; digest?: string; } /** - * Image leg (ADR 0021): build the runtime stage, tag sha- + latest, - * push both, and capture the digest. Callers skip the leg with a notice when - * no image is configured (child repos get build+upload+publish only). + * Image leg (ADR 0021): build the runtime stage, tag `sha-` + version + * tags, push them, capture the digest, and push `:latest` unless the version + * is a prerelease. Callers skip the leg with a notice when no image is + * configured (child repos get build+upload+publish only). */ export async function buildAndPushImage(input: { image: string; @@ -73,53 +135,63 @@ export async function buildAndPushImage(input: { `Failed to resolve the current git SHA in ${input.configDir} (exit code ${shaResult?.exitCode ?? "unknown"}${detail ? `: ${detail}` : ""})`, ); } - const tag = `sha-${shortSha}`; - console.log(` Building runtime image ${colors.cyan(`${input.image}:${tag}`)}...`); + const version = readFrameworkVersion(input.configDir); + const { tags, pushLatest, latestHoldReason } = computeImageTags({ shortSha, version }); + const refs = tags.map((tag) => `${input.image}:${tag}`); + const latestRef = `${input.image}:latest`; + const buildRefs = pushLatest ? [...refs, latestRef] : refs; + + console.log( + ` Building runtime image ${colors.cyan(buildRefs.join(", "))}${pushLatest ? "" : colors.yellow(" (latest held)")}`, + ); await run( "docker", - [ - "build", - "--target", - "runtime", - "-t", - `${input.image}:${tag}`, - "-t", - `${input.image}:latest`, - ".", - ], + ["build", "--target", "runtime", ...buildRefs.flatMap((ref) => ["-t", ref]), "."], { cwd: input.configDir }, ); - console.log(` Pushing ${colors.cyan(`${input.image}:${tag}`)} and ${colors.cyan(":latest")}...`); - const pushSha = await run("docker", ["push", `${input.image}:${tag}`], { - cwd: input.configDir, - capture: true, - }); - if (!pushSha || pushSha.exitCode !== 0) { - throw new Error( - `docker push ${input.image}:${tag} failed with exit code ${pushSha?.exitCode ?? "unknown"}`, - ); - } - const pushLatest = await run("docker", ["push", `${input.image}:latest`], { - cwd: input.configDir, - capture: true, - }); - if (!pushLatest || pushLatest.exitCode !== 0) { - throw new Error( - `docker push ${input.image}:latest failed with exit code ${pushLatest?.exitCode ?? "unknown"}`, - ); + console.log(` Pushing ${colors.cyan(refs.join(", "))}...`); + let digest: string | undefined; + for (const ref of refs) { + const push = await run("docker", ["push", ref], { + cwd: input.configDir, + capture: true, + }); + if (!push || push.exitCode !== 0) { + throw new Error(`docker push ${ref} failed with exit code ${push?.exitCode ?? "unknown"}`); + } + digest ??= parseDigest(push.stdout); + if (input.verbose && push.stdout.trim()) { + console.log(colors.dim(push.stdout.trim())); + } } - if (input.verbose && pushSha.stdout.trim()) { - console.log(colors.dim(pushSha.stdout.trim())); + + let latestPushed = false; + if (pushLatest) { + const pushLatestRef = await run("docker", ["push", latestRef], { + cwd: input.configDir, + capture: true, + }); + if (!pushLatestRef || pushLatestRef.exitCode !== 0) { + throw new Error( + `docker push ${latestRef} failed with exit code ${pushLatestRef?.exitCode ?? "unknown"}`, + ); + } + digest ??= parseDigest(pushLatestRef.stdout); + if (input.verbose && pushLatestRef.stdout.trim()) { + console.log(colors.dim(pushLatestRef.stdout.trim())); + } + latestPushed = true; + } else { + console.log(colors.yellow(` latest held: ${latestHoldReason}`)); } - const digest = parseDigest(pushSha.stdout) ?? parseDigest(pushLatest.stdout); if (!digest) { console.log(colors.yellow(" Could not capture the image digest from push output")); } - return { image: input.image, tag, digest }; + return { image: input.image, tag: tags[0], tags, latestPushed, digest }; } /** diff --git a/packages/everything-dev/tests/unit/image-deploy.test.ts b/packages/everything-dev/tests/unit/image-deploy.test.ts index 2d29a78f9..9b8a3d300 100644 --- a/packages/everything-dev/tests/unit/image-deploy.test.ts +++ b/packages/everything-dev/tests/unit/image-deploy.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -7,7 +7,12 @@ const { runMock } = vi.hoisted(() => ({ runMock: vi.fn() })); vi.mock("../../src/utils/run", () => ({ run: runMock })); -import { buildAndPushImage, deployImageToRailway, resolveImageRef } from "../../src/image-deploy"; +import { + buildAndPushImage, + computeImageTags, + deployImageToRailway, + resolveImageRef, +} from "../../src/image-deploy"; describe("resolveImageRef", () => { it("prefers ci.image from bos.config.json", () => { @@ -44,6 +49,37 @@ describe("resolveImageRef", () => { }); }); +describe("computeImageTags", () => { + it("always pins the sha tag", () => { + expect(computeImageTags({ shortSha: "cf2ab4d13" })).toEqual({ + tags: ["sha-cf2ab4d13"], + pushLatest: true, + }); + }); + + it("adds the exact prerelease tag and holds latest", () => { + expect(computeImageTags({ shortSha: "cf2ab4d13", version: "2.0.0-rc.0" })).toEqual({ + tags: ["sha-cf2ab4d13", "v2.0.0-rc.0"], + pushLatest: false, + latestHoldReason: "v2.0.0-rc.0 is a prerelease — :latest still serves the last stable image", + }); + }); + + it("adds exact and floating major tags plus latest on stable releases", () => { + expect(computeImageTags({ shortSha: "cf2ab4d13", version: "2.0.0" })).toEqual({ + tags: ["sha-cf2ab4d13", "v2.0.0", "v2"], + pushLatest: true, + }); + }); + + it("keeps latest behavior for unknown versions", () => { + expect(computeImageTags({ shortSha: "cf2ab4d13", version: "not-a-version" })).toEqual({ + tags: ["sha-cf2ab4d13"], + pushLatest: true, + }); + }); +}); + describe("buildAndPushImage", () => { let configDir: string; @@ -60,6 +96,12 @@ describe("buildAndPushImage", () => { return { stdout, stderr: "", exitCode: 0 }; } + function writeWorkspaceVersion(version: string) { + const pkgDir = join(configDir, "packages", "everything-dev"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ version })); + } + it("builds the runtime stage with sha and latest tags and captures the digest", async () => { runMock.mockImplementation(async (cmd: string, args: string[]) => { if (cmd === "git") return ok("cf2ab4d13"); @@ -77,6 +119,8 @@ describe("buildAndPushImage", () => { expect(result).toEqual({ image: "ghcr.io/acme/app", tag: "sha-cf2ab4d13", + tags: ["sha-cf2ab4d13"], + latestPushed: true, digest: "sha256:abc123", }); expect(runMock).toHaveBeenCalledWith( @@ -100,6 +144,76 @@ describe("buildAndPushImage", () => { ]); }); + it("holds latest and pushes the exact tag for a prerelease workspace version", async () => { + writeWorkspaceVersion("2.0.0-rc.0"); + runMock.mockImplementation(async (cmd: string, args: string[]) => { + if (cmd === "git") return ok("cf2ab4d13"); + if (cmd === "docker" && args[0] === "build") return undefined; + if (cmd === "docker" && args[0] === "push") { + return ok("digest: sha256:def456"); + } + return ok(); + }); + + const result = await buildAndPushImage({ image: "ghcr.io/acme/app", configDir }); + + expect(result).toEqual({ + image: "ghcr.io/acme/app", + tag: "sha-cf2ab4d13", + tags: ["sha-cf2ab4d13", "v2.0.0-rc.0"], + latestPushed: false, + digest: "sha256:def456", + }); + expect(runMock).toHaveBeenCalledWith( + "docker", + [ + "build", + "--target", + "runtime", + "-t", + "ghcr.io/acme/app:sha-cf2ab4d13", + "-t", + "ghcr.io/acme/app:v2.0.0-rc.0", + ".", + ], + { cwd: configDir }, + ); + const pushes = runMock.mock.calls.filter((c) => c[0] === "docker" && c[1][0] === "push"); + expect(pushes.map((c) => c[1][1])).toEqual([ + "ghcr.io/acme/app:sha-cf2ab4d13", + "ghcr.io/acme/app:v2.0.0-rc.0", + ]); + }); + + it("pushes exact, major, and latest tags for a stable workspace version", async () => { + writeWorkspaceVersion("2.0.0"); + runMock.mockImplementation(async (cmd: string, args: string[]) => { + if (cmd === "git") return ok("cf2ab4d13"); + if (cmd === "docker" && args[0] === "build") return undefined; + if (cmd === "docker" && args[0] === "push") { + return ok("digest: sha256:fa7891"); + } + return ok(); + }); + + const result = await buildAndPushImage({ image: "ghcr.io/acme/app", configDir }); + + expect(result).toEqual({ + image: "ghcr.io/acme/app", + tag: "sha-cf2ab4d13", + tags: ["sha-cf2ab4d13", "v2.0.0", "v2"], + latestPushed: true, + digest: "sha256:fa7891", + }); + const pushes = runMock.mock.calls.filter((c) => c[0] === "docker" && c[1][0] === "push"); + expect(pushes.map((c) => c[1][1])).toEqual([ + "ghcr.io/acme/app:sha-cf2ab4d13", + "ghcr.io/acme/app:v2.0.0", + "ghcr.io/acme/app:v2", + "ghcr.io/acme/app:latest", + ]); + }); + it("throws when the build fails", async () => { runMock.mockImplementation(async (cmd: string, args: string[]) => { if (cmd === "git") return ok("cf2ab4d13");