diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d4055675d..e6ba80eef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,11 +7,12 @@ updates: default-days: 7 directory: / schedule: - interval: weekly - day: 'tuesday' - time: '01:00' - timezone: 'Asia/Kolkata' + interval: daily open-pull-requests-limit: 5 + labels: + - internal + commit-message: + prefix: chore groups: github-actions: patterns: @@ -22,12 +23,32 @@ updates: default-days: 7 directory: / schedule: - interval: weekly - day: 'tuesday' - time: '01:00' - timezone: 'Asia/Kolkata' + interval: daily open-pull-requests-limit: 5 + labels: + - internal + commit-message: + prefix: chore groups: docker: patterns: - '*' + + - package-ecosystem: npm + cooldown: + default-days: 1 + directory: / + schedule: + interval: daily + open-pull-requests-limit: 10 + labels: + - internal + commit-message: + prefix: chore + groups: + # Group all non-major updates together to reduce noise + npm-minor-patch: + update-types: + - minor + - patch + # Major updates are ungrouped so they get individual review diff --git a/.github/renovate.json b/.github/renovate.json deleted file mode 100644 index 62b0cf2a9..000000000 --- a/.github/renovate.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended", ":dependencyDashboardApproval"], - "labels": ["Dependencies"], - "packageRules": [ - { - "matchUpdateTypes": ["lockFileMaintenance"] - } - ], - "lockFileMaintenance": { - "enabled": true - }, - "dependencyDashboard": true -} diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml new file mode 100644 index 000000000..ec0ebdae2 --- /dev/null +++ b/.github/workflows/cloudflare-web-preview.yml @@ -0,0 +1,132 @@ +name: Cloudflare Worker Preview Deploy + +on: + pull_request: + +concurrency: + group: cloudflare-worker-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + deploy: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Prepare preview metadata + id: metadata + shell: bash + run: | + preview_message="$(git log -1 --pretty=%s)" + preview_message="$(printf '%s' "$preview_message" | head -c 100)" + + { + echo 'preview_message<> "$GITHUB_OUTPUT" + + - name: Setup app and build + uses: ./.github/actions/setup + with: + build: 'true' + + - name: Upload Worker preview + id: deploy + uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1 + env: + PREVIEW_MESSAGE: ${{ steps.metadata.outputs.preview_message }} + with: + apiToken: ${{ secrets.TF_CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.TF_VAR_ACCOUNT_ID }} + command: > + versions upload + -c dist/wrangler.json + --preview-alias pr-${{ github.event.pull_request.number }} + --message "$PREVIEW_MESSAGE" + + - name: Resolve preview URL + id: preview + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + COMMAND_OUTPUT: ${{ steps.deploy.outputs.command-output }} + PR_NUMBER: ${{ github.event.pull_request.number }} + shell: bash + run: | + alias="pr-${PR_NUMBER}" + preview_url="" + alias_url_pattern="^https?://${alias}-[^[:space:]]+$" + + if printf '%s\n' "$DEPLOYMENT_URL" | grep -Eq "$alias_url_pattern"; then + preview_url="$DEPLOYMENT_URL" + else + preview_url="$(printf '%s\n' "$COMMAND_OUTPUT" | grep -Eo "https?://${alias}-[^[:space:]\"'<>)]+" | head -n 1 || true)" + fi + + if ! printf '%s\n' "$preview_url" | grep -Eq "$alias_url_pattern"; then + echo "Failed to resolve aliased Worker preview URL." >&2 + exit 1 + fi + + { + echo "preview_alias=${alias}" + echo "preview_url=${preview_url}" + } >> "$GITHUB_OUTPUT" + + - name: Publish preview URL and write summary + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + MARKER: '' + PREVIEW_URL: ${{ steps.preview.outputs.preview_url }} + PREVIEW_ALIAS: ${{ steps.preview.outputs.preview_alias }} + SHORT_SHA: ${{ github.event.pull_request.head.sha }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const marker = process.env.MARKER; + const previewUrl = process.env.PREVIEW_URL; + const previewAlias = process.env.PREVIEW_ALIAS; + const shortSha = process.env.SHORT_SHA?.slice(0, 7); + const now = new Date().toUTCString().replace(':00 GMT', ' UTC'); + + if (!previewUrl) { + core.setFailed("Missing preview URL from Cloudflare deploy step."); + return; + } + + const tableRow = "| ✅ Deployment successful! | " + previewUrl + " | " + shortSha + " | `" + previewAlias + "` | " + now + " |"; + const comment = [ + marker, + `## Deploying with  Cloudflare Workers  Cloudflare Workers`, + ``, + `| Status | Preview URL | Commit | Alias | Updated (UTC) |`, + `| - | - | - | - | - |`, + tableRow, + ].join("\n"); + + // Write to step summary (marker stripped — not needed there) + await core.summary.addRaw(comment.replace(marker + "\n", "")).write(); + + // Always delete and recreate so the comment appears fresh after each push + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, + }); + const existing = comments.find( + (c) => c.user.type === "Bot" && c.body.includes(marker), + ); + if (existing) { + await github.rest.issues.deleteComment({ + owner, repo, comment_id: existing.id, + }); + } + + await github.rest.issues.createComment({ owner, repo, issue_number, body: comment }); diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ea43d4be4..88e4c4d6e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -66,18 +66,16 @@ jobs: id: vars run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - - name: Set up Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - name: Setup app + uses: ./.github/actions/setup with: - node-version: '24' - cache: 'npm' + install-command: npm ci --ignore-scripts - name: Build site env: VITE_BUILD_HASH: ${{ steps.vars.outputs.short_sha }} VITE_IS_RELEASE_TAG: ${{ steps.release_tag.outputs.is_release }} run: | - npm ci --ignore-scripts NODE_OPTIONS=--max_old_space_size=4096 npm run build - name: Set up QEMU diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 000000000..b10ca16d9 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,121 @@ +name: Create Release PR + +on: + push: + branches: [dev] + +permissions: {} + +jobs: + load-packages: + if: "!contains(github.event.head_commit.message, 'chore: prepare release')" + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.parse.outputs.matrix }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Parse packages from knope.toml + id: parse + shell: python3 {0} + run: | + import tomllib, json, os + + with open("knope.toml", "rb") as f: + config = tomllib.load(f) + + packages = config.get("packages", {}) + matrix = [ + {"package": name, "changelog": pkg["changelog"]} + for name, pkg in packages.items() + ] + + with open(os.environ["GITHUB_OUTPUT"], "a") as out: + out.write(f"matrix={json.dumps(matrix)}\n") + + prepare-release: + needs: load-packages + if: needs.load-packages.outputs.matrix != '[]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + strategy: + # Run each package independently so one with no changes doesn't block others + fail-fast: false + matrix: + include: ${{ fromJSON(needs.load-packages.outputs.matrix) }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: true + + - uses: fregante/setup-git-user@024bc0b8e177d7e77203b48dab6fb45666854b35 # v2.0.2 + + - uses: knope-dev/action@407e9ef7c272d2dd53a4e71e39a7839e29933c48 # v2.1.0 + with: + version: 0.22.1 + + - name: Switch to release branch + shell: bash + run: git switch -c release/${{ matrix.package }} + + - name: Prepare Release + id: knope + shell: bash + run: | + if knope prepare-release --package ${{ matrix.package }} --verbose; then + echo "released=true" >> "$GITHUB_OUTPUT" + else + echo "released=false" >> "$GITHUB_OUTPUT" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit and push release branch + if: steps.knope.outputs.released == 'true' + shell: bash + run: | + git commit -m "chore: prepare release ${{ matrix.package }}" + git push --force --set-upstream origin release/${{ matrix.package }} + + - name: Read version and changelog + id: meta + if: steps.knope.outputs.released == 'true' + shell: bash + run: | + # Read the top version header from the changelog knope just wrote. + VERSION=$(awk '/^## [0-9]/{print $2; exit}' ${{ matrix.changelog }}) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + CHANGELOG=$(awk "/^## $VERSION/{found=1; next} found && /^## /{exit} found{print}" ${{ matrix.changelog }}) + { + echo "changelog<> "$GITHUB_OUTPUT" + + - name: Create or update release PR + if: steps.knope.outputs.released == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PACKAGE: ${{ matrix.package }} + VERSION: ${{ steps.meta.outputs.version }} + CHANGELOG: ${{ steps.meta.outputs.changelog }} + run: | + BRANCH="release/${PACKAGE}" + TITLE="chore: prepare release ${PACKAGE} ${VERSION}" + BODY="> [!IMPORTANT] + > Merging this PR will create a new release. + + ${CHANGELOG}" + + if gh pr view "$BRANCH" --json number -q '.number' &>/dev/null; then + gh pr edit "$BRANCH" --title "$TITLE" --body "$BODY" + else + gh pr create --head "$BRANCH" --base main --title "$TITLE" --body "$BODY" + fi diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 9a37398e6..dc534338b 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -3,6 +3,7 @@ name: Quality checks on: pull_request: push: + branches: [dev] jobs: format: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..938d4cbf9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + pull_request: + types: [closed] + branches: [dev] + +permissions: {} + +jobs: + release: + # Matches any release/ branch pattern + if: startsWith(github.head_ref, 'release/') && github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Resolve package name from branch + id: branch + shell: bash + run: | + # Strips the "release/" prefix to get the package name, e.g. release/sable -> sable + echo "package=${GITHUB_HEAD_REF#release/}" >> "$GITHUB_OUTPUT" + + - uses: knope-dev/action@407e9ef7c272d2dd53a4e71e39a7839e29933c48 # v2.1.0 + with: + version: 0.22.1 + + - name: Create Release + run: knope release --package ${{ steps.branch.outputs.package }} --verbose + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/require-changeset.yml b/.github/workflows/require-changeset.yml new file mode 100644 index 000000000..1f38bcca6 --- /dev/null +++ b/.github/workflows/require-changeset.yml @@ -0,0 +1,104 @@ +name: Require Changeset + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + branches: [dev] + +permissions: {} + +jobs: + require-changeset: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Check for internal label + id: labels + shell: bash + env: + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + if echo "$LABELS" | grep -q '"internal"'; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: steps.labels.outputs.skip == 'false' + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check for changeset added by this PR + if: steps.labels.outputs.skip == 'false' + id: check + shell: bash + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + # Only count changeset files this PR added, ignoring pre-existing ones on the base branch. + count=$(git diff --name-only --diff-filter=A "origin/${BASE_REF}...HEAD" | grep -E '^\.changeset/.+\.md$' | grep -cv README.md || true) + if [[ "$count" -eq 0 ]]; then + echo "found=false" >> "$GITHUB_OUTPUT" + else + echo "found=true" >> "$GITHUB_OUTPUT" + echo "Found $count new changeset file(s)." + fi + + - name: Manage changeset comment + if: steps.labels.outputs.skip == 'false' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + CHANGESET_FOUND: ${{ steps.check.outputs.found }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const marker = ''; + const found = process.env.CHANGESET_FOUND === 'true'; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + // Always delete the old comment first so the new one appears fresh after each commit + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes(marker), + ); + if (existing) { + await github.rest.issues.deleteComment({ + owner, repo, comment_id: existing.id, + }); + } + + if (!found) { + const body = `${marker} + ### ⚠️ Missing changeset + + This pull request does not include a changeset. Please add one before requesting review so the change is properly documented and included in the release notes. + + **How to add a changeset:** + + 1. Run \`npm run document-change\` (interactive) and commit the generated file, or + 2. Manually create \`.changeset/.md\`: + + \`\`\`md + --- + sable: patch + --- + + Short user-facing summary of the change. + \`\`\` + + Replace \`patch\` with \`major\`, \`minor\`, \`patch\`, \`docs\`, or \`note\` as appropriate. + + 📖 Read more in [CONTRIBUTING.md](https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md#release-notes-and-versioning-knope). + + > If this PR is internal/maintenance with no user-facing impact, a maintainer can add the \`internal\` label to skip this check.`; + + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + core.setFailed('No changeset found. Add a changeset file or apply the "internal" label to skip.'); + } diff --git a/.npmrc b/.npmrc index 567dd1f3d..e4e8189be 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ legacy-peer-deps=true -save-exact=true engine-strict=true +min-release-age=1440 \ No newline at end of file diff --git a/knope.toml b/knope.toml index f18550d86..6276f386c 100644 --- a/knope.toml +++ b/knope.toml @@ -1,4 +1,3 @@ - [packages.sable] versioned_files = ["package.json", "package-lock.json"] changelog = "CHANGELOG.md" @@ -12,19 +11,15 @@ extra_changelog_sections = [ ignore_conventional_commits = true [[workflows]] -name = "release" -help_text = "Prepare and create a new release (update changelog, bump version, create release)" +name = "prepare-release" +help_text = "Bump version and update changelog for the next release (used by CI)" [[workflows.steps]] type = "PrepareRelease" -[[workflows.steps]] -type = "Command" -command = 'git commit -m "chore: prepare release $version"' - -[[workflows.steps]] -type = "Command" -command = "git push" +[[workflows]] +name = "release" +help_text = "Create a GitHub release for the sable package (used by CI after PR merge)" [[workflows.steps]] type = "Release" @@ -40,13 +35,6 @@ type = "CreateChangeFile" owner = "SableClient" repo = "Sable" -[bot.releases] -enabled = true -#pull_request.title = "$version changelog" - -[bot.checks] -skip_labels = ["internal"] - [release_notes] change_templates = [ "* $summary",