diff --git a/.changeset/config.json b/.changeset/config.json index 2be13d4..91bf739 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,12 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [ + "vinext-host", + "vinext-island", + "vinext-remote" + ], + "snapshot": { + "useCalculatedVersion": true + } } diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..d568fa9 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,28 @@ +name: Validate PR title + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + branches: + - main + +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + pull-requests: read + +jobs: + semantic-pr: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - name: Accept generated release title + if: ${{ startsWith(github.event.pull_request.title, 'Release ') }} + run: echo "Generated release PR title accepted." + + - uses: amannn/action-semantic-pull-request@v6 + if: ${{ !startsWith(github.event.pull_request.title, 'Release ') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-pull-request.yml b/.github/workflows/release-pull-request.yml new file mode 100644 index 0000000..bb09bcc --- /dev/null +++ b/.github/workflows/release-pull-request.yml @@ -0,0 +1,85 @@ +name: Release Pull Request + +on: + workflow_dispatch: + inputs: + version: + type: choice + description: Release Type (next, beta, alpha, latest) + required: true + default: latest + options: + - next + - beta + - alpha + - latest + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-pull-request + cancel-in-progress: false + +jobs: + release: + name: Create Release Pull Request + runs-on: ubuntu-latest + steps: + - name: Validate release request + env: + REPO_SCOPED_TOKEN: ${{ secrets.REPO_SCOPED_TOKEN }} + run: | + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "Release pull requests must be created from main." + echo "Got: $GITHUB_REF" + exit 1 + fi + + if [ -z "$REPO_SCOPED_TOKEN" ]; then + echo "REPO_SCOPED_TOKEN is required so release PR events trigger CI." + exit 1 + fi + + - name: Checkout repo + uses: actions/checkout@v6 + with: + fetch-depth: 10 + token: ${{ secrets.REPO_SCOPED_TOKEN }} + + - name: Cache tool downloads + uses: actions/cache@v4 + with: + path: ~/.cache + key: ${{ runner.os }}-toolcache-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-toolcache- + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: "10.33.2" + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24.15.0" + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Create release pull request + uses: module-federation/actions@842df3a7740093a5ff44de2b9a890106046b8e32 # v2 + with: + version: ${{ github.event.inputs.version || 'latest' }} + versionNumber: auto + type: pull request + tools: changeset + coreNpmName: "@module-federation/vinext" + env: + GITHUB_TOKEN: ${{ secrets.REPO_SCOPED_TOKEN }} + REPOSITORY: ${{ github.repository }} + REF: ${{ github.ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bce9799..6d97228 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,77 +1,73 @@ name: Release on: - push: - branches: - - main + release: + types: [published] workflow_dispatch: + inputs: + version: + type: choice + description: Release Version + required: true + default: next + options: + - latest + - next + branch: + description: Release Branch (confirm release branch) + required: true + default: main + +permissions: + contents: read + id-token: write concurrency: - group: release-${{ github.event_name }}-${{ github.ref }} + group: publish-${{ github.workflow }}-${{ github.ref_name || github.run_id }} cancel-in-progress: false +env: + PACKAGE_NAME: "@module-federation/vinext" + jobs: - release: - name: Create release PR or publish - if: github.event_name == 'push' + publish: + name: Publish to npm runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - id-token: write + environment: Publish steps: - - name: Check out repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - with: - version: "10.33.2" - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "24.15.0" - registry-url: "https://registry.npmjs.org" - package-manager-cache: false - - - name: Set up npm - run: npm install --global npm@12.0.2 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run checks - run: pnpm run check - - - name: Create or update release PR - id: changesets - uses: changesets/action@v1 - with: - version: pnpm run version - title: "chore: release" - commit: "chore: release" + - name: Validate manual publish target + if: github.event_name == 'workflow_dispatch' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish stable version - if: steps.changesets.outputs.hasChangesets == 'false' - run: pnpm run release + RELEASE_BRANCH: ${{ github.event.inputs.branch }} + run: | + if [ "$RELEASE_BRANCH" != "main" ]; then + echo "Releases must publish from main." + echo "Got: $RELEASE_BRANCH" + exit 1 + fi - preview: - name: Publish preview - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - name: Check out repository uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ github.event.release.tag_name || github.event.inputs.branch }} + + - name: Verify source is on main + run: | + git fetch --no-tags origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "Release source must be reachable from main." + echo "Got: $(git rev-parse HEAD)" + exit 1 + fi + + - name: Cache tool downloads + uses: actions/cache@v4 + with: + path: ~/.cache + key: ${{ runner.os }}-toolcache-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-toolcache- - name: Set up pnpm uses: pnpm/action-setup@v4 @@ -82,8 +78,8 @@ jobs: uses: actions/setup-node@v6 with: node-version: "24.15.0" + cache: pnpm registry-url: "https://registry.npmjs.org" - package-manager-cache: false - name: Set up npm run: npm install --global npm@12.0.2 @@ -91,22 +87,152 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Generate preview version + if: github.event_name == 'workflow_dispatch' && github.event.inputs.version == 'next' + run: | + pnpm exec changeset version --snapshot next + SNAPSHOT_VERSION="$(node -p "require('./package.json').version")" + BASE_VERSION="${SNAPSHOT_VERSION%%-*}" + NEXT_VERSION="${BASE_VERSION}-next.${GITHUB_RUN_ID}" + npm pkg set version="$NEXT_VERSION" + echo "Set preview version: $NEXT_VERSION" + + - name: Verify tag matches package version + if: github.event_name == 'release' + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + TAG="$RELEASE_TAG" + if [ "${TAG#v}" != "$TAG" ]; then + echo "Invalid tag format; refusing to publish." + echo "Expected: 1.2.3 (no leading 'v')" + echo "Got: $TAG" + exit 1 + fi + + PKG_VERSION="$(node -p "require('./package.json').version")" + if [ "$PKG_VERSION" != "$TAG" ]; then + echo "Version mismatch; refusing to publish." + echo "Git tag: $TAG" + echo "package.json version: $PKG_VERSION" + exit 1 + fi + + - name: Set deterministic prerelease version + if: github.event_name == 'release' && github.event.release.prerelease + run: | + CURRENT_VERSION="$(node -p "require('./package.json').version")" + BASE_VERSION="${CURRENT_VERSION%%-*}" + NEXT_VERSION="${BASE_VERSION}-next.${GITHUB_RUN_ID}" + npm pkg set version="$NEXT_VERSION" + echo "Set prerelease version: $NEXT_VERSION" + - name: Run checks run: pnpm run check - - name: Create preview version - run: pnpm exec changeset version --snapshot preview + - name: Check formatting + run: pnpm run fmt.check - - name: Update lockfile - run: pnpm install --lockfile-only --ignore-scripts + - name: Inspect package contents + run: pnpm pack --dry-run + + - name: Compute npm dist-tag + id: dist + env: + EVENT_NAME: ${{ github.event_name }} + IS_PRERELEASE: ${{ github.event.release.prerelease || false }} + RELEASE_VERSION: ${{ github.event.inputs.version }} + run: | + DIST_TAG="latest" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "$RELEASE_VERSION" = "next" ]; then + DIST_TAG="next" + fi + elif [ "$IS_PRERELEASE" = "true" ]; then + DIST_TAG="next" + fi + + echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT" + + - name: Resolve publish action + id: publish_action + env: + DIST_TAG: ${{ steps.dist.outputs.dist_tag }} + run: | + PKG_VERSION="$(node -p "require('./package.json').version")" + ACTION="publish" + REASON="new version not yet on npm" + + EXISTING_VERSION="$(npm view "$PACKAGE_NAME@$PKG_VERSION" version --json 2>/dev/null || true)" + EXISTING_VERSION="${EXISTING_VERSION#\"}" + EXISTING_VERSION="${EXISTING_VERSION%\"}" + + if [ "$EXISTING_VERSION" = "$PKG_VERSION" ]; then + CURRENT_TAG_VERSION="$(npm view "$PACKAGE_NAME" "dist-tags.$DIST_TAG" --json 2>/dev/null || true)" + CURRENT_TAG_VERSION="${CURRENT_TAG_VERSION#\"}" + CURRENT_TAG_VERSION="${CURRENT_TAG_VERSION%\"}" + if [ "$CURRENT_TAG_VERSION" = "null" ]; then + CURRENT_TAG_VERSION="" + fi + + if [ "$CURRENT_TAG_VERSION" = "$PKG_VERSION" ]; then + ACTION="skip" + REASON="version already published and dist-tag already points to it" + else + echo "Refusing to republish existing npm version:" + echo "Package: $PACKAGE_NAME" + echo "Version: $PKG_VERSION" + echo "Target dist-tag: $DIST_TAG" + echo "Current '$DIST_TAG' dist-tag points to: ${CURRENT_TAG_VERSION:-}" + echo "" + echo "Trusted Publishers do not support dist-tag promotion without publishing." + echo "Use a new semver version for this release flow." + exit 1 + fi + fi + + echo "action=$ACTION" >> "$GITHUB_OUTPUT" + echo "reason=$REASON" >> "$GITHUB_OUTPUT" + + - name: Publish to npm + if: steps.publish_action.outputs.action == 'publish' + env: + DIST_TAG: ${{ steps.dist.outputs.dist_tag }} + run: npm publish --provenance --tag "$DIST_TAG" --access public - - name: Publish preview version - run: pnpm exec changeset publish --tag preview --no-git-tag + - name: Skip npm publish + if: steps.publish_action.outputs.action == 'skip' + env: + REASON: ${{ steps.publish_action.outputs.reason }} + run: | + echo "Skipping npm publish:" + echo "$REASON" - - name: Show install command + - name: Post summary + if: always() + continue-on-error: true + env: + SOURCE_REF: ${{ github.event.release.tag_name || github.event.inputs.branch }} + DIST_TAG: ${{ steps.dist.outputs.dist_tag }} + ACTION: ${{ steps.publish_action.outputs.action || 'unknown' }} + REASON: ${{ steps.publish_action.outputs.reason || 'n/a' }} + EVENT_NAME: ${{ github.event_name }} run: | - PACKAGE_VERSION=$(node --print "require('./package.json').version") - echo "### Preview published" >> "$GITHUB_STEP_SUMMARY" - echo '```sh' >> "$GITHUB_STEP_SUMMARY" - echo "pnpm add @module-federation/vinext@$PACKAGE_VERSION" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" + PKG_VERSION="$(node -p "require('./package.json').version")" + { + echo "## npm publish" + echo "" + echo "Package: \`$PACKAGE_NAME\`" + echo "Version: \`$PKG_VERSION\`" + echo "Dist-tag: \`$DIST_TAG\`" + echo "Action: \`$ACTION\`" + echo "Reason: \`$REASON\`" + echo "Trigger: \`$EVENT_NAME\`" + echo "Source ref: \`$SOURCE_REF\`" + if [ "$ACTION" = "publish" ]; then + echo "" + echo '```sh' + echo "pnpm add $PACKAGE_NAME@$PKG_VERSION" + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a402c45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# AGENTS.md + +## Pull request titles + +PR titles are validated by `.github/workflows/pr.yml` and must follow +[Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): +``` + +- Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, + `build`, `ci`, `chore`, `revert`. +- Keep the summary short, lowercase, and in the imperative mood, e.g. + `feat: support shared remote components`, `ci: add e2e test`, + `chore: bump @module-federation/vite`. +- Mark breaking changes with `!`, e.g. `feat!: drop vinext beta support`. +- Squash-merged PRs land on `main` with the PR title (or the commit title for + single-commit PRs), so give commits the same format. +- Titles starting with `Release ` are reserved for the PR generated by the + **Release Pull Request** workflow and skip validation. Don't use that prefix + for anything else. diff --git a/docs/releasing.md b/docs/releasing.md index 10d57cb..92f2685 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -10,31 +10,51 @@ pnpm run changeset Select the version change, write a short user-facing summary, and commit the generated file under `.changeset/`. -## Publish a preview +Pull request titles must follow +[Conventional Commits](https://www.conventionalcommits.org/) (`feat: …`, +`fix: …`, `chore: …`). Generated `Release …` pull requests are exempt. -1. Push the branch containing the code and its changeset. -2. Open **Actions → Release → Run workflow**. -3. Select that branch and run the workflow. -4. Open the completed workflow run and copy the install command from its - summary. +## Try a pull request build -Preview versions use the npm `preview` tag. The latest preview can also be -installed with: +Every pull request and every push to `main` is published to +[pkg.pr.new](https://pkg.pr.new). The install command is posted on the pull +request. + +## Publish a stable release + +1. Merge pull requests containing their changeset files into `main`. +2. Open **Actions → Release Pull Request → Run workflow**, keep `main` and the + `latest` release type, and run it. +3. Review the version bump and changelog in the generated `Release vX.Y.Z` + pull request, then merge it. +4. Create a GitHub release from `main` with the tag `X.Y.Z` (no leading `v`), + matching the version in `package.json`, and publish it. + +Publishing the GitHub release runs the **Release** workflow, which checks that +the tag matches `package.json`, runs the full checks, and publishes to npm with +the `latest` tag. + +## Publish a prerelease + +Either: + +- Publish a GitHub release marked as **pre-release**, or +- Open **Actions → Release → Run workflow** on `main` with the `next` version. + +Both publish `X.Y.Z-next.` with the npm `next` tag: ```sh -pnpm add @module-federation/vinext@preview +pnpm add @module-federation/vinext@next ``` -Running the preview workflow does not change the branch or the future stable -version. +Neither changes `main` or the next stable version. -## Publish a stable release - -1. Merge pull requests containing their changeset files into `main`. -2. The **Release** workflow automatically creates or updates the - `chore: release` pull request. -3. Review the version and changelog in that release pull request. -4. Merge the release pull request. +## Repository setup -The next **Release** workflow run publishes the stable package automatically. -No npm token or manual npm publish is needed. +- Secret `REPO_SCOPED_TOKEN`: a token with `contents` and `pull-requests` write + access, so the generated release pull request triggers CI. +- Environment `Publish`: used by the **Release** workflow. Add required + reviewers to gate npm publishes. +- npm Trusted Publishing for `@module-federation/vinext`, pointing at this + repository, the `release.yml` workflow, and the `Publish` environment. No npm + token is needed. diff --git a/package.json b/package.json index a194098..23aba1b 100644 --- a/package.json +++ b/package.json @@ -45,11 +45,9 @@ "dev:island": "pnpm --filter vinext-island dev", "dev:remote": "pnpm --filter vinext-remote dev", "preview": "pnpm run build:apps && pnpm --filter 'vinext-*' --parallel preview", - "release": "pnpm run build:package && changeset publish", "test": "pnpm run build:package && node --test", "test:e2e": "playwright test --config=playwright.config.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", - "version": "changeset version && pnpm install --lockfile-only --ignore-scripts", "prepack": "pnpm run build:package" }, "dependencies": {