diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d886cf2..aaeea2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,92 @@ name: CI +# A pull request runs the whole suite (build, postgres18 and browser, the checks +# master's rules require). A push to master, a merged pull request, runs the +# quick job alone: lint, type check and the tests the push changed. The release +# workflow calls this workflow (workflow_call) before each daily release, which +# runs the whole suite with coverage on master and uploads it to Codecov. A +# called workflow sees its caller's event (schedule or workflow_dispatch) in +# github.event_name, so the whole suite runs there. on: push: branches: [ master ] pull_request: branches: [ master ] + workflow_call: + secrets: + CODECOV_TOKEN: + required: false jobs: + quick: + if: github.event_name == 'push' + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:0.8.7-pg16 + env: + POSTGRES_USER: adapter + POSTGRES_PASSWORD: adapter + POSTGRES_DB: adapter_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U adapter -d adapter_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + TZ: Europe/Berlin + POSTGRES_TEST_URL: postgresql://adapter:adapter@localhost:5432/adapter_test + SYNC_TEST_REQUIRE_POSTGRES: '1' + SYNC_TEST_POSTGRES_URL: postgresql://adapter:adapter@localhost:5432/adapter_test + steps: + - uses: actions/checkout@v5 + with: + # The commit before the push, which the changed-tests run compares with. + fetch-depth: 0 + - uses: actions/setup-node@v5 + with: + node-version: '22' + + # Seconds, and outside Vitest: the release workflow's suite runs only when + # the package changed, so a push that changes only the workflows is + # checked here. + - name: No workflow pushes to master + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + node --test .github/scripts/check-workflow-pushes.test.mjs + node .github/scripts/check-workflow-pushes.mjs + + - name: Install dependencies + run: npm install + + - name: Lint + run: npm run lint || true + + - name: TypeCheck + run: npm run typecheck + + # The specs whose files, or the modules they import, the push changed. + # Coverage is off here: vitest.config.ts turns it on with thresholds over + # all of src/, which a few specs alone need not meet. The release run's + # whole suite collects it. + - name: Run the tests the push changed + env: + BEFORE: ${{ github.event.before }} + run: | + since="$BEFORE" + if [ -z "$since" ] || [ "$since" = 0000000000000000000000000000000000000000 ] || ! git cat-file -e "${since}^{commit}" 2>/dev/null; then + echo "The commit before the push is not in the history; running every test" + npx vitest run --coverage.enabled=false + else + echo "Tests affected by the changes since ${since}" + npx vitest run --changed "$since" --passWithNoTests --coverage.enabled=false + fi + build: + if: github.event_name != 'push' runs-on: ubuntu-latest # The Postgres specs that need a server (createDatabase.postgres, # postgres.integration, postgres.dialect.integration, postgres.segment, @@ -252,6 +331,7 @@ jobs: postgres18: + if: github.event_name != 'push' runs-on: ubuntu-latest services: postgres: @@ -284,6 +364,7 @@ jobs: # key-value module, on the browsers' own IndexedDB, Web Locks and structured # clone: Chromium, WebKit and Firefox, through Playwright. browser: + if: github.event_name != 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca03f26..c381ccd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,25 @@ name: Release sql-storage-adapter +# A release runs once a day, at 10:17 UTC, when master holds releasable +# changes, and whenever a maintainer starts this workflow by hand on master +# (Actions, Release sql-storage-adapter, Run workflow; or +# `gh workflow run release.yml --ref master`). A merge to master publishes +# nothing by itself: ci.yml's quick checks run on it, and the next daily run +# publishes what it changed. GitHub may start a scheduled run some minutes late +# when Actions is under load. RELEASING.md has the whole flow. on: - push: - branches: - - master + schedule: + - cron: '17 10 * * *' workflow_dispatch: + inputs: + force: + description: Skip the releasable-changes check and run the suite and the release job (the release job still decides the version, and publishes nothing when nothing changed) + type: boolean + default: false + dry_run: + description: Run everything up to the publish and stop there (no npm publish, tag or GitHub release) + type: boolean + default: false # One release run at a time, in the order the runs began to wait: a run that # starts while another runs waits for it, and up to 100 wait (queue: max), so a @@ -16,11 +31,81 @@ concurrency: cancel-in-progress: false queue: max +permissions: + contents: read + jobs: - publish: + # Seconds, with git and Node alone: nothing is installed. It runs the release + # decision (scripts/release-version.mjs) and ends the run green when nothing + # is releasable. + gate: + name: Releasable changes # A run started by hand on another branch publishes nothing. if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest + permissions: + contents: read + outputs: + releasable: ${{ steps.decide.outputs.releasable }} + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + # Every commit and tag: the decision finds the commit the newest + # version was published from and compares master with it. + fetch-depth: 0 + persist-credentials: false + + - name: Use Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + + - name: Read the release decision + id: plan + run: node scripts/release-version.mjs + + - name: Decide whether to run the suite and the release + id: decide + env: + PUBLISH: ${{ steps.plan.outputs.publish }} + VERSION: ${{ steps.plan.outputs.version }} + REPAIR: ${{ steps.plan.outputs.repair }} + FORCE: ${{ inputs.force == true }} + run: | + if [ "$FORCE" = true ]; then + releasable=true + reason="force is set, so the check is skipped; the release job decides the version again" + elif [ "$PUBLISH" = true ]; then + releasable=true + reason="master holds releasable changes: ${VERSION} would be published" + elif [ -n "$REPAIR" ]; then + releasable=true + reason="no tag names v${REPAIR}, the newest published version: the release job makes its GitHub release" + else + releasable=false + reason="nothing releasable on master since the newest published version; no install, build or test runs" + fi + echo "Releasable: ${releasable} (${reason})" + echo "releasable=${releasable}" >> "$GITHUB_OUTPUT" + echo "Releasable: ${releasable} (${reason})" >> "$GITHUB_STEP_SUMMARY" + + # The full suite of ci.yml on this commit, with coverage uploaded to Codecov, + # which keeps master's coverage badge current. + suite: + needs: gate + if: needs.gate.outputs.releasable == 'true' + permissions: + contents: read + uses: ./.github/workflows/ci.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + publish: + needs: [gate, suite] + # Only after the suite passed on a releasable master. + if: needs.gate.outputs.releasable == 'true' && needs.suite.result == 'success' + runs-on: ubuntu-latest permissions: contents: write packages: write @@ -45,10 +130,11 @@ jobs: node-version: 22 registry-url: https://registry.npmjs.org - # A push that changed a file the package ships publishes the newest - # published version with its patch raised by one, or package.json's - # version when that is higher; only the run of master's head publishes - # (RELEASING.md). + # The decision again, at publish time: a file the package ships changed + # since the newest published version publishes that version with its patch + # raised by one, or package.json's version when that is higher. Only a run + # of master's head publishes: when master moved while the suite ran, this + # run publishes nothing and the next one publishes (RELEASING.md). - name: Decide the version to publish id: plan run: node scripts/release-version.mjs --notes "$RUNNER_TEMP" @@ -57,10 +143,6 @@ jobs: if: steps.plan.outputs.publish == 'true' run: npm install - - name: Run tests - if: steps.plan.outputs.publish == 'true' - run: npx vitest run - - name: Build package if: steps.plan.outputs.publish == 'true' run: npm run build @@ -72,19 +154,38 @@ jobs: VERSION: ${{ steps.plan.outputs.version }} run: npm version "$VERSION" --no-git-tag-version --allow-same-version + - name: Stop before the publish (dry run) + if: inputs.dry_run == true + env: + PUBLISH: ${{ steps.plan.outputs.publish }} + VERSION: ${{ steps.plan.outputs.version }} + REPAIR: ${{ steps.plan.outputs.repair }} + run: | + if [ "$PUBLISH" = true ]; then + npm pack --dry-run + line="Dry run: would publish ${VERSION}; nothing was published, tagged or released." + else + line="Dry run: nothing to publish." + fi + if [ -n "$REPAIR" ]; then + line="${line} Would make the missing GitHub release of v${REPAIR}." + fi + echo "$line" + echo "$line" >> "$GITHUB_STEP_SUMMARY" + # Trusted publishing needs npm 11.5.1 or later. - name: npm 11.20.0 for trusted publishing - if: steps.plan.outputs.publish == 'true' + if: steps.plan.outputs.publish == 'true' && inputs.dry_run != true run: npm install -g npm@11.20.0 && npm --version - name: Publish to npm - if: steps.plan.outputs.publish == 'true' + if: steps.plan.outputs.publish == 'true' && inputs.dry_run != true run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub release - if: steps.plan.outputs.release != '' + if: steps.plan.outputs.release != '' && inputs.dry_run != true uses: ncipollo/release-action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -98,7 +199,7 @@ jobs: # The newest published version when no tag names it: a run that published # it and failed before its release. - name: Make the missing release of the newest published version - if: steps.plan.outputs.repair != '' + if: steps.plan.outputs.repair != '' && inputs.dry_run != true uses: ncipollo/release-action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08d5777..d3045a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -192,10 +192,10 @@ docs: update README with Capacitor examples ## Release Process -Every push to `master` that changes what the package ships publishes it. The [release workflow](https://github.com/framerslab/sql-storage-adapter/blob/master/.github/workflows/release.yml) decides the version before it installs anything: the newest published version with its patch raised by one, or `package.json`'s version when that is higher. It then installs, runs the tests, builds, publishes to npm and creates the GitHub release `v`, which creates the tag. Nothing is committed to `master`. [RELEASING.md](./RELEASING.md) has the whole decision. +A release runs daily at 10:17 UTC when `master` holds changes to what the package ships since the newest published version; a merge to `master` runs quick checks and publishes nothing by itself. The [release workflow](https://github.com/framerslab/sql-storage-adapter/blob/master/.github/workflows/release.yml) decides the version before it installs anything: the newest published version with its patch raised by one, or `package.json`'s version when that is higher. It then runs the whole CI suite, builds, publishes to npm and creates the GitHub release `v`, which creates the tag. Nothing is committed to `master`. To release now, start it by hand on `master` (`gh workflow run release.yml --ref master`; `-f dry_run=true` stops before the publish). [RELEASING.md](./RELEASING.md) has the whole decision. -- **A fix** needs no release step: its merge publishes the next patch. Add its notes to `CHANGELOG.md` under `## [Unreleased]`. -- **A new minor or major version**: raise `version` in `package.json` in the pull request, following [semantic versioning](https://semver.org/) (MAJOR for breaking changes, 1.0.0 to 2.0.0; MINOR for new features, backward compatible, 1.0.0 to 1.1.0), and add a section to `CHANGELOG.md` headed `## [x.y.z]` with what was added, changed, deprecated, removed and fixed. The merge publishes that version, with that section as its release notes. +- **A fix** needs no release step: the next daily run after its merge publishes the next patch. Add its notes to `CHANGELOG.md` under `## [Unreleased]`. +- **A new minor or major version**: raise `version` in `package.json` in the pull request, following [semantic versioning](https://semver.org/) (MAJOR for breaking changes, 1.0.0 to 2.0.0; MINOR for new features, backward compatible, 1.0.0 to 1.1.0), and add a section to `CHANGELOG.md` headed `## [x.y.z]` with what was added, changed, deprecated, removed and fixed. The next daily run after the merge publishes that version, with that section as its release notes. Do not push a version tag by hand: the workflow creates it with the GitHub release. diff --git a/README.md b/README.md index 6b3ab90..b10cdd6 100644 --- a/README.md +++ b/README.md @@ -1022,11 +1022,11 @@ TLS follows pg's, and so does the check of the server. pg's TLS is Node's, which ## CI, Releases, and Badges - GitHub Actions workflows: - - `ci.yml` runs on pushes and pull requests to `master`: lint, type check, tests with coverage and the build, then it packs the package, installs the tarball into a new ES module project and type-checks a consumer with `--module nodenext --moduleResolution nodenext`. The job starts a PostgreSQL 16 service, and the specs that need a server read its URL from `POSTGRES_TEST_URL`. - - `release.yml` runs on every push to `master`: when a file the package ships changed since the newest published version, it publishes the next patch to npm (or `package.json`'s version when that is higher), tags the commit (`vX.Y.Z`) and creates the GitHub Release. + - `ci.yml` runs on pull requests to `master`: lint, type check, tests with coverage and the build, then it packs the package, installs the tarball into a new ES module project and type-checks a consumer with `--module nodenext --moduleResolution nodenext`. The job starts a PostgreSQL 16 service, and the specs that need a server read its URL from `POSTGRES_TEST_URL`. A push to `master` runs its quick job alone: lint, type check and the tests the push changed. + - `release.yml` runs daily at 10:17 UTC: when a file the package ships changed since the newest published version, it runs the whole of `ci.yml` (coverage uploaded to Codecov), then publishes the next patch to npm (or `package.json`'s version when that is higher), tags the commit (`vX.Y.Z`) and creates the GitHub Release. When nothing is releasable the run ends in seconds. Start it by hand to release now: `gh workflow run release.yml --repo framerslab/sql-storage-adapter --ref master` (inputs `force` and `dry_run`). - Check badge health whenever builds fail: - CI badge should be green for `master`. - - Codecov badge updates after coverage reports upload. + - Codecov badge updates when a release run uploads coverage for `master`. - npm badge reflects the latest published version. - Manual verification commands: ```bash diff --git a/RELEASING.md b/RELEASING.md index 668853b..df7cc01 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,30 +4,46 @@ ## What starts a release -Every push to `master`. A maintainer can also start the workflow by hand on `master`; started on another branch, it publishes nothing. +A schedule: the workflow runs once a day at 10:17 UTC, and publishes when `master` holds releasable changes since the newest published version. GitHub can start a scheduled run some minutes late when Actions is under load ([GitHub's events page](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule)). A merge to `master` publishes nothing by itself: `ci.yml`'s quick checks run on it (lint, type check and the tests the push changed), and the next daily run publishes it. GitHub turns off a public repository's scheduled workflows after 60 days with no activity in it; the Actions tab turns this one on again. + +To release now, start the workflow by hand on `master`: Actions, Release sql-storage-adapter, Run workflow, or + +```bash +gh workflow run release.yml --repo framerslab/sql-storage-adapter --ref master +``` + +Two inputs, both off by default: + +- `force` skips the releasable-changes check and runs the suite and the release job. The release job still decides the version, so a run with nothing changed publishes nothing. +- `dry_run` runs everything up to the publish (the check, the suite, the decision, the build and `npm pack --dry-run`) and stops before any publish, tag or GitHub release: `gh workflow run release.yml --ref master -f dry_run=true`. + +Started on another branch, the workflow publishes nothing. ## What the workflow does Its runs go one at a time, in the order they began to wait: a run that starts while another runs waits for it, and up to 100 wait, so a newer run never replaces one still waiting. -1. Decides what to publish, with [`scripts/release-version.mjs`](https://github.com/framerslab/sql-storage-adapter/blob/master/scripts/release-version.mjs), before it installs anything. Only the run of master's head decides: a run of an older commit (a re-run, or a push master moved past while the run waited) publishes nothing, since master's head holds its changes and that commit's own run publishes them. The newest published version is the highest of the versions npm holds and the `v` tags (a run's tag is read at once, while npm's documents can name the older state for minutes after a publish), and the commit it was published from is its tag's, or npm's `gitHead` for it. +1. **Releasable changes** (the `gate` job, seconds, with git and Node alone: nothing is installed) runs the decision below. When it finds nothing to publish and no missing release, the run ends there, green, and its summary says why. +2. **Suite** calls `ci.yml` (its `workflow_call` trigger) on the same commit: the whole suite of a pull request, with coverage uploaded to Codecov, which keeps `master`'s coverage badge current. The release job runs only when it passes. +3. **Publish** decides what to publish again, with [`scripts/release-version.mjs`](https://github.com/framerslab/sql-storage-adapter/blob/master/scripts/release-version.mjs), before it installs anything. Only the run of master's head decides: a run of an older commit (a re-run, or a run whose commit master moved past while it waited or tested) publishes nothing, since master's head holds its changes and the next run publishes them. The newest published version is the highest of the versions npm holds and the `v` tags (a run's tag is read at once, while npm's documents can name the older state for minutes after a publish), and the commit it was published from is its tag's, or npm's `gitHead` for it. - When `package.json` names a version above the newest published one, that version is published. - Otherwise, when a file the package ships changed since that commit, the newest published version with its patch raised by one is published. The files it ships and builds from are `src/`, `package.json`, `README.md`, `LICENSE`, `tsconfig.json`, `tsconfig.build.json`, `scripts/fix-esm-imports.mjs` and `scripts/build-cjs.mjs`. - - Otherwise nothing is published: a push that changed only the tests, the docs outside the package, `CHANGELOG.md` or CI publishes nothing. + - Otherwise nothing is published: changes to only the tests, the docs outside the package, `CHANGELOG.md` or CI publishes nothing. - When npm already holds the version the run would publish, that version is the newest published one and the decision is made again from its commit, so a run never publishes past a version it has not compared with its own commit. - A commit whose history does not hold the commit the newest version was published from fails the run, which says to raise `package.json`'s version above that version, or to delete a tag npm holds no version for. - When no tag names the newest published version (a run published it and failed before its release), the run makes that version's GitHub release and tag at the commit it was published from, beside any version it publishes. -2. Installs the dependencies, runs the tests and builds the package. -3. Writes the version into its checkout's `package.json` and publishes the package to npm with a provenance statement. Nothing is committed to `master`: its `package.json` keeps the version a maintainer last named, while npm and the `v` tags hold the published ones. -4. Creates the GitHub release `v` on the published commit, with the notes of that version's `## []` section in `CHANGELOG.md`, or, when it has none, the commits since the previous version that changed what the package ships. The release creates the tag. + - When `master` moved while the suite ran, this run is no longer the run of master's head and publishes nothing; the next daily run, or one started by hand, publishes. +4. Installs the dependencies and builds the package (`npm publish` runs the tests again through `prepublishOnly`). +5. Writes the version into its checkout's `package.json` and publishes the package to npm with a provenance statement. Nothing is committed to `master`: its `package.json` keeps the version a maintainer last named, while npm and the `v` tags hold the published ones. +6. Creates the GitHub release `v` on the published commit, with the notes of that version's `## []` section in `CHANGELOG.md`, or, when it has none, the commits since the previous version that changed what the package ships. The release creates the tag. Each run's job summary says what it published, or why it published nothing. ## Releasing a version -A merged change to what the package ships needs no step: its push publishes the next patch. +A merged change to what the package ships needs no step: the next daily run publishes the next patch, with every other change merged since the newest published version. -For a new minor or major version, raise `version` in `package.json` in the pull request (semantic versioning: a breaking change raises the major version, a new feature the minor version) and add the version's section to `CHANGELOG.md`, headed `## [x.y.z]`. Its merge publishes that version. +For a new minor or major version, raise `version` in `package.json` in the pull request (semantic versioning: a breaking change raises the major version, a new feature the minor version) and add the version's section to `CHANGELOG.md`, headed `## [x.y.z]`. The next daily run after its merge publishes that version. A pull request's notes for `CHANGELOG.md` go under `## [Unreleased]`, which a maintainer heads with the version that published them. diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 4fe5463..01ff446 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node -// Decides what a push to master publishes, for .github/workflows/release.yml, -// which runs it before it installs anything: it uses Node's own modules alone. +// Decides what master publishes, for .github/workflows/release.yml, which runs +// it daily (and when started by hand) before it installs anything: it uses +// Node's own modules alone. // -// A push publishes when a file the package ships changed since the newest +// A run publishes when a file the package ships changed since the newest // published version, or when package.json names a version above every // published one. The version is package.json's in the second case and the // newest published version with its patch raised by one in the first. The @@ -23,8 +24,9 @@ // master's package.json keeps the version a maintainer last named. // // Only the run of master's head publishes: a run of an older commit, such as -// a re-run, publishes nothing, since master's head holds its changes and that -// commit's own run publishes them. +// a re-run or a run whose commit master moved past while it tested, publishes +// nothing, since master's head holds its changes and the next run publishes +// them. // // Usage: node scripts/release-version.mjs [--notes ] // Writes publish, version, base, base_commit, release, release_commit, repair @@ -81,14 +83,14 @@ export function raisePatch(version) { * before its release), whose release and tag it makes at repairCommit. * * tip is master's head when the run decides: a run of an older commit - * publishes nothing, since the tip's own run carries its changes. commitOf + * publishes nothing, since the next run of the tip carries its changes. commitOf * (version) resolves to { commit, tagged } or null. */ export async function planRelease({ packageVersion, latest, published = [], tagVersions, head, tip, commitOf, isAncestor, shippedChanged, isPublished }) { const skipped = { publish: false, version: null, base: null, baseCommit: null, release: null, releaseCommit: null, repair: null, repairCommit: null }; if (tip && tip !== head) { if (await isAncestor(head, tip)) { - return { ...skipped, reason: `master has moved past this commit to ${tip.slice(0, 8)}, whose own run publishes this one's changes` }; + return { ...skipped, reason: `master has moved past this commit to ${tip.slice(0, 8)}, whose run publishes this one's changes` }; } throw new Error(`master's tip ${tip.slice(0, 8)} does not hold this commit ${head.slice(0, 8)}: only master publishes`); }