Constructdocs
section · operations

Release And Deploy

14 min read·operations / maintenance / release-and-deploy

Release and deploy automation

Policy sibling: docs/operations/maintenance/release-policy.md describes when to tag. This doc describes what fires when you do.

A consolidated reference for how Construct ships. Every artifact (npm, Docker, binaries, Homebrew, GitHub Pages, AWS smoke) has an automated path; this doc lists them, their triggers, and how to verify each is healthy.

The intent: stop re-deriving the release flow on every cycle. If a step is not automated yet, it is listed here as such, with the bead that tracks it.

Triggers

WorkflowFileTriggerOutput
ci.github/workflows/ci.ymlpush, PRTests on Ubuntu × Node 20/22 (PRs) or Ubuntu/macOS × Node 20/22 (main push/schedule/dispatch), comment + prose + profile lints, docs drift, retrieval evals, dependency CVE audit
release.github/workflows/release.ymltag v*npm publish (OIDC), Docker image (GHCR), SEA binaries (linux/darwin/windows × x64/arm64), Homebrew tap bump, GitHub Release
pages.github/workflows/pages.ymlpush to main, manualGitHub Pages docs site
docs.github/workflows/docs.ymlpush to main affecting docsAuto-regenerates AUTO doc regions
deploy.github/workflows/deploy.ymlpush to mainContainer deploy (if configured)
aws-smoke.github/workflows/aws-smoke.ymlmanualECS smoke test (gated, optional)

Pre-release channels

Construct uses semver pre-release identifiers to ship preview versions without polluting the stable line. A tag like v1.0.5-alpha.1 is detected by the release workflow and routed to a separate dist channel; the next stable tag (v1.0.5, v1.1.0) is what npm install keeps installing.

Channels

ChannelWhen to usenpm install command
latest (no suffix)Stable release. Default for everyone.npm install @geraldmaron/construct
alphaEarly experiments. Breaking changes likely. Feature not yet complete.npm install @geraldmaron/construct@alpha
betaFeature complete. Bugs likely. Looking for testers.npm install @geraldmaron/construct@beta
rcRelease candidate. Ships as-is unless a blocker surfaces.npm install @geraldmaron/construct@rc
nextCatch-all for any other pre-release suffix.npm install @geraldmaron/construct@next

Tag format

Standard semver pre-release:

v1.0.5-alpha.1
v1.0.5-alpha.2
v1.0.5-beta.1
v1.0.5-rc.1
v1.0.5            # stable, when ready

The workflow extracts the channel name from the segment after - and before the first .. Any suffix that does not match a known channel routes to next.

What runs differently for a pre-release

  • npm publish uses --tag \<channel> instead of the implicit latest, so the dist-tag for latest is unchanged.
  • Docker image gets :\<version> and :\<channel> tags. The :latest tag is NOT moved.
  • Homebrew tap bump is skipped. Homebrew tracks stable only.
  • GitHub Release is marked as pre-release in the API.

All other steps (preflight, gate, binary builds, GHCR push, Trivy scan) run identically.

Typical pre-release flow

# Bump to alpha
npm version 1.0.5-alpha.1
git push origin v1.0.5-alpha.1

# Iterate
npm version 1.0.5-alpha.2
git push origin v1.0.5-alpha.2

# Promote to beta when feature complete
npm version 1.0.5-beta.1
git push origin v1.0.5-beta.1

# Release candidate
npm version 1.0.5-rc.1
git push origin v1.0.5-rc.1

# Ship stable
npm version 1.0.5
git push origin v1.0.5

npm version updates package.json and tags in one step. Pushing the tag fires the release workflow.

The version npm lifecycle script runs scripts/sync-construct-version.mjs after the bump and stages .construct/launcher/version into the same commit, so the npx pin that project-local launchers feed to npx -p @geraldmaron/construct@\<version> never drifts behind the published release. (A stale pin 404s with ETARGET and breaks consumer hooks before any Construct code runs.) The preflight re-asserts the pin with --check; to repair drift by hand, run node scripts/sync-construct-version.mjs.

