diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b1e4ad1..b6d0588 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,9 +14,6 @@ updates: all-dependencies: patterns: - '*' - ignore: - - dependency-name: '*' - update-types: ['version-update:semver-major'] - package-ecosystem: 'github-actions' directory: '/' diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml new file mode 100644 index 0000000..515496d --- /dev/null +++ b/.github/workflows/release-train.yml @@ -0,0 +1,244 @@ +name: Release Train + +# Fully automated Friday MINOR release of @chemistry/molpad. Level 1 train: it +# runs an hour after the @chemistry base packages release (20:00 Prague), first +# bumps the internal @chemistry/* deps to their freshly published versions, then +# cuts a minor release so molpad always ships against the latest base packages. +# +# GITHUB_TOKEN caveats handled here: its branch pushes do not fire pr-validation +# on the release PR (the in-workflow `npm run verify` is the gate), and its merges +# never fire release.yml's tag-triggered push - so after merging, the train +# dispatches release.yml explicitly, which releases the current package.json +# version and creates the v-tag idempotently. +on: + schedule: + # Friday 21:00 Europe/Prague, both DST offsets; the gate job filters. + - cron: '0 19 * * 5' + - cron: '0 20 * * 5' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + actions: write + +jobs: + prague-gate: + name: 🕘 Friday 21:00 Prague gate + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + go: ${{ steps.gate.outputs.go }} + steps: + - name: Check local Prague hour (DST-proof) + id: gate + run: | + if [ "${{ github.event_name }}" != "schedule" ]; then + echo "workflow_dispatch - bypassing hour gate." + echo "go=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + HOUR=$(TZ=Europe/Prague date +%H) + if [ "$HOUR" = "21" ]; then + echo "go=true" >> "$GITHUB_OUTPUT" + else + echo "Prague hour is $HOUR, not 21 - wrong DST cron slot, skipping." + echo "go=false" >> "$GITHUB_OUTPUT" + fi + + merge-green-dependabot: + name: 🤖 Merge green dependabot PRs + needs: prague-gate + if: needs.prague-gate.outputs.go == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Squash-merge dependabot PRs whose checks are green + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # The chemistry org has no shared dependabot triage automation, so the + # train merges its own green dependabot PRs before releasing. Red ones + # are left open. + PRS=$(gh pr list -R "$REPO" --author "app/dependabot" --state open --json number --jq '.[].number') + if [ -z "$PRS" ]; then + echo "No open dependabot PRs." | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "### Dependabot merge results" >> "$GITHUB_STEP_SUMMARY" + for PR in $PRS; do + echo "Processing dependabot PR #$PR..." + # Poll until checks settle: ~10 min max (60 x 10s). + FINAL="" + for i in $(seq 1 60); do + BUCKETS=$(gh pr checks "$PR" -R "$REPO" --json bucket --jq '.[].bucket' 2>/dev/null || true) + if [ -z "$BUCKETS" ]; then + # No checks reported yet; give it a few tries, then treat as no-checks. + if [ "$i" -ge 3 ]; then FINAL="none"; break; fi + sleep 10; continue + fi + if printf '%s\n' "$BUCKETS" | grep -q "pending"; then + echo " poll $i: checks pending for #$PR..." + sleep 10; continue + fi + FINAL="$BUCKETS"; break + done + if [ -z "$FINAL" ]; then + echo "- #$PR: checks did not settle in ~10 min - left open" >> "$GITHUB_STEP_SUMMARY" + elif [ "$FINAL" = "none" ]; then + gh pr merge "$PR" -R "$REPO" --squash \ + && echo "- #$PR: no checks - squash-merged" >> "$GITHUB_STEP_SUMMARY" \ + || echo "- #$PR: merge failed - left open" >> "$GITHUB_STEP_SUMMARY" + elif printf '%s\n' "$FINAL" | grep -qE 'fail|cancel'; then + echo "- #$PR: red checks - left open" >> "$GITHUB_STEP_SUMMARY" + else + gh pr merge "$PR" -R "$REPO" --squash \ + && echo "- #$PR: green - squash-merged" >> "$GITHUB_STEP_SUMMARY" \ + || echo "- #$PR: merge failed - left open" >> "$GITHUB_STEP_SUMMARY" + fi + done + + release: + name: 🚝 Cut minor release + needs: [prague-gate, merge-green-dependabot] + if: needs.prague-gate.outputs.go == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Skip if an open release PR exists + id: guard + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + EXISTING=$(gh pr list --state open --json headRefName \ + --jq '[.[] | select(.headRefName | startswith("release/"))] | length') + if [ "$EXISTING" != "0" ]; then + echo "Open release PR already exists - a previous train is stuck. Skipping." | tee -a "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Bump internal @chemistry/* dependencies + id: deps + if: steps.guard.outputs.skip != 'true' + run: | + set -euo pipefail + # The point of a Level 1 train: pull in the @chemistry base packages + # released at 20:00, so molpad ships against the latest ones. + npx --yes npm-check-updates -u --dep prod,dev --filter "@chemistry/*" + npm install + if git diff --quiet -- package.json; then + echo "No internal @chemistry/* dependency changes." + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "Internal @chemistry/* dependencies bumped:" + git --no-pager diff -- package.json + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Detect releasable commits since the last tag + id: detect + if: steps.guard.outputs.skip != 'true' + run: | + set -euo pipefail + LAST_TAG=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -1) + RANGE=""; [ -n "$LAST_TAG" ] && RANGE="${LAST_TAG}..HEAD" + echo "Last tag: ${LAST_TAG:-none}" + SUBJECTS=$(git log $RANGE --no-merges --pretty=%s) + # Releasable = anything except docs/ci/plain-chore; chore(deps) counts + # (dependabot bundles ship weekly), chore(release) does not. + RELEASABLE=$(printf '%s\n' "$SUBJECTS" \ + | grep -vE '^(docs|ci)(\([^)]*\))?!?:' \ + | grep -vE '^chore(\([^)]*\))?!?:' || true) + DEPS=$(printf '%s\n' "$SUBJECTS" | grep -E '^chore\(deps' || true) + COUNT=$(printf '%s\n%s\n' "$RELEASABLE" "$DEPS" | grep -c . || true) + if [ "$COUNT" -eq 0 ]; then + echo "No releasable commits since ${LAST_TAG:-repo start}." + echo "commits=false" >> "$GITHUB_OUTPUT" + else + echo "Found $COUNT releasable commit(s)." + echo "commits=true" >> "$GITHUB_OUTPUT" + fi + + - name: Decide whether to release + id: gate + if: steps.guard.outputs.skip != 'true' + run: | + set -euo pipefail + # An internal dep bump alone justifies a release, even with no other + # releasable commits. + if [ "${{ steps.deps.outputs.changed }}" = "true" ] || [ "${{ steps.detect.outputs.commits }}" = "true" ]; then + echo "release=true" >> "$GITHUB_OUTPUT" + else + echo "No releasable commits and no internal dep changes - no release this week." | tee -a "$GITHUB_STEP_SUMMARY" + echo "release=false" >> "$GITHUB_OUTPUT" + fi + + - name: Bump minor version (package.json + lockfile) + id: bump + if: steps.gate.outputs.release == 'true' + run: | + set -euo pipefail + # npm version edits only the version field (prettier-safe) and updates the lockfile. + NEW=$(npm version minor --no-git-tag-version | sed 's/^v//') + echo "New version: $NEW" + echo "version=$NEW" >> "$GITHUB_OUTPUT" + + - name: Verify (proves the new @chemistry deps integrate) + if: steps.bump.outputs.version + run: npm run verify + + - name: Create + auto-merge release PR + id: pr + if: steps.bump.outputs.version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + VERSION="${{ steps.bump.outputs.version }}" + BRANCH="release/${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add package.json package-lock.json + git commit -m "chore(release): ${VERSION}" + git push origin "$BRANCH" + PR_URL=$(gh pr create --base master --head "$BRANCH" \ + --title "chore(release): ${VERSION}" \ + --body "Weekly minor release train (L1). Bumped internal @chemistry/* deps and cut minor ${VERSION}. Publish happens via release.yml dispatch after merge.") + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + sleep 5 + if ! OUT=$(gh pr merge "$PR_URL" --auto --squash 2>&1); then + echo "$OUT" + # Auto-merge unavailable (no branch protection) - merge directly. + gh pr merge "$PR_URL" --squash + fi + + - name: Dispatch release workflow + if: steps.pr.outputs.pr_url + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # A GITHUB_TOKEN merge never fires release.yml's tag-triggered push, so + # dispatch it explicitly. Its dispatch path releases the current + # package.json version and creates the v-tag idempotently. + gh workflow run release.yml --ref master --repo "${{ github.repository }}" + echo "Released ${{ steps.bump.outputs.version }} - release.yml dispatched." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79fa46a..dfa44ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,8 @@ jobs: steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Use Node.js 22.x uses: actions/setup-node@v7 @@ -23,6 +25,31 @@ jobs: cache: 'npm' registry-url: 'https://registry.npmjs.org' + - name: Resolve release tag + id: tag + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "push" ]; then + TAG="${GITHUB_REF_NAME}" + else + # workflow_dispatch releases the current package.json version. + VERSION=$(node -p "require('./package.json').version") + TAG="v${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists locally." + elif git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists on origin." + else + # Create the v-tag idempotently so tag-triggered runs stay aligned. + git tag "${TAG}" + git push origin "${TAG}" + echo "Created and pushed ${TAG}." + fi + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + - name: Install dependencies run: npm ci @@ -37,6 +64,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: + tag_name: ${{ steps.tag.outputs.tag }} generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}