diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..3404b118e35 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +name: Release + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Resolve release version + id: version + run: | + echo "value=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" + + - name: Check npm publish status + id: npm + run: | + VERSION="${{ steps.version.outputs.value }}" + if npm view "denchclaw@${VERSION}" version >/dev/null 2>&1; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "denchclaw@${VERSION} is already published" + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "denchclaw@${VERSION} is not published yet" + fi + + - name: Check GitHub release status + id: github_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v${{ steps.version.outputs.value }}" + if gh release view "$TAG" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "${TAG} release already exists" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "${TAG} release does not exist yet" + fi + + - name: Validate release checks + if: steps.npm.outputs.published != 'true' + env: + POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} + run: pnpm run deploy:check + + - name: Publish packages to npm + if: steps.npm.outputs.published != 'true' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} + run: pnpm run deploy -- --skip-tests --skip-build + + - name: Create GitHub release + if: steps.github_release.outputs.exists != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v${{ steps.version.outputs.value }}" + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --generate-notes diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000000..281170cc42d --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,51 @@ +# Releasing + +`package.json` is the release source of truth for `denchclaw`. + +## Main flow + +1. Bump the root package version in `package.json`. +2. Push or merge that commit to `main`. +3. GitHub Actions runs `.github/workflows/release.yml`. +4. If that version is not already on npm, the workflow runs the same `deploy.sh` test and build checks in validation mode. +5. Only after those checks pass does the workflow publish `denchclaw` to npm. +6. The workflow creates a matching GitHub release named `v`. + +If the npm package already exists, the workflow skips publishing. If the GitHub release already exists, the workflow skips creating it. This makes reruns safe. + +## Local commands + +- `pnpm run deploy` +- `pnpm run deploy:check` +- `pnpm run deploy:patch` +- `pnpm run deploy:minor` +- `pnpm run deploy:major` +- `pnpm run github:sync-secrets` + +The deploy commands load `.env` automatically when it exists. + +Examples: + +```bash +pnpm run deploy:check +pnpm run deploy +pnpm run deploy -- --dry-run --version 2.3.15 +pnpm run deploy:patch +``` + +## GitHub Actions secrets + +The release workflow expects: + +- `POSTHOG_KEY` +- `NPM_TOKEN` + +To sync the current local `.env` values into GitHub repository secrets: + +```bash +pnpm run github:sync-secrets +``` + +## Better long-term option + +The workflow supports `NPM_TOKEN` today because that matches the current local deploy process. For better security, configure npm trusted publishing for `.github/workflows/release.yml` and then remove `NPM_TOKEN`. The deploy script already supports GitHub Actions OIDC when no `NPM_TOKEN` is present. diff --git a/package.json b/package.json index d615f85a3dd..d06edc82fe8 100644 --- a/package.json +++ b/package.json @@ -53,10 +53,12 @@ "web:dev": "pnpm --dir apps/web dev", "web:install": "pnpm --dir apps/web install", "web:prepack": "cp -r apps/web/public apps/web/.next/standalone/apps/web/public && cp -r apps/web/.next/static apps/web/.next/standalone/apps/web/.next/static && node scripts/flatten-standalone-deps.mjs", - "deploy": "set -a && source .env && set +a && bash scripts/deploy.sh", - "deploy:major": "set -a && source .env && set +a && bash scripts/deploy.sh --bump major", - "deploy:minor": "set -a && source .env && set +a && bash scripts/deploy.sh --bump minor", - "deploy:patch": "set -a && source .env && set +a && bash scripts/deploy.sh --bump patch" + "deploy:check": "bash scripts/with-root-env.sh bash scripts/deploy.sh --skip-publish", + "deploy": "bash scripts/with-root-env.sh bash scripts/deploy.sh", + "deploy:major": "bash scripts/with-root-env.sh bash scripts/deploy.sh --bump major", + "deploy:minor": "bash scripts/with-root-env.sh bash scripts/deploy.sh --bump minor", + "deploy:patch": "bash scripts/with-root-env.sh bash scripts/deploy.sh --bump patch", + "github:sync-secrets": "bash scripts/with-root-env.sh bash scripts/sync-github-secrets.sh" }, "dependencies": { "@clack/prompts": "^1.0.1", diff --git a/scripts/deploy.sh b/scripts/deploy.sh index f2bcfa935a9..538e1d460e7 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -10,10 +10,14 @@ # # Flags: # --skip-tests Skip running tests before build/publish. +# --skip-publish Run all validation/build checks but do not publish. # --skip-npx-smoke Skip post-publish npx binary verification. # # Environment: -# NPM_TOKEN Required. npm auth token for publishing. +# NPM_TOKEN Optional. npm auth token for publishing. +# Required only when actually publishing outside GitHub Actions. +# If omitted in GitHub Actions, npm trusted publishing via OIDC +# can be used instead. set -euo pipefail @@ -29,6 +33,14 @@ cd "$ROOT_DIR" die() { echo "error: $*" >&2; exit 1; } +run_npm() { + if [[ ${#NPM_FLAGS[@]} -gt 0 ]]; then + npm "$@" "${NPM_FLAGS[@]}" + else + npm "$@" + fi +} + current_version() { node -p "require('./package.json').version" } @@ -126,6 +138,7 @@ EXPLICIT_VERSION="" DRY_RUN=false SKIP_BUILD=false SKIP_TESTS=false +SKIP_PUBLISH=false SKIP_NPX_SMOKE=false set_mode() { @@ -138,6 +151,9 @@ set_mode() { while [[ $# -gt 0 ]]; do case $1 in + --) + shift + ;; --version) set_mode "version" EXPLICIT_VERSION="${2:?--version requires a semver argument (x.y.z)}" @@ -163,6 +179,10 @@ while [[ $# -gt 0 ]]; do SKIP_TESTS=true shift ;; + --skip-publish) + SKIP_PUBLISH=true + shift + ;; --skip-npx-smoke) SKIP_NPX_SMOKE=true shift @@ -179,16 +199,22 @@ done # ── auth ───────────────────────────────────────────────────────────────────── -if [[ -z "${NPM_TOKEN:-}" ]]; then - die "NPM_TOKEN environment variable is required" -fi +NPM_FLAGS=() -# Write a temporary .npmrc for auth (npm_config_ env vars can't encode -# registry-scoped keys because they contain slashes and colons). -NPMRC_TEMP="${ROOT_DIR}/.npmrc.deploy" -trap 'rm -f "$NPMRC_TEMP"' EXIT -echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$NPMRC_TEMP" -NPM_FLAGS=(--userconfig "$NPMRC_TEMP") +if [[ "$SKIP_PUBLISH" == true ]]; then + : +elif [[ -n "${NPM_TOKEN:-}" ]]; then + # Write a temporary .npmrc for auth (npm_config_ env vars can't encode + # registry-scoped keys because they contain slashes and colons). + NPMRC_TEMP="${ROOT_DIR}/.npmrc.deploy" + trap 'rm -f "$NPMRC_TEMP"' EXIT + echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$NPMRC_TEMP" + NPM_FLAGS=(--userconfig "$NPMRC_TEMP") +elif [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "using npm trusted publishing via GitHub Actions OIDC" +else + die "NPM_TOKEN environment variable is required outside GitHub Actions" +fi # ── compute version ───────────────────────────────────────────────────────── @@ -213,7 +239,11 @@ case "$MODE" in esac if npm_version_exists "$VERSION"; then - die "version $VERSION already exists on npm. Use --bump or --version ." + if [[ "$SKIP_PUBLISH" == true ]]; then + echo "version $VERSION already exists on npm; continuing because --skip-publish was requested" + else + die "version $VERSION already exists on npm. Use --bump or --version ." + fi fi if [[ "$DRY_RUN" == true ]]; then @@ -223,7 +253,7 @@ fi # ── set version ────────────────────────────────────────────────────────────── -npm version "$VERSION" --no-git-tag-version --allow-same-version "${NPM_FLAGS[@]}" +run_npm version "$VERSION" --no-git-tag-version --allow-same-version # ── pre-flight: tests ──────────────────────────────────────────────────────── @@ -307,13 +337,18 @@ for mod in $SERVER_EXTERNAL; do done echo "standalone node_modules verified ($CHECKED)" +if [[ "$SKIP_PUBLISH" == true ]]; then + echo "pre-publish checks passed; skipping publish" + exit 0 +fi + # ── publish ────────────────────────────────────────────────────────────────── # Always tag as "latest" — npm skips the latest tag for prerelease versions # by default, but we want `npm i -g denchclaw` to always resolve to # the most recently published version. echo "publishing ${PACKAGE_NAME}@${VERSION}..." -npm publish --access public --tag latest "${NPM_FLAGS[@]}" +run_npm publish --access public --tag latest # ── publish alias package (dench → denchclaw) ──────────────────────────────── @@ -329,7 +364,7 @@ if [[ -d "$ALIAS_DIR" ]]; then fs.writeFileSync('${ALIAS_DIR}/package.json', JSON.stringify(pkg, null, 2) + '\n'); " echo "publishing ${ALIAS_PACKAGE_NAME}@${VERSION}..." - if (cd "$ALIAS_DIR" && npm publish --access public --tag latest "${NPM_FLAGS[@]}" 2>/dev/null); then + if (cd "$ALIAS_DIR" && run_npm publish --access public --tag latest 2>/dev/null); then ALIAS_PUBLISHED=true echo "published ${ALIAS_PACKAGE_NAME}@${VERSION}" else diff --git a/scripts/sync-github-secrets.sh b/scripts/sync-github-secrets.sh new file mode 100644 index 00000000000..f7916930cf9 --- /dev/null +++ b/scripts/sync-github-secrets.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$ROOT_DIR" + +die() { echo "error: $*" >&2; exit 1; } + +if ! command -v gh >/dev/null 2>&1; then + die "gh CLI is required to sync repository secrets" +fi + +if ! gh auth status >/dev/null 2>&1; then + die "run 'gh auth login' before syncing GitHub secrets" +fi + +if [[ -z "${POSTHOG_KEY:-}" ]]; then + die "POSTHOG_KEY environment variable is required" +fi + +gh secret set POSTHOG_KEY --body "$POSTHOG_KEY" +echo "synced POSTHOG_KEY" + +if [[ -n "${NPM_TOKEN:-}" ]]; then + gh secret set NPM_TOKEN --body "$NPM_TOKEN" + echo "synced NPM_TOKEN" +else + echo "skipped NPM_TOKEN (not set)" +fi + +echo "" +echo "GitHub Actions secrets are ready for the release workflow." +echo "If you configure npm trusted publishing later, you can remove NPM_TOKEN." diff --git a/scripts/with-root-env.sh b/scripts/with-root-env.sh new file mode 100644 index 00000000000..47384899725 --- /dev/null +++ b/scripts/with-root-env.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$ROOT_DIR" + +if [[ -f ".env" ]]; then + set -a + # shellcheck disable=SC1091 + source ".env" + set +a +fi + +exec "$@"