Promotion

Pre-release versions become available on their channel immediately. To promote an existing pre-release to stable without republishing, use npm dist-tag from a machine logged in via OIDC or with a publish-scoped token:

npm dist-tag add @geraldmaron/construct@1.0.5-rc.1 latest

Usually you do not promote a pre-release; you ship a clean stable tag instead so the version on latest has no - suffix.

Release flow

   bump version in package.json
              │
              ▼
    update CHANGELOG.md
              │
              ▼
   npm run release:preflight (local)
              │
              ▼
    git tag vX.Y.Z && git push origin vX.Y.Z
              │
              ▼
    .github/workflows/release.yml fires
              │
   ┌──────────┼──────────────┬──────────┬──────────┐
   ▼          ▼              ▼          ▼          ▼
  npm     binaries (x5)    Docker   Homebrew   GitHub Release
publish   linux/darwin/    GHCR     tap bump   with artifacts
 (OIDC)   windows × x64/
          arm64

Pre-release preflight

Runs locally before tagging so CI is the backstop, not the gate.

npm run release:preflight              # all checks, requires npm login
npm run release:preflight:no-auth      # skip the auth check

Validates: clean git tree, on main, .construct/launcher/version pin matches package.json, CHANGELOG entry for the version, all tests pass, comment policy, docs verify, npm audit, npm pack --dry-run, the release evidence gate (below), and (when not --no-auth) npm whoami against the OIDC environment.

Exit 0 means safe to tag. Anything else: fix locally, do not push the tag and hope.

Release evidence gate (LMCP-M5)

node scripts/release-evidence-gate.mjs             # packaging + acceptance tests
node scripts/release-evidence-gate.mjs --skip-tests # packaging only (fast; step 11 of release:preflight)

Blocking step, run by both npm run release:preflight and npm run release:check: for every capability lib/mode-capabilities.mjs's CAPABILITY_REGISTRY marks 'implemented', verifies the file(s) that implement it actually landed in the packed artifact (npm pack --json --dry-run, no tarball written) and that its registered acceptance test (tests/acceptance/modes/*.acceptance.test.mjs, tests/enterprise/audit-isolation.test.mjs) passes. A release cannot ship claiming a capability the packed artifact does not actually contain or that has stopped passing its acceptance test. Self-test: node --test tests/scripts/release-evidence-gate.test.mjs.

Release gate (in CI)

release:check job in release.yml re-runs the preflight equivalent before any artifact step:

  • npm test (full suite)
  • node bin/construct doctor
  • node ./bin/construct docs:verify
  • node ./bin/construct lint:comments
  • npm run lint:scopes -- --quiet
  • npm run test:functional
  • node scripts/release-evidence-gate.mjs --skip-tests — release evidence gate (above); packaging-only here since npm test already ran every capability's acceptance test
  • npm audit --audit-level=high
  • npm run audit:published — packs the artifact and audits a clean downstream install with no overrides in scope, catching transitive advisories a repo-local override would mask

If any of these fail, no artifacts ship. The prose lint is enforced at PR time only (changed-files scope) and is intentionally not in the release gate; running it --all on the current historical baseline would always fail until the cleanup PR (construct-fj0, construct-ze6) lands.

test:functional includes tests/functional/release-gate.functional.test.mjs, which asserts the refit invariants on HEAD (construct-d1r7.16): no implicit active model defaults (every tier resolves to not configured on a clean install), optional MCP silence (catalog-only and disabled servers raise no diagnostics), and certified document I/O (the --certified matrix must pass when every export engine is installed, degrading to the graceful local matrix on a leaner leg).

npm publish (OIDC, no stored token)

