Merge pull request #98 from crystian/changeset-release/main #251
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: release | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - '.changeset/*.md' | |
| - '*/package.json' | |
| - 'package.json' | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| id-token: write | |
| jobs: | |
| release: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: true | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v6 | |
| - name: Setup Node | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: 24 | |
| cache: 'pnpm' | |
| - name: Install deps | |
| run: pnpm install --frozen-lockfile | |
| - name: Build UI | |
| # The CLI publish step depends on the Angular SPA being built so | |
| # tsup's onSuccess hook can copy `ui/dist/ui/browser/` into | |
| # `src/dist/ui/`. The runtime resolver in `src/server/paths.ts` | |
| # serves the bundle from there in installed mode; without this | |
| # step the published tarball would ship without any UI and | |
| # `sm` would render the placeholder forever. | |
| # | |
| # When Sentry telemetry is wired (auth token + org present), build | |
| # with the `release-sourcemaps` configuration instead: identical | |
| # minified output plus HIDDEN `.map` files (no sourceMappingURL | |
| # comment, so nothing changes for the shipped JS). The maps are | |
| # stripped from the tarball below and uploaded post-publish. The | |
| # maps MUST come from this exact build, a separate rebuild produces | |
| # different content hashes and would not match the shipped bundle. | |
| env: | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| SENTRY_ORG: ${{ vars.SENTRY_ORG }} | |
| run: | | |
| if [ -n "$SENTRY_AUTH_TOKEN" ] && [ -n "$SENTRY_ORG" ]; then | |
| echo "Building UI with hidden sourcemaps (Sentry upload enabled)" | |
| pnpm --filter ui exec ng build --configuration release-sourcemaps | |
| else | |
| pnpm --filter ui build | |
| fi | |
| - name: Build CLI | |
| run: pnpm --filter @skill-map/cli build | |
| - name: Inject Sentry debug IDs into the CLI bundle | |
| # Node packages install to per-machine paths, so the browser-style | |
| # `release` + `url-prefix` matching the UI upload uses CANNOT resolve | |
| # CLI stack frames (the abs path differs on every install). Debug IDs | |
| # are the path-independent alternative: a stable id is embedded in | |
| # each shipped JS file (and its `.map`), and Sentry matches crash | |
| # events to maps by that id. Injection MUST run before the publish so | |
| # the id lands in the published JS; `changeset publish` does not | |
| # rebuild (and there is no prepublish hook), so the injected dist is | |
| # exactly what ships. | |
| # | |
| # EXCLUDES `src/dist/ui`: that is the BFF-served UI bundle, whose maps | |
| # are matched by filename in the UI upload below. Mutating its JS here | |
| # would shift it relative to those maps and break UI symbolication. | |
| # Self-skips when Sentry is not wired (no token / org), leaving the | |
| # published bundle byte-identical to a no-telemetry release. | |
| env: | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| SENTRY_ORG: ${{ vars.SENTRY_ORG }} | |
| run: | | |
| if [ -n "$SENTRY_AUTH_TOKEN" ] && [ -n "$SENTRY_ORG" ]; then | |
| echo "Injecting Sentry debug IDs into src/dist (excluding ui/)" | |
| npx @sentry/cli@2 sourcemaps inject --ignore '**/ui/**' src/dist | |
| else | |
| echo "Sentry not configured; skipping CLI debug-id injection." | |
| fi | |
| - name: Stage and strip CLI sourcemaps from the published bundle | |
| # Mirror of the UI strip, for the CLI's own maps. The `.map` files | |
| # must NOT ship in the npm tarball (they embed the original TS via | |
| # `sourcesContent`), but the post-publish upload still needs them. | |
| # Copy them out of the published tree (preserving the `dist/` layout) | |
| # then delete them from `src/dist`. Debug ids (injected above) live | |
| # inside the maps, so the upload matches by id regardless of where | |
| # the file sits. Excludes `src/dist/ui` (handled by the UI strip). | |
| # Gated on Sentry being wired: a no-telemetry release keeps shipping | |
| # its maps exactly as before (no behaviour change without a token). | |
| env: | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| SENTRY_ORG: ${{ vars.SENTRY_ORG }} | |
| run: | | |
| if [ -z "$SENTRY_AUTH_TOKEN" ] || [ -z "$SENTRY_ORG" ]; then | |
| echo "Sentry not configured; leaving CLI sourcemaps in the bundle as-is." | |
| exit 0 | |
| fi | |
| mkdir -p .tmp/cli-sentry-maps | |
| staged=0 | |
| while IFS= read -r m; do | |
| cp --parents "$m" .tmp/cli-sentry-maps/ | |
| rm -f "$m" | |
| staged=$((staged + 1)) | |
| done < <(find src/dist -name '*.map' -not -path '*/ui/*') | |
| echo "Staged + stripped ${staged} CLI sourcemap(s) from the published bundle." | |
| - name: Strip UI sourcemaps from the published bundle | |
| # The `.map` files stay under `ui/dist/` for the post-publish upload, | |
| # but must NEVER ship inside the npm tarball (they would expose the | |
| # original TypeScript source). Scoped to `src/dist/ui` so the CLI's | |
| # own dist maps are untouched. No-op when the UI was built without | |
| # sourcemaps (no token / org). | |
| run: | | |
| before=$(find src/dist/ui -name '*.map' 2>/dev/null | wc -l) | |
| find src/dist/ui -name '*.map' -delete 2>/dev/null || true | |
| echo "Removed ${before} UI sourcemap(s) from the published bundle." | |
| - name: Verify spec index is up to date | |
| run: pnpm --filter @skill-map/spec spec:check | |
| - name: Changesets — version PR or publish | |
| id: changesets | |
| uses: changesets/action@v1 | |
| with: | |
| version: pnpm release:version | |
| publish: pnpm release:publish | |
| commit: "chore: version packages" | |
| title: "chore: version packages" | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| - name: Smoke install from npm (post-publish) | |
| if: steps.changesets.outputs.published == 'true' | |
| env: | |
| PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }} | |
| run: | | |
| # Read the cli version from the changesets output rather than | |
| # resolving `@latest`. The `latest` dist-tag updates before | |
| # every edge of the npm CDN has the tarball metadata, so a | |
| # `@latest` install can return ETARGET even when the publish | |
| # itself succeeded. The explicit version sidesteps that race. | |
| # | |
| # Why npm and not pnpm: end users install `sm` via | |
| # `npm i -g @skill-map/cli`, so the smoke replays that flow | |
| # exactly. The monorepo's package manager choice is irrelevant | |
| # to the published tarball's install path. | |
| cli_version=$(node -e ' | |
| const pkgs = JSON.parse(process.env.PUBLISHED || "[]"); | |
| const cli = pkgs.find((p) => p.name === "@skill-map/cli"); | |
| if (cli) process.stdout.write(cli.version); | |
| ') | |
| if [ -z "$cli_version" ]; then | |
| echo "@skill-map/cli not in this release; skipping smoke install." | |
| exit 0 | |
| fi | |
| echo "Smoke-installing @skill-map/cli@${cli_version}" | |
| # Step into a clean directory so the repo's `.npmrc` | |
| # (which pins pnpm-specific flags like strict-dep-builds and | |
| # minimum-release-age) does not bleed into the npm CLI's | |
| # config resolution. The smoke is meant to mirror a fresh | |
| # end-user shell, not the contributor environment. | |
| cd "$(mktemp -d)" | |
| # Retry while the CDN catches up. Two failure modes are | |
| # possible: | |
| # 1. The tarball metadata has not propagated to every edge | |
| # yet (genuine propagation lag, fixed by waiting). | |
| # 2. The npm CLI cached the first 404 and keeps returning | |
| # ETARGET even when the edge now has the version | |
| # (`--prefer-online` forces a fresh staleness check on | |
| # every attempt, dodging the cached negative). | |
| # 10 retries * 30 s = up to ~5 minutes of patience, which | |
| # covers the worst observed propagation windows. | |
| for attempt in 1 2 3 4 5 6 7 8 9 10; do | |
| if npm i -g --prefer-online "@skill-map/cli@${cli_version}"; then | |
| break | |
| fi | |
| if [ "$attempt" = "10" ]; then | |
| echo "registry never served @skill-map/cli@${cli_version} after 10 retries" | |
| exit 1 | |
| fi | |
| echo "attempt $attempt failed (likely ETARGET propagation lag or cached 404); sleeping 30s before retry" | |
| sleep 30 | |
| done | |
| sm --version | |
| skill-map --version | |
| sm --help > /dev/null | |
| - name: Upload UI sourcemaps to Sentry (post-publish) | |
| # Only on an actual publish, and only when the operator has wired | |
| # the Sentry secret + org. Self-skips (exit 0) otherwise, so a repo | |
| # without telemetry configured releases exactly as before. This | |
| # never touches the already-published bundle: it rebuilds the UI | |
| # with HIDDEN sourcemaps (identical JS + content hashes as the | |
| # shipped production build, plus the `.map` files) purely to upload | |
| # them. Sentry matches events to these maps via the `release` tag | |
| # the SDK sets (`skill-map-cli@<version>`) plus the `~/` url-prefix. | |
| if: steps.changesets.outputs.published == 'true' | |
| env: | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| SENTRY_ORG: ${{ vars.SENTRY_ORG }} | |
| SENTRY_PROJECT: skill-map-ui | |
| PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }} | |
| run: | | |
| if [ -z "$SENTRY_AUTH_TOKEN" ] || [ -z "$SENTRY_ORG" ]; then | |
| echo "SENTRY_AUTH_TOKEN secret or SENTRY_ORG variable not set; skipping sourcemap upload." | |
| exit 0 | |
| fi | |
| cli_version=$(node -e ' | |
| const pkgs = JSON.parse(process.env.PUBLISHED || "[]"); | |
| const cli = pkgs.find((p) => p.name === "@skill-map/cli"); | |
| if (cli) process.stdout.write(cli.version); | |
| ') | |
| if [ -z "$cli_version" ]; then | |
| echo "@skill-map/cli not in this release; skipping sourcemap upload." | |
| exit 0 | |
| fi | |
| if [ -z "$(find ui/dist/ui/browser -name '*.map' -print -quit 2>/dev/null)" ]; then | |
| echo "No UI sourcemaps on disk (UI built without them); skipping upload." | |
| exit 0 | |
| fi | |
| release="skill-map-cli@${cli_version}" | |
| echo "Uploading UI sourcemaps for release ${release}" | |
| # Maps come from the same 'release-sourcemaps' build that produced | |
| # the shipped JS, so they match the bundle by filename. Sentry ties | |
| # them to events via the SDK's `release` tag + the `~/` url-prefix. | |
| npx @sentry/cli@2 sourcemaps upload \ | |
| --release "${release}" \ | |
| --url-prefix '~/' \ | |
| ui/dist/ui/browser | |
| - name: Upload CLI sourcemaps to Sentry (post-publish) | |
| # Companion to the UI upload above, targeting the shared Node project | |
| # (`skill-map-cli`, also home to the `surface: bff` events). The CLI | |
| # bundle was debug-id-injected before publish, so Sentry matches CLI | |
| # crash events to these maps by debug id; `--release` only associates | |
| # the artifact bundle for housekeeping (no url-prefix / path matching | |
| # is involved). Excludes `src/dist/ui` (covered by the UI upload). | |
| # Self-skips unless this run published @skill-map/cli with Sentry | |
| # wired and CLI maps present on disk. | |
| if: steps.changesets.outputs.published == 'true' | |
| env: | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| SENTRY_ORG: ${{ vars.SENTRY_ORG }} | |
| SENTRY_PROJECT: skill-map-cli | |
| PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }} | |
| run: | | |
| if [ -z "$SENTRY_AUTH_TOKEN" ] || [ -z "$SENTRY_ORG" ]; then | |
| echo "SENTRY_AUTH_TOKEN secret or SENTRY_ORG variable not set; skipping sourcemap upload." | |
| exit 0 | |
| fi | |
| cli_version=$(node -e ' | |
| const pkgs = JSON.parse(process.env.PUBLISHED || "[]"); | |
| const cli = pkgs.find((p) => p.name === "@skill-map/cli"); | |
| if (cli) process.stdout.write(cli.version); | |
| ') | |
| if [ -z "$cli_version" ]; then | |
| echo "@skill-map/cli not in this release; skipping sourcemap upload." | |
| exit 0 | |
| fi | |
| if [ -z "$(find .tmp/cli-sentry-maps -name '*.map' -print -quit 2>/dev/null)" ]; then | |
| echo "No staged CLI sourcemaps on disk (built without them); skipping upload." | |
| exit 0 | |
| fi | |
| release="skill-map-cli@${cli_version}" | |
| echo "Uploading CLI sourcemaps for release ${release}" | |
| # Reads from the staging dir populated before publish (the maps were | |
| # stripped from the tarball there). Debug-id matched, so no | |
| # url-prefix / path matching is needed. | |
| npx @sentry/cli@2 sourcemaps upload \ | |
| --release "${release}" \ | |
| .tmp/cli-sentry-maps |