From f2272d565603f6c6d6a92f756465ccfb68790099 Mon Sep 17 00:00:00 2001 From: omermorad Date: Tue, 29 Sep 2026 08:46:38 +0300 Subject: [PATCH 1/4] fix(*): prepare next workflow and release safeguards --- .github/pull_request_template.md | 11 ++ .github/rulesets/next.json | 64 ++++++++ .github/workflows/ci.yml | 161 ++++--------------- .github/workflows/e2e-release.yml | 2 +- .github/workflows/pr-title.yml | 17 ++ .github/workflows/publish-packages.yml | 132 +++++---------- .github/workflows/release-packages.yml | 141 ++++++---------- .github/workflows/tag-global.yml | 27 +++- AGENTS.md | 8 + CONTRIBUTING.md | 33 ++++ README.md | 18 ++- RELEASING.md | 23 +++ e2e/cli-basic/package.json | 2 +- e2e/cli-lifecycle/package.json | 2 +- e2e/packages-import/package.json | 2 +- package.json | 12 +- packages/changesets/package.json | 4 +- packages/cli/package.json | 6 +- packages/cli/src/commands.ts | 8 +- packages/cli/src/commands/lint.command.ts | 14 +- packages/cli/src/commands/version.command.ts | 15 ++ packages/cli/src/core/diff.ts | 5 +- packages/differs.core/package.json | 8 +- packages/differs.json-schema/package.json | 10 +- packages/differs.openapi/package.json | 4 +- packages/governance/package.json | 6 +- packages/types/package.json | 6 +- scripts/check-packages.mjs | 55 +++++++ scripts/check-pr-title.mjs | 15 ++ scripts/check-pr-title.test.mjs | 28 ++++ scripts/release-preflight.mjs | 19 +++ tests/e2e/05-version.test.ts | 24 ++- tests/e2e/12-edge-cases.test.ts | 9 +- tests/e2e/22-release-safety.test.ts | 41 +++++ vitest.config.ts | 2 +- 35 files changed, 554 insertions(+), 380 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 .github/rulesets/next.json create mode 100644 .github/workflows/pr-title.yml create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 RELEASING.md create mode 100644 scripts/check-packages.mjs create mode 100644 scripts/check-pr-title.mjs create mode 100644 scripts/check-pr-title.test.mjs create mode 100644 scripts/release-preflight.mjs create mode 100644 tests/e2e/22-release-safety.test.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..dbc2b5e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Change + +Describe the problem and resulting behavior. Target `next` and use a Conventional Commit title, for example `fix(cli): handle missing snapshots`. + +## Validation + +List the relevant checks and their results. + +## Release impact + +Describe any compatibility change. Tagging or publishing requires Omer's explicit approval; a PR is not release approval. diff --git a/.github/rulesets/next.json b/.github/rulesets/next.json new file mode 100644 index 0000000..bd8e9da --- /dev/null +++ b/.github/rulesets/next.json @@ -0,0 +1,64 @@ +{ + "name": "next: reviewed conventional PRs", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "refs/heads/next" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_linear_history" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "CI", + "integration_id": 15368 + }, + { + "context": "PR title", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": true + } + }, + { + "type": "commit_message_pattern", + "parameters": { + "name": "Conventional Commit subject", + "operator": "regex", + "pattern": "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\\((cli|changesets|types|governance|differs\\.core|differs\\.json-schema|differs\\.openapi|\\*)\\))?!?: \\S", + "negate": false + } + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a02ba4..c16c34e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,142 +1,45 @@ name: Continuous Integration -on: pull_request - +on: + pull_request: + branches: [next] + push: + branches: [next] permissions: - id-token: write contents: read - actions: read - checks: write - pull-requests: write - +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: - build: - name: Build + verify: + name: Verify (Node ${{ matrix.node }}) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node-version: [20.x, 22.x, 24.x] + node: [22, 24] steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Use Node ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.4 - - - uses: actions/cache@v4 - with: - path: '**/node_modules' - key: ${{ runner.os }}-modules-${{ hashFiles('**/pnpm-lock.yaml') }} - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '22.x' - - - name: Install pnpm - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 with: version: 9.15.4 - - - uses: actions/cache@v4 - with: - path: '**/node_modules' - key: ${{ runner.os }}-modules-${{ hashFiles('**/pnpm-lock.yaml') }} - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm lint - - test: - name: Unit Tests + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm lint + - run: pnpm test + - run: node --test scripts/*.test.mjs + - run: pnpm test:e2e + - run: pnpm pack:check + ci: + name: CI + if: always() + needs: [verify] runs-on: ubuntu-latest - strategy: - matrix: - project: ['cli', 'governance', 'changesets', 'json-schema-differ', 'types'] steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '22.x' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.4 - - - uses: actions/cache@v4 - with: - path: '**/node_modules' - key: ${{ runner.os }}-modules-${{ hashFiles('**/pnpm-lock.yaml') }} - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - - name: Create Coverage Directory - run: mkdir -p ${{ github.workspace }}/coverage - - - name: Test - run: pnpm lerna run test --scope @contractual/${{ matrix.project }} --stream - continue-on-error: true + - name: Require every verification job env: - COVERAGE_DIR: ${{ github.workspace }}/coverage - COVERAGE_FILE: coverage-${{ matrix.project }}.xml - - e2e: - name: E2E Tests - runs-on: ubuntu-latest - needs: [build] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '22.x' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.4 - - - uses: actions/cache@v4 - with: - path: '**/node_modules' - key: ${{ runner.os }}-modules-${{ hashFiles('**/pnpm-lock.yaml') }} - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - - name: Run E2E Tests - run: pnpm test:e2e + RESULT: ${{ needs.verify.result }} + run: test "$RESULT" = success diff --git a/.github/workflows/e2e-release.yml b/.github/workflows/e2e-release.yml index 9ab5fd0..ae23d70 100644 --- a/.github/workflows/e2e-release.yml +++ b/.github/workflows/e2e-release.yml @@ -29,7 +29,7 @@ jobs: fail-fast: false matrix: e2e-project: ['cli-basic', 'cli-lifecycle', 'packages-import'] - node-version: [20.x, 22.x, 24.x] + node-version: [22.x, 24.x] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..f02058d --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,17 @@ +name: Pull request title +on: + pull_request: + branches: [next] + types: [opened, edited, synchronize, reopened, ready_for_review] +permissions: + contents: read +jobs: + title: + name: PR title + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Require a Conventional Commit title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: node scripts/check-pr-title.mjs diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 7bb567e..76c6c2c 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -1,106 +1,60 @@ name: Publish Packages -env: - CI: true - -permissions: - id-token: write - contents: write - on: workflow_dispatch: inputs: - dist_tag: - description: 'Distribution Tag' - type: choice - options: - - 'next' - - 'latest' - - 'rc' - - 'dev' - - 'alpha' - - 'beta' + commit: + description: Full reviewed commit SHA on next, explicitly approved by Omer required: true - default: 'next' - target_branch: - description: 'Target branch to release from' + type: string + dist_tag: + description: Approved npm distribution tag type: choice - options: - - 'next' - - 'master' + options: [dev, next, rc, alpha, beta, latest] + default: dev required: true - default: 'next' - +permissions: + id-token: write + contents: read +concurrency: + group: release + cancel-in-progress: false jobs: publish: - name: Publish to NPM + if: github.ref == 'refs/heads/next' + environment: release runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.target_branch }} + ref: ${{ inputs.commit }} fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '22.x' - registry-url: https://registry.npmjs.org/ - scope: '@contractual' - always-auth: true - - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Verify approved commit belongs to next + env: + APPROVED_COMMIT: ${{ inputs.commit }} + run: | + [[ "$APPROVED_COMMIT" =~ ^[0-9a-f]{40}$ ]] + test "$(git rev-parse HEAD)" = "$APPROVED_COMMIT" + git merge-base --is-ancestor HEAD origin/next + - uses: pnpm/action-setup@v4 with: version: 9.15.4 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm lerna run build - - - name: Capture Versions for Report - run: | - echo "## Publishing to NPM" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Registry:** \`https://registry.npmjs.org/\`" >> $GITHUB_STEP_SUMMARY - echo "**Dist Tag:** \`${{ github.event.inputs.dist_tag }}\`" >> $GITHUB_STEP_SUMMARY - echo "**Branch:** \`${{ github.event.inputs.target_branch }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Packages:" >> $GITHUB_STEP_SUMMARY - lerna ls --json | jq -r '.[] | "- **\(.name)** -> `v\(.version)`"' >> $GITHUB_STEP_SUMMARY - - - name: Publish Packages - run: | - npx lerna publish from-package --yes \ - --dist-tag ${{ github.event.inputs.dist_tag }} \ - --no-git-reset + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org/ + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm lint + - run: pnpm test + - run: pnpm test:e2e + - run: pnpm pack:check + - name: Check npm authorization and package metadata + run: node scripts/release-preflight.mjs env: + DIST_TAG: ${{ inputs.dist_tag }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Generate Success Summary - if: success() - run: | - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Publish Successful" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "All packages have been successfully published to npm!" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Installation command:**" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "npm install @contractual/cli@${{ github.event.inputs.dist_tag }}" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - - - name: Generate Failure Summary - if: failure() - run: | - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Publish Failed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The publish step failed. Check the logs above for details." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Common issues:**" >> $GITHUB_STEP_SUMMARY - echo "- Version already exists in npm registry" >> $GITHUB_STEP_SUMMARY - echo "- Authentication token is invalid or expired" >> $GITHUB_STEP_SUMMARY - echo "- Network connectivity issues" >> $GITHUB_STEP_SUMMARY + - name: Publish approved versions (does not create Git tags) + env: + DIST_TAG: ${{ inputs.dist_tag }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm exec lerna publish from-package --yes --dist-tag "$DIST_TAG" --no-git-reset diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 43032a0..8a39c7e 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -3,117 +3,66 @@ on: workflow_dispatch: inputs: release_type: - description: 'Release Type' + description: Approved release type type: choice - options: - - 'graduate' - - 'prerelease' + options: [prerelease, graduate] + default: prerelease required: true - default: 'prerelease' - target_branch: - description: 'Target Branch' - type: choice - options: - - 'master' - - 'next' - required: true - default: 'next' preid: - description: 'Prefix Id' + description: Prerelease identifier type: choice - options: - - 'latest' - - 'next' - - 'rc' - - 'dev' - - 'alpha' - - 'beta' + options: [dev, next, rc, alpha, beta] + default: dev required: true - default: 'next' - exact_version: - description: 'Exact Version' - type: string - required: false - permissions: contents: write - id-token: write - + pull-requests: write +concurrency: + group: release + cancel-in-progress: false jobs: - tag-version: - name: Tag Version + prepare: + if: github.ref == 'refs/heads/next' + environment: release runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.target_branch }} + ref: next fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '22.x' - - - name: Install pnpm - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v4 with: version: 9.15.4 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - - name: Config Git User - run: | - git config --global user.name "${{ github.actor }}" - git config --global user.email "${{ github.actor }}@users.noreply.github.com" - - - name: Prerelease Version (Exact Version) - if: ${{ github.event.inputs.release_type == 'prerelease' && github.event.inputs.exact_version }} - run: | - npx lerna version ${{ github.event.inputs.exact_version }} --yes --no-changelog - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Prerelease Version - if: ${{ github.event.inputs.release_type == 'prerelease' && !github.event.inputs.exact_version }} - run: | - npx lerna version --yes \ - --conventional-commits \ - --conventional-prerelease \ - --preid ${{ github.event.inputs.preid }} \ - --no-changelog - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Graduate Version - if: ${{ github.event.inputs.release_type == 'graduate' }} - run: npx lerna version --conventional-graduate --yes + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm test + - run: pnpm test:e2e + - name: Prepare version changes without tags env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Push Changes to Branch + RELEASE_TYPE: ${{ inputs.release_type }} + PREID: ${{ inputs.preid }} run: | - git push origin ${{ github.event.inputs.target_branch }} --no-verify - git push origin --tags + git switch -c "chore/release-${GITHUB_RUN_ID}" + if [ "$RELEASE_TYPE" = graduate ]; then + pnpm exec lerna version --conventional-graduate --yes --no-git-tag-version --no-push --no-commit-hooks --allow-branch "chore/release-${GITHUB_RUN_ID}" + else + pnpm exec lerna version --conventional-commits --conventional-prerelease --preid "$PREID" --yes --no-git-tag-version --no-push --no-commit-hooks --allow-branch "chore/release-${GITHUB_RUN_ID}" + fi + - run: pnpm pack:check + - name: Open a release PR env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Generate Release Summary + GH_TOKEN: ${{ github.token }} run: | - echo "## Release Prepared" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Branch:** \`${{ github.event.inputs.target_branch }}\`" >> $GITHUB_STEP_SUMMARY - echo "**Release Type:** \`${{ github.event.inputs.release_type }}\`" >> $GITHUB_STEP_SUMMARY - echo "**Preid:** \`${{ github.event.inputs.preid }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Tagged Packages" >> $GITHUB_STEP_SUMMARY - git tag --sort=-creatordate | head -10 | while read tag; do - echo "- \`$tag\`" >> $GITHUB_STEP_SUMMARY - done - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Next Steps" >> $GITHUB_STEP_SUMMARY - echo "Run the **Publish Packages** workflow to publish to npm" >> $GITHUB_STEP_SUMMARY + if git diff --quiet; then + echo 'No version changes to prepare.' + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add packages pnpm-lock.yaml + git commit -m 'chore: prepare package versions' + git push origin "HEAD:refs/heads/chore/release-${GITHUB_RUN_ID}" + gh pr create --base next --head "chore/release-${GITHUB_RUN_ID}" --title 'chore: prepare package versions' --body 'Review package versions and validation before merging. This PR does not tag or publish. Publishing requires a separate explicit approval from Omer. Because this PR uses GITHUB_TOKEN, a maintainer must reopen it to trigger PR checks.' diff --git a/.github/workflows/tag-global.yml b/.github/workflows/tag-global.yml index 31f955e..6723a1b 100644 --- a/.github/workflows/tag-global.yml +++ b/.github/workflows/tag-global.yml @@ -6,13 +6,18 @@ on: tag_name: description: 'Tag Name (e.g. v1.0.0)' required: true + commit: + description: 'Full reviewed commit SHA on next, explicitly approved by Omer' + required: true + type: string permissions: contents: write - id-token: write jobs: tag-and-push: + if: github.ref == 'refs/heads/next' + environment: release name: Tag and Push runs-on: ubuntu-latest @@ -20,22 +25,36 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 with: + ref: ${{ inputs.commit }} fetch-depth: 0 + - name: Verify approved commit + env: + APPROVED_COMMIT: ${{ inputs.commit }} + run: | + [[ "$APPROVED_COMMIT" =~ ^[0-9a-f]{40}$ ]] + test "$(git rev-parse HEAD)" = "$APPROVED_COMMIT" + git merge-base --is-ancestor HEAD origin/next + - name: Config Git run: | git config --global user.email "${{ github.actor }}@users.noreply.github.com" git config --global user.name "${{ github.actor }}" - name: Tag and Push + env: + TAG_NAME: ${{ inputs.tag_name }} run: | - git tag -a ${{ inputs.tag_name }} -m "Tag Version ${{ inputs.tag_name }}" - git push origin ${{ inputs.tag_name }} + git check-ref-format "refs/tags/$TAG_NAME" + git tag -a "$TAG_NAME" -m "Tag Version $TAG_NAME" + git push origin "refs/tags/$TAG_NAME" - name: Generate Summary + env: + TAG_NAME: ${{ inputs.tag_name }} run: | echo "## Tag Created" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "**Tag:** \`${{ inputs.tag_name }}\`" >> $GITHUB_STEP_SUMMARY + echo "**Tag:** \`$TAG_NAME\`" >> $GITHUB_STEP_SUMMARY echo "**Commit:** \`$(git rev-parse HEAD)\`" >> $GITHUB_STEP_SUMMARY echo "**Branch:** \`$(git rev-parse --abbrev-ref HEAD)\`" >> $GITHUB_STEP_SUMMARY diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bd546b6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# Repository workflow + +- Start feature/fix branches from the latest `origin/next`; open PRs targeting `next`. +- Use Conventional Commit PR titles and commits, for example `fix(cli): handle missing snapshots`. +- Squash merge only after required CI and review. Never bypass the ruleset or push directly to `next`. +- Run `pnpm build`, `pnpm lint`, `pnpm test`, `pnpm test:e2e`, and `pnpm pack:check` for release-related changes. +- Always ask Omer for explicit approval before creating, moving, or pushing any version tag; publishing packages or GitHub Releases; changing npm distribution tags; or dispatching a workflow that performs those actions. Approval to fix code or open a PR does not authorize a release. +- Do not change package versions unless a release preparation was explicitly requested. Preserve existing untracked `docs/` material. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0f57bd0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing to Contractual + +All development targets `next`, the schema lifecycle implementation. `master` contains the previous code-generation product. + +```sh +git fetch origin +git switch -c fix/describe-the-change origin/next +pnpm install --frozen-lockfile +pnpm build +``` + +Use Node 22 or newer and pnpm 9.15.4. Before opening a PR, run: + +```sh +pnpm lint +pnpm test +pnpm test:e2e +pnpm pack:check +``` + +The checks validate source, behavior, and the contents and imports of packed npm artifacts. `pack:check` creates temporary local tarballs; it never publishes or tags. + +## Open a PR to next + +Use a Conventional Commit title: `fix(cli): handle missing snapshots`, `feat(differs.core): classify new constraints`, or `docs: clarify supported formats`. Supported types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, and `revert`. Use `!` before the colon for a breaking change. + +One approving review, resolved conversations, an up-to-date branch, and passing `CI` and `PR title` checks are required. PRs are squash merged, with the PR title used as the commit subject. + +Scopes must match a workspace package: `cli`, `changesets`, `types`, `governance`, `differs.core`, `differs.json-schema`, or `differs.openapi`. Use `*` for changes across packages or omit the scope for repository-wide changes. The PR-title check reads these names from package manifests; update the repository ruleset when adding or renaming a package. + +## Release approval + +Normal development never publishes packages or creates version tags. Ask Omer before tagging, publishing, changing npm distribution tags, or dispatching a release workflow. See [RELEASING.md](RELEASING.md) for the release procedure and current support boundary. diff --git a/README.md b/README.md index f21b5d8..addcde6 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@