Uses GitHub Actions OIDC + npm Trusted Publishers. No NPM_TOKEN secret is set, stored, or needed. Configuration:

  • actions/setup-node@v6 is configured without registry-url. With registry-url set, setup-node injects github.token as NODE_AUTH_TOKEN, which the npm registry rejects with 404.
  • npm config set registry https://registry.npmjs.org/ runs as a separate step.
  • npm publish --provenance --access public exchanges the OIDC token for a short-lived publish token.

One-time setup on npmjs.com: package → Settings → Trusted Publishers → add GitHub Actions with owner/repo and workflow filename. See docs/operations/maintenance/release-policy.md for the full one-time setup. If the publish step ever fails with ENEEDAUTH, that setup got reverted or the workflow filename moved.

Docker image (GHCR)

  • Tag pattern: ghcr.io/geraldmaron/construct:vX.Y.Z + :latest.
  • Built and pushed from release.yml on every tag.
  • Trivy scan in the same workflow fails the release on CRITICAL or HIGH CVEs.

SEA binaries (5 platforms)

  • linux x64, linux arm64, darwin x64, darwin arm64, windows x64.
  • Built with node --experimental-sea-config.
  • Uploaded to the GitHub Release for download.
  • Homebrew tap formula bump uses these binaries (linux + darwin only).

Homebrew tap

  • geraldmaron/homebrew-construct repo holds Formula/construct.rb.
  • release.yml last step bumps the formula's urls and sha256s for each platform.
  • Requires HOMEBREW_TAP_TOKEN secret with push access to the tap repo.
  • Gated on vars.HOMEBREW_TAP_ENABLED == 'true'.
  • This is the Node-SEA formula (templates/homebrew/construct.rb), wired into release.yml today. See the Bun-compiled-binary track below for the formula that references Bun binaries instead — the two are not interchangeable until one is chosen as the shipped binary (ADR-0064).

Bun-compiled binaries (parallel track, construct-rf26.19)

ADR-0064 affirms bun build --compile as the primary distribution path going forward, with Node SEA (above) as the recorded fallback if Bun's native-module compatibility ever breaks for LanceDB's N-API bindings or the MCP SDK. This track exists alongside the SEA pipeline and does not gate it — release.yml still ships SEA binaries and the Node-SEA Homebrew formula; nothing here runs on a tag push yet.

