diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31fe173d..2003e472 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,8 @@ name: Release # 3. `publish`: waits for the `npm-publish` environment's approval (one click, the maintainer's # 2FA), then scripts/publish-ci.ts builds, smoke-tests the packed tarballs, publishes them with # npm trusted publishing (OIDC, provenance), and creates one `v` tag and GitHub Release. +# 4. `template`: after publishing, template-sync.yml opens or updates the cloudhal-template PR +# (the Deploy to Cloudflare button's repository; CONTRIBUTING.md "Releasing"). # npm's trusted-publisher entry for each package names this file and the `npm-publish` # environment: renaming either breaks publishing. @@ -96,4 +98,16 @@ jobs: - run: node scripts/publish-ci.ts env: GH_TOKEN: ${{ github.token }} + + template: + name: cloudhal-template PR for ${{ needs.plan.outputs.version }} + needs: [plan, publish] + if: needs.publish.result == 'success' + permissions: + contents: read + uses: ./.github/workflows/template-sync.yml + with: + version: ${{ needs.plan.outputs.version }} + secrets: + TEMPLATE_SYNC_APP_PRIVATE_KEY: ${{ secrets.TEMPLATE_SYNC_APP_PRIVATE_KEY }} # crates.io publishing is added once cloudhal-schema reaches 0.1 (see docs/adr/0006). diff --git a/.github/workflows/template-sync.yml b/.github/workflows/template-sync.yml new file mode 100644 index 00000000..23328884 --- /dev/null +++ b/.github/workflows/template-sync.yml @@ -0,0 +1,216 @@ +name: Template sync + +# Keeps cloudhal-dev/cloudhal-template, the repository the Deploy to Cloudflare button copies +# (docs/guides/deploy-button.md), on the latest release (issue #111). release.yml calls this after +# a successful npm publish; to re-sync by hand, run Actions › Template sync › Run workflow with a +# version npm has. +# scripts/template-sync.ts copies src/, public/, astro.config.mjs and tsconfig.json from the blog +# starter `@cloudhal/cli@` ships and moves @cloudhal/* to ^; the lockfile is +# refreshed with every @cloudhal/* at exactly , without running package code; then one PR +# is opened or updated from cloudhal/v, and older cloudhal/v* PRs the App opened are +# closed as superseded. Only PRs from cloudhal-template's own branches count: anyone can open a +# PR from a fork branch of the same name. Before any write token exists, a copy of the synced +# template is installed (npm ci) and deployed dry (`cloudhal deploy --dry-run`, WORKERS_CI=1); the +# result is the PR's `cloudhal/template-check` commit status, and a person merges it. The template +# holds no workflow files of its own: the button copies it with a GitHub App, and whether that +# copy may contain .github/workflows/ is not documented. +# Needs the TEMPLATE_SYNC_APP_CLIENT_ID variable and the TEMPLATE_SYNC_APP_PRIVATE_KEY secret, a +# GitHub App installed on cloudhal-template only (CONTRIBUTING.md "Releasing"). Without them the +# job logs a notice and does nothing. The App token is created only after the npm steps, and only +# the last step sees it. + +on: + workflow_call: + inputs: + version: + description: Cloudhal version on npm, x.y.z + type: string + required: true + secrets: + TEMPLATE_SYNC_APP_PRIVATE_KEY: + required: false + workflow_dispatch: + inputs: + version: + description: Cloudhal version on npm, x.y.z + type: string + required: true + +permissions: {} + +jobs: + sync: + name: Open the cloudhal-template PR + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + # Job level: a workflow-level group could wait on the calling release run forever. + concurrency: + group: template-sync + cancel-in-progress: false + env: + VERSION: ${{ inputs.version }} + TEMPLATE: cloudhal-dev/cloudhal-template + BRANCH: cloudhal/v${{ inputs.version }} + steps: + - id: gate + name: Is the GitHub App set up? + env: + CLIENT_ID: ${{ vars.TEMPLATE_SYNC_APP_CLIENT_ID }} + HAS_KEY: ${{ secrets.TEMPLATE_SYNC_APP_PRIVATE_KEY != '' }} + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::The version must be x.y.z, not '$VERSION'" + exit 1 + fi + if [ -n "$CLIENT_ID" ] && [ "$HAS_KEY" = "true" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "::notice title=cloudhal-template not synced::Set the TEMPLATE_SYNC_APP_CLIENT_ID variable and the TEMPLATE_SYNC_APP_PRIVATE_KEY secret (CONTRIBUTING.md, Releasing)" + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + - if: steps.gate.outputs.enabled == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Public, so the job's read-only token is enough; the App token comes later. + - if: steps.gate.outputs.enabled == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: cloudhal-dev/cloudhal-template + path: template + persist-credentials: false + + - if: steps.gate.outputs.enabled == 'true' + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Copy the starter, move @cloudhal/* to ^${{ inputs.version }} + if: steps.gate.outputs.enabled == 'true' + run: node scripts/template-sync.ts --template template --version "$VERSION" --wait 900 --summary "$RUNNER_TEMP/pr-body.md" + + # A plain refresh keeps (or picks) the newest @cloudhal/* in range, not this version: pin it, + # then fail unless the lock holds it everywhere. Retries while npm catches up. + - name: Refresh package-lock.json at ${{ inputs.version }} (no install scripts) + if: steps.gate.outputs.enabled == 'true' + run: node scripts/template-sync.ts --template template --version "$VERSION" --lock --wait 300 + + - id: diff + name: Anything to change? + if: steps.gate.outputs.enabled == 'true' + working-directory: template + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "::notice title=cloudhal-template is current::main already matches $VERSION" + echo "changed=false" >> "$GITHUB_OUTPUT" + else + git status --short + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + # Package code runs here (install scripts, the CLI): in a copy, so nothing it writes reaches + # the commit, and before any write token exists. A failure still opens the PR, marked failed. + - id: check + name: Install and dry-run deploy a copy (WORKERS_CI=1) + if: steps.diff.outputs.changed == 'true' + continue-on-error: true + run: | + set -euo pipefail + CHECK="$RUNNER_TEMP/template-check" + rsync -a --exclude .git template/ "$CHECK/" + cd "$CHECK" + npm ci --no-audit --no-fund + WORKERS_CI=1 npx --no-install cloudhal deploy --dry-run + + - id: app + if: steps.diff.outputs.changed == 'true' + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.TEMPLATE_SYNC_APP_CLIENT_ID }} + private-key: ${{ secrets.TEMPLATE_SYNC_APP_PRIVATE_KEY }} + owner: cloudhal-dev + repositories: cloudhal-template + permission-contents: write + permission-pull-requests: write + permission-statuses: write + + - name: Push cloudhal/v${{ inputs.version }} and open or update the PR + if: steps.diff.outputs.changed == 'true' + working-directory: template + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + SLUG: ${{ steps.app.outputs.app-slug }} + BODY: ${{ runner.temp }}/pr-body.md + CHECK_OUTCOME: ${{ steps.check.outcome }} + run: | + set -euo pipefail + TITLE="chore: Cloudhal $VERSION and the $VERSION starter theme" + gh auth setup-git + ID=$(gh api "/users/${SLUG}%5Bbot%5D" --jq .id) + git config user.name "${SLUG}[bot]" + git config user.email "${ID}+${SLUG}[bot]@users.noreply.github.com" + + # The branch is rebuilt from main on every run, so nobody pushes to it by hand. + BASE=$(git rev-parse HEAD) + git switch -c "$BRANCH" + git add -A + git commit -q -s -m "$TITLE" \ + -m "src/, public/, astro.config.mjs and tsconfig.json from the blog starter in @cloudhal/cli@$VERSION; @cloudhal/* at ^$VERSION and a package-lock.json that installs exactly $VERSION." \ + -m "Opened by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + if git fetch -q origin "$BRANCH" 2>/dev/null \ + && [ "$(git rev-parse 'FETCH_HEAD^{tree}')" = "$(git rev-parse 'HEAD^{tree}')" ] \ + && [ "$(git rev-parse 'FETCH_HEAD^')" = "$BASE" ]; then + echo "origin/$BRANCH already has this change on the same main" + SHA=$(git rev-parse FETCH_HEAD) + else + git push -q --force origin "HEAD:refs/heads/$BRANCH" + SHA=$(git rev-parse HEAD) + fi + + # REST with owner:branch, not `gh pr list --head`, which matches fork PRs from a branch + # of the same name too; the select drops anything else. + PR=$(gh api "repos/$TEMPLATE/pulls?state=open&head=${TEMPLATE%%/*}:$BRANCH" \ + --jq '[.[] | select(.head.repo.full_name == env.TEMPLATE)][0].number // empty') + if [ -n "$PR" ]; then + # REST, not `gh pr edit`: that one also queries projects over GraphQL. + gh api -X PATCH "repos/$TEMPLATE/pulls/$PR" -f title="$TITLE" -F "body=@$BODY" > /dev/null + echo "Updated $GITHUB_SERVER_URL/$TEMPLATE/pull/$PR" + else + URL=$(gh pr create --repo "$TEMPLATE" --base main --head "$BRANCH" --title "$TITLE" --body-file "$BODY") + PR=${URL##*/} + echo "Opened $URL" + fi + + if [ "$CHECK_OUTCOME" = "success" ]; then + STATE=success DESC="npm ci and cloudhal deploy --dry-run (WORKERS_CI=1) passed" + else + STATE=failure DESC="npm ci or cloudhal deploy --dry-run (WORKERS_CI=1) failed" + fi + gh api -X POST "repos/$TEMPLATE/statuses/$SHA" -f state="$STATE" \ + -f context=cloudhal/template-check -f description="$DESC" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > /dev/null + + # Older cloudhal/v* PRs this App opened from this repository are superseded; newer ones, + # fork PRs and everyone else's are left alone. + gh api --paginate "repos/$TEMPLATE/pulls?state=open&per_page=100" \ + --jq '.[] | select(.head.repo.full_name == env.TEMPLATE + and .user.login == "\(env.SLUG)[bot]" + and (.head.ref | startswith("cloudhal/v"))) + | "\(.number) \(.head.ref)"' | + while read -r NUM HEAD; do + OLD=${HEAD#cloudhal/v} + if [ "$NUM" = "$PR" ]; then continue; fi + if [ "$(printf '%s\n%s\n' "$OLD" "$VERSION" | sort -V | tail -n 1)" = "$VERSION" ]; then + gh pr close "$NUM" --repo "$TEMPLATE" --delete-branch --comment "Superseded by #$PR (Cloudhal $VERSION)." + fi + done + + - name: Fail when the dry-run deploy failed + if: steps.check.outcome == 'failure' + run: | + echo "::error title=cloudhal-template check failed::npm ci or cloudhal deploy --dry-run failed on the synced template; its PR carries a failing cloudhal/template-check status" + exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a7849ce..5fba5b80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,7 @@ Commit the regenerated files; CI fails on drift. Add a valid and an invalid fixt - **Conventional commits**: `feat(contracts): add Redirect node`, `fix(cli): …`, `docs: …`, `chore: …`. - **DCO sign-off**: every commit needs `Signed-off-by:` — use `git commit -s`. This certifies the [Developer Certificate of Origin](https://developercertificate.org/); there is no CLA. - Add a changeset (`pnpm changeset`) when a published package changes behaviour. +- A PR that changes what the renderer gives themes freezes the last released starter in the same PR: copy it under `packages/cli/test/fixtures/theme-v/` and add that version to `FROZEN` in `packages/cli/test/old-theme-compat.test.ts` (see `theme-v0.5.0/README.md` there), so the release that ships the change is already tested against the theme existing sites have. - Keep modules small and single-purpose: [`docs/engineering/code-structure.md`](docs/engineering/code-structure.md) sets file/function limits that `pnpm size:check` enforces with a shrink-only baseline. - CI must be green: lint, size check, typecheck, tests (TS + Rust), contract drift. @@ -49,10 +50,14 @@ All publishable packages (`@cloudhal/*` and `create-cloudhal`) share one version 2. **Release PR.** On each push to `main`, the Changesets action opens or updates `chore(release): version packages` with the version bump and changelogs. GitHub does not run CI on PRs opened by Actions; the publish job runs the full checks instead. Merging it is the decision to release. 3. **Approval.** The merge starts the `publish` job, which waits for the `npm-publish` environment's approval (the maintainer, with 2FA). Approve it in the run's page on GitHub. 4. **Publish.** `scripts/publish-ci.ts` checks the build, packs every package and installs the tarballs from scratch with npm (`scripts/smoke-pack.ts`), publishes exactly those tarballs with npm trusted publishing (OIDC, with provenance), waits until npm serves all of them, then creates the `vX.Y.Z` tag and the GitHub Release from the changelogs. +5. **After the release.** Bring the rest along: + - **cloudhal-template** (the Deploy to Cloudflare button): the workflow's `template` job (`.github/workflows/template-sync.yml`) opens `chore: Cloudhal X.Y.Z and the X.Y.Z starter theme` from `cloudhal/vX.Y.Z`, with `src/`, `public/`, `astro.config.mjs` and `tsconfig.json` from the published starter, `@cloudhal/*` at `^X.Y.Z` and a lockfile that installs exactly `X.Y.Z`. Merge it once its `cloudhal/template-check` status is green: the same run installs a copy of the synced template with `npm ci` and runs `cloudhal deploy --dry-run` with `WORKERS_CI=1`. To sync again, run Actions › Template sync › Run workflow. Without the `TEMPLATE_SYNC_APP_CLIENT_ID` variable and the `TEMPLATE_SYNC_APP_PRIVATE_KEY` secret (a GitHub App installed on cloudhal-template only, with Contents, Pull requests and Commit statuses write) the job only logs a notice; Dependabot's `@cloudhal/*` PR on the template is the fallback, and it brings dependencies only, no theme. + - **cloudhal.com**: Dependabot opens the `@cloudhal/*` PR the next morning (06:00 Europe/Istanbul). A patch release merges itself after its check; a minor bump waits for a person. + - **try.cloudhal.com** (`cloudhal-dev/try`): by hand. Bump its exact `@cloudhal/*` pins, then `pnpm install` and `pnpm run deploy`. -**If something fails:** re-run the workflow (Actions › Release › Run workflow). The script skips packages npm already has and an existing tag or release, so a re-run finishes a half-done release. npm's trusted-publisher entry for each package names `release.yml` and the `npm-publish` environment: renaming either breaks publishing. +**If something fails:** use **Re-run failed jobs** on the failed run. The script skips packages npm already has and an existing tag or release, so it finishes a half-done release. A new run (Actions › Release › Run workflow) does not: once npm has every package, `plan` skips `publish`, so a missing tag or GitHub Release stays missing. To redo only the template PR, run Actions › Template sync › Run workflow. npm's trusted-publisher entry for each package names `release.yml` and the `npm-publish` environment: renaming either breaks publishing. -**Turning it off:** the repository variable `RELEASE_ENABLED` gates the workflow. Without it, release by hand: `pnpm changeset version` in a PR, merge, then `pnpm release` from an up-to-date `main` and push one `vX.Y.Z` tag. +**Turning it off:** the repository variable `RELEASE_ENABLED` gates the workflow. Without it, release by hand: `pnpm changeset version` in a PR, merge, then `pnpm release` from an up-to-date `main` and push one `vX.Y.Z` tag. Nothing then opens the cloudhal-template PR: run Actions › Template sync › Run workflow with `X.Y.Z` (it needs the GitHub App, not `RELEASE_ENABLED`), and bring cloudhal.com and try.cloudhal.com along as in step 5. ## Where to start diff --git a/docs/guides/deploy-button.md b/docs/guides/deploy-button.md index bbff4aa0..3f631aaf 100644 --- a/docs/guides/deploy-button.md +++ b/docs/guides/deploy-button.md @@ -45,6 +45,14 @@ Start from `npm create cloudhal@latest`, then: - commit `cloudhal.json` and a root `wrangler.jsonc` that names the site Worker, a `DB` D1 database (placeholder id `00000000-0000-0000-0000-000000000000`) and a `BLOBS` R2 bucket - remove the `build` script (Workers Builds would run it before `deploy`), keep `"deploy": "cloudhal deploy"` - commit a lockfile and a `.node-version` of `24`. The CLI runs on Node 24. +- copy cloudhal-template's `.github/dependabot.yml` to get a PR for each release, and keep `src/` in step with the starter yourself: Dependabot moves dependencies only. Keep workflow files (`.github/workflows/`) out of a template: whether the button's copy may contain them is not documented. + +## Keeping the template current + +- Each Cloudhal release opens a PR on cloudhal-template that moves it to the new version and that version's starter theme (`src/`, `public/`, `astro.config.mjs`, `tsconfig.json`). It is merged once its `cloudhal/template-check` status is green (the sync installs a copy with `npm ci` and runs `cloudhal deploy --dry-run` with `WORKERS_CI=1`), so the button deploys the latest release. +- If your copy has the template's `.github/dependabot.yml`, Dependabot opens a PR in it for each `@cloudhal/*` release. Nothing checks that PR in your copy, and merging it deploys: run `npm ci` and `WORKERS_CI=1 npx cloudhal deploy --dry-run` on its branch first, or add a check workflow of your own. +- Your theme stays as it was when the copy was made: Dependabot moves the dependencies only, and releases keep older themes working. In 0.x a minor version may break, so read the release notes before merging a minor bump. +- Delete `.github/dependabot.yml` to stop the PRs. ## Before 0.3.2: the site Worker shows Studio diff --git a/packages/cli/test/template-sync.test.ts b/packages/cli/test/template-sync.test.ts new file mode 100644 index 00000000..dc98bd5c --- /dev/null +++ b/packages/cli/test/template-sync.test.ts @@ -0,0 +1,538 @@ +import { + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { lockPins, lockProblems } from "../../../scripts/template-lock.ts"; +import { + applySync, + assertKnownFields, + assertPublishedStarter, + compareVersions, + currentVersion, + dependencyChanges, + KNOWN_STARTER_FIELDS, + mergeGitignore, + mergeScripts, + planSync, + SYNCED_DIRS, + SYNCED_FILES, + type SyncOp, + templatePackageJson, + unknownEntries, +} from "../../../scripts/template-plan.ts"; +import { prBody } from "../../../scripts/template-pr.ts"; +import { starterFilter } from "../src/starter-files.ts"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +/** cloudhal-template's package.json as of 0.6.0 (cloudhal-template a42d4a5). */ +const TEMPLATE_PKG = { + name: "cloudhal-blog", + version: "0.0.1", + private: true, + description: "Cloudhal, the agent-native site platform: a blog on Cloudflare.", + type: "module", + scripts: { + dev: "cloudhal dev", + studio: "cloudhal studio", + snapshot: "cloudhal snapshot", + mcp: "cloudhal mcp", + "build:static": "cloudhal build", + deploy: "cloudhal deploy", + astro: "astro", + }, + dependencies: { + "@astrojs/cloudflare": "14.3.4", + "@cloudhal/astro-renderer": "^0.6.0", + "@cloudhal/cli": "^0.6.0", + "@cloudhal/core-ts": "^0.6.0", + "@tailwindcss/vite": "^4.3.3", + astro: "7.3.6", + "better-sqlite3": "13.0.3", + tailwindcss: "^4.3.3", + }, + devDependencies: { "@cloudhal/cloudflare": "^0.6.0", wrangler: "^4.148.0" }, + trustedDependencies: ["better-sqlite3", "esbuild", "workerd"], + license: "Apache-2.0", + engines: { node: ">=24" }, + cloudflare: { label: "Cloudhal blog", products: ["Workers", "D1", "R2"] }, +}; + +/** The starter's package.json as @cloudhal/cli@0.6.1 ships it (materialized, exact pins). */ +function starterPkg(version = "0.6.1") { + return { + name: "@cloudhal/starter-blog", + version: "0.0.1", + private: true, + description: "Cloudhal blog starter (Astro). Copied by `cloudhal init`.", + type: "module", + scripts: { + dev: "cloudhal dev", + build: "cloudhal build", + studio: "cloudhal studio", + snapshot: "cloudhal snapshot", + mcp: "cloudhal mcp", + deploy: "cloudhal deploy", + astro: "astro", + }, + dependencies: { + "@cloudhal/astro-renderer": version, + "@cloudhal/core-ts": version, + "@tailwindcss/vite": "^4.3.3", + astro: "7.3.6", + "better-sqlite3": "13.0.3", + tailwindcss: "^4.3.3", + "@astrojs/cloudflare": "14.3.4", + "@cloudhal/cli": version, + }, + devDependencies: { wrangler: "^4.148.0", "@cloudhal/cloudflare": version }, + trustedDependencies: ["better-sqlite3", "esbuild", "workerd"], + }; +} + +const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; +/** Not UTF-8: the sync must copy bytes, not text. */ +const FONT = Buffer.from([0x77, 0x4f, 0x46, 0x32, 0xff, 0xfe, 0x00, 0x80, 0xc3]); + +let tmp: string; +let starter: string; +let template: string; + +async function put(root: string, rel: string, data: string | Buffer): Promise { + await mkdir(path.dirname(path.join(root, rel)), { recursive: true }); + await writeFile(path.join(root, rel), data); +} + +/** Every file under `dir` (recursive), relative, with its bytes. */ +async function tree(dir: string): Promise> { + const out = new Map(); + for (const rel of (await readdir(dir, { recursive: true })).sort()) { + const abs = path.join(dir, rel); + if ((await lstat(abs)).isFile()) out.set(rel.split(path.sep).join("/"), await readFile(abs)); + } + return out; +} + +async function fixtureStarter(version = "0.6.1"): Promise { + await put(starter, "src/pages/[...path].astro", "---\n---\n