Contractual

-Schema contract lifecycle for OpenAPI, JSON Schema, and AsyncAPI +Schema contract lifecycle for OpenAPI and JSON Schema
Linting • Breaking change detection • Versioning • Release automation

license - PRs welcome + PRs welcome npm downloads
@@ -27,7 +27,7 @@ Linting • Breaking change detection • Versioning • Release automation

-Supported Formats: OpenAPI, JSON Schema, AsyncAPI +Supported Formats: OpenAPI, JSON Schema

## Features @@ -38,7 +38,7 @@ Linting • Breaking change detection • Versioning • Release automation - **CI Integration** - GitHub Action posts diff tables on PRs, auto-generates changesets, and opens Version PRs for release automation. -- **Format Agnostic** - Works with OpenAPI, JSON Schema, and AsyncAPI. Custom linters and differs can be configured per contract. +- **Format Agnostic** - Works with OpenAPI and JSON Schema. Custom linters and differs can be configured per contract. ## Quick Example @@ -78,15 +78,17 @@ Updated CHANGELOG.md ## Installation +Development releases are currently available under the npm `dev` tag. There is no stable release yet. Built-in linting and diffing support OpenAPI and JSON Schema; AsyncAPI and ODCS require custom engines. AI, fixed versioning, and generation hooks are planned. See [release scope](RELEASING.md) and [contributing](CONTRIBUTING.md). + ```bash -npm install -g @contractual/cli +npm install -g @contractual/cli@dev ``` Or with other package managers: ```bash -pnpm add -g @contractual/cli -yarn global add @contractual/cli +pnpm add -g @contractual/cli@dev +yarn global add @contractual/cli@dev ``` ## Getting Started @@ -102,7 +104,7 @@ yarn global add @contractual/cli ## Community - [Documentation](https://contractual.dev) -- [GitHub Issues](https://github.com/contractual-dev/contractual/issues) +- [GitHub Issues](https://github.com/codotech/contractual/issues) ## License diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b52f4b9 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,23 @@ +# Releasing Contractual + +Releases require Omer's explicit approval. Fixes and merged PRs do not authorize tagging or publishing. The `release` environment requires Omer's approval for release jobs. + +## Supported release scope + +The first stable scope is OpenAPI 3.0/3.1 and JSON Schema linting and diffing, changesets, independent versioning, and the GitHub Action. AsyncAPI and ODCS can be tracked and versioned but need explicit custom lint/diff commands or disabled checks. They have no built-in governance engines. Fixed versioning, AI features, and generation hooks are not implemented; do not advertise them as stable functionality. + +## Prepare a reviewed release + +1. Run all checks in [CONTRIBUTING.md](CONTRIBUTING.md), including packed artifact validation. +2. Present Omer with the exact commit, proposed package versions, npm distribution tag, and validation results. Obtain approval before preparing version changes or running a release workflow. +3. Run **Prepare Release** from `next`. It opens a version PR targeting `next`; it does not create tags or publish. Review and merge that PR normally. +4. Obtain approval for the exact merged release commit, then run **Publish Packages** with that full commit SHA and the approved distribution tag. `latest` rejects prerelease versions. Approve the `release` environment job in GitHub. +5. Verify package installations and imports from npm. Update the Action's pinned dependencies, rebuild its committed bundle, and review the Action PR before requesting approval for an Action tag or GitHub Release. + +Tag creation is separate and manual. Do not move existing development tags or publish a stable major alias without approval. The old `next → master` PR is historical and is not a prerequisite for releasing from `next`. + +## Publishing failures + +The June 18 attempt left CLI `0.1.0-dev.8` and changesets `0.1.0-dev.6` tagged but unpublished. The last npm CLI release is `0.1.0-dev.7` under `dev`; `latest` still points to `0.1.0-dev.0`. Do not repair these registry tags without approval. + +Publishing preflight checks authentication, package write access, repository metadata, and version/tag compatibility before running Lerna. If npm returns `E404`, verify the configured `NPM_TOKEN` can write every `@contractual` package and that npm access and provenance match `codotech/contractual`. Never print a token in logs. A successful build is not evidence of npm authorization. diff --git a/e2e/cli-basic/package.json b/e2e/cli-basic/package.json index f907462..dfb5a3e 100644 --- a/e2e/cli-basic/package.json +++ b/e2e/cli-basic/package.json @@ -15,6 +15,6 @@ "test": "vitest run" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } } diff --git a/e2e/cli-lifecycle/package.json b/e2e/cli-lifecycle/package.json index 78d3385..96a432e 100644 --- a/e2e/cli-lifecycle/package.json +++ b/e2e/cli-lifecycle/package.json @@ -16,6 +16,6 @@ "test": "vitest run" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } } diff --git a/e2e/packages-import/package.json b/e2e/packages-import/package.json index ee0f6d8..34c3b20 100644 --- a/e2e/packages-import/package.json +++ b/e2e/packages-import/package.json @@ -18,6 +18,6 @@ "test": "tsc --noEmit && vitest run" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } } diff --git a/package.json b/package.json index 14a20c1..532c574 100644 --- a/package.json +++ b/package.json @@ -2,17 +2,17 @@ "name": "contractual-monorepo", "private": true, "version": "0.0.0", - "license": "Apache-2.0", + "license": "MIT", "type": "module", "repository": { "type": "git", - "url": "https://github.com/contractual-dev/contractual.git" + "url": "https://github.com/codotech/contractual.git" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "bugs": { - "url": "https://github.com/contractual-dev/contractual.git" + "url": "https://github.com/codotech/contractual.git" }, "homepage": "https://contractual.dev", "contributors": [ @@ -24,13 +24,13 @@ "scripts": { "build": "pnpm lerna run build --stream", "build:watch": "pnpm -r run build:watch", - "test": "pnpm lerna run test --stream", + "test": "vitest run", "test:e2e": "vitest run --config vitest.config.e2e.ts", "test:e2e:watch": "vitest --config vitest.config.e2e.ts", "lint": "pnpm lerna run lint --parallel", "prepare": "husky", "version:preview": "lerna changed --json | jq -r '.[] | \"\\(.name) → v\\(.version)\"'", - "publish:dry": "lerna publish from-package --yes --no-git-reset --dry-run", + "pack:check": "node scripts/check-packages.mjs", "e2e:verdaccio:up": "docker compose -f local-e2e.docker-compose.yaml up -d", "e2e:verdaccio:down": "docker compose -f local-e2e.docker-compose.yaml down -v" }, diff --git a/packages/changesets/package.json b/packages/changesets/package.json index 5ab172d..4e11066 100644 --- a/packages/changesets/package.json +++ b/packages/changesets/package.json @@ -25,11 +25,11 @@ "sideEffects": false, "repository": { "type": "git", - "url": "https://github.com/contractual-dev/contractual.git", + "url": "https://github.com/codotech/contractual.git", "directory": "packages/changesets" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "scripts": { "prebuild": "pnpm rimraf dist", diff --git a/packages/cli/package.json b/packages/cli/package.json index f3fa679..f4dff6e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,12 +17,12 @@ }, "repository": { "type": "git", - "url": "https://github.com/contractual-dev/contractual.git", + "url": "https://github.com/codotech/contractual.git", "directory": "packages/cli" }, "homepage": "https://contractual.dev", "bugs": { - "url": "https://github.com/contractual-dev/contractual/issues" + "url": "https://github.com/codotech/contractual/issues" }, "contributors": [ { @@ -41,7 +41,7 @@ } ], "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "scripts": { "prebuild": "pnpm rimraf dist", diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index cd3a3d6..ae73ec5 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { readFileSync } from 'node:fs'; import { initCommand } from './commands/init.command.js'; import { contractAddCommand, contractListCommand } from './commands/contract.command.js'; import { lintCommand } from './commands/lint.command.js'; @@ -11,7 +12,12 @@ import { statusCommand } from './commands/status.command.js'; const program = new Command(); -program.name('contractual').description('Schema contract lifecycle orchestrator').version('0.1.0'); +const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + .version as string; +program + .name('contractual') + .description('Schema contract lifecycle orchestrator') + .version(packageVersion); program .command('init') diff --git a/packages/cli/src/commands/lint.command.ts b/packages/cli/src/commands/lint.command.ts index a2a7ca1..dda57d2 100644 --- a/packages/cli/src/commands/lint.command.ts +++ b/packages/cli/src/commands/lint.command.ts @@ -38,7 +38,7 @@ function getLinterForContract(contract: ResolvedContract): LinterLookupResult { const linter = getRegisteredLinter(contract.type, contract.lint); if (linter === null) { - return { status: 'disabled' }; + return { status: 'not-found', type: contract.type }; } if (!linter) { @@ -106,6 +106,18 @@ export async function lintCommand(options: LintOptions = {}): Promise { // No linter registered for this type if (linterResult.status === 'not-found') { + results.push({ + contract: contract.name, + specPath: contract.absolutePath, + errors: [ + { + path: '', + severity: 'error', + message: `No linter available for ${contract.type}. Configure a custom lint command or set lint: false.`, + }, + ], + warnings: [], + }); if (format === 'text') { spinner?.stopAndPersist({ symbol: chalk.yellow('!'), diff --git a/packages/cli/src/commands/version.command.ts b/packages/cli/src/commands/version.command.ts index c20dae4..43ffc08 100644 --- a/packages/cli/src/commands/version.command.ts +++ b/packages/cli/src/commands/version.command.ts @@ -69,6 +69,12 @@ export async function versionCommand(options: VersionOptions = {}): Promise !config.contracts.some((contract) => contract.name === name) + ); + if (unknownContracts.length > 0) { + throw new Error( + `Changesets reference unknown contracts: ${unknownContracts.join(', ')}. No changesets were consumed.` + ); + } + if (Object.keys(aggregatedBumps).length === 0) { if (options.json) { console.log(JSON.stringify({ bumps: [], changesets: changesets.length }, null, 2)); diff --git a/packages/cli/src/core/diff.ts b/packages/cli/src/core/diff.ts index 2ff6b4e..44cddec 100644 --- a/packages/cli/src/core/diff.ts +++ b/packages/cli/src/core/diff.ts @@ -88,8 +88,9 @@ async function diffSingleContract( const differ = getDiffer(contract.type, contract.breaking); if (differ === null) { - // Disabled via config override - return createEmptyResult(contract.name, 'disabled'); + throw new Error( + `No differ available for ${contract.type} contract "${contract.name}". Configure a custom breaking command or set breaking: false explicitly.` + ); } if (!differ) { diff --git a/packages/differs.core/package.json b/packages/differs.core/package.json index a0ab895..2cb7517 100644 --- a/packages/differs.core/package.json +++ b/packages/differs.core/package.json @@ -14,12 +14,12 @@ }, "repository": { "type": "git", - "url": "https://github.com/contractual-dev/contractual.git", + "url": "https://github.com/codotech/contractual.git", "directory": "packages/differs.core" }, - "homepage": "https://github.com/contractual-dev/contractual/tree/main/packages/differs.core", + "homepage": "https://github.com/codotech/contractual/tree/next/packages/differs.core", "bugs": { - "url": "https://github.com/contractual-dev/contractual/issues" + "url": "https://github.com/codotech/contractual/issues" }, "keywords": [ "json-schema", @@ -31,7 +31,7 @@ "compatibility" ], "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "scripts": { "prebuild": "rimraf dist", diff --git a/packages/differs.json-schema/package.json b/packages/differs.json-schema/package.json index 0366b2f..cc642d1 100644 --- a/packages/differs.json-schema/package.json +++ b/packages/differs.json-schema/package.json @@ -14,12 +14,12 @@ }, "repository": { "type": "git", - "url": "https://github.com/contractual-dev/contractual.git", + "url": "https://github.com/codotech/contractual.git", "directory": "packages/differs.json-schema" }, - "homepage": "https://github.com/contractual-dev/contractual/tree/main/packages/differs.json-schema", + "homepage": "https://github.com/codotech/contractual/tree/next/packages/differs.json-schema", "bugs": { - "url": "https://github.com/contractual-dev/contractual/issues" + "url": "https://github.com/codotech/contractual/issues" }, "keywords": [ "json-schema", @@ -33,13 +33,13 @@ "validation" ], "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "scripts": { "prebuild": "rimraf dist", "build": "tsc -p tsconfig.build.json", "build:watch": "tsc -p tsconfig.build.json --watch", - "test": "vitest run", + "test": "vitest run --config ../../vitest.config.e2e.ts --root ../.. tests/e2e/09-differs.json-schema.test.ts", "test:watch": "vitest", "lint": "eslint \"src/**/*.ts\"" }, diff --git a/packages/differs.openapi/package.json b/packages/differs.openapi/package.json index 9614eb8..9e34ead 100644 --- a/packages/differs.openapi/package.json +++ b/packages/differs.openapi/package.json @@ -15,11 +15,11 @@ }, "repository": { "type": "git", - "url": "https://github.com/contractual-dev/contractual.git", + "url": "https://github.com/codotech/contractual.git", "directory": "packages/differs.openapi" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "scripts": { "prebuild": "rimraf dist", diff --git a/packages/governance/package.json b/packages/governance/package.json index a86c7ba..94c4fc7 100644 --- a/packages/governance/package.json +++ b/packages/governance/package.json @@ -22,15 +22,15 @@ }, "repository": { "type": "git", - "url": "https://github.com/contractual-dev/contractual.git", + "url": "https://github.com/codotech/contractual.git", "directory": "packages/governance" }, "homepage": "https://contractual.dev", "bugs": { - "url": "https://github.com/contractual-dev/contractual/issues" + "url": "https://github.com/codotech/contractual/issues" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "scripts": { "prebuild": "pnpm rimraf dist", diff --git a/packages/types/package.json b/packages/types/package.json index d596426..b66def6 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -15,12 +15,12 @@ }, "repository": { "type": "git", - "url": "https://github.com/contractual-dev/contractual.git", + "url": "https://github.com/codotech/contractual.git", "directory": "packages/types" }, "homepage": "https://contractual.dev", "bugs": { - "url": "https://github.com/contractual-dev/contractual/issues" + "url": "https://github.com/codotech/contractual/issues" }, "keywords": [ "contractual", @@ -34,7 +34,7 @@ "contract" ], "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "scripts": { "prebuild": "pnpm rimraf dist", diff --git a/scripts/check-packages.mjs b/scripts/check-packages.mjs new file mode 100644 index 0000000..a97280c --- /dev/null +++ b/scripts/check-packages.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const directory = mkdtempSync(join(tmpdir(), 'contractual-pack-check-')); +const run = (command, args, cwd = directory) => execFileSync(command, args, { + cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], +}); + +try { + const packages = readdirSync(join(root, 'packages')).map(name => { + const path = join(root, 'packages', name); + return { path, ...JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')) }; + }).filter(pkg => !pkg.private); + const dependencies = {}; + for (const pkg of packages) { + run('pnpm', ['pack', '--pack-destination', directory], pkg.path); + const filename = `${pkg.name.replace('@', '').replace('/', '-')}-${pkg.version}.tgz`; + const tarball = join(directory, filename); + const contents = run('tar', ['-tzf', tarball]); + assert.match(contents, /package\/dist\/index.js/); + assert.doesNotMatch(contents, /\.(test|spec)\.[cm]?[jt]s(?:\n|$)/); + const packed = JSON.parse(run('tar', ['-xOzf', tarball, 'package/package.json'])); + for (const version of Object.values(packed.dependencies || {})) { + assert.ok(!version.startsWith('workspace:'), `${pkg.name} contains a workspace dependency`); + } + dependencies[pkg.name] = `file:${tarball}`; + } + writeFileSync(join(directory, 'package.json'), JSON.stringify({ + private: true, type: 'module', dependencies, overrides: dependencies, + })); + run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false']); + run('node', ['--input-type=module', '-e', + `for (const name of ${JSON.stringify(Object.keys(dependencies))}) await import(name);`]); + const cli = resolve(directory, 'node_modules/@contractual/cli/bin/cli.js'); + assert.match(run('node', [cli, '--help']), /changeset/); + writeFileSync(join(directory, 'order.schema.json'), JSON.stringify({ + $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', + properties: { id: { type: 'string' } }, + })); + run('node', [cli, 'init', '--yes']); + run('node', [cli, 'lint']); + run('node', [cli, 'diff', '--format', 'json']); + run('node', [cli, 'status']); + console.log(`Packed, installed, imported and smoke-tested ${packages.length} packages.`); +} catch (error) { + console.error(error.stderr?.toString() || error.message); + process.exitCode = 1; +} finally { + rmSync(directory, { recursive: true, force: true }); +} diff --git a/scripts/check-pr-title.mjs b/scripts/check-pr-title.mjs new file mode 100644 index 0000000..eeef545 --- /dev/null +++ b/scripts/check-pr-title.mjs @@ -0,0 +1,15 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const directory = fileURLToPath(new URL('../packages/', import.meta.url)); +const scopes = readdirSync(directory).map(name => + JSON.parse(readFileSync(join(directory, name, 'package.json'), 'utf8')).name.replace('@contractual/', '') +); +scopes.push('*'); +const title = process.env.PR_TITLE || ''; +const match = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(([^)]+)\))?!?: \S[^\r\n]*$/.exec(title); +if (!match || (match[2] && !scopes.includes(match[2]))) { + console.error(`Use a Conventional Commit title with a workspace scope (${scopes.join(', ')}), or no scope for a repository-wide change. Example: fix(cli): handle missing snapshots`); + process.exit(1); +} diff --git a/scripts/check-pr-title.test.mjs b/scripts/check-pr-title.test.mjs new file mode 100644 index 0000000..243cee3 --- /dev/null +++ b/scripts/check-pr-title.test.mjs @@ -0,0 +1,28 @@ +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +for (const [title, valid] of [ + ['fix(cli): handle missing snapshots', true], + ['feat(differs.core)!: classify constraints', true], + ['fix(differs.json-schema): validate schema', true], + ['fix(differs.openapi): validate schema', true], + ['test(changesets): preserve changesets', true], + ['fix(governance): check schema origins', true], + ['feat(types): expose options', true], + ['fix(*): update dependencies', true], + ['ci: verify builds', true], + ['fix(diff): invalid package scope', false], + ['fix(release): invalid package scope', false], + ['update dependencies', false], + ['fix(cli): ', false], + ['fix(cli): title\ninjected body', false], +]) { + test(title, () => { + const result = spawnSync(process.execPath, [new URL('./check-pr-title.mjs', import.meta.url).pathname], { + env: { ...process.env, PR_TITLE: title }, + encoding: 'utf8', + }); + assert.equal(result.status, valid ? 0 : 1, result.stderr); + }); +} diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs new file mode 100644 index 0000000..5dc5df1 --- /dev/null +++ b/scripts/release-preflight.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; + +const tag = process.env.DIST_TAG; +assert.ok(['latest', 'next', 'dev', 'rc', 'alpha', 'beta'].includes(tag), 'Invalid distribution tag'); +assert.ok(process.env.NODE_AUTH_TOKEN, 'NPM_TOKEN is required; ask Omer to configure package write access'); +const npm = args => execFileSync('npm', args, { encoding: 'utf8' }).trim(); +const username = npm(['whoami', '--registry=https://registry.npmjs.org/']); +const access = JSON.parse(npm(['access', 'list', 'packages', username, '--json'])); +for (const directory of readdirSync('packages')) { + const pkg = JSON.parse(readFileSync(`packages/${directory}/package.json`, 'utf8')); + if (pkg.private) continue; + assert.equal(pkg.repository?.url, 'https://github.com/codotech/contractual.git', `${pkg.name}: repository URL must match provenance`); + assert.equal(pkg.publishConfig?.access, 'public', `${pkg.name}: public access must be explicit`); + assert.equal(access[pkg.name], 'read-write', `${username} cannot publish ${pkg.name}; verify npm token permissions`); + assert.ok(tag !== 'latest' || !pkg.version.includes('-'), `${pkg.name}: refusing a prerelease on latest`); + console.log(`${pkg.name}@${pkg.version}: publishing preflight passed (${tag})`); +} diff --git a/tests/e2e/05-version.test.ts b/tests/e2e/05-version.test.ts index 3fc73c3..90b40a6 100644 --- a/tests/e2e/05-version.test.ts +++ b/tests/e2e/05-version.test.ts @@ -420,7 +420,7 @@ describe('contractual version', () => { } }); - test('skips contracts not found in config', () => { + test('preserves all changesets and versions when a contract is unknown', () => { const { dir, cleanup } = createTempRepo(); try { setupRepoWithConfig(dir, [ @@ -456,31 +456,27 @@ describe('contractual version', () => { ` ); - const result = run('version', dir); + const result = run('version', dir, { expectFail: true }); - // Assert: command succeeds (processes what it can) - expect(result.exitCode).toBe(0); + // Validation happens before any version or changeset mutation. + expect(result.exitCode).not.toBe(0); - // Assert: existing contract was bumped + // Assert: existing contract was not bumped const versions = readJSON(dir, '.contractual/versions.json') as Record< string, { version: string } >; - expect(versions['existing-api'].version).toBe('1.1.0'); + expect(versions['existing-api'].version).toBe('1.0.0'); // Assert: nonexistent contract was not added to versions expect(versions['nonexistent-api']).toBeUndefined(); - // Assert: changeset was still consumed + // Assert: changeset was preserved const changesetFiles = listFiles(dir, '.contractual/changesets'); - expect(changesetFiles).toHaveLength(0); + expect(changesetFiles).toEqual(['mixed-update.md']); - // Assert: changelog only contains the valid contract - const changelog = readFile(dir, 'CHANGELOG.md'); - expect(changelog).toContain('[existing-api] v1.1.0'); - expect(changelog).toContain('Updated existing API'); - // The nonexistent-api changes should NOT appear in changelog (no version bumped for it) - expect(changelog).not.toContain('[nonexistent-api]'); + // Assert: no changelog was created + expect(fileExists(dir, 'CHANGELOG.md')).toBe(false); } finally { cleanup(); } diff --git a/tests/e2e/12-edge-cases.test.ts b/tests/e2e/12-edge-cases.test.ts index b4eeda0..58c1a27 100644 --- a/tests/e2e/12-edge-cases.test.ts +++ b/tests/e2e/12-edge-cases.test.ts @@ -7,6 +7,7 @@ import { setupRepoWithConfig, writeFile, ensureCliBuilt, + fileExists, } from './helpers.js'; beforeAll(() => { @@ -339,9 +340,11 @@ This references a non-existent contract ` ); - const result = run('version', dir); - // Should process changesets - the unknown one will be in the aggregation - expect(result.exitCode).toBe(0); + const result = run('version', dir, { expectFail: true }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('ghost-api'); + expect(fileExists(dir, '.contractual/changesets/valid-change.md')).toBe(true); + expect(fileExists(dir, '.contractual/changesets/orphan-change.md')).toBe(true); } finally { cleanup(); } diff --git a/tests/e2e/22-release-safety.test.ts b/tests/e2e/22-release-safety.test.ts new file mode 100644 index 0000000..d6fb700 --- /dev/null +++ b/tests/e2e/22-release-safety.test.ts @@ -0,0 +1,41 @@ +import { test, expect } from 'vitest'; +import { createTempRepo, writeFile, run, fileExists, readFile } from './helpers.js'; + +test('fixed versioning fails before modifying versions or consuming changesets', () => { + const { dir, cleanup } = createTempRepo(); + try { + writeFile( + dir, + 'contractual.yaml', + 'contracts:\n - name: api\n type: json-schema\n path: api.json\nversioning:\n mode: fixed\n' + ); + writeFile(dir, 'api.json', '{"type":"object"}'); + writeFile(dir, '.contractual/versions.json', '{"api":{"version":"1.0.0"}}'); + writeFile(dir, '.contractual/changesets/change.md', '---\napi: minor\n---\nChange\n'); + const result = run('version --yes', dir, { expectFail: true }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('Fixed versioning is not implemented'); + expect(readFile(dir, '.contractual/versions.json')).toBe('{"api":{"version":"1.0.0"}}'); + expect(fileExists(dir, '.contractual/changesets/change.md')).toBe(true); + } finally { + cleanup(); + } +}); + +test('a missing built-in engine cannot report a successful lint or diff', () => { + const { dir, cleanup } = createTempRepo(); + try { + writeFile( + dir, + 'contractual.yaml', + 'contracts:\n - name: events\n type: asyncapi\n path: events.yaml\n' + ); + writeFile(dir, 'events.yaml', 'asyncapi: 3.0.0\ninfo:\n title: Events\n version: 1.0.0\n'); + writeFile(dir, '.contractual/versions.json', '{"events":{"version":"1.0.0"}}'); + writeFile(dir, '.contractual/snapshots/events.yaml', 'asyncapi: 3.0.0\n'); + expect(run('lint --format json', dir, { expectFail: true }).exitCode).not.toBe(0); + expect(run('diff', dir, { expectFail: true }).exitCode).not.toBe(0); + } finally { + cleanup(); + } +}); diff --git a/vitest.config.ts b/vitest.config.ts index 05725e6..5394774 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import process from 'process'; export default defineConfig({ test: { - workspace: ['packages/*', 'packages/generators/*', 'packages/providers/*'], + include: ['packages/**/*.test.ts', 'packages/**/*.spec.ts'], reporters: ['default', 'junit'], outputFile: { junit: From 1e91ddf1bf523aa4242ff270a5fd1a544b7a117b Mon Sep 17 00:00:00 2001 From: omermorad Date: Tue, 29 Sep 2026 08:53:00 +0300 Subject: [PATCH 2/4] ci: replace helper scripts with native actions --- .github/actions/verify-packages/action.yml | 49 +++++++++++++++++++ .github/workflows/ci.yml | 3 +- .github/workflows/pr-title.yml | 32 +++++++++++-- .github/workflows/publish-packages.yml | 18 ++++++- .github/workflows/release-packages.yml | 2 +- AGENTS.md | 3 +- CONTRIBUTING.md | 5 +- package.json | 1 - scripts/check-packages.mjs | 55 ---------------------- scripts/check-pr-title.mjs | 15 ------ scripts/check-pr-title.test.mjs | 28 ----------- scripts/release-preflight.mjs | 19 -------- 12 files changed, 98 insertions(+), 132 deletions(-) create mode 100644 .github/actions/verify-packages/action.yml delete mode 100644 scripts/check-packages.mjs delete mode 100644 scripts/check-pr-title.mjs delete mode 100644 scripts/check-pr-title.test.mjs delete mode 100644 scripts/release-preflight.mjs diff --git a/.github/actions/verify-packages/action.yml b/.github/actions/verify-packages/action.yml new file mode 100644 index 0000000..d916093 --- /dev/null +++ b/.github/actions/verify-packages/action.yml @@ -0,0 +1,49 @@ +name: Verify packed packages +description: Pack, install, and smoke-test local packages without publishing or tagging +runs: + using: composite + steps: + - name: Pack workspace packages + id: pack + shell: bash + run: | + package_dir="$(mktemp -d "$RUNNER_TEMP/contractual-packages.XXXXXX")" + echo "directory=$package_dir" >> "$GITHUB_OUTPUT" + pnpm -r exec pnpm pack --pack-destination "$package_dir" + - name: Verify and install tarballs + shell: bash + env: + PACKAGE_DIR: ${{ steps.pack.outputs.directory }} + run: | + jq -n '{private: true, type: "module", dependencies: {}, overrides: {}}' > "$PACKAGE_DIR/package.json" + for tarball in "$PACKAGE_DIR"/*.tgz; do + contents="$(tar -tzf "$tarball")" + grep -qx 'package/dist/index.js' <<< "$contents" + if grep -Eq '\.(test|spec)\.[cm]?[jt]s$' <<< "$contents"; then + echo "Tests must not be included in $tarball" >&2 + exit 1 + fi + manifest="$(tar -xOzf "$tarball" package/package.json)" + jq -e '[.dependencies // {} | .[] | startswith("workspace:")] | any | not' <<< "$manifest" > /dev/null + package_name="$(jq -r '.name' <<< "$manifest")" + jq --arg name "$package_name" --arg file "file:$tarball" '.dependencies[$name] = $file | .overrides[$name] = $file' "$PACKAGE_DIR/package.json" > "$PACKAGE_DIR/package.tmp.json" + mv "$PACKAGE_DIR/package.tmp.json" "$PACKAGE_DIR/package.json" + done + cd "$PACKAGE_DIR" + npm install --ignore-scripts --no-audit --no-fund --package-lock=false + while IFS= read -r package_name; do + node --input-type=module -e 'await import(process.argv[1])' "$package_name" + done < <(jq -r '.dependencies | keys[]' package.json) + - name: Smoke-test the installed CLI + shell: bash + env: + PACKAGE_DIR: ${{ steps.pack.outputs.directory }} + run: | + cd "$PACKAGE_DIR" + jq -n '{"$schema": "http://json-schema.org/draft-07/schema#", type: "object", properties: {id: {type: "string"}}}' > order.schema.json + cli=node_modules/@contractual/cli/bin/cli.js + node "$cli" --help + node "$cli" init --yes + node "$cli" lint + node "$cli" diff --format json + node "$cli" status diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c16c34e..e09b2b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,8 @@ jobs: - run: pnpm build - run: pnpm lint - run: pnpm test - - run: node --test scripts/*.test.mjs - run: pnpm test:e2e - - run: pnpm pack:check + - uses: ./.github/actions/verify-packages ci: name: CI if: always() diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index f02058d..31cdbe1 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -4,14 +4,36 @@ on: branches: [next] types: [opened, edited, synchronize, reopened, ready_for_review] permissions: - contents: read + pull-requests: read jobs: title: name: PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Require a Conventional Commit title + - uses: amannn/action-semantic-pull-request@v6 env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: node scripts/check-pr-title.mjs + GITHUB_TOKEN: ${{ github.token }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + scopes: | + cli + changesets + types + governance + differs\.core + differs\.json-schema + differs\.openapi + \* + requireScope: false + subjectPattern: '^\S[^\r\n]*$' diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 76c6c2c..c35d795 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -47,12 +47,26 @@ jobs: - run: pnpm lint - run: pnpm test - run: pnpm test:e2e - - run: pnpm pack:check + - uses: ./.github/actions/verify-packages - name: Check npm authorization and package metadata - run: node scripts/release-preflight.mjs env: DIST_TAG: ${{ inputs.dist_tag }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + case "$DIST_TAG" in dev|next|rc|alpha|beta|latest) ;; *) exit 1 ;; esac + test -n "$NODE_AUTH_TOKEN" + npm_user="$(npm whoami --registry=https://registry.npmjs.org/)" + package_access="$(npm access list packages "$npm_user" --json)" + for manifest in packages/*/package.json; do + if jq -e '.private == true' "$manifest" > /dev/null; then continue; fi + package_name="$(jq -r '.name' "$manifest")" + jq -e '.repository.url == "https://github.com/codotech/contractual.git" and .publishConfig.access == "public"' "$manifest" > /dev/null + jq -e --arg name "$package_name" '.[$name] == "read-write"' <<< "$package_access" > /dev/null + if [ "$DIST_TAG" = latest ]; then + jq -e '.version | contains("-") | not' "$manifest" > /dev/null + fi + echo "$package_name: publishing preflight passed ($DIST_TAG)" + done - name: Publish approved versions (does not create Git tags) env: DIST_TAG: ${{ inputs.dist_tag }} diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 8a39c7e..3e413e5 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -51,7 +51,7 @@ jobs: else pnpm exec lerna version --conventional-commits --conventional-prerelease --preid "$PREID" --yes --no-git-tag-version --no-push --no-commit-hooks --allow-branch "chore/release-${GITHUB_RUN_ID}" fi - - run: pnpm pack:check + - uses: ./.github/actions/verify-packages - name: Open a release PR env: GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index bd546b6..5425672 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ - Start feature/fix branches from the latest `origin/next`; open PRs targeting `next`. - Use Conventional Commit PR titles and commits, for example `fix(cli): handle missing snapshots`. - Squash merge only after required CI and review. Never bypass the ruleset or push directly to `next`. -- Run `pnpm build`, `pnpm lint`, `pnpm test`, `pnpm test:e2e`, and `pnpm pack:check` for release-related changes. +- Run `pnpm build`, `pnpm lint`, `pnpm test`, and `pnpm test:e2e` for release-related changes. CI also validates packed artifacts through `.github/actions/verify-packages`. +- Keep automation in GitHub Actions workflows and composite actions; do not add a `scripts/` folder for workflow helpers. - Always ask Omer for explicit approval before creating, moving, or pushing any version tag; publishing packages or GitHub Releases; changing npm distribution tags; or dispatching a workflow that performs those actions. Approval to fix code or open a PR does not authorize a release. - Do not change package versions unless a release preparation was explicitly requested. Preserve existing untracked `docs/` material. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f57bd0..6143671 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,10 +15,9 @@ Use Node 22 or newer and pnpm 9.15.4. Before opening a PR, run: pnpm lint pnpm test pnpm test:e2e -pnpm pack:check ``` -The checks validate source, behavior, and the contents and imports of packed npm artifacts. `pack:check` creates temporary local tarballs; it never publishes or tags. +These commands validate source and behavior. CI additionally uses the `verify-packages` composite action to pack, install, import, and smoke-test temporary local tarballs. It never publishes or tags. Workflow automation lives in `.github/`, without a separate scripts folder. ## Open a PR to next @@ -26,7 +25,7 @@ Use a Conventional Commit title: `fix(cli): handle missing snapshots`, `feat(dif One approving review, resolved conversations, an up-to-date branch, and passing `CI` and `PR title` checks are required. PRs are squash merged, with the PR title used as the commit subject. -Scopes must match a workspace package: `cli`, `changesets`, `types`, `governance`, `differs.core`, `differs.json-schema`, or `differs.openapi`. Use `*` for changes across packages or omit the scope for repository-wide changes. The PR-title check reads these names from package manifests; update the repository ruleset when adding or renaming a package. +Scopes must match a workspace package: `cli`, `changesets`, `types`, `governance`, `differs.core`, `differs.json-schema`, or `differs.openapi`. Use `*` for changes across packages or omit the scope for repository-wide changes. The semantic PR action enforces this allowlist; update both its workflow configuration and the repository ruleset when adding or renaming a package. ## Release approval diff --git a/package.json b/package.json index 532c574..c4cf13b 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "lint": "pnpm lerna run lint --parallel", "prepare": "husky", "version:preview": "lerna changed --json | jq -r '.[] | \"\\(.name) → v\\(.version)\"'", - "pack:check": "node scripts/check-packages.mjs", "e2e:verdaccio:up": "docker compose -f local-e2e.docker-compose.yaml up -d", "e2e:verdaccio:down": "docker compose -f local-e2e.docker-compose.yaml down -v" }, diff --git a/scripts/check-packages.mjs b/scripts/check-packages.mjs deleted file mode 100644 index a97280c..0000000 --- a/scripts/check-packages.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = fileURLToPath(new URL('../', import.meta.url)); -const directory = mkdtempSync(join(tmpdir(), 'contractual-pack-check-')); -const run = (command, args, cwd = directory) => execFileSync(command, args, { - cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], -}); - -try { - const packages = readdirSync(join(root, 'packages')).map(name => { - const path = join(root, 'packages', name); - return { path, ...JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')) }; - }).filter(pkg => !pkg.private); - const dependencies = {}; - for (const pkg of packages) { - run('pnpm', ['pack', '--pack-destination', directory], pkg.path); - const filename = `${pkg.name.replace('@', '').replace('/', '-')}-${pkg.version}.tgz`; - const tarball = join(directory, filename); - const contents = run('tar', ['-tzf', tarball]); - assert.match(contents, /package\/dist\/index.js/); - assert.doesNotMatch(contents, /\.(test|spec)\.[cm]?[jt]s(?:\n|$)/); - const packed = JSON.parse(run('tar', ['-xOzf', tarball, 'package/package.json'])); - for (const version of Object.values(packed.dependencies || {})) { - assert.ok(!version.startsWith('workspace:'), `${pkg.name} contains a workspace dependency`); - } - dependencies[pkg.name] = `file:${tarball}`; - } - writeFileSync(join(directory, 'package.json'), JSON.stringify({ - private: true, type: 'module', dependencies, overrides: dependencies, - })); - run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false']); - run('node', ['--input-type=module', '-e', - `for (const name of ${JSON.stringify(Object.keys(dependencies))}) await import(name);`]); - const cli = resolve(directory, 'node_modules/@contractual/cli/bin/cli.js'); - assert.match(run('node', [cli, '--help']), /changeset/); - writeFileSync(join(directory, 'order.schema.json'), JSON.stringify({ - $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', - properties: { id: { type: 'string' } }, - })); - run('node', [cli, 'init', '--yes']); - run('node', [cli, 'lint']); - run('node', [cli, 'diff', '--format', 'json']); - run('node', [cli, 'status']); - console.log(`Packed, installed, imported and smoke-tested ${packages.length} packages.`); -} catch (error) { - console.error(error.stderr?.toString() || error.message); - process.exitCode = 1; -} finally { - rmSync(directory, { recursive: true, force: true }); -} diff --git a/scripts/check-pr-title.mjs b/scripts/check-pr-title.mjs deleted file mode 100644 index eeef545..0000000 --- a/scripts/check-pr-title.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { join } from 'node:path'; - -const directory = fileURLToPath(new URL('../packages/', import.meta.url)); -const scopes = readdirSync(directory).map(name => - JSON.parse(readFileSync(join(directory, name, 'package.json'), 'utf8')).name.replace('@contractual/', '') -); -scopes.push('*'); -const title = process.env.PR_TITLE || ''; -const match = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(([^)]+)\))?!?: \S[^\r\n]*$/.exec(title); -if (!match || (match[2] && !scopes.includes(match[2]))) { - console.error(`Use a Conventional Commit title with a workspace scope (${scopes.join(', ')}), or no scope for a repository-wide change. Example: fix(cli): handle missing snapshots`); - process.exit(1); -} diff --git a/scripts/check-pr-title.test.mjs b/scripts/check-pr-title.test.mjs deleted file mode 100644 index 243cee3..0000000 --- a/scripts/check-pr-title.test.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { test } from 'node:test'; -import assert from 'node:assert/strict'; - -for (const [title, valid] of [ - ['fix(cli): handle missing snapshots', true], - ['feat(differs.core)!: classify constraints', true], - ['fix(differs.json-schema): validate schema', true], - ['fix(differs.openapi): validate schema', true], - ['test(changesets): preserve changesets', true], - ['fix(governance): check schema origins', true], - ['feat(types): expose options', true], - ['fix(*): update dependencies', true], - ['ci: verify builds', true], - ['fix(diff): invalid package scope', false], - ['fix(release): invalid package scope', false], - ['update dependencies', false], - ['fix(cli): ', false], - ['fix(cli): title\ninjected body', false], -]) { - test(title, () => { - const result = spawnSync(process.execPath, [new URL('./check-pr-title.mjs', import.meta.url).pathname], { - env: { ...process.env, PR_TITLE: title }, - encoding: 'utf8', - }); - assert.equal(result.status, valid ? 0 : 1, result.stderr); - }); -} diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs deleted file mode 100644 index 5dc5df1..0000000 --- a/scripts/release-preflight.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { readFileSync, readdirSync } from 'node:fs'; - -const tag = process.env.DIST_TAG; -assert.ok(['latest', 'next', 'dev', 'rc', 'alpha', 'beta'].includes(tag), 'Invalid distribution tag'); -assert.ok(process.env.NODE_AUTH_TOKEN, 'NPM_TOKEN is required; ask Omer to configure package write access'); -const npm = args => execFileSync('npm', args, { encoding: 'utf8' }).trim(); -const username = npm(['whoami', '--registry=https://registry.npmjs.org/']); -const access = JSON.parse(npm(['access', 'list', 'packages', username, '--json'])); -for (const directory of readdirSync('packages')) { - const pkg = JSON.parse(readFileSync(`packages/${directory}/package.json`, 'utf8')); - if (pkg.private) continue; - assert.equal(pkg.repository?.url, 'https://github.com/codotech/contractual.git', `${pkg.name}: repository URL must match provenance`); - assert.equal(pkg.publishConfig?.access, 'public', `${pkg.name}: public access must be explicit`); - assert.equal(access[pkg.name], 'read-write', `${username} cannot publish ${pkg.name}; verify npm token permissions`); - assert.ok(tag !== 'latest' || !pkg.version.includes('-'), `${pkg.name}: refusing a prerelease on latest`); - console.log(`${pkg.name}@${pkg.version}: publishing preflight passed (${tag})`); -} From 2bb92073454eaee7b92f5da501c17f4b2d0f8b0a Mon Sep 17 00:00:00 2001 From: omermorad Date: Tue, 29 Sep 2026 08:58:42 +0300 Subject: [PATCH 3/4] ci: remove custom package verification and ruleset files --- .github/actions/verify-packages/action.yml | 49 ----------------- .github/rulesets/next.json | 64 ---------------------- .github/workflows/ci.yml | 1 - .github/workflows/publish-packages.yml | 1 - .github/workflows/release-packages.yml | 1 - AGENTS.md | 4 +- CONTRIBUTING.md | 4 +- RELEASING.md | 2 +- 8 files changed, 5 insertions(+), 121 deletions(-) delete mode 100644 .github/actions/verify-packages/action.yml delete mode 100644 .github/rulesets/next.json diff --git a/.github/actions/verify-packages/action.yml b/.github/actions/verify-packages/action.yml deleted file mode 100644 index d916093..0000000 --- a/.github/actions/verify-packages/action.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Verify packed packages -description: Pack, install, and smoke-test local packages without publishing or tagging -runs: - using: composite - steps: - - name: Pack workspace packages - id: pack - shell: bash - run: | - package_dir="$(mktemp -d "$RUNNER_TEMP/contractual-packages.XXXXXX")" - echo "directory=$package_dir" >> "$GITHUB_OUTPUT" - pnpm -r exec pnpm pack --pack-destination "$package_dir" - - name: Verify and install tarballs - shell: bash - env: - PACKAGE_DIR: ${{ steps.pack.outputs.directory }} - run: | - jq -n '{private: true, type: "module", dependencies: {}, overrides: {}}' > "$PACKAGE_DIR/package.json" - for tarball in "$PACKAGE_DIR"/*.tgz; do - contents="$(tar -tzf "$tarball")" - grep -qx 'package/dist/index.js' <<< "$contents" - if grep -Eq '\.(test|spec)\.[cm]?[jt]s$' <<< "$contents"; then - echo "Tests must not be included in $tarball" >&2 - exit 1 - fi - manifest="$(tar -xOzf "$tarball" package/package.json)" - jq -e '[.dependencies // {} | .[] | startswith("workspace:")] | any | not' <<< "$manifest" > /dev/null - package_name="$(jq -r '.name' <<< "$manifest")" - jq --arg name "$package_name" --arg file "file:$tarball" '.dependencies[$name] = $file | .overrides[$name] = $file' "$PACKAGE_DIR/package.json" > "$PACKAGE_DIR/package.tmp.json" - mv "$PACKAGE_DIR/package.tmp.json" "$PACKAGE_DIR/package.json" - done - cd "$PACKAGE_DIR" - npm install --ignore-scripts --no-audit --no-fund --package-lock=false - while IFS= read -r package_name; do - node --input-type=module -e 'await import(process.argv[1])' "$package_name" - done < <(jq -r '.dependencies | keys[]' package.json) - - name: Smoke-test the installed CLI - shell: bash - env: - PACKAGE_DIR: ${{ steps.pack.outputs.directory }} - run: | - cd "$PACKAGE_DIR" - jq -n '{"$schema": "http://json-schema.org/draft-07/schema#", type: "object", properties: {id: {type: "string"}}}' > order.schema.json - cli=node_modules/@contractual/cli/bin/cli.js - node "$cli" --help - node "$cli" init --yes - node "$cli" lint - node "$cli" diff --format json - node "$cli" status diff --git a/.github/rulesets/next.json b/.github/rulesets/next.json deleted file mode 100644 index bd8e9da..0000000 --- a/.github/rulesets/next.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "name": "next: reviewed conventional PRs", - "target": "branch", - "enforcement": "active", - "bypass_actors": [], - "conditions": { - "ref_name": { - "include": [ - "refs/heads/next" - ], - "exclude": [] - } - }, - "rules": [ - { - "type": "deletion" - }, - { - "type": "non_fast_forward" - }, - { - "type": "required_linear_history" - }, - { - "type": "pull_request", - "parameters": { - "required_approving_review_count": 1, - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_review_thread_resolution": true, - "allowed_merge_methods": [ - "squash" - ] - } - }, - { - "type": "required_status_checks", - "parameters": { - "required_status_checks": [ - { - "context": "CI", - "integration_id": 15368 - }, - { - "context": "PR title", - "integration_id": 15368 - } - ], - "strict_required_status_checks_policy": true, - "do_not_enforce_on_create": true - } - }, - { - "type": "commit_message_pattern", - "parameters": { - "name": "Conventional Commit subject", - "operator": "regex", - "pattern": "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\\((cli|changesets|types|governance|differs\\.core|differs\\.json-schema|differs\\.openapi|\\*)\\))?!?: \\S", - "negate": false - } - } - ] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e09b2b5..81b1f04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,6 @@ jobs: - run: pnpm lint - run: pnpm test - run: pnpm test:e2e - - uses: ./.github/actions/verify-packages ci: name: CI if: always() diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index c35d795..d1bfb99 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -47,7 +47,6 @@ jobs: - run: pnpm lint - run: pnpm test - run: pnpm test:e2e - - uses: ./.github/actions/verify-packages - name: Check npm authorization and package metadata env: DIST_TAG: ${{ inputs.dist_tag }} diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 3e413e5..c068a23 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -51,7 +51,6 @@ jobs: else pnpm exec lerna version --conventional-commits --conventional-prerelease --preid "$PREID" --yes --no-git-tag-version --no-push --no-commit-hooks --allow-branch "chore/release-${GITHUB_RUN_ID}" fi - - uses: ./.github/actions/verify-packages - name: Open a release PR env: GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index 5425672..9ac3f44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ - Start feature/fix branches from the latest `origin/next`; open PRs targeting `next`. - Use Conventional Commit PR titles and commits, for example `fix(cli): handle missing snapshots`. - Squash merge only after required CI and review. Never bypass the ruleset or push directly to `next`. -- Run `pnpm build`, `pnpm lint`, `pnpm test`, and `pnpm test:e2e` for release-related changes. CI also validates packed artifacts through `.github/actions/verify-packages`. -- Keep automation in GitHub Actions workflows and composite actions; do not add a `scripts/` folder for workflow helpers. +- Run `pnpm build`, `pnpm lint`, `pnpm test`, and `pnpm test:e2e` for release-related changes. +- Use existing GitHub Actions and native workflow commands; do not add helper scripts or custom package-verification actions. Manage rulesets directly in GitHub, not checked-in JSON copies. - Always ask Omer for explicit approval before creating, moving, or pushing any version tag; publishing packages or GitHub Releases; changing npm distribution tags; or dispatching a workflow that performs those actions. Approval to fix code or open a PR does not authorize a release. - Do not change package versions unless a release preparation was explicitly requested. Preserve existing untracked `docs/` material. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6143671..4c2bf12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,13 +17,13 @@ pnpm test pnpm test:e2e ``` -These commands validate source and behavior. CI additionally uses the `verify-packages` composite action to pack, install, import, and smoke-test temporary local tarballs. It never publishes or tags. Workflow automation lives in `.github/`, without a separate scripts folder. +These commands validate source and behavior. CI runs them on Node 22 and 24. Normal CI never publishes or tags. Use existing GitHub Actions and native workflow commands, without a separate scripts folder or custom package-verification action. ## Open a PR to next Use a Conventional Commit title: `fix(cli): handle missing snapshots`, `feat(differs.core): classify new constraints`, or `docs: clarify supported formats`. Supported types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, and `revert`. Use `!` before the colon for a breaking change. -One approving review, resolved conversations, an up-to-date branch, and passing `CI` and `PR title` checks are required. PRs are squash merged, with the PR title used as the commit subject. +One approving review, resolved conversations, an up-to-date branch, and passing `CI` and `PR title` checks are required. PRs are squash merged, with the PR title used as the commit subject. The ruleset is managed directly in the repository's GitHub settings; no ruleset JSON file is needed. Scopes must match a workspace package: `cli`, `changesets`, `types`, `governance`, `differs.core`, `differs.json-schema`, or `differs.openapi`. Use `*` for changes across packages or omit the scope for repository-wide changes. The semantic PR action enforces this allowlist; update both its workflow configuration and the repository ruleset when adding or renaming a package. diff --git a/RELEASING.md b/RELEASING.md index b52f4b9..f71e0f4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,7 +8,7 @@ The first stable scope is OpenAPI 3.0/3.1 and JSON Schema linting and diffing, c ## Prepare a reviewed release -1. Run all checks in [CONTRIBUTING.md](CONTRIBUTING.md), including packed artifact validation. +1. Run all checks in [CONTRIBUTING.md](CONTRIBUTING.md). 2. Present Omer with the exact commit, proposed package versions, npm distribution tag, and validation results. Obtain approval before preparing version changes or running a release workflow. 3. Run **Prepare Release** from `next`. It opens a version PR targeting `next`; it does not create tags or publish. Review and merge that PR normally. 4. Obtain approval for the exact merged release commit, then run **Publish Packages** with that full commit SHA and the approved distribution tag. `latest` rejects prerelease versions. Approve the `release` environment job in GitHub. From 3eb082369985d7af5562fc903f209b37661d5d88 Mon Sep 17 00:00:00 2001 From: omermorad Date: Tue, 29 Sep 2026 09:02:02 +0300 Subject: [PATCH 4/4] ci: test Node 26 alongside Node 22 and 24 --- .github/workflows/ci.yml | 2 +- .github/workflows/e2e-release.yml | 2 +- CONTRIBUTING.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81b1f04..73a9537 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - node: [22, 24] + node: [22, 24, 26] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/.github/workflows/e2e-release.yml b/.github/workflows/e2e-release.yml index ae23d70..6f3029b 100644 --- a/.github/workflows/e2e-release.yml +++ b/.github/workflows/e2e-release.yml @@ -29,7 +29,7 @@ jobs: fail-fast: false matrix: e2e-project: ['cli-basic', 'cli-lifecycle', 'packages-import'] - node-version: [22.x, 24.x] + node-version: [22.x, 24.x, 26.x] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c2bf12..c7f8c1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ pnpm test pnpm test:e2e ``` -These commands validate source and behavior. CI runs them on Node 22 and 24. Normal CI never publishes or tags. Use existing GitHub Actions and native workflow commands, without a separate scripts folder or custom package-verification action. +These commands validate source and behavior. CI runs them on Node 22, 24, and 26. Normal CI never publishes or tags. Use existing GitHub Actions and native workflow commands, without a separate scripts folder or custom package-verification action. ## Open a PR to next