Skip to content

Refresh

Refresh #92

Workflow file for this run

name: Refresh
on:
schedule:
# Every 4 hours — cuts upstream-merge → user-visible latency from
# ~24 h to ~4 h. The diff-check step bails out cheap when nothing
# upstream has moved, so most runs are no-ops.
- cron: "0 */4 * * *"
workflow_dispatch:
# Lets a maintainer trigger a refresh check on demand from the
# Actions tab. No inputs.
# One refresh job at a time. If yesterday's hasn't published yet,
# don't queue another.
concurrency:
group: refresh
cancel-in-progress: false
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: write
# `gh workflow run deploy-worker.yml` needs actions:write when it
# falls back to GITHUB_TOKEN (the App token uses the App's grants).
actions: write
# `gh issue create` for a 402 release-branch drift (step below).
issues: write
steps:
- name: Mint GitHub App installation token
id: app-token
# Preferred path: short-lived (~60 min) installation token from
# a GitHub App with `contents: write` on this repo. Beats the
# long-lived WORKFLOW_PAT below because App tokens expire on
# their own, the identity is a bot (auditable in commit history),
# and the scope is exactly what the refresh chain needs.
#
# `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY` are deliberately
# generic — same names work across every repo in the org, so
# the same App can grow into other automation (auto-merge, stale
# closers, template sync) without renaming or re-keying. Store
# them at the org level (`gh variable set BOT_APP_ID --org X`
# and `gh secret set BOT_APP_PRIVATE_KEY --org X`) so new repos
# pick them up by name without per-repo setup.
#
# Activates only when `vars.BOT_APP_ID` is set. Without it, the
# step is skipped and the checkout below falls back to
# WORKFLOW_PAT, then GITHUB_TOKEN. See CONTRIBUTING.md →
# "Maintainer setup: refresh.yml authentication".
if: vars.BOT_APP_ID != ''
uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Auth precedence: GitHub App installation token (preferred,
# short-lived) → WORKFLOW_PAT (legacy fallback) → GITHUB_TOKEN
# (last resort; can't trigger downstream workflows).
#
# The default GITHUB_TOKEN cannot trigger release.yml or
# deploy-worker.yml on tag push — that's GitHub's
# anti-recursion safety: GITHUB_TOKEN-driven pushes never
# fire other workflows. The App token and the PAT both can.
#
# If only GITHUB_TOKEN is available, the bump + tag + push
# still happens (the repo state is correct), it just won't
# auto-cascade into publish/deploy. A maintainer then has to
# re-push the tag manually to trigger them.
token: ${{ steps.app-token.outputs.token || secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Fetch upstream specs
run: npm run fetch-spec
- name: Fetch test262 + proposals
run: |
npm run fetch-test262
npm run fetch-proposals
- name: Read upstream SHAs into env
# Capture the SHAs we diff against what last shipped: both specs'
# main + test262 + proposals, plus the current ECMA-402 release
# branch. 402 ships editions as branches (not 262's immutable
# tags), so the current one can still take editorial commits
# after it's cut — track it so that drift triggers a refresh on
# its own, not just incidentally on the next 402/main move.
run: |
latest_402=$(npx tsx -e "import {LATEST_402_RELEASE} from './src/editions.ts'; process.stdout.write(LATEST_402_RELEASE)")
{
echo "UPSTREAM_262_MAIN=$(git -C vendor/ecma262-main rev-parse HEAD)"
echo "UPSTREAM_402_MAIN=$(git -C vendor/ecma402-main rev-parse HEAD)"
echo "UPSTREAM_402_LATEST=$(git -C vendor/ecma402-$latest_402 rev-parse HEAD)"
echo "EDITION_402_LATEST=$latest_402"
echo "UPSTREAM_TEST262=$(git -C vendor/test262 rev-parse HEAD)"
echo "UPSTREAM_PROPOSALS=$(git -C vendor/proposals rev-parse HEAD)"
} >> "$GITHUB_ENV"
- name: Decide refresh + publish
id: decide
# Single source of truth: src/refresh/decide.ts (unit-tested in
# decide.test.ts), invoked through the thin run-decide.ts wrapper.
# It diffs the UPSTREAM_* env above against ./.last-refresh.json,
# applies the monthly bundle-re-bake gate, and emits
# needs_refresh / should_publish / next_version — and rewrites the
# sentinel (only when something moved). No decision logic in bash.
run: npx tsx src/refresh/run-decide.ts
# ── From v0.2.0: data freshness rides R2, not the npm version ──
#
# The 4 h cadence still detects upstream movement, but the two
# data consumers update on different clocks:
#
# • R2 (the live data stdio fetches + the Worker serves) is
# refreshed on EVERY run that sees movement, by dispatching
# deploy-worker.yml — but only AFTER the new sentinel is pushed
# (see that step for why). Networked users stay ≤ 4 h fresh.
#
# • The npm bundle (the cold-start fallback for OFFLINE users)
# is re-baked at most monthly. A new annual edition re-bakes
# it sooner — but a new edition is a *code* change to the
# catalog, so that publish rides the normal release path; the
# refresh job only detects it and nudges (below).
#
# Net: ~12 data publishes/year instead of ~2000, with no loss of
# live freshness. See docs/deployment.md → "Freshness model".
- name: Nudge on a new upstream edition not yet in the catalog
# A new annual edition (e.g. es2026) can't be picked up
# automatically — it needs a deliberate catalog change in
# src/editions.ts plus a parser check — but it should prompt a
# release the day it lands (offline users get it via that code
# release). Detect and surface it; don't act on it.
continue-on-error: true
run: |
catalog=$(npx tsx -e "import {RELEASED_262_EDITIONS, RELEASED_402_EDITIONS} from './src/editions.ts'; console.log([...RELEASED_262_EDITIONS, ...RELEASED_402_EDITIONS].join('\n'))" | grep -oE 'es20[0-9]{2}' | sort -u)
upstream=$(
{ git ls-remote --tags https://github.com/tc39/ecma262 'refs/tags/es20*' ;
git ls-remote --heads https://github.com/tc39/ecma402 'refs/heads/es20*' ; } \
| grep -oE 'es20[0-9]{2}' | sort -u)
new=$(comm -13 <(printf '%s\n' "$catalog") <(printf '%s\n' "$upstream") || true)
if [ -n "$new" ]; then
list=$(echo "$new" | tr '\n' ' ')
echo "::warning title=New TC39 edition upstream::Not in the catalog yet: ${list}. Add to src/editions.ts (+ LATEST_*), verify the parser, cut a release."
{
echo "### ⚠️ New upstream edition(s) not in the catalog"
echo
echo "\`${list}\` — add to \`src/editions.ts\`, bump \`LATEST_*\`, verify the parser, cut a release."
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Surface a 402 release-branch move
# 402 ships editions as branches, so the current release branch
# can take editorial commits after it's cut (262's editions are
# immutable tags). The data refresh below already handles the
# drift; this just makes the move visible in the run summary
# instead of leaving it in the decide step's log.
if: steps.decide.outputs.moved_402_latest == 'true'
env:
PREV: ${{ steps.decide.outputs.prev_402_latest }}
CURR: ${{ steps.decide.outputs.curr_402_latest }}
run: |
echo "::notice title=ECMA-402 release branch moved::$EDITION_402_LATEST branch $PREV -> $CURR"
{
echo "### ECMA-402 \`$EDITION_402_LATEST\` release branch moved"
echo
echo "The current 402 release branch took new upstream commits since the last refresh:"
echo
echo "- \`$PREV\` → \`$CURR\`"
echo
echo "402 publishes editions as branches (not 262's immutable tags), so this is expected post-publication editorial drift. The new SHA reaches R2 via the deploy this run triggers."
} >> "$GITHUB_STEP_SUMMARY"
- name: File a tracking issue for the 402 branch move
# A passing scheduled run notifies no one, so also open a GitHub
# issue when the current 402 release branch drifts — a durable
# signal that reaches repo watchers. Deduped on the new SHA so a
# manual re-run before the sentinel commit lands won't double-
# file. The new SHA reaches R2 via the deploy this run triggers;
# the issue is purely a prompt to review the upstream change.
if: steps.decide.outputs.moved_402_latest == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PREV: ${{ steps.decide.outputs.prev_402_latest }}
CURR: ${{ steps.decide.outputs.curr_402_latest }}
run: |
gh label create ecma402-drift --color FBCA04 \
--description "ECMA-402 release branch drifted from its pin" 2>/dev/null || true
short_prev=$(printf '%s' "$PREV" | cut -c1-10)
short_curr=$(printf '%s' "$CURR" | cut -c1-10)
if gh issue list --state open --label ecma402-drift --json title --jq '.[].title' \
| grep -qF "$short_curr"; then
echo "An open ecma402-drift issue already references $short_curr; not filing another."
exit 0
fi
body=$(printf '%s\n' \
"The current ECMA-402 release branch ($EDITION_402_LATEST) took new upstream commits since the last refresh." \
"" \
"- **Spec:** ECMA-402" \
"- **Edition:** $EDITION_402_LATEST (current latest)" \
"- **Previous SHA:** $PREV" \
"- **New SHA:** $CURR" \
"- **Compare:** https://github.com/tc39/ecma402/compare/$PREV...$CURR" \
"" \
"402 publishes annual editions as branches (not 262's immutable tags), so the current one can take editorial commits after it is cut. The new SHA reaches R2 via the deploy this run triggers; no action is required unless the change is unexpected. Close once reviewed.")
gh issue create \
--label ecma402-drift \
--title "ECMA-402 $EDITION_402_LATEST release branch moved ($short_prev -> $short_curr)" \
--body "$body"
echo "Filed an ecma402-drift issue for $EDITION_402_LATEST $short_prev -> $short_curr."
- name: Bump PATCH version (only when re-baking the bundle)
# The decider already wrote the sentinel (incl. the advanced
# last_npm_publish); this just bumps package.json + the lockfile
# to the version it chose, so the commit + tag below match.
if: steps.decide.outputs.needs_refresh == 'true' && steps.decide.outputs.should_publish == 'true'
env:
NEXT_VERSION: ${{ steps.decide.outputs.next_version }}
run: npm version "$NEXT_VERSION" --no-git-tag-version --no-commit-hooks
- name: Configure git
if: steps.decide.outputs.needs_refresh == 'true'
run: |
git config user.name "tc39-mcp-bot"
git config user.email "tc39-mcp-bot@users.noreply.github.com"
- name: Commit the refreshed sentinel (+ tag only when publishing)
if: steps.decide.outputs.needs_refresh == 'true'
# Data-only refresh: commit the sentinel to main, no tag — R2 is
# already current (dispatched below) and npm is left alone. A
# monthly bundle re-bake additionally bumps + tags; the tag fires
# release.yml (npm publish), the same path a code release uses.
env:
SHOULD_PUBLISH: ${{ steps.decide.outputs.should_publish }}
NEXT_VERSION: ${{ steps.decide.outputs.next_version }}
run: |
if [ "$SHOULD_PUBLISH" = "true" ]; then
git add package.json package-lock.json .last-refresh.json
git commit -m "refresh: v$NEXT_VERSION (monthly data bundle)"
git tag "v$NEXT_VERSION"
git push origin HEAD:main
git push origin "v$NEXT_VERSION"
else
git add .last-refresh.json
git commit -m "chore(data): refresh upstream snapshot pointers"
git push origin HEAD:main
fi
- name: Refresh R2 (data-only runs, after the sentinel is pushed)
# Ordering is load-bearing: deploy-worker's build-spec-data
# caches vendor/ + build/ keyed on hashFiles('.last-refresh.json').
# It only re-fetches + re-parses (→ uploads fresh data to R2)
# once main carries the new SHAs — so we must push the sentinel
# BEFORE dispatching. On a publishing run we skip this entirely:
# the v* tag pushed above already triggers deploy-worker the same
# way, and double-firing would just redeploy the Worker twice.
if: steps.decide.outputs.needs_refresh == 'true' && steps.decide.outputs.should_publish != 'true'
env:
# Only the App token / PAT can trigger another workflow;
# GITHUB_TOKEN cannot.
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }}
run: gh workflow run deploy-worker.yml --ref main
- name: No-op summary
if: steps.decide.outputs.needs_refresh != 'true'
run: echo "No upstream changes since last refresh; skipping."