page

\n"); + await put(starter, "src/styles/features/tokens.css", ".ch-richtext a:not(.ch-button) {}\n"); + await put(starter, "public/fonts/v1/a.woff2", FONT); + await put(starter, "public/_headers", "/fonts/*\n Cache-Control: immutable\n"); + await put(starter, "astro.config.mjs", "export default {};\n"); + await put(starter, "tsconfig.json", '{ "extends": "astro/tsconfigs/strict" }\n'); + await put(starter, "package.json", json(starterPkg(version))); + await put(starter, "_gitignore", "dist/\n.astro/\n.cloudhal/\nnode_modules/\n.wrangler/\n"); + await put(starter, "README.md", "# Starter readme\n"); + await put(starter, ".yarnrc.yml", "nodeLinker: node-modules\n"); + await put(starter, "node_modules/junk.js", "junk\n"); +} + +const TEMPLATE_OWN = { + "README.md": "# Cloudhal blog template\n", + "wrangler.jsonc": '{ "name": "cloudhal-blog-site" }\n', + "cloudhal.json": '{ "siteId": "site_NSA1J36SDRCY24Q2QSEM" }\n', + ".node-version": "24\n", + ".github/workflows/check.yml": "name: Check\n", +}; + +async function fixtureTemplate(pkg: unknown = TEMPLATE_PKG): Promise { + await put(template, "src/pages/[...path].astro", "---\n---\n

page

\n"); + await put(template, "src/styles/features/tokens.css", ".ch-richtext a {}\n"); + await put(template, "src/old.astro", "

gone in the new starter

\n"); + await put(template, "public/fonts/old.woff2", FONT.subarray(2)); + await put(template, "public/fonts/v1/a.woff2", FONT); + await put(template, "public/_headers", "/fonts/*\n Cache-Control: immutable\n"); + await put(template, "astro.config.mjs", "export default {};\n"); + await put(template, "tsconfig.json", '{ "extends": "astro/tsconfigs/strict" }\n'); + await put(template, "package.json", json(pkg)); + // Lacks .wrangler/, which the merge appends. + await put(template, ".gitignore", "dist/\n.astro/\n.cloudhal/\nnode_modules/\n.dev.vars\n.env\n"); + for (const [rel, data] of Object.entries(TEMPLATE_OWN)) await put(template, rel, data); +} + +beforeEach(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), "template-sync-")); + starter = path.join(tmp, "starter"); + template = path.join(tmp, "template"); +}); + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); +}); + +describe("template package.json", () => { + it("moves only the @cloudhal/* ranges for the 0.6.1 starter", () => { + const out = templatePackageJson(TEMPLATE_PKG, starterPkg(), "0.6.1"); + const expected = { + ...TEMPLATE_PKG, + dependencies: { + ...TEMPLATE_PKG.dependencies, + "@cloudhal/astro-renderer": "^0.6.1", + "@cloudhal/cli": "^0.6.1", + "@cloudhal/core-ts": "^0.6.1", + }, + devDependencies: { ...TEMPLATE_PKG.devDependencies, "@cloudhal/cloudflare": "^0.6.1" }, + }; + expect(json(out)).toBe(json(expected)); + expect(Object.keys(out)).toEqual(Object.keys(TEMPLATE_PKG)); + }); + + it("follows the starter's third-party specs and keeps the template's own fields", () => { + const next = starterPkg("0.7.0"); + next.dependencies.astro = "7.4.0"; + next.trustedDependencies = ["better-sqlite3", "workerd"]; + const out = templatePackageJson(TEMPLATE_PKG, next, "0.7.0") as typeof TEMPLATE_PKG; + expect(out.dependencies.astro).toBe("7.4.0"); + expect(out.dependencies["@cloudhal/cli"]).toBe("^0.7.0"); + expect(out.devDependencies["@cloudhal/cloudflare"]).toBe("^0.7.0"); + expect(Object.keys(out.dependencies)).toEqual(Object.keys(out.dependencies).sort()); + expect(out.trustedDependencies).toEqual(["better-sqlite3", "workerd"]); + for (const key of [ + "name", + "version", + "private", + "description", + "type", + "license", + "engines", + "cloudflare", + ] as const) + expect(out[key]).toEqual(TEMPLATE_PKG[key]); + }); + + it("renames build, keeps the template's order, appends new scripts and drops removed ones", () => { + const { snapshot: _snapshot, ...withoutSnapshot } = starterPkg().scripts; + const merged = mergeScripts(TEMPLATE_PKG.scripts, { + ...withoutSnapshot, + preview: "cloudhal preview", + }); + expect(Object.keys(merged)).toEqual([ + "dev", + "studio", + "mcp", + "build:static", + "deploy", + "astro", + "preview", + ]); + expect(merged["build:static"]).toBe("cloudhal build"); + expect(merged).not.toHaveProperty("build"); + expect(mergeScripts(TEMPLATE_PKG.scripts, starterPkg().scripts)).toEqual(TEMPLATE_PKG.scripts); + expect(() => mergeScripts({}, { build: "a", "build:static": "b", deploy: "c" })).toThrow( + /both build/, + ); + expect(() => mergeScripts({}, { dev: "cloudhal dev" })).toThrow(/no deploy script/); + }); + + it("lists the dependencies a sync moves or removes", () => { + const { "better-sqlite3": _gone, ...dependencies } = starterPkg().dependencies; + const next = { ...starterPkg(), dependencies }; + const changes = dependencyChanges( + TEMPLATE_PKG, + templatePackageJson(TEMPLATE_PKG, next, "0.6.1"), + ); + expect(changes).toContainEqual({ + field: "dependencies", + name: "@cloudhal/cli", + from: "^0.6.0", + to: "^0.6.1", + }); + expect(changes).toContainEqual({ + field: "dependencies", + name: "better-sqlite3", + from: "13.0.3", + to: undefined, + }); + expect(changes).toHaveLength(5); + }); +}); + +describe("starter checks", () => { + it("accepts the published starter and refuses anything else", () => { + expect(() => assertPublishedStarter(starterPkg(), "0.6.1")).not.toThrow(); + for (const spec of ["workspace:*", "catalog:", "link:../x"]) { + const pkg = starterPkg(); + pkg.dependencies.astro = spec; + expect(() => assertPublishedStarter(pkg, "0.6.1")).toThrow(/not a published starter/); + } + expect(() => assertPublishedStarter(starterPkg("0.6.0"), "0.6.1")).toThrow( + /@cloudhal\/astro-renderer@0\.6\.0/, + ); + const noCli = { ...starterPkg(), dependencies: { astro: "7.3.6" } }; + expect(() => assertPublishedStarter(noCli, "0.6.1")).toThrow(/does not name @cloudhal\/cli/); + }); + + it("flags new top-level entries, not known or never-copied ones", () => { + expect(unknownEntries(["src", "public", "env.d.ts", "content.config.ts", "README.md"])).toEqual( + ["content.config.ts", "env.d.ts"], + ); + expect( + unknownEntries(["node_modules", ".astro", "wrangler.jsonc", "_gitignore", ".yarnrc.yml"]), + ).toEqual([]); + }); + + it("refuses a package.json field no rule covers, or another type", () => { + expect(() => assertKnownFields(TEMPLATE_PKG, starterPkg())).not.toThrow(); + const extras: Record[] = [ + { overrides: { hono: "4.13.14" } }, + { imports: { "#theme/*": "./src/theme/*" } }, + { engines: { node: ">=26" } }, + { packageManager: "pnpm@12.0.0" }, + ]; + for (const extra of extras) + expect(() => assertKnownFields(TEMPLATE_PKG, { ...starterPkg(), ...extra })).toThrow( + `new fields (${Object.keys(extra).join(", ")})`, + ); + expect(() => assertKnownFields(TEMPLATE_PKG, { ...starterPkg(), type: "commonjs" })).toThrow( + /type is commonjs, the template's module/, + ); + }); + + it("compares versions and reads the template's", () => { + expect(compareVersions("0.6.1", "0.6.0")).toBe(1); + expect(compareVersions("0.10.0", "0.9.9")).toBe(1); + expect(compareVersions("0.6.1", "0.6.1")).toBe(0); + expect(currentVersion(TEMPLATE_PKG)).toBe("0.6.0"); + expect(currentVersion({ dependencies: { "@cloudhal/cli": "~1.2.3" } })).toBe("1.2.3"); + expect(currentVersion({})).toBeUndefined(); + expect(() => currentVersion({ dependencies: { "@cloudhal/cli": ">=0.6" } })).toThrow( + /\^x\.y\.z/, + ); + }); + + it("the repo's starter has a rule for every top-level entry (a new one fails here, not at release)", async () => { + const dir = path.join(ROOT, "starters/blog"); + const keep = await starterFilter(dir); + const names = (await readdir(dir)).filter((n) => keep(path.join(dir, n))); + expect(unknownEntries(names)).toEqual([]); + for (const name of [...SYNCED_DIRS, ...SYNCED_FILES]) expect(names).toContain(name); + const pkg = JSON.parse(await readFile(path.join(dir, "package.json"), "utf8")) as object; + expect(Object.keys(pkg).filter((key) => !KNOWN_STARTER_FIELDS.has(key))).toEqual([]); + }); +}); + +describe("merge .gitignore", () => { + it("appends the starter's missing lines and is idempotent", () => { + const tpl = "dist/\nnode_modules/\n.dev.vars\n.env\n"; + const merged = mergeGitignore(tpl, "dist/\n.astro/\nnode_modules/\n.wrangler/\n"); + expect(merged).toBe("dist/\nnode_modules/\n.dev.vars\n.env\n.astro/\n.wrangler/\n"); + expect(mergeGitignore(merged, "dist/\n.astro/\nnode_modules/\n.wrangler/\n")).toBe(merged); + expect(mergeGitignore("dist/", ".astro/\n")).toBe("dist/\n.astro/\n"); + expect(mergeGitignore(tpl, "dist/\n")).toBe(tpl); + }); +}); + +describe("plan and apply", () => { + it("mirrors src/ and public/, merges package.json and leaves the template's own files", async () => { + await fixtureStarter(); + await fixtureTemplate(); + const ownBefore = await tree(template); + const ops = await planSync({ starterDir: starter, templateDir: template, version: "0.6.1" }); + expect(ops.map((op) => `${op.kind} ${op.path}`)).toEqual([ + "write .gitignore", + "write package.json", + "delete public/fonts/old.woff2", + "delete src/old.astro", + "write src/styles/features/tokens.css", + ]); + await applySync(template, ops); + for (const dir of SYNCED_DIRS) { + const want = [...(await tree(path.join(starter, dir)))]; + expect([...(await tree(path.join(template, dir)))]).toEqual(want); + } + await expect(stat(path.join(template, "public/fonts/v1"))).resolves.toBeTruthy(); + await expect(readdir(path.join(template, "public/fonts"))).resolves.toEqual(["v1"]); + const after = await tree(template); + for (const rel of Object.keys(TEMPLATE_OWN)) expect(after.get(rel)).toEqual(ownBefore.get(rel)); + expect(after.has("node_modules/junk.js")).toBe(false); + expect(after.has(".yarnrc.yml")).toBe(false); + expect(after.has("_gitignore")).toBe(false); + expect(after.get(".gitignore")?.toString()).toBe( + "dist/\n.astro/\n.cloudhal/\nnode_modules/\n.dev.vars\n.env\n.wrangler/\n", + ); + expect(after.get("package.json")?.toString()).toBe( + json(templatePackageJson(TEMPLATE_PKG, starterPkg(), "0.6.1")), + ); + }); + + it("adds new starter files and removes a directory it emptied", async () => { + await fixtureStarter(); + await fixtureTemplate(); + await put(starter, "src/components/New.astro", "

new

\n"); + await rm(path.join(starter, "public/fonts"), { recursive: true }); + const ops = await planSync({ starterDir: starter, templateDir: template, version: "0.6.1" }); + const write = ops.find((op) => op.path === "src/components/New.astro"); + expect(write).toMatchObject({ kind: "write", added: true }); + await applySync(template, ops); + await expect(readdir(path.join(template, "public"))).resolves.toEqual(["_headers"]); + }); + + it("is idempotent: a second plan is empty and applying it writes nothing", async () => { + await fixtureStarter(); + await fixtureTemplate(); + await applySync( + template, + await planSync({ starterDir: starter, templateDir: template, version: "0.6.1" }), + ); + const again = await planSync({ starterDir: starter, templateDir: template, version: "0.6.1" }); + expect(again).toEqual([]); + const mtimes = async () => { + const out: Record = {}; + for (const rel of (await tree(template)).keys()) + out[rel] = (await stat(path.join(template, rel))).mtimeMs; + return out; + }; + const before = await mtimes(); + await applySync(template, again); + expect(await mtimes()).toEqual(before); + }); + + it("fails before writing anything", async () => { + await fixtureStarter(); + await fixtureTemplate(); + const before = await tree(template); + const plan = (version: string) => + planSync({ starterDir: starter, templateDir: template, version }); + + await put(starter, "content.config.ts", "export {};\n"); + await expect(plan("0.6.1")).rejects.toThrow(/new top-level entries \(content\.config\.ts\)/); + await rm(path.join(starter, "content.config.ts")); + + await symlink(path.join(starter, "astro.config.mjs"), path.join(starter, "src/link.mjs")); + await expect(plan("0.6.1")).rejects.toThrow(/not a regular file or directory/); + await rm(path.join(starter, "src/link.mjs")); + + await put( + starter, + "package.json", + json({ ...starterPkg(), dependencies: { "@cloudhal/cli": "workspace:*" } }), + ); + await expect(plan("0.6.1")).rejects.toThrow(/not a published starter/); + await put(starter, "package.json", json({ ...starterPkg(), overrides: { hono: "4.13.14" } })); + await expect(plan("0.6.1")).rejects.toThrow(/new fields \(overrides\)/); + await put(starter, "package.json", json(starterPkg())); + + await expect(plan("0.6")).rejects.toThrow(/x\.y\.z/); + expect(await tree(template)).toEqual(before); + }); + + it("refuses a downgrade unless asked, and re-syncs the same version", async () => { + await fixtureStarter("0.6.0"); + await fixtureTemplate(templatePackageJson(TEMPLATE_PKG, starterPkg(), "0.6.1")); + const plan = (allowDowngrade: boolean) => + planSync({ starterDir: starter, templateDir: template, version: "0.6.0", allowDowngrade }); + await expect(plan(false)).rejects.toThrow(/on 0\.6\.1; 0\.6\.0 would downgrade/); + await expect(plan(true)).resolves.toContainEqual( + expect.objectContaining({ path: "package.json" }), + ); + await fixtureStarter("0.6.1"); + await expect( + planSync({ starterDir: starter, templateDir: template, version: "0.6.1" }), + ).resolves.toBeInstanceOf(Array); + }); +}); + +describe("lockfile", () => { + /** A lockfileVersion 3 excerpt: two direct @cloudhal/* packages and one they depend on. */ + const lock = (versions: Record) => ({ + lockfileVersion: 3, + packages: { + "": { dependencies: { "@cloudhal/cli": "^0.6.1" } }, + "node_modules/astro": { version: "7.3.6" }, + ...Object.fromEntries(Object.entries(versions).map(([key, v]) => [key, { version: v }])), + }, + }); + const synced = templatePackageJson(TEMPLATE_PKG, starterPkg(), "0.6.1"); + + it("pins every @cloudhal/* package.json names, dependencies and devDependencies alike", () => { + expect(lockPins(synced, "0.6.1")).toEqual([ + "@cloudhal/astro-renderer@0.6.1", + "@cloudhal/cli@0.6.1", + "@cloudhal/cloudflare@0.6.1", + "@cloudhal/core-ts@0.6.1", + ]); + expect(() => lockPins({ dependencies: { astro: "7.3.6" } }, "0.6.1")).toThrow(/names no/); + }); + + it("accepts a lock at exactly the synced version and names everything else", () => { + const exact = lock({ + "node_modules/@cloudhal/cli": "0.6.1", + "node_modules/@cloudhal/core-contracts": "0.6.1", + }); + expect(lockProblems(synced, exact, "0.6.1")).toEqual([]); + // What a plain refresh leaves after a sync to 0.6.0 with 0.6.1 out: in range, not 0.6.0. + const downgraded = templatePackageJson(TEMPLATE_PKG, starterPkg("0.6.0"), "0.6.0"); + expect(lockProblems(downgraded, exact, "0.6.0")).toEqual([ + "node_modules/@cloudhal/cli is locked at 0.6.1", + "node_modules/@cloudhal/core-contracts is locked at 0.6.1", + ]); + const nested = lock({ + "node_modules/@cloudhal/cli": "0.6.1", + "node_modules/@cloudhal/cli/node_modules/@cloudhal/storage": "0.6.2", + }); + expect(lockProblems(synced, nested, "0.6.1")).toEqual([ + "node_modules/@cloudhal/cli/node_modules/@cloudhal/storage is locked at 0.6.2", + ]); + const exactSpecs = { ...synced, devDependencies: { "@cloudhal/cloudflare": "0.6.1" } }; + expect(lockProblems(exactSpecs, exact, "0.6.1")).toEqual([ + "package.json devDependencies has @cloudhal/cloudflare@0.6.1", + ]); + expect(lockProblems(synced, lock({}), "0.6.1")).toEqual([ + "package-lock.json locks no @cloudhal/* package", + ]); + }); +}); + +describe("PR body", () => { + const write = (p: string, added = false): SyncOp => ({ + kind: "write", + path: p, + data: Buffer.from(""), + added, + }); + + it("names both versions, the paths, the dependencies and what must pass", () => { + const body = prBody({ + ops: [ + write("src/styles/features/tokens.css"), + write("src/new.astro", true), + { kind: "delete", path: "src/old.astro" }, + ], + from: "0.6.0", + to: "0.7.0", + deps: [{ field: "dependencies", name: "@cloudhal/cli", from: "^0.6.0", to: "^0.7.0" }], + runUrl: "https://github.com/cloudhal-dev/cloudhal/actions/runs/1", + }); + expect(body).toContain("from Cloudhal 0.6.0 to **0.7.0**"); + expect(body).toContain("Changed (1):\n\n- `src/styles/features/tokens.css`"); + expect(body).toContain("Added (1):\n\n- `src/new.astro`"); + expect(body).toContain("Removed (1):\n\n- `src/old.astro`"); + expect(body).toContain("- `@cloudhal/cli`: `^0.6.0` → `^0.7.0`"); + expect(body).toContain("https://github.com/cloudhal-dev/cloudhal/releases/tag/v0.7.0"); + expect(body).toContain("**This is a minor step.** In 0.x a minor version may break"); + expect(body).toContain("cloudhal/template-check"); + expect(body).toContain("([run](https://github.com/cloudhal-dev/cloudhal/actions/runs/1))"); + expect(body).toContain("Do not push to this branch"); + }); + + it("caps each path list at 50", () => { + const ops = Array.from({ length: 60 }, (_, i) => + write(`src/f${String(i).padStart(2, "0")}.css`), + ); + const body = prBody({ ops, from: "0.6.0", to: "0.6.1" }); + expect(body).toContain("- `src/f49.css`\n- … and 10 more"); + expect(body).not.toContain("src/f50.css"); + expect(body).not.toContain("This is a minor step"); + }); +}); diff --git a/scripts/publish-ci.ts b/scripts/publish-ci.ts index 61a30153..e331d101 100644 --- a/scripts/publish-ci.ts +++ b/scripts/publish-ci.ts @@ -14,17 +14,12 @@ import { appendFileSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { readPackages, releaseNotes, sharedVersion, unpublished } from "./release-plan.ts"; +import { onNpm, readPackages, releaseNotes, sharedVersion, unpublished } from "./release-plan.ts"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const sh = (cmd: string, args: string[], quiet = false) => execFileSync(cmd, args, { cwd: ROOT, encoding: "utf8", stdio: quiet ? "pipe" : "inherit" }); -async function onNpm(name: string, version: string): Promise { - const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}/${version}`); - return res.ok; -} - const pkgs = readPackages(ROOT); const version = sharedVersion(pkgs); const missing = await unpublished(pkgs, onNpm); diff --git a/scripts/release-plan.ts b/scripts/release-plan.ts index c8db6ae6..5b0960e7 100644 --- a/scripts/release-plan.ts +++ b/scripts/release-plan.ts @@ -1,8 +1,9 @@ /** * What a CI release publishes (ADR-0013; the fixed changesets group): the one version all * publishable packages share, which of them npm does not have yet, and the release notes for the - * single `v` tag and GitHub Release. Pure functions over package files, used by - * `scripts/publish-ci.ts` and its tests. + * single `v` tag and GitHub Release. Functions over package files and the npm registry, + * used by `scripts/publish-ci.ts`, `scripts/template-sync.ts` and their tests. Node built-ins only: + * the release workflow runs them without a pnpm install. */ import { readFileSync } from "node:fs"; import path from "node:path"; @@ -34,6 +35,12 @@ export function sharedVersion(pkgs: readonly Pkg[]): string { return [...versions][0] as string; } +/** Whether the npm registry serves `name@version`. */ +export async function onNpm(name: string, version: string): Promise { + const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}/${version}`); + return res.ok; +} + /** The packages whose current version npm does not have, in dependency (publish) order. */ export async function unpublished( pkgs: readonly Pkg[], diff --git a/scripts/template-lock.ts b/scripts/template-lock.ts new file mode 100644 index 00000000..02a1fb3c --- /dev/null +++ b/scripts/template-lock.ts @@ -0,0 +1,58 @@ +/** + * The lockfile half of a cloudhal-template sync (issue #111). A plain + * `npm install --package-lock-only` against the `^` ranges keeps every locked + * `@cloudhal/*` that already satisfies them and moves the rest to the newest release in range, so + * a sync to anything but the newest release would lock another version than the PR names (and its + * check would test that one). `scripts/template-sync.ts --lock` therefore installs the template's + * direct `@cloudhal/*` dependencies at exactly (theirs on each other are exact pins), then + * fails unless `lockProblems` is empty. Pure functions; no imports. + */ + +type Json = Record; +type Specs = Record; + +const SCOPE = "@cloudhal/"; +const DEP_FIELDS = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; +/** A lock `packages` key of an installed `@cloudhal/*` package, nested ones included. */ +const LOCKED_SCOPED = /(?:^|\/)node_modules\/@cloudhal\/[^/]+$/; + +function scoped(pkg: Json): [field: string, name: string, spec: string][] { + return DEP_FIELDS.flatMap((field) => + Object.entries((pkg[field] ?? {}) as Specs) + .filter(([name]) => name.startsWith(SCOPE)) + .map(([name, spec]): [string, string, string] => [field, name, spec]), + ); +} + +/** `name@` for every `@cloudhal/*` the template's package.json names, sorted. */ +export function lockPins(pkg: Json, version: string): string[] { + const names = [...new Set(scoped(pkg).map(([, name]) => name))].sort(); + if (!names.length) throw new Error(`the template's package.json names no ${SCOPE}* package`); + return names.map((name) => `${name}@${version}`); +} + +/** + * Why `pkg` and `lock` do not install exactly `version`: a `@cloudhal/*` range in package.json + * other than `^` (npm rewrote it), or a locked `@cloudhal/*` package at another version. + * Empty when they match the release the PR names. + */ +export function lockProblems(pkg: Json, lock: Json, version: string): string[] { + const problems: string[] = []; + for (const [field, name, spec] of scoped(pkg)) + if (spec !== `^${version}`) problems.push(`package.json ${field} has ${name}@${spec}`); + const packages = (lock.packages ?? {}) as Record; + const locked = Object.keys(packages) + .filter((key) => LOCKED_SCOPED.test(key)) + .sort(); + if (!locked.length) problems.push(`package-lock.json locks no ${SCOPE}* package`); + for (const key of locked) { + const at = packages[key]?.version; + if (at !== version) problems.push(`${key} is locked at ${at ?? "no version"}`); + } + return problems; +} diff --git a/scripts/template-plan.ts b/scripts/template-plan.ts new file mode 100644 index 00000000..641dd9c6 --- /dev/null +++ b/scripts/template-plan.ts @@ -0,0 +1,380 @@ +/** + * Keeps cloudhal-template, the repository the Deploy to Cloudflare button copies + * (docs/guides/deploy-button.md), on the latest release (issue #111). One sync makes the + * template's `src/`, `public/`, `astro.config.mjs` and `tsconfig.json` the blog starter that + * `@cloudhal/cli@` ships (what `npm create cloudhal` copies), gives its package.json the + * starter's dependencies with `@cloudhal/*` at `^`, and appends the starter's .gitignore + * lines. Everything else is the template's own and stays: README, LICENSE, wrangler.jsonc, + * cloudhal.json, .node-version, .github/ and package.json's name, `cloudflare` block and the rest. + * + * It fails before writing anything when the starter has a top-level entry or a package.json field + * nobody decided about (or another package.json `type`), is not the published starter of that + * version, or would move the template to an older version. + * Used by scripts/template-sync.ts and its tests; the PR text is scripts/template-pr.ts. Node + * built-ins only (starter-files.ts too): .github/workflows/template-sync.yml runs it without a + * pnpm install. + */ +import { lstat, mkdir, readdir, readFile, rm, rmdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { STARTER_SKIP, starterFilter } from "../packages/cli/src/starter-files.ts"; + +type Json = Record; +type Specs = Record; + +/** Mirrored from the starter: written when missing or different, deleted when it lacks them. */ +export const SYNCED_DIRS = ["src", "public"] as const; +/** Overwritten from the starter when their bytes differ, deleted when it lacks them. */ +export const SYNCED_FILES = ["astro.config.mjs", "tsconfig.json"] as const; +/** Top-level starter entries the sync has a rule for: copied, merged or deliberately left out. */ +export const KNOWN_STARTER_ENTRIES: ReadonlySet = new Set([ + ...SYNCED_DIRS, + ...SYNCED_FILES, + "package.json", + "_gitignore", + ".gitignore", + "README.md", + ".yarnrc.yml", +]); + +const DEP_FIELDS = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; +/** + * Starter package.json fields the sync has a rule for: scripts, the dependency maps and + * trustedDependencies are merged; name, version, private and description are the template's own; + * `type` must match. `cloudhal init` keeps every field, so a new one (`engines`, `overrides`, + * `imports`, …) fails the sync until someone decides whether the template follows it. + */ +export const KNOWN_STARTER_FIELDS: ReadonlySet = new Set([ + "name", + "version", + "private", + "description", + "type", + "scripts", + ...DEP_FIELDS, + "trustedDependencies", +]); +const VERSION = /^\d+\.\d+\.\d+$/; +const SCOPE = "@cloudhal/"; +const CLI = "@cloudhal/cli"; + +export type SyncOp = + | { + readonly kind: "write"; + readonly path: string; + readonly data: Buffer; + readonly added: boolean; + } + | { readonly kind: "delete"; readonly path: string }; + +export interface SyncOptions { + /** A published-layout starter: `package/dist/assets/starters/blog` of the CLI tarball. */ + readonly starterDir: string; + readonly templateDir: string; + readonly version: string; + /** Accept a version older than the one the template's `@cloudhal/cli` range names. */ + readonly allowDowngrade?: boolean; +} + +export interface DepChange { + readonly field: string; + readonly name: string; + readonly from: string | undefined; + readonly to: string | undefined; +} + +const byPath = (a: { path: string }, b: { path: string }) => + a.path < b.path ? -1 : Number(a.path > b.path); + +/** The starter's top-level names that no rule covers (neither known nor never copied). */ +export function unknownEntries(names: Iterable): string[] { + return [...names].filter((n) => !KNOWN_STARTER_ENTRIES.has(n) && !STARTER_SKIP.has(n)).sort(); +} + +function depMaps(pkg: Json): [field: string, specs: Specs][] { + return DEP_FIELDS.flatMap((field): [string, Specs][] => { + const specs = pkg[field]; + return specs && typeof specs === "object" ? [[field, specs as Specs]] : []; + }); +} + +/** Throws unless `pkg` is the starter `@cloudhal/cli@` ships (exact pins only). */ +export function assertPublishedStarter(pkg: Json, version: string): void { + let cli: string | undefined; + for (const [field, specs] of depMaps(pkg)) { + for (const [name, spec] of Object.entries(specs)) { + if (/^(?:workspace|catalog|link|file):/.test(spec)) + throw new Error(`not a published starter: ${field} has ${name}@${spec}`); + if (name.startsWith(SCOPE) && spec !== version) + throw new Error(`not the starter of ${version}: it pins ${name}@${spec}`); + if (name === CLI) cli = spec; + } + } + if (cli === undefined) throw new Error(`not a published starter: it does not name ${CLI}`); +} + +/** Throws when the starter's package.json has a field no rule covers, or another `type`. */ +export function assertKnownFields(template: Json, starter: Json): void { + const unknown = Object.keys(starter) + .filter((key) => !KNOWN_STARTER_FIELDS.has(key)) + .sort(); + if (unknown.length) + throw new Error( + `the starter's package.json has new fields (${unknown.join(", ")}): give each a rule in scripts/template-plan.ts first`, + ); + if (starter.type !== template.type) + throw new Error( + `the starter's package.json type is ${String(starter.type)}, the template's ${String(template.type)}: decide which the template uses first`, + ); +} + +/** -1, 0 or 1, comparing two `x.y.z` versions numerically. */ +export function compareVersions(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d) return Math.sign(d); + } + return 0; +} + +/** The version the template's `@cloudhal/cli` range names (`^0.6.0` → `0.6.0`), if it has one. */ +export function currentVersion(pkg: Json): string | undefined { + for (const [, specs] of depMaps(pkg)) { + const spec = specs[CLI]; + if (spec === undefined) continue; + const version = spec.replace(/^[\^~]/, ""); + if (!VERSION.test(version)) + throw new Error(`the template's ${CLI} range ${spec} is not ^x.y.z`); + return version; + } + return undefined; +} + +/** + * The starter's scripts with `build` renamed `build:static` (Workers Builds runs a `build` script + * before `deploy`): names the template has keep its order, new ones are appended, names the + * starter dropped go. + */ +export function mergeScripts(template: Specs, starter: Specs): Specs { + if (Object.hasOwn(starter, "build") && Object.hasOwn(starter, "build:static")) + throw new Error("the starter has both build and build:static scripts"); + const wanted = new Map( + Object.entries(starter).map(([name, cmd]) => [name === "build" ? "build:static" : name, cmd]), + ); + const out: Specs = {}; + for (const name of Object.keys(template)) { + const cmd = wanted.get(name); + if (cmd !== undefined) out[name] = cmd; + } + for (const [name, cmd] of wanted) if (!Object.hasOwn(out, name)) out[name] = cmd; + if (Object.hasOwn(out, "build")) throw new Error("the template must not have a build script"); + if (!Object.hasOwn(out, "deploy")) + throw new Error("the starter has no deploy script, which Workers Builds runs"); + return out; +} + +function sortedSpecs(specs: Specs, version: string): Specs { + const out: Specs = {}; + for (const name of Object.keys(specs).sort()) + out[name] = name.startsWith(SCOPE) ? `^${version}` : (specs[name] as string); + return out; +} + +/** + * The template's package.json after a sync: its own fields and key order, the starter's + * dependencies (sorted, `@cloudhal/*` at `^`), trustedDependencies and scripts. + */ +export function templatePackageJson(template: Json, starter: Json, version: string): Json { + const fromStarter: Json = { + scripts: mergeScripts((template.scripts ?? {}) as Specs, (starter.scripts ?? {}) as Specs), + trustedDependencies: starter.trustedDependencies, + }; + for (const field of DEP_FIELDS) { + const specs = starter[field] as Specs | undefined; + fromStarter[field] = specs ? sortedSpecs(specs, version) : undefined; + } + const out: Json = {}; + for (const key of new Set([...Object.keys(template), ...Object.keys(fromStarter)])) { + const value = Object.hasOwn(fromStarter, key) ? fromStarter[key] : template[key]; + if (value !== undefined) out[key] = value; + } + return out; +} + +/** Every dependency whose spec differs between two package.json files (added, moved, removed). */ +export function dependencyChanges(before: Json, after: Json): DepChange[] { + const out: DepChange[] = []; + const a = new Map(depMaps(before)); + const b = new Map(depMaps(after)); + for (const field of DEP_FIELDS) { + const from = a.get(field) ?? {}; + const to = b.get(field) ?? {}; + for (const name of [...new Set([...Object.keys(from), ...Object.keys(to)])].sort()) + if (from[name] !== to[name]) out.push({ field, name, from: from[name], to: to[name] }); + } + return out; +} + +/** The template's .gitignore with every line of the starter's it lacks appended. */ +export function mergeGitignore(template: string, starter: string): string { + const have = new Set(template.split("\n").map((l) => l.trim())); + const add = [...new Set(starter.split("\n").map((l) => l.trim()))].filter( + (l) => l && !have.has(l), + ); + if (!add.length) return template; + const base = template === "" || template.endsWith("\n") ? template : `${template}\n`; + return `${base}${add.join("\n")}\n`; +} + +async function kindOf(abs: string): Promise<"file" | "dir" | "missing"> { + try { + const st = await lstat(abs); + if (st.isFile()) return "file"; + if (st.isDirectory()) return "dir"; + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + throw e; + } + throw new Error(`${abs} is not a regular file or directory (a symlink?): the sync copies files`); +} + +/** A file's bytes; undefined when it is missing, filtered out or a directory. */ +async function readIfFile(abs: string, keep: (src: string) => boolean) { + if (!keep(abs)) return undefined; + return (await kindOf(abs)) === "file" ? readFile(abs) : undefined; +} + +/** The regular files under `root/sub` that `keep` admits, as `sub/…` paths. */ +async function listFiles(root: string, sub: string, keep: (src: string) => boolean) { + const out: string[] = []; + const visit = async (rel: string): Promise => { + const abs = path.join(root, rel); + if (!keep(abs)) return; + const kind = await kindOf(abs); + if (kind === "file") out.push(rel); + else if (kind === "dir") + for (const name of (await readdir(abs)).sort()) await visit(`${rel}/${name}`); + }; + await visit(sub); + return out; +} + +function fileOp(rel: string, data: Buffer, current: Buffer | undefined): SyncOp[] { + if (current && Buffer.compare(current, data) === 0) return []; + return [{ kind: "write", path: rel, data, added: current === undefined }]; +} + +interface Sides { + readonly starterDir: string; + readonly templateDir: string; + readonly keepStarter: (src: string) => boolean; + readonly keepTemplate: (src: string) => boolean; +} + +async function mirrorDir(s: Sides, dir: string): Promise { + if ((await kindOf(path.join(s.starterDir, dir))) !== "dir") + throw new Error(`the starter has no ${dir}/: decide what the template keeps before syncing`); + const wanted = await listFiles(s.starterDir, dir, s.keepStarter); + const ops: SyncOp[] = []; + for (const rel of wanted) { + const current = await readIfFile(path.join(s.templateDir, rel), s.keepTemplate); + ops.push(...fileOp(rel, await readFile(path.join(s.starterDir, rel)), current)); + } + const keep = new Set(wanted); + for (const rel of await listFiles(s.templateDir, dir, s.keepTemplate)) + if (!keep.has(rel)) ops.push({ kind: "delete", path: rel }); + return ops; +} + +async function syncFile(s: Sides, rel: string): Promise { + const data = await readIfFile(path.join(s.starterDir, rel), s.keepStarter); + const current = await readIfFile(path.join(s.templateDir, rel), s.keepTemplate); + if (data) return fileOp(rel, data, current); + return current ? [{ kind: "delete", path: rel }] : []; +} + +async function readJson(file: string): Promise { + return JSON.parse(await readFile(file, "utf8")) as Json; +} + +async function gitignoreOp(s: Sides): Promise { + const starter = + (await readIfFile(path.join(s.starterDir, "_gitignore"), s.keepStarter)) ?? + (await readIfFile(path.join(s.starterDir, ".gitignore"), s.keepStarter)); + if (!starter) return []; + const current = await readIfFile(path.join(s.templateDir, ".gitignore"), () => true); + const text = new TextDecoder(); + const merged = mergeGitignore(current ? text.decode(current) : "", text.decode(starter)); + return fileOp(".gitignore", Buffer.from(merged), current); +} + +/** What one sync changes in the template, sorted by path; throws before anything is written. */ +export async function planSync(options: SyncOptions): Promise { + const { starterDir, templateDir, version } = options; + if (!VERSION.test(version)) throw new Error(`the version must be x.y.z, not ${version}`); + const s: Sides = { + starterDir, + templateDir, + keepStarter: await starterFilter(starterDir), + keepTemplate: await starterFilter(templateDir), + }; + const top = (await readdir(starterDir)).filter((n) => s.keepStarter(path.join(starterDir, n))); + const unknown = unknownEntries(top); + if (unknown.length) + throw new Error( + `the starter has new top-level entries (${unknown.join(", ")}): give each a rule in scripts/template-plan.ts first`, + ); + const starterPkg = await readJson(path.join(starterDir, "package.json")); + assertPublishedStarter(starterPkg, version); + const templatePkg = await readJson(path.join(templateDir, "package.json")); + assertKnownFields(templatePkg, starterPkg); + const current = currentVersion(templatePkg); + if (current && compareVersions(version, current) < 0 && !options.allowDowngrade) + throw new Error(`the template is on ${current}; ${version} would downgrade it`); + const ops: SyncOp[] = []; + for (const dir of SYNCED_DIRS) ops.push(...(await mirrorDir(s, dir))); + for (const file of SYNCED_FILES) ops.push(...(await syncFile(s, file))); + const pkg = `${JSON.stringify(templatePackageJson(templatePkg, starterPkg, version), null, 2)}\n`; + const pkgNow = await readFile(path.join(templateDir, "package.json")); + ops.push(...fileOp("package.json", Buffer.from(pkg), pkgNow)); + ops.push(...(await gitignoreOp(s))); + return ops.sort(byPath); +} + +/** Removes `dir` and its parents up to (not including) `root` while they are empty. */ +async function pruneEmpty(dir: string, root: string): Promise { + for (let d = dir; d.startsWith(`${root}${path.sep}`); d = path.dirname(d)) { + const kind = await kindOf(d); + if (kind === "missing") continue; + if (kind !== "dir" || (await readdir(d)).length) return; + await rmdir(d); + } +} + +/** Applies `planSync`'s operations: deletes (and the directories they empty), then writes. */ +export async function applySync(templateDir: string, ops: readonly SyncOp[]): Promise { + const root = path.resolve(templateDir); + const target = (rel: string) => { + const abs = path.resolve(root, rel); + if (!abs.startsWith(`${root}${path.sep}`)) throw new Error(`${rel} is outside the template`); + return abs; + }; + const emptied = new Set(); + for (const op of ops) { + if (op.kind !== "delete") continue; + await rm(target(op.path), { force: true }); + emptied.add(path.dirname(target(op.path))); + } + for (const dir of [...emptied].sort().reverse()) await pruneEmpty(dir, root); + for (const op of ops) { + if (op.kind !== "write") continue; + await mkdir(path.dirname(target(op.path)), { recursive: true }); + await writeFile(target(op.path), op.data); + } +} diff --git a/scripts/template-pr.ts b/scripts/template-pr.ts new file mode 100644 index 00000000..20388d58 --- /dev/null +++ b/scripts/template-pr.ts @@ -0,0 +1,77 @@ +/** + * The text of the cloudhal-template PR that scripts/template-sync.ts writes for + * .github/workflows/template-sync.yml (issue #111): what moved, what to read, what must pass. + * A pure function over scripts/template-plan.ts's operations; Node built-ins only. + */ +import type { DepChange, SyncOp } from "./template-plan.ts"; + +export interface PrBodyOptions { + readonly ops: readonly SyncOp[]; + /** The version the template was on (`currentVersion` before the sync). */ + readonly from: string | undefined; + readonly to: string; + readonly deps?: readonly DepChange[] | undefined; + /** The workflow run that opened the PR. */ + readonly runUrl?: string | undefined; +} + +/** Paths listed per section; the rest is counted. */ +const LIST_CAP = 50; + +function pathList(title: string, paths: readonly string[]): string[] { + if (!paths.length) return []; + const shown = paths.slice(0, LIST_CAP).map((p) => `- \`${p}\``); + if (paths.length > LIST_CAP) shown.push(`- … and ${paths.length - LIST_CAP} more`); + return [`${title} (${paths.length}):`, "", ...shown, ""]; +} + +function depLine(d: DepChange): string { + const where = d.field === "dependencies" ? "" : ` (${d.field})`; + const from = d.from === undefined ? "added" : `\`${d.from}\``; + const to = d.to === undefined ? "removed" : `\`${d.to}\``; + return `- \`${d.name}\`${where}: ${from} → ${to}`; +} + +const minor = (v: string) => v.split(".", 2).join("."); + +/** The PR body (Markdown). */ +export function prBody(o: PrBodyOptions): string { + const paths = (pick: (op: SyncOp) => boolean) => o.ops.filter(pick).map((op) => op.path); + const files = [ + ...pathList( + "Changed", + paths((op) => op.kind === "write" && !op.added), + ), + ...pathList( + "Added", + paths((op) => op.kind === "write" && op.added), + ), + ...pathList( + "Removed", + paths((op) => op.kind === "delete"), + ), + ]; + const deps = (o.deps ?? []).map(depLine); + const minorStep = o.from !== undefined && minor(o.from) !== minor(o.to); + return [ + `Moves this template from Cloudhal ${o.from ?? "(unknown)"} to **${o.to}**: \`@cloudhal/*\` at \`^${o.to}\`, and \`src/\`, \`public/\`, \`astro.config.mjs\` and \`tsconfig.json\` from the blog starter \`@cloudhal/cli@${o.to}\` ships (what \`npm create cloudhal\` copies), with a refreshed \`package-lock.json\`. README, \`wrangler.jsonc\`, \`cloudhal.json\` and \`.github/\` are the template's own and stay as they are.`, + "", + `Release notes: https://github.com/cloudhal-dev/cloudhal/releases/tag/v${o.to}`, + "", + `${minorStep ? "**This is a minor step.** " : ""}In 0.x a minor version may break: read the release notes before merging a minor step.`, + "", + "### Files", + "", + ...(files.length ? files : ["None: dependencies and the lockfile only.", ""]), + "### Dependencies", + "", + ...(deps.length ? deps : ["None changed in package.json."]), + "", + "### Before merging", + "", + "The `cloudhal/template-check` status must be green: the Template sync run installed a copy of this branch with `npm ci` and ran `cloudhal deploy --dry-run` with `WORKERS_CI=1`. Merging changes what the Deploy to Cloudflare button deploys from now on; sites created earlier keep their theme.", + "", + `Opened by the Cloudhal release workflow${o.runUrl ? ` ([run](${o.runUrl}))` : ""}. Do not push to this branch: a re-run of Template sync rebuilds it from \`main\` and replaces it.`, + "", + ].join("\n"); +} diff --git a/scripts/template-sync.ts b/scripts/template-sync.ts new file mode 100644 index 00000000..317eed52 --- /dev/null +++ b/scripts/template-sync.ts @@ -0,0 +1,202 @@ +/** + * Moves cloudhal-template (the Deploy to Cloudflare button) to a Cloudhal release, issue #111. + * .github/workflows/template-sync.yml runs it after each npm publish, then refreshes the + * lockfile and opens the PR; scripts/template-plan.ts has the rules. + * + * node scripts/template-sync.ts --template --version + * [--starter ] [--wait ] [--summary ] [--check] [--allow-downgrade] + * node scripts/template-sync.ts --template --version --lock [--wait ] + * + * Without --starter it waits up to --wait seconds (default 0) for npm to serve + * `@cloudhal/cli@`, takes the blog starter out of `npm pack` of it (no package code + * runs) and waits for every `@cloudhal/*` package that starter names. --starter reads a + * published-layout starter from disk instead, offline (the repo's starters/blog is refused: its + * specs are workspace:). --check writes nothing and exits 1 when the template is not current. + * --summary writes the PR body. The sync itself never runs npm install, nor `cloudhal deploy`, + * which rewrites the tracked wrangler.jsonc. + * + * --lock, run after a sync (an --allow-downgrade one too), refreshes package-lock.json with + * `npm install --package-lock-only --ignore-scripts` and every `@cloudhal/*` at exactly + * (scripts/template-lock.ts), retrying for up to --wait seconds while npm catches up, and fails + * unless the lock holds that version everywhere. Node built-ins only: the workflow runs it + * without a pnpm install. + */ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { parseArgs } from "node:util"; +import { onNpm, unpublished } from "./release-plan.ts"; +import { lockPins, lockProblems } from "./template-lock.ts"; +import { + applySync, + currentVersion, + dependencyChanges, + planSync, + type SyncOp, +} from "./template-plan.ts"; +import { prBody } from "./template-pr.ts"; + +/** Where the CLI tarball carries the starter `cloudhal init` copies. */ +const STARTER_IN_TARBALL = "package/dist/assets/starters/blog"; + +function args() { + const { values } = parseArgs({ + options: { + template: { type: "string" }, + version: { type: "string" }, + starter: { type: "string" }, + wait: { type: "string", default: "0" }, + summary: { type: "string" }, + check: { type: "boolean", default: false }, + "allow-downgrade": { type: "boolean", default: false }, + lock: { type: "boolean", default: false }, + }, + strict: true, + }); + const { template, version } = values; + const wait = Number(values.wait); + if (!template || !version) throw new Error("--template and --version are required"); + if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`--version must be x.y.z, not ${version}`); + if (!Number.isInteger(wait) || wait < 0) throw new Error("--wait takes whole seconds"); + if ( + values.lock && + (values.starter || values.summary || values.check || values["allow-downgrade"]) + ) + throw new Error("--lock takes only --template, --version and --wait"); + return { ...values, template, version, wait }; +} + +/** Polls npm until it serves every package at `version`; throws once `seconds` have passed. */ +async function waitForNpm(names: readonly string[], version: string, seconds: number) { + const pkgs = names.map((name) => ({ dir: name, name, version })); + const served = (name: string, v: string) => onNpm(name, v).catch(() => false); + const deadline = Date.now() + seconds * 1000; + for (;;) { + const missing = (await unpublished(pkgs, served)).map((p) => `${p.name}@${version}`); + if (!missing.length) return; + if (Date.now() >= deadline) + throw new Error(`npm does not serve ${missing.join(", ")} (waited ${seconds} s)`); + console.log(`waiting for npm to serve ${missing.join(", ")}`); + await new Promise((r) => setTimeout(r, Math.min(15_000, Math.max(0, deadline - Date.now())))); + } +} + +/** Downloads `@cloudhal/cli@` into `tmp` and extracts only its blog starter. */ +async function fetchStarter(version: string, wait: number, tmp: string): Promise { + await waitForNpm(["@cloudhal/cli"], version, wait); + const out = execFileSync( + "npm", + ["pack", `@cloudhal/cli@${version}`, "--pack-destination", tmp, "--ignore-scripts", "--json"], + { cwd: tmp, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, + ); + const [packed] = JSON.parse(out) as { filename?: string }[]; + if (!packed?.filename) throw new Error(`npm pack printed no tarball: ${out}`); + const into = path.join(tmp, "x"); + await mkdir(into); + const tgz = path.join(tmp, path.basename(packed.filename)); + execFileSync("tar", ["-xzf", tgz, "-C", into, STARTER_IN_TARBALL], { stdio: "inherit" }); + const starter = path.join(into, STARTER_IN_TARBALL); + const pkg = JSON.parse(await readFile(path.join(starter, "package.json"), "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + }; + const scoped = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }) + .filter((name) => name.startsWith("@cloudhal/")) + .sort(); + await waitForNpm(scoped, version, wait); + return starter; +} + +const readJson = async (file: string) => + JSON.parse(await readFile(file, "utf8")) as Record; + +/** --lock: package-lock.json with every `@cloudhal/*` at exactly `version`, or an error. */ +async function refreshLock(templateDir: string, version: string, wait: number): Promise { + const pkgFile = path.join(templateDir, "package.json"); + const npmArgs = [ + "install", + "--package-lock-only", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--save-prefix=^", + "--no-save-exact", + ...lockPins(await readJson(pkgFile), version), + ]; + // npm can serve a new version's metadata minutes after the publish. + const deadline = Date.now() + wait * 1000; + for (let attempt = 1; ; attempt++) { + try { + execFileSync("npm", npmArgs, { cwd: templateDir, stdio: "inherit" }); + break; + } catch { + if (Date.now() >= deadline) + throw new Error( + `npm install --package-lock-only failed (attempt ${attempt}, --wait ${wait})`, + ); + console.log(`npm install failed (attempt ${attempt}); retrying in up to 60 s`); + await new Promise((r) => setTimeout(r, Math.min(60_000, deadline - Date.now()))); + } + } + const lock = await readJson(path.join(templateDir, "package-lock.json")); + const problems = lockProblems(await readJson(pkgFile), lock, version); + if (problems.length) + throw new Error(`the refreshed lock does not install ${version}:\n ${problems.join("\n ")}`); + console.log(`package-lock.json locks every @cloudhal/* package at ${version}`); +} + +function describe(op: SyncOp): string { + if (op.kind === "delete") return `removed ${op.path}`; + return `${op.added ? "added" : "updated"} ${op.path}`; +} + +function runUrl(): string | undefined { + const { GITHUB_SERVER_URL: server, GITHUB_REPOSITORY: repo, GITHUB_RUN_ID: run } = process.env; + return server && repo && run ? `${server}/${repo}/actions/runs/${run}` : undefined; +} + +async function main(): Promise { + const opts = args(); + const templateDir = path.resolve(opts.template); + if (opts.lock) return refreshLock(templateDir, opts.version, opts.wait); + const tmp = await mkdtemp(path.join(os.tmpdir(), "cloudhal-template-sync-")); + try { + const starterDir = opts.starter + ? path.resolve(opts.starter) + : await fetchStarter(opts.version, opts.wait, tmp); + const pkgFile = path.join(templateDir, "package.json"); + const before = await readJson(pkgFile); + const ops = await planSync({ + starterDir, + templateDir, + version: opts.version, + allowDowngrade: opts["allow-downgrade"], + }); + for (const op of ops) console.log(describe(op)); + if (!ops.length) console.log(`cloudhal-template is current at ${opts.version}`); + if (opts.check) { + process.exitCode = ops.length ? 1 : 0; + return; + } + await applySync(templateDir, ops); + if (opts.summary) { + const after = await readJson(pkgFile); + const deps = dependencyChanges(before, after); + const from = currentVersion(before); + await writeFile( + opts.summary, + prBody({ ops, from, to: opts.version, deps, runUrl: runUrl() }), + ); + } + } finally { + await rm(tmp, { recursive: true, force: true }); + } +} + +try { + await main(); +} catch (e) { + console.error(`template-sync: ${(e as Error).message}`); + process.exitCode = 1; +}