From 0220dfb6354ad0f29415752e376a7a2140d3a1c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20L=C3=BCder?= Date: Thu, 23 Apr 2026 10:11:42 -0500 Subject: [PATCH] ci(release)!: unify release+publish with the rest of the ecosystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move semantic-release para _release.yml reusável (guard de packages_changed + GitHub App token), chamado por ci.yml após o job `checks` - Adicionar _publish.yml pinado ao release_sha, com checagem idempotente por pacote e provenance - Remover publishCmd inline do .releaserc.cjs (agora .github/ workflows/_publish.yml é o responsável por publicar) - Remover release.yml standalone (cron semanal era no-op) e a entrada correspondente em templates.manifest.yml Resolve o CI rodando checks e release em paralelo no push para main. --- .github/workflows/_publish.yml | 77 ++++++++++++ .github/workflows/_release.yml | 156 +++++++++++++++++++++++++ .github/workflows/ci.yml | 49 ++++++-- .github/workflows/release.yml | 76 ------------ .releaserc.cjs | 19 +-- templates/github/workflows/release.yml | 76 ------------ templates/templates.manifest.yml | 8 -- 7 files changed, 278 insertions(+), 183 deletions(-) create mode 100644 .github/workflows/_publish.yml create mode 100644 .github/workflows/_release.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 templates/github/workflows/release.yml diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml new file mode 100644 index 0000000..3b4082c --- /dev/null +++ b/.github/workflows/_publish.yml @@ -0,0 +1,77 @@ +name: Publish to npm + +# Reusable workflow — publishes workspace packages to npm after release. +# Consumers pass the list of package dirs as a newline-separated input; +# each dir is published idempotently (checks the registry first). +# +# Pins to `release_sha` so a concurrent push can't change the tree +# between release creation and publish. + +on: + workflow_call: + inputs: + release_sha: + description: 'Commit SHA to check out (the chore(release) commit from _release.yml)' + type: string + required: true + packages: + description: 'Newline-separated list of package directories to publish (e.g. "packages/core\npackages/calculators")' + type: string + required: true + +jobs: + publish: + name: Publish + runs-on: ubuntu-latest + # `id-token: write` is for Sigstore attestations (`--provenance`), + # NOT for npm auth. Auth uses `NPM_TOKEN` org-secret — OIDC trusted + # publishing was evaluated and rejected because it requires manual + # per-package click-through in the npm web UI (no CLI/API to + # automate). `--provenance` gives us supply chain attestations + # regardless of auth method. + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.release_sha }} + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm turbo run build + + - name: Publish packages + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + PACKAGES: ${{ inputs.packages }} + run: | + set -e + publish_if_needed() { + local dir="$1" + local pkg_name pkg_version + pkg_name=$(node -p "require('./$dir/package.json').name") + pkg_version=$(node -p "require('./$dir/package.json').version") + + if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then + echo "Skipping $pkg_name@$pkg_version (already published)" + else + echo "Publishing $pkg_name@$pkg_version..." + ( cd "$dir" && pnpm publish --provenance --access public --no-git-checks ) + fi + } + + while IFS= read -r dir; do + [ -z "$dir" ] && continue + publish_if_needed "$dir" + done <<< "$PACKAGES" diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml new file mode 100644 index 0000000..4d997bb --- /dev/null +++ b/.github/workflows/_release.yml @@ -0,0 +1,156 @@ +name: Release + +# Reusable workflow — semantic-release with packages_changed guard. +# Produces outputs so downstream jobs (publish, deploy-site) can gate +# on whether a release happened and which commit carries the bump. +# +# Guards release on `packages/**` or root `package.json` changing in +# the pushed range. Site-only or docs-only pushes skip the release +# cycle. Set `require_package_changes: false` to release regardless. + +on: + workflow_call: + inputs: + require_package_changes: + description: 'Only run semantic-release if packages/** or package.json changed' + type: boolean + default: true + outputs: + released: + description: 'Whether a new version was released' + value: ${{ jobs.release.outputs.released }} + version: + description: 'The released version number (without leading v)' + value: ${{ jobs.release.outputs.version }} + release_sha: + description: 'The commit SHA that was released (chore(release) commit, or github.sha on no-op)' + value: ${{ jobs.release.outputs.release_sha }} + +jobs: + release: + name: semantic-release + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + outputs: + released: ${{ steps.semantic.outputs.released }} + version: ${{ steps.semantic.outputs.version }} + # Fallback to github.sha on no-op — consumers without an `if: released == 'true'` + # guard won't checkout an empty ref. + release_sha: ${{ steps.semantic.outputs.release_sha || github.sha }} + steps: + - name: Check for package changes across pushed commits + id: changes + if: inputs.require_package_changes + uses: actions/github-script@v7 + with: + # Compares `before..after` from the push payload. Paginates explicitly + # because `compareCommits` truncates at 300 files per page — a large + # push could otherwise silently miss detection. + script: | + const before = context.payload.before; + const after = context.sha; + const isInitial = !before || /^0+$/.test(before); + let filenames = []; + if (isInitial) { + const { data: commit } = await github.rest.repos.getCommit({ + owner: context.repo.owner, repo: context.repo.repo, ref: after, + }); + filenames = (commit.files ?? []).map(f => f.filename); + } else { + const pages = await github.paginate( + github.rest.repos.compareCommitsWithBasehead, + { + owner: context.repo.owner, + repo: context.repo.repo, + basehead: `${before}...${after}`, + per_page: 100, + }, + ); + filenames = pages.flatMap(p => (p.files ?? []).map(f => f.filename)); + } + const packageChanged = filenames.some( + f => f.startsWith('packages/') || f === 'package.json' + ); + core.setOutput('packages_changed', packageChanged ? 'true' : 'false'); + core.info(`inspected ${filenames.length} files, packages_changed=${packageChanged}`); + + - name: Generate GitHub App token + id: app-token + if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'" + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'" + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - uses: pnpm/action-setup@v4 + if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'" + + - uses: actions/setup-node@v6 + if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'" + with: + node-version: 22 + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'" + run: pnpm install --frozen-lockfile + + - name: Build + if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'" + run: pnpm turbo run build + + - name: Run semantic-release + id: semantic + if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'" + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + HUSKY: 0 + # Three cases: + # (a) success with release: stdout contains 'Published release' → + # we take the version from `git describe --tags` (more robust + # to format changes in semantic-release's stdout). + # (b) no-op: exit 0 AND stdout mentions 'no relevant changes' → + # released=false. Requiring BOTH conditions avoids masking + # silent failures where the tool exits 0 without publishing + # or explaining why. + # (c) real error: any other combination → non-zero exit. + run: | + set +e + OUTPUT=$(npx semantic-release 2>&1) + EXIT=$? + set -e + echo "$OUTPUT" + + if echo "$OUTPUT" | grep -q "Published release"; then + RAW_TAG=$(git describe --tags --abbrev=0) + VERSION="${RAW_TAG#v}" + if [ -z "$VERSION" ]; then + echo "::error::semantic-release published but git describe found no tag" + exit 1 + fi + RELEASE_SHA=$(git rev-parse HEAD) + { + echo "released=true" + echo "version=$VERSION" + echo "release_sha=$RELEASE_SHA" + } >> "$GITHUB_OUTPUT" + elif [ "$EXIT" -eq 0 ] && echo "$OUTPUT" | grep -qE "no relevant changes|There are no relevant changes"; then + echo "released=false" >> "$GITHUB_OUTPUT" + else + echo "::error::semantic-release exited $EXIT with no recognized release or no-op marker" + exit "${EXIT:-1}" + fi + + - name: Skip release (no package changes) + if: inputs.require_package_changes && steps.changes.outputs.packages_changed != 'true' + run: echo "No changes under packages/** — release skipped" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f4b559..2620bb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,13 @@ -name: CI +name: CI/CD -# Tooling publishes packages via standalone `release.yml` (semantic-release -# fires on every push to main). ci.yml wires the canonical `_checks.yml` -# + `review.yml` reusables so checks run identically to the rest of the -# ecosystem. +# Orchestrator — wires reusable workflows. Mirrors the canonical shape +# used by datasus-brasil / fhir-brasil / medbench-brasil / platform. +# Each concern is a reusable workflow under `.github/workflows/`: +# +# _checks.yml — parallel CI checks (lint, typecheck, format, build, test) +# _release.yml — semantic-release with packages_changed guard +# _publish.yml — npm publish pinned to release_sha +# review.yml — automated code review (PRs only) on: push: @@ -13,12 +17,15 @@ on: workflow_dispatch: permissions: - contents: read + # `contents: write` porque _release.yml (reusable) precisa criar + # commits e tags via semantic-release. Workflows reusáveis só + # conseguem pedir permissões que o caller também concede. + contents: write issues: write pull-requests: write - # `models: read` é necessário porque review.yml (reusable) pode chamar - # GitHub Models via GITHUB_TOKEN no fallback. Workflows reusáveis só - # conseguem pedir permissões que o caller também concede. + id-token: write + # `models: read` pelo mesmo motivo: review.yml chama GitHub Models + # via GITHUB_TOKEN no fallback de providers. models: read concurrency: @@ -37,3 +44,27 @@ jobs: with: pr_number: ${{ github.event.pull_request.number }} secrets: inherit + + release: + needs: checks + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + uses: ./.github/workflows/_release.yml + secrets: inherit + + publish: + needs: release + if: needs.release.outputs.released == 'true' + uses: ./.github/workflows/_publish.yml + with: + release_sha: ${{ needs.release.outputs.release_sha }} + packages: | + packages/agent-instructions + packages/cli + packages/commitlint-config + packages/eslint-config + packages/prettier-config + packages/themes + packages/tsconfig + packages/ui + packages/worktree-cli + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 82136af..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Release - -# Release standalone para repos que publicam pacotes SEM site associado -# (ex.: tooling). Repos que têm site costumam integrar release+publish no -# ci.yml — veja ci.yml do template. - -on: - push: - branches: [main] - # Weekly cadence — picks up any docs/CI/chore commits that semantic- - # release normally wouldn't promote. Ensures consumers stay within - # one minor of the current HEAD even during low-commit weeks. - # semantic-release is a no-op when there are no releasable commits, - # so this is safe to run unconditionally. - schedule: - - cron: '0 9 * * 1' - workflow_dispatch: - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -jobs: - release: - name: semantic-release - runs-on: ubuntu-latest - # `id-token: write` is for Sigstore attestations (`--provenance`), - # not for npm auth. npm auth uses the `NPM_TOKEN` org-secret. - permissions: - contents: write - issues: write - pull-requests: write - id-token: write - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: 'pnpm' - registry-url: 'https://registry.npmjs.org' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm -r build - - - name: Configure npm auth - # `actions/setup-node` writes `~/.npmrc` with a literal - # `${NODE_AUTH_TOKEN}` placeholder that `npm` expands at publish - # time, but `pnpm publish` (used by semantic-release's - # `publishCmd`) doesn't always expand it. Write the resolved - # token directly so pnpm reads it unambiguously. Lives in $HOME - # so it doesn't pollute the working tree. - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - if [ -z "${NPM_TOKEN}" ]; then - echo "::error::NPM_TOKEN secret is not configured" - exit 1 - fi - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "${HOME}/.npmrc" - echo "registry=https://registry.npmjs.org/" >> "${HOME}/.npmrc" - - - name: Release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm exec semantic-release diff --git a/.releaserc.cjs b/.releaserc.cjs index d7d42ec..e5589ac 100644 --- a/.releaserc.cjs +++ b/.releaserc.cjs @@ -3,9 +3,11 @@ * * Multi-package monorepo strategy: every publishable workspace package is * released together at the same version. A single commit on `main` triggers - * one release cycle that updates the root version, syncs it to every - * `packages/*` that isn't marked `private`, and publishes each package to - * npm via `pnpm publish`. + * one release cycle that updates the root version and syncs it to every + * `packages/*` that isn't marked `private`. The actual `pnpm publish` step + * runs in `.github/workflows/_publish.yml` (pinned to the release SHA), so + * this config only handles version bumping, changelog, and the release + * commit/tag. * * Version bumps follow Conventional Commits: * - feat: minor @@ -57,17 +59,6 @@ module.exports = { // Propagate the new version to every non-private workspace package. ['@semantic-release/exec', { prepareCmd: 'node scripts/sync-versions.cjs' }], - // Publish each public workspace package to npm. Runs after prepare, so - // every package.json already has the new version. `--no-git-checks` - // lets pnpm publish from a dirty working tree (semantic-release holds - // staged changes until after the @semantic-release/git step). - [ - '@semantic-release/exec', - { - publishCmd: 'pnpm -r --filter "./packages/*" publish --access public --no-git-checks', - }, - ], - [ '@semantic-release/git', { diff --git a/templates/github/workflows/release.yml b/templates/github/workflows/release.yml deleted file mode 100644 index 82136af..0000000 --- a/templates/github/workflows/release.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Release - -# Release standalone para repos que publicam pacotes SEM site associado -# (ex.: tooling). Repos que têm site costumam integrar release+publish no -# ci.yml — veja ci.yml do template. - -on: - push: - branches: [main] - # Weekly cadence — picks up any docs/CI/chore commits that semantic- - # release normally wouldn't promote. Ensures consumers stay within - # one minor of the current HEAD even during low-commit weeks. - # semantic-release is a no-op when there are no releasable commits, - # so this is safe to run unconditionally. - schedule: - - cron: '0 9 * * 1' - workflow_dispatch: - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -jobs: - release: - name: semantic-release - runs-on: ubuntu-latest - # `id-token: write` is for Sigstore attestations (`--provenance`), - # not for npm auth. npm auth uses the `NPM_TOKEN` org-secret. - permissions: - contents: write - issues: write - pull-requests: write - id-token: write - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: 'pnpm' - registry-url: 'https://registry.npmjs.org' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm -r build - - - name: Configure npm auth - # `actions/setup-node` writes `~/.npmrc` with a literal - # `${NODE_AUTH_TOKEN}` placeholder that `npm` expands at publish - # time, but `pnpm publish` (used by semantic-release's - # `publishCmd`) doesn't always expand it. Write the resolved - # token directly so pnpm reads it unambiguously. Lives in $HOME - # so it doesn't pollute the working tree. - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - if [ -z "${NPM_TOKEN}" ]; then - echo "::error::NPM_TOKEN secret is not configured" - exit 1 - fi - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "${HOME}/.npmrc" - echo "registry=https://registry.npmjs.org/" >> "${HOME}/.npmrc" - - - name: Release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm exec semantic-release diff --git a/templates/templates.manifest.yml b/templates/templates.manifest.yml index 0c3c474..8ee4b6f 100644 --- a/templates/templates.manifest.yml +++ b/templates/templates.manifest.yml @@ -245,11 +245,3 @@ templates: target: .github/workflows/doctor.yml required_when: always merge_strategy: overwrite - - # Standalone release workflow (alternative to _release+_publish split) - # for tooling-style monorepos. Gated off by default; consumers choose - # one or the other. - - source: github/workflows/release.yml - target: .github/workflows/release.yml - required_when: never - merge_strategy: overwrite