diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 61c73f0..4f3fe49 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -5,12 +5,16 @@ on: push: branches: [main] workflow_dispatch: + # Reused by the release workflow so releases run the same checks. + workflow_call: permissions: contents: read concurrency: - group: quality-${{ github.ref }} + # github.workflow is the caller's name when reused, so a release never + # shares (and gets cancelled with) the group of a regular push run. + group: ${{ github.workflow }}-quality-${{ github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4654020 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,198 @@ +name: Release + +# Manual release: validates the repository, tags `main`, creates the GitHub +# release from the CHANGELOG section, and publishes the Studio packages to npm +# through npm Trusted Publishing (OIDC, no stored npm token). +# +# Each step is idempotent: an existing tag or release is kept, and package +# versions already on npm are skipped, so a failed run can be re-run safely. + +on: + workflow_dispatch: + inputs: + version: + description: Release version without the leading v (for example 3.4.0). Must have a CHANGELOG section. + required: true + type: string + target: + description: What to release. + required: true + default: all + type: choice + options: + - all + - github + - npm + dry_run: + description: Validate and preview only; create nothing and publish nothing. + required: true + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tag: ${{ steps.meta.outputs.tag }} + steps: + - name: Require main + if: github.ref != 'refs/heads/main' + run: | + echo "::error::Run the release workflow from main (got ${GITHUB_REF})." + exit 1 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Validate version and CHANGELOG + id: meta + env: + VERSION: ${{ inputs.version }} + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Version '$VERSION' is not semver without a leading v." + exit 1 + fi + if ! grep -qF "## [$VERSION]" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## [$VERSION]' section." + exit 1 + fi + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + quality: + needs: prepare + uses: ./.github/workflows/quality.yml + + github: + needs: [prepare, quality] + if: inputs.target == 'all' || inputs.target == 'github' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + env: + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - name: Extract release notes + run: | + awk -v header="## [$VERSION]" ' + index($0, header) == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md | sed -e '/./,$!d' > "$RUNNER_TEMP/notes.md" + compare=$(grep -F "[$VERSION]: " CHANGELOG.md | head -1 | sed 's/^[^:]*: //') + if [ -n "$compare" ]; then + printf '\n**Full Changelog**: %s\n' "$compare" >> "$RUNNER_TEMP/notes.md" + fi + if ! grep -q '[^[:space:]]' "$RUNNER_TEMP/notes.md"; then + echo "::error::The CHANGELOG section for $VERSION is empty." + exit 1 + fi + cat "$RUNNER_TEMP/notes.md" + - name: Create tag + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null; then + echo "Tag $TAG already exists; keeping it." + exit 0 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "[dry run] Would tag $GITHUB_SHA as $TAG." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" "$GITHUB_SHA" -m "$TAG" + git push origin "refs/tags/$TAG" + - name: Create GitHub release + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists; keeping it." + exit 0 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "[dry run] Would create release $TAG with the notes above." + exit 0 + fi + gh release create "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" --verify-tag + + npm: + needs: [prepare, quality, github] + if: >- + !cancelled() + && needs.quality.result == 'success' + && (needs.github.result == 'success' || needs.github.result == 'skipped') + && (inputs.target == 'all' || inputs.target == 'npm') + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # npm Trusted Publishing and provenance + env: + TAG: ${{ needs.prepare.outputs.tag }} + DRY_RUN: ${{ inputs.dry_run }} + steps: + - name: Resolve source ref + id: source + run: | + # Publish exactly what was tagged; a dry run before tagging uses main. + if git ls-remote --exit-code --tags "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$TAG" >/dev/null; then + echo "ref=refs/tags/$TAG" >> "$GITHUB_OUTPUT" + elif [ "$DRY_RUN" = "true" ]; then + echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + else + echo "::error::Tag $TAG does not exist. Run with target 'all' or 'github' first." + exit 1 + fi + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ steps.source.outputs.ref }} + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10.11.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "24" + registry-url: https://registry.npmjs.org + cache: pnpm + cache-dependency-path: studio/pnpm-lock.yaml + - name: Use an npm that supports Trusted Publishing + run: npm install -g npm@^11.5.1 + - name: Build Studio packages + working-directory: studio + run: | + pnpm install --frozen-lockfile + pnpm build + - name: Pack and publish + run: | + mkdir -p "$RUNNER_TEMP/packages" + # editor-core first: the Web Component depends on it. + for dir in studio/packages/editor-core studio/packages/web-component; do + name=$(node -p "require('./$dir/package.json').name") + version=$(node -p "require('./$dir/package.json').version") + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "$name@$version is already on npm; skipping." + continue + fi + # pnpm pack rewrites workspace:* to the real version. + tarball=$(cd "$dir" && pnpm pack --pack-destination "$RUNNER_TEMP/packages" | tail -1) + if tar -xOzf "$tarball" package/package.json | grep -q '"workspace:'; then + echo "::error::$name@$version still has a workspace: dependency." + exit 1 + fi + if [ "$DRY_RUN" = "true" ]; then + npm publish "$tarball" --access public --dry-run + else + npm publish "$tarball" --access public --provenance + fi + done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 516a464..c96a540 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,7 @@ Thank you for your interest in contributing to StatePro! This guide outlines how - [Documentation](#documentation) - [Pull Request Process](#pull-request-process) - [Reporting Issues](#reporting-issues) +- [Releasing](#releasing) - [Getting Help](#getting-help) - [Recognition](#recognition) @@ -379,6 +380,29 @@ For new features, include: - `help wanted`: Community contribution needed - `question`: Questions and discussions +## Releasing + +Releases are cut by maintainers with the **Release** workflow (`.github/workflows/release.yml`). + +1. Open a PR that moves the `[Unreleased]` entries in `CHANGELOG.md` to a `## [X.Y.Z] - YYYY-MM-DD` + section, updates the compare links, and bumps the Studio package versions when they changed. + Merge it to `main`. +2. Run **Actions → Release → Run workflow** on `main` with the version `X.Y.Z` and `dry_run` enabled. + Check the previewed notes and the npm dry-run output. +3. Run it again with `dry_run` disabled. The workflow runs the Quality checks, creates the annotated + tag `vX.Y.Z` on `main`, creates the GitHub release from the CHANGELOG section, and publishes the + Studio packages to npm with provenance. + +`target` limits the run to `github` (tag and release only, for example a Go-only release) or `npm` +(publish an existing tag). Every step is idempotent: an existing tag or release is kept and package +versions already on npm are skipped, so a failed run can be re-run. The Go module needs no extra step; +the tag is enough for `go get`. + +npm publishing uses [Trusted Publishing](https://docs.npmjs.com/trusted-publishers), so no npm token is +stored. Each package needs a one-time trusted publisher on npmjs.com (package **Settings → Trusted +Publisher → GitHub Actions**) with organization `rendis`, repository `statepro`, and workflow +`release.yml`. + ## Getting Help - **Issues**: Use GitHub issues for bugs and feature requests