Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Changesets

Releases of the public npm packages (`@maple-dev/effect-sdk`, `@maple-dev/browser`,
`@maple-dev/alchemy`) are driven by changesets. Run `bunx changeset` in a PR that changes one
of them, pick the bump, and describe the change for the CHANGELOG.

On `main`, `.github/workflows/release.yml` keeps a "Version Packages" PR open. Merging it
publishes every bumped package to npm (`scripts/publish-packages.ts`) and tags the release.
12 changes: 12 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://unpkg.com/@changesets/config@4.0.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@maple/landing", "@maple/clickhouse-cli", "@effect-router/core"],
"privatePackages": { "version": false, "tag": false }
}
59 changes: 59 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Release

# Keeps a "Version Packages" PR open on main while changesets are pending. Merging it bumps
# the public npm packages, so the next run finds unpublished versions and publishes them
# (scripts/publish-packages.ts), then pushes the tags and cuts GitHub releases.

on:
push:
branches: [main]
paths:
- ".changeset/**"
- "packages/effect-sdk/package.json"
- "packages/browser/package.json"
- "packages/alchemy-maple/package.json"
- "scripts/publish-packages.ts"
- ".github/workflows/release.yml"
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false

permissions:
contents: read

jobs:
release:
name: Version or publish
runs-on: ubuntu-latest
timeout-minutes: 20
environment: npm-publish
permissions:
contents: write # version commit, tags, GitHub releases
pull-requests: write # the Version Packages PR
id-token: write # npm trusted publishing + provenance
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
- run: bun install --frozen-lockfile
- name: Configure npm token fallback
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -n "$NODE_AUTH_TOKEN" ]; then
echo '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}' > "$HOME/.npmrc"
fi

- uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2
with:
version-script: bun run changeset:version
publish-script: bun run changeset:publish
commit-message: "chore(release): version packages"
pr-title: "chore(release): version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Fallback until each package has npm trusted publishing configured
# (a brand-new package needs its first publish before that is possible).
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: "true"
43 changes: 43 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"alchemy:deploy:pr": "bun run alchemy:build-deps && alchemy deploy --yes --adopt --stage pr-${PR_NUMBER}",
"alchemy:destroy:pr": "bun run alchemy:build-deps && alchemy destroy --yes --stage pr-${PR_NUMBER}",
"test": "turbo test",
"changeset": "changeset",
"changeset:version": "changeset version && bun install --lockfile-only",
"changeset:publish": "bun run ./scripts/publish-packages.ts",
"tinybird:manifest": "bun run ./scripts/generate-tinybird-project-manifest.ts",
"tinybird:manifest:check": "bun run ./scripts/generate-tinybird-project-manifest.ts --check",
"tinybird:datafiles": "bun run ./scripts/write-tinybird-datafiles.ts",
Expand All @@ -51,6 +54,7 @@
"pg": "^8.23.1"
},
"devDependencies": {
"@changesets/cli": "3.0.3",
"@cloudflare/workers-types": "catalog:alchemy",
"@effect/platform-node": "catalog:effect",
"@effect/tsgo": "0.48.1",
Expand Down
7 changes: 7 additions & 0 deletions packages/alchemy-maple/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @maple-dev/alchemy

## 0.2.0

### Minor Changes

- Initial release.
137 changes: 137 additions & 0 deletions scripts/publish-packages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Publish step of `.github/workflows/release.yml` (the changesets/action `publish-script`).
*
* `changeset publish` shells out to `npm publish` in a Bun workspace, which ships `workspace:*`
* and `catalog:` ranges verbatim. So this packs each package with `bun pm pack` (which rewrites
* them) and publishes the tarball with npm, which owns OIDC trusted publishing and provenance.
*
* Publishes every public workspace package whose current version is not on npm yet, then tags it
* and reports the tag to the action through `CHANGESETS_OUTPUT` so it can push the tag and cut a
* GitHub release. `--dry-run` packs and runs `npm publish --dry-run` without tagging.
*/
import { appendFileSync, mkdtempSync, readFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { Glob } from "bun"

interface Manifest {
readonly name: string
readonly version: string
readonly private?: boolean
readonly publishConfig?: { readonly access?: string }
readonly dependencies?: Readonly<Record<string, string>>
readonly peerDependencies?: Readonly<Record<string, string>>
}

interface Candidate {
readonly dir: string
readonly manifest: Manifest
}

const root = resolve(import.meta.dir, "..")
const dryRun = process.argv.includes("--dry-run")

const run = (cmd: ReadonlyArray<string>, cwd = root, quiet = false) => {
const result = Bun.spawnSync([...cmd], {
cwd,
stdout: quiet ? "pipe" : "inherit",
stderr: quiet ? "pipe" : "inherit",
})
return { ok: result.exitCode === 0, stdout: result.stdout?.toString() ?? "" }
}

const fail = (message: string): never => {
console.error(`::error::${message}`)
process.exit(1)
}

const changesetConfig: { readonly ignore?: ReadonlyArray<string> } = JSON.parse(
readFileSync(join(root, ".changeset/config.json"), "utf8"),
)
const ignored = new Set(changesetConfig.ignore ?? [])
const rootManifest: { readonly workspaces: ReadonlyArray<string> } = JSON.parse(
readFileSync(join(root, "package.json"), "utf8"),
)

const workspaces: ReadonlyArray<Candidate> = rootManifest.workspaces.flatMap((pattern) =>
Array.from(new Glob(`${pattern}/package.json`).scanSync({ cwd: root })).map((file) => ({
dir: join(root, file, ".."),
manifest: JSON.parse(readFileSync(join(root, file), "utf8")),
})),
)

const isOnNpm = ({ name, version }: Manifest) =>
run(["npm", "view", `${name}@${version}`, "version"], root, true).stdout.trim() === version

const isTagged = (tag: string) =>
run(["git", "ls-remote", "--tags", "origin", `refs/tags/${tag}`], root, true).stdout.trim() !== ""

const outputFile = process.env.CHANGESETS_OUTPUT

// Tags the release locally and reports it so the action pushes the tag and cuts the GitHub release.
const reportRelease = ({ name, version }: Manifest) => {
const tag = `${name}@${version}`
if (!run(["git", "tag", "-a", tag, "-m", tag]).ok) fail(`git tag failed for ${tag}`)
console.log(`New tag: ${tag}`)
if (outputFile) {
appendFileSync(outputFile, `${JSON.stringify({ type: "git-tag", tag, packageName: name })}\n`)
}
}

const publishable = workspaces.filter(
({ manifest }) =>
manifest.private !== true &&
manifest.publishConfig?.access === "public" &&
!ignored.has(manifest.name),
)
const [published, pending] = publishable.reduce<[Array<Candidate>, Array<Candidate>]>(
([onNpm, notOnNpm], candidate) =>
isOnNpm(candidate.manifest) ? [[...onNpm, candidate], notOnNpm] : [onNpm, [...notOnNpm, candidate]],
[[], []],
)

// A rerun after a publish whose tag push or release failed: npm already has the version, so only
// the tag and release are still owed.
if (!dryRun) {
for (const { manifest } of published) {
if (!isTagged(`${manifest.name}@${manifest.version}`)) reportRelease(manifest)
}
}

if (pending.length === 0) {
console.log("Nothing to publish: every public package version is already on npm.")
process.exit(0)
}

// A package goes after any pending package it depends on, so `workspace:*` resolves to a
// version that already exists on npm by the time its dependent is published.
const pendingNames = new Set(pending.map(({ manifest }) => manifest.name))
const internalDeps = ({ manifest }: Candidate) =>
Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies }).filter((dep) =>
pendingNames.has(dep),
).length
const ordered = pending.toSorted((a, b) => internalDeps(a) - internalDeps(b))

console.log(`Publishing: ${ordered.map(({ manifest }) => `${manifest.name}@${manifest.version}`).join(", ")}`)

if (
!run(["bunx", "turbo", "run", "build", ...ordered.map(({ manifest }) => `--filter=${manifest.name}`)]).ok
) {
fail("build failed")
}

const packDir = mkdtempSync(join(tmpdir(), "maple-publish-"))

for (const { dir, manifest } of ordered) {
const tag = `${manifest.name}@${manifest.version}`
const tarball = join(
packDir,
`${manifest.name.replace("@", "").replace("/", "-")}-${manifest.version}.tgz`,
)
if (!run(["bun", "pm", "pack", "--filename", tarball], dir).ok) fail(`bun pm pack failed for ${tag}`)
const publishArgs = ["npm", "publish", tarball, "--access", "public"]
if (!run(dryRun ? [...publishArgs, "--dry-run"] : [...publishArgs, "--provenance"]).ok) {
fail(`npm publish failed for ${tag}`)
}
if (!dryRun) reportRelease(manifest)
}
Loading