Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ updates:
all-dependencies:
patterns:
- '*'
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']

- package-ecosystem: 'github-actions'
directory: '/'
Expand Down
244 changes: 244 additions & 0 deletions .github/workflows/release-train.yml
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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 }}