CI / CD

Bascik uses three GitHub Actions workflows: one for continuous integration on every branch push and on pull requests from forks (which also deploys the docs and the Cloudflare adapter demo), one for CodeQL code scanning (.github/workflows/codeql.yml, actions and javascript-typescript, also run weekly), and one for publishing releases to npm when a version tag is pushed.

Continuous Integration

The CI workflow (.github/workflows/ci.yml) runs on every push to any branch, and on pull requests targeting main.

yaml
on:
  push:
    branches: ["**"]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.ref == 'refs/heads/main' && format('ci-main-{0}', github.run_id) || format('ci-{0}', github.ref) }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
  • Tags never run it. A push filter that lists only branches skips tag pushes; the release workflow handles tags.
  • Each commit is tested once. A pull request from a branch of this repository is already tested by that branch's push run. The four Stage 1 jobs skip on such a pull_request event (if: (github.event_name == 'push' && github.ref != 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)), and every later job needs them directly or through another stage, so the rest of that run skips as well: a job whose needs was skipped is skipped, not failed. The push run's checks are on the branch's head commit, the same commit the pull request points at, so they show on the pull request. Its Checks list also shows the skipped pull_request jobs under the same names, and GitHub reports a skipped job as successful, so a required status check should be one the push run reports. A same-repo branch is tested as its own commit, not merged with main.
  • A fork's pull request gets Stages 1 to 4 on the merge commit (refs/pull/<n>/merge) and never Stage 5.
  • A push to main only deploys. It is a merged pull request whose branch already passed every stage, so the Stage 1 jobs skip on main and every test job after them skips too. The two deploy jobs still run: their if starts with GitHub's not-canceled status check, and a job whose if has a status check is evaluated even when the jobs it needs were skipped. On any other branch the same condition still requires every Stage 4 job to have succeeded.
  • A docs-only push runs a short path. The changes job (also in Stage 1) compares the commit with the merge base of main. When every changed file is under docs/content/, the run skips Jelly, Semgrep, the typechecks, and every test job except standards, unit-docs, integration-docs, and e2e-docs, which run one after another, and then uploads only the docs Preview. The two adapter demos wait for the next full run, and so does the Preview comment (the comment's docs link keeps pointing at the newest docs Preview). One changed file outside docs/content/ anywhere on the branch makes every later push a full run, and an empty diff or a failed git fetch counts as a full run.
  • Only a merged pull request deploys. Since main runs no tests, each deploy job on main fails before Wrangler unless github.sha came from a pull request merged into main, so a commit pushed straight to main deploys nothing. The manual emergency deploy in DEPLOYMENT.md is the bypass.
  • A newer push to a branch cancels the older run, its Preview upload included. Runs on main each get their own concurrency group, so none is canceled or left pending behind another. No job has a group of its own: a shared group holds only one pending job, so on main an older commit's deploy that queued later would cancel the newer commit's pending deploy.

It runs four gated test stages on the .nvmrc Node (26, the current LTS) and Ubuntu, with a Windows job and a Node 24.21 job inside them, then a fifth stage that deploys. Every job in a stage waits for every job in the stage before it, so CI fails fast: a type error or a spelling mistake stops the run before any test starts, a failing unit test stops it before the slower integration tests, an integration failure stops it before the end-to-end matrix, and nothing deploys until every test has passed.

Stage 1: Static Analysis, Typechecks, and Standards

  • Semgrep Static Analysis (semgrep): Security and vulnerability rulesets via semgrep-action (p/default).
  • Jelly Static Analysis (jelly): Object spread and control flow analysis via @cs-au-dk/jelly on pkg/src/index.ts.
  • Workspace Typechecks (typecheck): Runs yarn typecheck:all across pkg/, create/, docs/, extensions/vscode-bascik/, adapters/cloudflare/, lsp/, and skills-tests/.
  • Spelling & Web Standards (standards): Runs yarn check:format (Prettier), yarn check:spelling (codespell), and yarn check:standards (webhint).

Stage 2: Unit Tests

Unit tests with coverage across @bascik/bascik (yarn pkg:test:ci), create-bascik (yarn create:test:ci), bascik-docs (yarn docs:unit), bascik-skills-tests (yarn skills:unit, job unit-skills), extensions/vscode-bascik (yarn ext:unit), @bascik/adapter-cloudflare (yarn adapter:cf:test:ci), and @bascik/language-server (yarn lsp:test:ci, job unit-lsp). The Windows job (unit-windows, windows-latest) runs the framework, language server, Cloudflare adapter, and create-bascik unit tests in this stage too, because Windows is a supported platform.

Stage 3: Integration Tests and the Node 24.21 Floor

Integration tests spawn real processes, servers, browsers, and worker threads, so they start only after every unit job passes:

  • Framework Integration Tests (integration-pkg): yarn pkg:integration.
  • Docs Integration Tests (integration-docs): yarn docs:integration.
  • Agent Skills Integration Tests (integration-skills): yarn skills:integration, with Chromium installed for the bascik-refactor parity checks.
  • Node 24.21 Floor (node-floor): Builds every workspace and runs yarn typecheck:all, every workspace's unit tests, and the framework integration tests on the published engine floor, to prove the packages still work there.

If framework integration tests fail, both framework jobs upload retained profiling test directories as profiling-diagnostics-node24 or profiling-diagnostics-node24.21, kept for seven days. These artifacts contain synthetic fixtures, capture logs, manifests, and profiler output for diagnosing capture failures. Unexpected profiler failures report the original subprocess error rather than a secondary assertion; the existing HeapProfiler invalid-allocation rejection remains strict. Successful profiling tests remove their temporary reports.

Stage 4: End-to-End Test Matrix

Runs after every Stage 3 job passes. The Playwright jobs install Chromium via playwright install chromium; the extension job downloads VS Code itself. The jobs execute:

  • Framework E2E (Static): yarn pkg:e2e
  • Framework E2E (Dev Server): yarn pkg:e2e:dev, yarn pkg:e2e:dev:exec (dev exec lifecycle), yarn pkg:e2e:exec:policy (exec failure policy), and yarn pkg:e2e:exec:build (build exec lifecycle)
  • Framework E2E (Production HTTP/1.1 Server): yarn pkg:e2e:prod:http1
  • Framework E2E (Production HTTP/2 TLS Server): yarn pkg:e2e:prod:http2
  • Cloudflare Adapter E2E (local workerd): yarn adapter:cf:e2e
  • Docs Site E2E: yarn docs:e2e
  • Create Scaffold E2E: yarn create:test-site
  • VS Code Extension E2E (real editor) (e2e-extension): xvfb-run -a yarn ext:e2e, both configurations in extensions/vscode-bascik/.vscode-test.cjs in a real VS Code. @vscode/test-cli downloads the newest stable VS Code into extensions/vscode-bascik/.vscode-test/, which the job caches under the newest stable version from VS Code's update API (the ISO week when the API does not answer), so each release downloads once; xvfb-run gives the Electron app a virtual display on Linux.

Each pkg lane runs as its own job, so CI does not use the local yarn pkg:e2e:all runner, which starts the same four lanes in parallel on one machine. Each lane uses its own ports and its own output directory (BASCIK_E2E_OUT, such as pkg/e2e/dist-dev), so the two setups behave the same. pkg/e2e/scripts/run-lanes.test.ts fails when a Playwright config in pkg/e2e/ has no package script that a CI job runs, or when a published workspace's unit tests leave the Linux jobs that the end-to-end stage waits on, and pkg/e2e/scripts/ci-deploy.test.ts fails when a workspace's root <prefix>:e2e script (such as ext:e2e) is run by no CI job.

Stage 5: Deploy to Cloudflare and Netlify

Runs after every Stage 4 job passes, only for a branch push in this repository (never for a pull_request event, a tag, or a fork's own copy of the workflow), and deploys the two Cloudflare Workers this repo owns: the docs (bascik, served at bascik.dev) and the adapter demo (cloudflare-adapter, served at cloudflare-adapter.bascik.dev). It also deploys the Netlify adapter demo (served at netlify-adapter.bascik.dev): a Preview on a branch, production on main. DEPLOYMENT.md in the repository root is the runbook: credentials, manual deploys, and how to check what is live.

  • Deploy Docs (deploy-docs): checks out the full git history (docs/scripts/generate-git-dates.ts reads it for sitemap lastmod and article dates), runs yarn pkg:build && yarn docs:build, then runs Wrangler from docs/.
  • Deploy Adapter Demo (deploy-demo): runs yarn adapter:cf:demo:build, which builds the fixture in adapters/cloudflare/e2e/fixture with --target cloudflare, then runs Wrangler with --config e2e/fixture/dist/.bascik/cloudflare/wrangler.jsonc from adapters/cloudflare.
  • Deploy Netlify Adapter Demo (deploy-netlify): runs yarn adapter:nf:demo:build, then yarn workspace @bascik/adapter-netlify demo:deploy:preview --alias <alias> --json (netlify-cli is installed for that workspace, not the root) with the NETLIFY_AUTH_TOKEN secret, and smoke-tests https://<alias>--bascik-netlify-adapter.netlify.app. On main it runs the merged pull request check below, then yarn workspace @bascik/adapter-netlify demo:deploy (--prod) and checks https://netlify-adapter.bascik.dev. Branch uploads never use --prod.
  • Merged pull request check (all three deploy jobs, on main only): after the build and before the newest-commit check, runs gh api repos/<owner>/<repo>/commits/<sha>/pulls with GH_TOKEN: ${{ github.token }} and passes the answer to .github/scripts/merged-pull-request.mjs, which fails the job, naming the commit and the pull requests it found, unless one was merged into main. GitHub can take a few seconds to link a fresh merge commit to its pull request, so while the answer is an empty list the step asks up to three times, 10 s apart. An API error body is reported as an error, not read as "no pull request".
  • Publish Preview URLs (preview-comment): on a branch other than main, waits for the three deploy jobs, writes the three Preview URLs to the run's job summary, and, when the branch has an open pull request in this repository, creates or updates one comment on it that lists them and the commit they were built from (github.sha, the branch commit the run tested). A branch without a pull request gets the summary only. The job is skipped when a deploy job uploaded nothing because its commit was no longer the newest.

What each event does:

EventStage 5
Push to mainNo tests. Fails unless the commit came from a pull request merged into main; then wrangler deploy for both Workers and netlify deploy --prod for the Netlify demo, then a check of https://bascik.dev/, https://cloudflare-adapter.bascik.dev/catalog and https://netlify-adapter.bascik.dev/catalog. Never a Preview.
Push to any other branchwrangler versions upload --preview-alias <alias> for both Workers and netlify deploy --alias <alias> for the Netlify demo: Previews that do not touch production. The alias is pr-<number> when the branch has an open pull request in this repository, otherwise the branch name, so each pull request or branch keeps the same three URLs. Each Preview is then checked.
Pull request from a branch of this repoSkipped with the rest of the run; the branch's push run deploys its Previews.
Pull request from a forkSkipped. Stage 5 runs only on push, and a fork's pull request gets no secrets.
TagCI does not run.

The helpers live in .github/scripts/ and are tested by pkg/e2e/scripts/ci-deploy.test.ts in the framework unit tests:

  • preview-alias.mjs turns pr-<number> (for a branch with an open same-repository pull request) or the branch name (github.ref_name) into the alias. A name that is already a valid alias (lowercase a-z, 0-9 and -, starting with a letter, short enough) is used as is. Any other name is lowercased, every run of other characters becomes one -, a leading digit gets a b- prefix, and a hash of the full branch name is appended, so feature/x and feature-x do not share an alias and overwrite each other's Preview. A name too long for <alias>--bascik-netlify-adapter (the longest hostname label, passed as -bascik-netlify-adapter) to fit in one 63-character DNS label is shortened before the hash.
  • merged-pull-request.mjs reads the gh api list of the commit's pull requests and fails unless one has merged_at set and base.ref main, so a commit pushed straight to main never deploys.
  • newest-commit.mjs compares the commit a run built with the branch tip from git ls-remote, so a deploy job skips Wrangler when the branch has moved on.
  • wrangler-preview-url.mjs reads the Preview alias URL from Wrangler's machine-readable output (WRANGLER_OUTPUT_FILE_PATH, one JSON object per line), so no step parses Wrangler's log text.
  • netlify-preview-url.mjs reads netlify deploy --json output and returns the alias URL only when it is https://<alias>--bascik-netlify-adapter.netlify.app on the dedicated project, so a production or other-project deploy fails the job.
  • smoke-test.mjs requests each path without following redirects and fails the job unless every one answers with the expected status, retrying for about a minute while a new version propagates. The docs check includes the 301 redirects from _redirects and the 404 page.
  • preview-comment.mjs writes the job summary, looks up the branch's open pull requests (GET /repos/{owner}/{repo}/pulls?head=<owner>:<branch>&state=open), finds the comment by a hidden marker, written by github-actions[bot] only, and edits it, or creates it the first time.

On a branch a newer push cancels the running upload along with the rest of the older run, so a branch has one upload at a time. On main no deploy is canceled or waits for another, because the deploy jobs have no concurrency group of their own (a shared group would let a later-queued older deploy cancel a newer one that was pending). Runs still finish in any order, so when an older commit's tests finish after a newer commit has deployed, newest-commit.mjs, run right before Wrangler, skips the older upload or deploy instead of putting older code live. Two main deploys could only overlap if a newer commit were pushed, tested and deployed during the minute between an older run's check and the end of its wrangler deploy.

On main, wrangler deploy also attaches the custom domains in each config's routes. Wrangler runs without a terminal in CI and then replaces a conflicting custom domain or DNS record without asking, so a hostname goes into routes only once it is meant to move to that Worker.

The test also reads ci.yml and fails when a job is outside the five stages, when a job does not wait on every job of the stage before it, when an upload, deploy or smoke step is not gated on the newest-commit check, when a deploy job on main does not check for a merged pull request before Wrangler, when a job has its own concurrency group, or when the Cloudflare token reaches anything but a Stage 5 Wrangler step. It evaluates the workflow's own if: conditions for a push to main, a branch push, a tag, a same-repo pull request, a fork's pull request and a push in a fork, and fails when main would get only a Preview, a branch push would not preview both Workers, a pull request or a tag would deploy, or a same-repo pull request would be tested twice.

All jobs enforce least-privilege with permissions: contents: read. The two deploy jobs add pull-requests: read for the merged pull request check, and the Preview URL job adds pull-requests: write. The Cloudflare credentials (secret CLOUDFLARE_API_TOKEN, variable CLOUDFLARE_ACCOUNT_ID) are set only on the Wrangler steps, and NETLIFY_AUTH_TOKEN only on the Netlify upload step, not on the builds.

Release Workflow

The release workflow (.github/workflows/release.yml) triggers on version tags. The five npm packages it publishes are independently versioned and released; pushing a tag only publishes the package that tag belongs to. The VS Code extension is published to the Marketplace by the same workflow. For the step-by-step publish order, see RELEASING.md in the repository root.

PackageTag formatExample
@bascik/bascikv<semver>v1.2.0
create-bascikcreate-v<semver>create-v1.0.3
@bascik/adapter-cloudflareadapter-cloudflare-v<semver>adapter-cloudflare-v1.0.0
@bascik/adapter-netlifyadapter-netlify-v<semver>adapter-netlify-v1.0.0
@bascik/language-serverlsp-v<semver>lsp-v1.0.0
Bascik VS Code extension (Marketplace)ext-v<semver>, or ext-pre-v<semver> for a pre-releaseext-v1.0.0

Each job uses an if: guard so only the relevant package is built and published:

yaml
jobs:
  release:
    if: startsWith(github.ref_name, 'v')
    # publishes @bascik/bascik

  release-create:
    if: startsWith(github.ref_name, 'create-v')
    # publishes create-bascik

  release-adapter-cloudflare:
    if: startsWith(github.ref_name, 'adapter-cloudflare-v')
    # publishes @bascik/adapter-cloudflare

  release-adapter-netlify:
    if: startsWith(github.ref_name, 'adapter-netlify-v')
    # publishes @bascik/adapter-netlify

  release-language-server:
    if: startsWith(github.ref_name, 'lsp-v')
    # publishes @bascik/language-server

  release-extension:
    if: startsWith(github.ref_name, 'ext-')
    # publishes the VS Code extension with vsce (Microsoft Entra ID credentials)

All jobs follow the same steps: check out the full history, check that the tagged commit is on main, install dependencies, build, run tests, then publish.

Releases come only from main. The step right after checkout runs node .github/scripts/release-on-main.mjs "$GITHUB_REF_NAME" "$GITHUB_SHA". It dereferences the tag to its commit and fails the job, naming the tag and the commit, unless that commit is an ancestor of origin/main (git merge-base --is-ancestor), so a tag pushed on an unmerged branch publishes nothing. The checkout uses fetch-depth: 0, because actions/checkout otherwise fetches only the tagged commit and origin/main would be missing; the script reports that case as a checkout error, not as "not on main". pkg/e2e/scripts/release-on-main.test.ts runs the script against a real throwaway repository and fails when a publish job in release.yml lacks the check right after a full-history checkout.

Publishing to npm

The five npm packages publish to npmjs.com using GitHub Actions OIDC trusted publishing, without an npm token. Configure a trusted publisher separately for each package with organization bascikdev, repository bascik, workflow filename release.yml, no environment, and permission to run npm publish. Every job calls .github/scripts/publish-npm.sh <tag-prefix> from the package directory:

yaml
- name: Publish to npm
  working-directory: pkg
  run: ${{ github.workspace }}/.github/scripts/publish-npm.sh v

The script fails unless the git tag equals the prefix plus the version in the package's package.json, then runs npm publish --provenance --access public --tag <dist-tag>:

  • --access public: required for scoped packages and explicit for create-bascik. Each package.json also declares "publishConfig": { "access": "public" } as a belt-and-suspenders default.
  • --provenance: generates a signed attestation on npmjs.com that links the published package to the exact GitHub Actions run that built it. This requires id-token: write permission on the job and a repository.url in package.json that matches the GitHub repository.
  • --tag: latest for stable versions. A prerelease such as 1.1.0-rc.1 publishes under the identifier before the first dot (rc), so npm install <pkg> never picks it up by default.

Tagging a Release

After merging version bumps into main, run yarn release from the repository root. .github/scripts/release.mjs reads versions from fetched origin/main and pushes tags in three stages: language server and Bascik, then scaffold and both adapters (Cloudflare and Netlify), then the extension. It waits for successful Actions runs and npm registry availability between stages, stops on failures, and refuses existing tags. Use yarn release pkg lsp to select packages or yarn release --dry-run to print commands without pushing. The examples below are the manual alternative.

@bascik/bascik

Tag the commit on main, after the release pull request is merged:

sh
# 1. On a branch: bump the version in pkg/package.json, update CHANGELOG.md, commit
# 2. Merge the branch into main through a pull request
git fetch origin main
git tag v1.2.0 origin/main
git push origin v1.2.0

create-bascik

sh
# 1. On a branch: bump the version in create/package.json, commit
# 2. Merge the branch into main through a pull request
git fetch origin main
git tag create-v1.0.3 origin/main
git push origin create-v1.0.3

The adapters and @bascik/language-server

sh
# Bump the version in lsp/package.json and merge it into main through a pull request, then:
git fetch origin main
git tag lsp-v1.0.0 origin/main
git push origin lsp-v1.0.0
# After @bascik/bascik is on npm, bump the version in adapters/cloudflare/package.json and merge it, then:
git fetch origin main
git tag adapter-cloudflare-v1.0.0 origin/main
git push origin adapter-cloudflare-v1.0.0
# The same for adapters/netlify/package.json:
git tag adapter-netlify-v1.0.0 origin/main
git push origin adapter-netlify-v1.0.0

Push each tag by name, never with git push --tags, which pushes every local tag (stale ones from a dry run included) and can start a publish job out of the order in RELEASING.md.

Release candidates

Use a prerelease version and the matching tag, for example 1.1.0-rc.1 with v1.1.0-rc.1. Publish @bascik/language-server and @bascik/bascik first, then the packages that depend on them (create-bascik, @bascik/adapter-cloudflare and @bascik/adapter-netlify). Install with npm install @bascik/bascik@rc. Dependents pin a range that includes the prerelease (for example ^1.1.0-rc.1), which also accepts the final 1.1.0. A prerelease never moves the latest dist-tag, so npm create bascik and a plain npm install @bascik/bascik keep installing the current stable release.

The release workflow picks up the tag, runs tests, builds dist/ (which is not committed to git), and publishes to npm.

Prerequisite. Save each package's npm trusted-publisher configuration before pushing a release tag. The jobs already have id-token: write and the .nvmrc Node with an OIDC-capable npm CLI; no NPM_TOKEN secret is needed.