Build: node scripts/build-binary.mjs [target] — targets: darwin-arm64, darwin-x64, linux-x64, linux-arm64. Requires Bun on PATH (curl -fsSL https://bun.sh/install | bash). Compiles from a temporary .mjs-suffixed copy of bin/construct (Bun's --compile bundler only traverses an entry's imports when the entry has a recognized extension) and smoke-tests the result with construct doctor when the build host matches the target arch; cross-compiled targets are produced but not executed on a foreign host.

Two Bun-compile-specific gaps (construct-qvou, fixed in lib/roots.mjs): every bundled module's import.meta.url/process.argv[1] collapses to the same virtual /$bunfs/root path under --compile, which broke (a) install-root resolution for every skills//specialists//templates//config//registry/ read, and (b) the import.meta.url === file://${argv[1]} "was I run directly" idiom used by ~19 lib/*.mjs files that double as standalone scripts (all evaluated true simultaneously). resolveInstallRoot() and isMainModule() in lib/roots.mjs are the fix; both assume the data directories ship next to the binary (true for dist/\<binary>, one level below the checkout root).

CI smoke: .github/workflows/bun-binary-smoke.ymlworkflow_dispatch or on changes to the build scripts / bin/construct / lib/roots.mjs. Builds one target per matrix leg (linux-x64 on ubuntu-latest, darwin-arm64 on macos-15) and asserts real output, not just exit code: construct doctor must print a Results: N passed, N warnings, N failed line, and a demo-flow step runs construct sandbox create/list/delete end to end, asserting on each command's actual stdout text. Kept separate from ci.yml/release.yml so a regression here never blocks an unrelated release.

Curl installer: scripts/install.sh — detects OS/arch, downloads construct-\<os>-\<arch> + its .sha256 sidecar from a GitHub Release, verifies the checksum, installs to /usr/local/bin (or ~/.local/bin, or $CONSTRUCT_INSTALL_DIR). Same URL scheme SEA binaries already publish under (releases/download/\<tag>/construct-\<os>-\<arch>), so it works unchanged once Bun binaries are attached to a release. CONSTRUCT_REPO and CONSTRUCT_VERSION env vars override the repo and pinned version.

Homebrew formula: Formula/construct.rb — separate from templates/homebrew/construct.rb (the live Node-SEA formula); references Bun binary release assets. Not yet pushed to the tap repo or wired into release.yml's Homebrew bump step.

npm downloader shim: bin/construct-shim.mjs implements the ADR-0064 "npm demoted to downloader shim" design — detect platform/arch, resolve a cached-or-downloaded Bun binary (same URL/checksum scheme as the curl installer, cached under lib/config/xdg.mjs's cacheDir() keyed by package version), exec it with argv/exit-code passthrough, and fall back to running the real Node CLI (rather than erroring silently) on an unsupported platform or a failed download/checksum. Not wired as package.json's published bin entry. Five existing install/acceptance tests (tests/acceptance/global-install.test.mjs, packed-install.test.mjs, tests/functional/install-scope.functional.test.mjs, install-parity.functional.test.mjs, install-legacy-global-cleanup.functional.test.mjs) spawn node bin/construct ... directly and assert on synchronous, network-independent stdout within tight timeouts; flipping the bin mapping needs those updated deliberately (mocked binaries or an offline-first fallback) so a real npm install -g does not regress into a flaky, network-dependent install. The shim's own logic is covered by tests/functional/construct-shim.functional.test.mjs — cache hit/miss, checksum mismatch, network failure, and the CONSTRUCT_BIN_OVERRIDE and unsupported-platform fallback paths, the latter proven against the real bin/construct CLI, not a stub.

What is genuinely verified vs. what is not (be precise here — do not restate this as "done"):

  • Verified directly on this machine: bun build --compile for darwin-arm64, executed, construct doctor and construct sandbox create/list/delete (the CI smoke workflow's demo flow) both produce real, correct output against the compiled binary.
  • Verified by cross-compilation only, not execution: darwin-x64, linux-x64, linux-arm64 binaries build successfully but have not been run on their native architecture from this environment.
  • Not verified here (needs real infrastructure): the GitHub Actions matrix itself (predicted to pass from a local dry run of its exact commands, not observed running in CI); scripts/install.sh against a real GitHub Release and a clean VM; a real npm install -g of a shim-based package from the npm registry; the Bun binary's behavior on Windows (unsupported — no target exists) or on any Linux distro other than whatever ubuntu-latest/this dev machine represent.

Branch flow: feature → staging → main

Construct uses the standard environment-promotion model. Promotion flows upward — work integrates on staging (pre-production) and is promoted to main (production).

BranchRoleDeploys to
feature / fix/* / research/*unit of work— (CI only)
stagingintegration / pre-production gatestaging environment (when provisioned — see below)
mainproductionconstruct-production via deploy.yml on push to main

How work ships

  1. Branch off staging and do the work.

  2. Open a PR into staging. CI must pass (ci-required, secret scanning); merge when green. This is the integration gate — multiple features land here and are validated together before production.

  3. Promote to production with a single staging → main PR. Merging it is the deliberate, reviewed step that triggers the production deploy. Open the promotion PR on demand:

    gh workflow run "Promote staging → main"
    # or manually:
    gh pr create --base main --head staging --title "release: promote staging → main"
    

main is never targeted by feature PRs directly. A hotfix that must bypass staging is an explicit, documented exception (PR straight to main), not the default.

Staging deploy (follow-up)

staging is the pre-production gate but does not yet have its own deploy target — only production (main) deploys today. Provisioning a staging environment (a parallel ECS service + Terraform workspace under deploy/terraform/environments/staging, deployed on push to staging) is the remaining step to make staging a true running pre-prod mirror. Until then, staging gates by CI + review, not by a live environment.

Historical: a sync-main-to-staging workflow previously mirrored main into staging (the inverse, downstream direction). It was removed when adopting this model — staging now leads main, not trails it.

Pre-push gate (local, runs before every push)

lib/hooks/pre-push-gate.mjs runs before any git push:

  • npm test
  • npm audit --omit=dev --audit-level=high
  • node bin/construct evals retrieval
  • node bin/construct docs:verify
  • npm run lint:scopes --quiet

Failures here block the push. There is no bypass env var: fix the underlying issue (or, for the SHA-aware re-push check, add a fix commit so HEAD advances past the rejected SHA).

When something fails

SymptomLikely causeFix
npm publish 404 / ENEEDAUTHOIDC misconfiguration; setup-node injected github.tokenConfirm registry-url is not in the setup-node step; re-verify Trusted Publishers on npmjs.com
Homebrew bump fails to pushHOMEBREW_TAP_TOKEN lacks push permsRegenerate the token with write access to geraldmaron/homebrew-construct
release:check fails on docs:verifyNew code without doc updateUpdate the affected doc, regenerate AUTO regions, recommit
Docker CVE scan blocks releaseNew high/critical CVEBump the affected dep or wait for an upstream patch; do not lower the severity threshold
Trivy @master warningAction drifted off pinned versionThe action is intentionally pinned to a specific v0.x.y release; ignore the prompt to use @master
Sync main → staging fails with not permitted to create or approve pull requestsRepo policy "Allow GitHub Actions to create and approve pull requests" is offEnable in Settings → Actions → General → Workflow permissions (or gh api -X PUT /repos/\<owner>/\<repo>/actions/permissions/workflow -F can_approve_pull_request_reviews=true); re-run via gh workflow run "Sync main → staging" --ref main
Sync PR has no CI checks; branch protection blocks mergeSync workflow pushed via GITHUB_TOKEN (anti-loop guard); downstream CI never firesSet the SYNC_PR_TOKEN repo secret (fine-grained PAT) so future syncs trigger CI. For the current PR, admin-merge OR push an empty commit to the sync branch
npm audit fails the release gate on a workspace-scoped depRelease gate is mis-scoped (was running with no --workspaces=false)Confirm release.yml uses --omit=dev --audit-level=high --workspaces=false to match ci.yml. Workspace-scoped vulns (apps/docs, dashboard) belong on their own remediation track, not the CLI release gate

Patterns codified (so we do not redo this manually)

  • No manual release-state docs. This file is the canonical reference. Any release-flow change updates this doc in the same PR.
  • Preflight before push. npm run release:preflight is the contract; CI is the backstop.
  • OIDC, no stored tokens. Trusted Publishers settings are documented; no secret rotation needed.
  • Doctor as a release gate. construct doctor runs in CI before artifacts ship. Adding a check to doctor automatically gates the next release.
  • lint:scopes + functional tests all run in CI; new categories of gate (e.g. when a B4 ships) follow the same wiring pattern.
  • All artifacts on a tag push. Tagging is the one action that ships everything; no separate "release the docker image" step exists.
  • Tests/AUDIT.md is refreshed when the suite passes 2000 tests or a new top-level test category lands, not every PR.
  • Release-policy in docs/operations/maintenance/release-policy.md captures what counts as a release (vs a doc tweak that does not need a version bump).

When this doc itself is out of date

Symptoms: a release step that runs in CI but is not listed here; a workflow file added in .github/workflows/ without a row in the table at top. Fix by editing this doc in the same PR that adds the workflow. The contract is: every release-affecting change updates this file.