From 8f312d776b2585426421f65bd0d0bb107eaa6f65 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 3 May 2026 21:58:26 -0400 Subject: [PATCH 1/2] Roll Out Local Build Workspace for EIPs Add the local build workspace metadata and scripts needed to run EIPs through the workspace-local build pipeline. Move homepage badges into structured front matter consumed by the theme renderer. Include the ERCRef Discord badge in the structured badge list. --- .build-eips.repo.toml | 17 + .github/CONTRIBUTING.md | 6 + .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 5 +- README.md | 230 +++++++++++--- content/_index.md | 42 ++- scripts/dev-setup | 569 ++++++++++++++++++++++++++++++++++ scripts/dev-setup.ps1 | 517 ++++++++++++++++++++++++++++++ 8 files changed, 1321 insertions(+), 67 deletions(-) create mode 100644 .build-eips.repo.toml create mode 100755 scripts/dev-setup create mode 100644 scripts/dev-setup.ps1 diff --git a/.build-eips.repo.toml b/.build-eips.repo.toml new file mode 100644 index 000000000..a349d22c4 --- /dev/null +++ b/.build-eips.repo.toml @@ -0,0 +1,17 @@ +repo_id = "EIPs" + +[production] +repository = "https://github.com/ethereum/EIPs.git" +base_url = "https://eips.ethereum.org/" + +[staging] +repository = "https://github.com/eips-wg/EIPs.git" +base_url = "https://eips-wg.github.io/EIPs/" + +[siblings.ERCs.production] +repository = "https://github.com/ethereum/ERCs.git" +base_url = "https://ercs.ethereum.org/" + +[siblings.ERCs.staging] +repository = "https://github.com/eips-wg/ERCs.git" +base_url = "https://eips-wg.github.io/ERCs/" diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 132390597..2d1d1514e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,3 +1,9 @@ Please review [EIP-1](https://eips.ethereum.org/1/) for proposal guidelines. +For workspace bootstrap and direct `build-eips` commands, run +`./scripts/dev-setup` from the repo root. See + for setup +details; the generated `WORKSPACE.md` contains the full command and +configuration reference. + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f53ba2ca..ac9a5e28a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: - name: EIPs Build (Check) uses: ./.github/actions/build-eips with: - args: check --format github + args: editorial check --against-upstream --format github markdownlint: if: github.event.pull_request.base.repo.owner.login == 'ethereum' || github.event.pull_request.base.repo.owner.login == 'eips-wg' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1fde71c40..8bf406b88 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,10 +20,7 @@ jobs: - name: EIPs Build (Build) uses: ./.github/actions/build-eips with: - # Disable eipw lints because there will never be any changed files - # between this checkout and `master` because, well, we just checked - # out `master`. - args: build --no-lint + args: build - name: Upload Artifact id: artifact diff --git a/README.md b/README.md index 4ac6f0620..183ec1a1f 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,226 @@ # EIPs vs. ERCs > [!IMPORTANT] -> Please direct application-level proposals (ERCs) to [`ethereum/ERCs`] and all -> other proposals (EIPs) to [`ethereum/EIPs`]. +> Please direct application-level proposals (ERCs) to [`ethereum/ERCs`] and all other proposals (EIPs) to [`ethereum/EIPs`]. # Ethereum Improvement Proposals (EIPs) -The EIP project standardizes and provides high-quality documentation for -Ethereum itself and conventions built upon it. This repository tracks past and -ongoing improvements to Ethereum in the form of Ethereum Improvement -Proposals (EIPs). [EIP-1] governs how EIPs are published. +The EIP project standardizes and provides high-quality documentation for Ethereum itself and conventions built upon it. This repository tracks past and ongoing improvements to Ethereum in the form of Ethereum Improvement Proposals (EIPs). [EIP-1] governs how EIPs are published. ## Taxonomy -The [status page][status] tracks and lists EIPs, which can be divided into the -following types and categories: +The [status page][status] tracks and lists EIPs, which can be divided into the following types and categories: - [Core EIPs] are improvements to the Ethereum consensus protocol. - [Networking EIPs] specify the peer-to-peer networking layer of Ethereum. -- [Interface EIPs] standardize interfaces to Ethereum, which determine how - users and applications interact with the blockchain. -- [ERCs] specify application layer standards, which determine how applications - running on Ethereum can interact with each other. -- [Meta EIPs] are miscellaneous improvements that nonetheless require some sort - of consensus. -- [Informational EIPs] are non-standard improvements that do not require any - form of consensus. +- [Interface EIPs] standardize interfaces to Ethereum, which determine how users and applications interact with the blockchain. +- [ERCs] specify application layer standards, which determine how applications running on Ethereum can interact with each other. +- [Meta EIPs] are miscellaneous improvements that nonetheless require some sort of consensus. +- [Informational EIPs] are non-standard improvements that do not require any form of consensus. ## Contributing > [!WARNING] -> Before you write an EIP, ideas MUST be thoroughly discussed on -> [Ethereum Magicians][ethmag] or [Ethereum Research][ethres]. Once consensus -> is reached, thoroughly read and review [EIP-1], which describes the EIP -> process. +> Before you write an EIP, ideas MUST be thoroughly discussed on [Ethereum Magicians][ethmag] or [Ethereum Research][ethres]. Once consensus is reached, thoroughly read and review [EIP-1], which describes the EIP process. -To create a new proposal, **copy** the [Proposal Template][template] into the -[`contents`] directory and rename it to `99999.md` (or `99999/index.md` if you -have additional assets). The template has more detailed instructions. +To create a new proposal, **copy** the [Proposal Template][template] into the [`contents`] directory and rename it to `99999.md` (or `99999/index.md` if you have additional assets). The template has more detailed instructions. -This repository is for documenting standards and not for help implementing -them. These types of inquiries should be directed to the -[Ethereum Stack Exchange][ethex]. For specific questions and concerns regarding -EIPs, it's best to comment on the relevant discussion thread of the EIP denoted -by the `discussions-to` tag in the EIP's preamble. +This repository is for documenting standards and not for help implementing them. These types of inquiries should be directed to the [Ethereum Stack Exchange][ethex]. For specific questions and concerns regarding EIPs, it's best to comment on the relevant discussion thread of the EIP denoted by the `discussions-to` tag in the EIP's preamble. If you would like to become an EIP Editor, please read [EIP-5069]. ### Validation and Auto-merging -All pull requests in this repository must pass checks before they can be -automatically merged: +All pull requests in this repository must pass checks before they can be automatically merged: - [eip-review-bot] determines when PRs can be automatically merged [^1] - EIP-1 rules are enforced using [`eipw`] [^2] - Markdown best practices are checked using [markdownlint] [^2] +- `build-eips` runs targeted proposal validation and site checks for changed proposal files [^2] -### Building Locally +Before opening or updating a pull request, run the same CI-style command locally as described in [Editorial Validation](#editorial-validation): `build-eips --staging editorial check --against-upstream --format github`. -It is possible to run the above checks and preview how proposals will render -using [`build-eips`]. See its documentation for more details. +## Local Workflows + +The EIPs repo uses the shared `build-eips` multi-repo workspace for local site builds, live previews, and editorial validation. The setup script bootstraps the surrounding workspace so, with just a few commands, you can: + +* run targeted `eipw` editorial checks for proposals before opening or updating pull requests +* build and serve the EIPs site with the local theme repo and sibling proposal repos like ERCs +* include tracked local edits without committing them first +* render only selected proposals to save time when a full site build is unnecessary +* diagnose missing workspace pieces with `build-eips doctor` + +Run the commands below from this EIPs repo. From the workspace root, use `-C EIPs` before the command. + +### Minimum Requirements + +Local workspace commands require these tools on `PATH`: + +* Git +* `build-eips` +* Zola 0.22.1 + +Git must be installed separately. The setup script locates or installs `build-eips` and Zola, adds locally installed tool directories to `PATH` for the current shell session, and prints guidance for making those `PATH` changes permanent. + +### Bootstrap The Workspace + +Run the setup script once from this repo. + +Linux and macOS: + +```sh +./scripts/dev-setup +``` + +Windows PowerShell: + +```powershell +.\scripts\dev-setup.ps1 +``` + +The setup script initializes the workspace one directory above this repo, runs `build-eips doctor`, and prints the next local commands. + +After setup, the generated workspace guide is available at `../WORKSPACE.md`. Use that file for the full command reference and workspace details. + +After setup, the workspace has this layout: + +```text +EIPs-project/ +├── .build-eips.toml +├── WORKSPACE.md +├── .local-build/ +├── EIPs/ +├── ERCs/ +└── theme/ +``` + +### Build And Serve Locally + +Build the full static site, then preview that built output: + +```bash +build-eips build +build-eips preview +``` + +`preview` serves the last output written by `build`. Run `build` again before `preview` when you want to inspect fresh output. + +Use `serve` when you want a live development server that livereloads changes instead of a reusable build output: + +```bash +build-eips serve +``` + +`serve` runs a fresh temporary site build each time it is invoked (without using `build`), starts a local development server, and watches tracked local edits. Its output cannot be reused by `preview`. + +Use `check` to quickly validate whether the site will build cleanly without producing the full built site: + +```bash +build-eips check +``` + +By default, `check`, `build`, and `serve` use the local workspace in dirty mode, which includes tracked working-tree edits from this repo. `preview` serves the last output written by `build`. Use `--clean` when you want to ignore tracked local proposal edits for one command: + +```bash +build-eips check --clean +build-eips build --clean +build-eips serve --clean +``` + +For staging, production, parity, and remote-sibling modes, see `../WORKSPACE.md`. + +### Local Settings + +Local build settings live in `../.build-eips.toml`, which the setup script generates. Use that workspace file to change the local server address or local site URL: + +```toml +[server] +host = "127.0.0.1" +port = 1111 + +[site] +base_url = "http://127.0.0.1:1111" +``` + +`serve` and `preview` use `[server]` for the local bind address. `build` and `serve` use `[site].base_url` when generating links. + +CLI flags such as `--host`, `--port`, and `--base-url` override the workspace config for one run: + +```bash +build-eips serve --host 0.0.0.0 --port 3000 --base-url http://127.0.0.1:3000 +``` + +### Render Specific Proposals Only + +Full local `build` and `serve` runs can take time because they process every proposal file. When you want to quickly test a single proposal or a specific batch, add a list of desired proposal numbers to the workspace `.build-eips.toml`: + +```toml +[render] +only = [555, 678] +``` + +Add one or more proposal numbers in `[render].only`, separated by commas. It's empty by default, but whenever it is populated, the regular `build` and `serve` commands render only those proposal pages. Links and references to excluded proposals are rewritten to the canonical public site. + +Use CLI `--only` when you want a one-run target list; it also overrides any proposals in `[render].only` for that run: + +```bash +build-eips serve --only 555 +build-eips build --only 555 +build-eips build --only 555 678 +``` + +Multiple proposal numbers in the CLI are space-separated; no commas. + +### Editorial Validation + +Use editorial commands to validate proposal files before opening or updating a pull request. + +- `editorial lint` runs targeted `eipw` proposal-rule checks. +- `editorial check` runs `editorial lint`, then checks that the selected proposal changes will not prevent the full site from building cleanly. + +Check one or more specific proposals by number: + +```bash +build-eips editorial check 1 +build-eips editorial check 1 123 +``` + +For the closest match to PR CI, use `editorial check` against the proposal files changed versus upstream: + +```bash +build-eips --staging editorial check --against-upstream --format github +``` + +Both commands accept the same selector modes: + +* proposal numbers or repo-relative proposal paths for explicit targets +* `--working-tree` for tracked dirty proposal files +* `--against-upstream` for proposal files changed versus the upstream merge-base +* `--batch ` for a repeatable target list + +They also accept `eipw` options such as `--format github`. + +Use a batch file when you want to lint or check the same proposal set repeatedly. A batch file is a plain text file with one proposal number per line: + +```txt +1 +7949 +``` + +```bash +build-eips editorial lint --batch ../editor-batch.txt +build-eips editorial check --batch ../editor-batch.txt +``` + +### Full Workspace Reference + +For remote staging/production commands, parity commands, source overrides, side-by-side build roots, and detailed dirty-mode behavior, use the generated workspace guide at `../WORKSPACE.md`. ## Preferred Citation Format -The canonical URL for an EIP that has achieved draft status at any point is at -. For example, the canonical URL for EIP-1 is -. +The canonical URL for an EIP that has achieved draft status at any point is at . For example, the canonical URL for EIP-1 is . -Consider any document not published at as a -working paper. Additionally, consider published EIPs with a status of "draft", -"review", or "last call" to be incomplete drafts, and note that their -specification is likely to be subject to change. +Consider any document not published at as a working paper. Additionally, consider published EIPs with a status of "draft", "review", or "last call" to be incomplete drafts, and note that their specification is likely to be subject to change. [^1]: [^2]: diff --git a/content/_index.md b/content/_index.md index 23de81bcf..b8823d37c 100644 --- a/content/_index.md +++ b/content/_index.md @@ -1,15 +1,29 @@ --- title: Home +extra: + homepage_badges: + - href: https://discord.gg/Nz6rtfJ8Cu + image: https://dcbadge.limes.pink/api/server/Nz6rtfJ8Cu?style=flat + alt: Badge for EIP Editor Discord channel + - href: https://discord.gg/EVTQ9crVgQ + image: https://dcbadge.limes.pink/api/server/EVTQ9crVgQ?style=flat + alt: Badge for Ethereum R&D Discord channel + - href: https://discord.gg/mRzPXmmYEA + image: https://dcbadge.limes.pink/api/server/mRzPXmmYEA?style=flat + alt: Badge for Ethereum Wallets Discord channel + - href: https://discord.gg/9FxN6CfaQR + image: https://dcbadge.limes.pink/api/server/9FxN6CfaQR?style=flat + alt: Badge for ERCRef Discord channel + - href: ./atom.xml + image: https://img.shields.io/badge/rss-Everything-red.svg + alt: RSS feed for everything + - href: ./status/last-call/atom.xml + image: https://img.shields.io/badge/rss-Last%20Calls-red.svg + alt: RSS feed for last calls --- # EIPs & ERCs -[![Badge for EIP Editor Discord channel][badge-discord-ech]][discord-ech] -[![Badge for Ethereum R&D Discord channel][badge-discord-rd]][discord-rd] -[![Badge for Ethereum Wallets Discord channel][badge-discord-wallet]][discord-wallet] -[![RSS Feed for Everything][badge-rss-all]][rss-all] -[![RSS Feed for Last Call][badge-rss-last-call]][rss-last-call] - Ethereum Improvement Proposals (EIPs) describe standards for the Ethereum @@ -107,22 +121,6 @@ Informational EIPs do not necessarily represent Ethereum community consensus or a recommendation, so users and implementers are free to ignore Informational EIPs or follow their advice. - -[discord-ech]: https://discord.gg/Nz6rtfJ8Cu -[badge-discord-ech]: https://dcbadge.limes.pink/api/server/Nz6rtfJ8Cu?style=flat - -[discord-rd]: https://discord.gg/EVTQ9crVgQ -[badge-discord-rd]: https://dcbadge.limes.pink/api/server/EVTQ9crVgQ?style=flat - -[discord-wallet]: https://discord.gg/mRzPXmmYEA -[badge-discord-wallet]: https://dcbadge.limes.pink/api/server/mRzPXmmYEA?style=flat - -[rss-all]: ./atom.xml -[badge-rss-all]: https://img.shields.io/badge/rss-Everything-red.svg - -[rss-last-call]: ./status/last-call/atom.xml -[badge-rss-last-call]: https://img.shields.io/badge/rss-Last%20Calls-red.svg - [ethpm]: https://github.com/ethereum/pm/ [template]: https://github.com/ethereum/EIPs/blob/master/docs/template.md?plain=1 diff --git a/scripts/dev-setup b/scripts/dev-setup new file mode 100755 index 000000000..37a73d3eb --- /dev/null +++ b/scripts/dev-setup @@ -0,0 +1,569 @@ +#!/bin/sh + +set -eu + +say() { + printf '%s\n' "$*" +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +normalize_dir_for_path_compare() { + dir=$1 + [ -n "$dir" ] || return 1 + + while [ "$dir" != "/" ] && [ "${dir%/}" != "$dir" ]; do + dir=${dir%/} + done + + case "$dir" in + /*) + absolute_dir=$dir + ;; + *) + absolute_dir=$(pwd -P)/$dir + ;; + esac + + if [ -d "$absolute_dir" ]; then + (CDPATH= cd -P -- "$absolute_dir" 2>/dev/null && pwd -P) + else + printf '%s\n' "$absolute_dir" + fi +} + +path_contains_dir() { + target=$(normalize_dir_for_path_compare "$1") || return 1 + old_ifs=$IFS + IFS=: + for path_entry in ${PATH:-}; do + [ -n "$path_entry" ] || continue + candidate=$(normalize_dir_for_path_compare "$path_entry") || continue + if [ "$candidate" = "$target" ]; then + IFS=$old_ifs + return 0 + fi + done + IFS=$old_ifs + return 1 +} + +move_dir_to_front_of_script_path() { + dir=$1 + target=$(normalize_dir_for_path_compare "$dir") || return 1 + new_path= + old_ifs=$IFS + IFS=: + for path_entry in ${PATH:-}; do + [ -n "$path_entry" ] || continue + candidate=$(normalize_dir_for_path_compare "$path_entry") || continue + if [ "$candidate" = "$target" ]; then + continue + fi + + if [ -n "$new_path" ]; then + new_path=$new_path:$path_entry + else + new_path=$path_entry + fi + done + IFS=$old_ifs + + if [ -n "$new_path" ]; then + updated_path=$dir:$new_path + else + updated_path=$dir + fi + + if [ "${PATH:-}" != "$updated_path" ]; then + PATH=$updated_path + add_path_note "$dir" + fi +} + +add_path_note() { + dir=$1 + target=$(normalize_dir_for_path_compare "$dir") || return 1 + old_ifs=$IFS + IFS=' +' + for path_note in $PATH_NOTES; do + candidate=$(normalize_dir_for_path_compare "$path_note") || continue + if [ "$candidate" = "$target" ]; then + IFS=$old_ifs + return 0 + fi + done + IFS=$old_ifs + + case " +$PATH_NOTES +" in + *" +$dir +"*) + return 0 + ;; + esac + + if [ -n "$PATH_NOTES" ]; then + PATH_NOTES=$PATH_NOTES' +'$dir + else + PATH_NOTES=$dir + fi +} + +pick_home_install_dir() { + [ -n "${HOME:-}" ] || return 1 + dir=$HOME/.local/bin + mkdir -p "$dir" 2>/dev/null || return 1 + [ -d "$dir" ] || return 1 + [ -w "$dir" ] || return 1 + printf '%s\n' "$dir" +} + +pick_writable_path_dir() { + old_ifs=$IFS + IFS=: + for dir in ${PATH:-}; do + [ -n "$dir" ] || continue + [ -d "$dir" ] || continue + [ -w "$dir" ] || continue + printf '%s\n' "$dir" + IFS=$old_ifs + return 0 + done + IFS=$old_ifs + return 1 +} + +pick_install_dir() { + if dir=$(pick_home_install_dir); then + printf '%s\n' "$dir" + return 0 + fi + + pick_writable_path_dir +} + +download() { + url=$1 + destination=$2 + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$destination" + return 0 + fi + + if command -v wget >/dev/null 2>&1; then + wget -qO "$destination" "$url" + return 0 + fi + + die "need curl or wget to download release binaries" +} + +file_sha256() { + path=$1 + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{ print $1 }' | tr 'A-F' 'a-f' + return 0 + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{ print $1 }' | tr 'A-F' 'a-f' + return 0 + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 -r "$path" | awk '{ print $1 }' | tr 'A-F' 'a-f' + return 0 + fi + + die "need sha256sum, shasum, or openssl to verify release checksums" +} + +normalized_sidecar_text() { + sidecar_path=$1 + bom=$(printf '\357\273\277') + cr=$(printf '\r') + first_three=$(dd if="$sidecar_path" bs=3 count=1 2>/dev/null || true) + + if [ "$first_three" = "$bom" ]; then + dd if="$sidecar_path" bs=3 skip=1 2>/dev/null | tr -d "$cr" + else + tr -d "$cr" < "$sidecar_path" + fi +} + +verify_archive_checksum() { + archive_path=$1 + sidecar_path=$2 + archive_name=$3 + + [ -f "$sidecar_path" ] || die "missing checksum sidecar: $sidecar_path" + + checksum_lines=$(normalized_sidecar_text "$sidecar_path" | sed '/^[[:space:]]*$/d') + checksum_line_count=$(printf '%s\n' "$checksum_lines" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ') + [ "$checksum_line_count" = "1" ] || die "checksum sidecar must contain exactly one checksum line" + + set -f + set -- $checksum_lines + set +f + [ "$#" -eq 2 ] || die "checksum sidecar must contain only a hash and archive filename" + + expected_hash=$(printf '%s' "$1" | tr 'A-F' 'a-f') + expected_name=$2 + + [ ${#expected_hash} -eq 64 ] || die "checksum sidecar hash must be 64 hex characters" + case "$expected_hash" in + *[!0123456789abcdef]*) + die "checksum sidecar hash must be hex" + ;; + esac + case "$expected_name" in + */*|*\\*) + die "checksum sidecar filename must be a basename" + ;; + esac + [ "$expected_name" = "$archive_name" ] || die "checksum sidecar filename '$expected_name' does not match '$archive_name'" + + actual_hash=$(file_sha256 "$archive_path") + [ "$actual_hash" = "$expected_hash" ] || die "checksum mismatch for $archive_name" +} + +verify_file_hash() { + archive_path=$1 + expected_hash=$2 + archive_name=$3 + + actual_hash=$(file_sha256 "$archive_path") + [ "$actual_hash" = "$expected_hash" ] || die "checksum mismatch for $archive_name" +} + +cleanup_tmpdir() { + if [ -n "${INSTALL_TMP_TO_CLEAN:-}" ]; then + rm -f "$INSTALL_TMP_TO_CLEAN" + INSTALL_TMP_TO_CLEAN= + fi + if [ -n "${TMPDIR_TO_CLEAN:-}" ]; then + rm -rf "$TMPDIR_TO_CLEAN" + TMPDIR_TO_CLEAN= + fi +} + +cleanup_for_signal() { + cleanup_tmpdir + trap - EXIT INT TERM HUP QUIT + exit 1 +} + +set_cleanup_traps() { + trap cleanup_tmpdir EXIT + trap cleanup_for_signal INT TERM HUP QUIT +} + +clear_cleanup_traps() { + trap - EXIT INT TERM HUP QUIT +} + +ensure_install_dir() { + [ -n "${INSTALL_DIR:-}" ] && return 0 + + INSTALL_DIR=$(pick_install_dir) || die "could not find a writable install directory" + move_dir_to_front_of_script_path "$INSTALL_DIR" +} + +install_build_eips() { + case "$(uname -s)" in + Linux) + archive_name=build-eips-ubuntu.tar.xz + ;; + Darwin) + archive_name=build-eips-macos.tar.xz + ;; + *) + die "unsupported operating system for automatic build-eips installation" + ;; + esac + + command -v tar >/dev/null 2>&1 || die "need tar to unpack the build-eips release archive" + ensure_install_dir + + tmpdir=$(mktemp -d) + TMPDIR_TO_CLEAN=$tmpdir + set_cleanup_traps + + archive_path=$tmpdir/$archive_name + sidecar_path=$tmpdir/$archive_name.sha256 + release_base_url=https://github.com/eips-wg/preprocessor/releases/latest/download + + say "Installing build-eips from $release_base_url/$archive_name" + download "$release_base_url/$archive_name" "$archive_path" + download "$release_base_url/$archive_name.sha256" "$sidecar_path" + verify_archive_checksum "$archive_path" "$sidecar_path" "$archive_name" + + tar -xf "$archive_path" -C "$tmpdir" + [ -f "$tmpdir/build-eips" ] || die "release archive did not contain build-eips" + + install_tmp=$(mktemp "$INSTALL_DIR/.build-eips.XXXXXX") || die "could not create temporary install file in $INSTALL_DIR" + INSTALL_TMP_TO_CLEAN=$install_tmp + cp "$tmpdir/build-eips" "$install_tmp" + chmod +x "$install_tmp" + mv "$install_tmp" "$INSTALL_DIR/build-eips" + INSTALL_TMP_TO_CLEAN= + + BUILD_EIPS=$INSTALL_DIR/build-eips + cleanup_tmpdir + clear_cleanup_traps +} + +unsupported_zola_platform() { + os=$1 + arch=$2 + die "Unsupported platform $os/$arch for automatic Zola install. Install Zola 0.22.1 manually from https://github.com/getzola/zola/releases and ensure it is on PATH." +} + +is_linux_musl() { + if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then + return 0 + fi + + set -- /lib/ld-musl-*.so.1 + [ -e "$1" ] +} + +select_zola_release() { + os=$(uname -s) + arch=$(uname -m) + + case "$os" in + Linux) + if is_linux_musl; then + case "$arch" in + x86_64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-x86_64-unknown-linux-musl.tar.gz + ZOLA_ARCHIVE_HASH=227df99b664421240a8ba77747067c51259b08159125d5603763b3b173b9a881 + ;; + *) + unsupported_zola_platform "$os" "$arch" + ;; + esac + else + case "$arch" in + x86_64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-x86_64-unknown-linux-gnu.tar.gz + ZOLA_ARCHIVE_HASH=0ca09aa40376aaa9ddfb512ff9ad963262ef95edb0d0f2d5ec6961b6f5cf22ef + ;; + aarch64|arm64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-aarch64-unknown-linux-gnu.tar.gz + ZOLA_ARCHIVE_HASH=8af437ec6352f33ccd24d7a1cfcb54a3db95d3ce376dc69525b4ef3fb6b8c1d1 + ;; + *) + unsupported_zola_platform "$os" "$arch" + ;; + esac + fi + ;; + Darwin) + case "$arch" in + x86_64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-x86_64-apple-darwin.tar.gz + ZOLA_ARCHIVE_HASH=3898709e154ae0593933264a540c869348bdb10d7f1b03a42dfb78d63703b3b5 + ;; + arm64|aarch64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-aarch64-apple-darwin.tar.gz + ZOLA_ARCHIVE_HASH=46ac45a9e7628dba8593b124ee8794f4f9aa1c6b569918ecd4bbc5d0be190515 + ;; + *) + unsupported_zola_platform "$os" "$arch" + ;; + esac + ;; + *) + unsupported_zola_platform "$os" "$arch" + ;; + esac +} + +parse_zola_version() { + output=$1 + + set -f + set -- $output + set +f + [ "$#" -ge 2 ] || return 1 + + ZOLA_FOUND_VERSION=$2 + version_core=$(printf '%s\n' "$ZOLA_FOUND_VERSION" | sed -n 's/^\([0-9][0-9]*\)\.\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2 \3/p') + [ -n "$version_core" ] || return 1 + + set -- $version_core + ZOLA_VERSION_MAJOR=$1 + ZOLA_VERSION_MINOR=$2 + ZOLA_VERSION_PATCH=$3 + ZOLA_VERSION_SUFFIX=$(printf '%s\n' "$ZOLA_FOUND_VERSION" | sed -n 's/^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*//p') + + return 0 +} + +zola_version_relation() { + if [ "$ZOLA_VERSION_MAJOR" -lt 0 ]; then + printf '%s\n' below + elif [ "$ZOLA_VERSION_MAJOR" -gt 0 ]; then + printf '%s\n' newer + elif [ "$ZOLA_VERSION_MINOR" -lt 22 ]; then + printf '%s\n' below + elif [ "$ZOLA_VERSION_MINOR" -gt 22 ]; then + printf '%s\n' newer + elif [ "$ZOLA_VERSION_PATCH" -lt 1 ]; then + printf '%s\n' below + elif [ "$ZOLA_VERSION_PATCH" -gt 1 ]; then + printf '%s\n' newer + elif [ -n "$ZOLA_VERSION_SUFFIX" ]; then + printf '%s\n' below + else + printf '%s\n' equal + fi +} + +install_zola() { + command -v tar >/dev/null 2>&1 || die "need tar to unpack the zola release archive" + select_zola_release + ensure_install_dir + + tmpdir=$(mktemp -d) + TMPDIR_TO_CLEAN=$tmpdir + set_cleanup_traps + + archive_path=$tmpdir/$ZOLA_ARCHIVE_NAME + release_base_url=https://github.com/getzola/zola/releases/download/v0.22.1 + + say "Installing zola 0.22.1 from $release_base_url/$ZOLA_ARCHIVE_NAME" + download "$release_base_url/$ZOLA_ARCHIVE_NAME" "$archive_path" + verify_file_hash "$archive_path" "$ZOLA_ARCHIVE_HASH" "$ZOLA_ARCHIVE_NAME" + + tar -xzf "$archive_path" -C "$tmpdir" + [ -f "$tmpdir/zola" ] || die "zola release archive did not contain zola" + + install_tmp=$(mktemp "$INSTALL_DIR/.zola.XXXXXX") || die "could not create temporary zola install file in $INSTALL_DIR" + INSTALL_TMP_TO_CLEAN=$install_tmp + cp "$tmpdir/zola" "$install_tmp" + chmod +x "$install_tmp" + mv "$install_tmp" "$INSTALL_DIR/zola" + INSTALL_TMP_TO_CLEAN= + + ZOLA=$INSTALL_DIR/zola + cleanup_tmpdir + clear_cleanup_traps +} + +ensure_zola() { + if command -v zola >/dev/null 2>&1; then + ZOLA=$(command -v zola) + elif [ -n "${HOME:-}" ] && [ -x "$HOME/.local/bin/zola" ]; then + INSTALL_DIR=$HOME/.local/bin + ZOLA=$INSTALL_DIR/zola + move_dir_to_front_of_script_path "$INSTALL_DIR" + else + install_zola + return 0 + fi + + if [ -n "${ZOLA:-}" ]; then + zola_output=$("$ZOLA" --version 2>/dev/null || true) + if parse_zola_version "$zola_output"; then + relation=$(zola_version_relation) + case "$relation" in + below) + say "Found zola $ZOLA_FOUND_VERSION below supported 0.22.1. Installing zola 0.22.1." + install_zola + ;; + equal) + say "Using existing zola $ZOLA_FOUND_VERSION at $ZOLA" + ;; + newer) + say "Found zola $ZOLA_FOUND_VERSION. build-eips is tested with zola 0.22.1 or newer. Continuing with the installed version." + ;; + esac + else + say "Found zola with unparseable version output. Installing zola 0.22.1." + install_zola + fi + fi +} + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +WORKSPACE_ROOT=$(CDPATH= cd -- "$REPO_ROOT/.." && pwd) +INSTALL_DIR= +PATH_NOTES= +TMPDIR_TO_CLEAN= +INSTALL_TMP_TO_CLEAN= + +if command -v build-eips >/dev/null 2>&1; then + BUILD_EIPS=$(command -v build-eips) + say "Using existing build-eips at $BUILD_EIPS" +elif [ -n "${HOME:-}" ] && [ -x "$HOME/.local/bin/build-eips" ]; then + BUILD_EIPS=$HOME/.local/bin/build-eips + INSTALL_DIR=$HOME/.local/bin + move_dir_to_front_of_script_path "$INSTALL_DIR" + say "Using existing build-eips at $BUILD_EIPS" +else + install_build_eips +fi + +ensure_zola + +say "Active proposal repo: $REPO_ROOT" +say "Workspace root: $WORKSPACE_ROOT" + +say "Bootstrapping workspace at $WORKSPACE_ROOT" +if ! "$BUILD_EIPS" -C "$REPO_ROOT" init "$WORKSPACE_ROOT" --template --platform-dev; then + die "build-eips init failed" +fi + +say "Running build-eips doctor" +if ! "$BUILD_EIPS" -C "$REPO_ROOT" doctor; then + say "Warning: build-eips doctor reported issues above. Fix them before relying on direct build-eips commands." +fi + +WORKSPACE_DOC=$WORKSPACE_ROOT/WORKSPACE.md +say "" +if [ -f "$WORKSPACE_DOC" ]; then + say "Workspace docs: $WORKSPACE_DOC (../WORKSPACE.md from this repo)" +else + say "Warning: workspace docs were not found at $WORKSPACE_DOC after build-eips init" +fi + +if [ -n "$PATH_NOTES" ]; then + say "" + say "Updated PATH for this script process only:" + old_ifs=$IFS + IFS=' +' + for path_note in $PATH_NOTES; do + say " $path_note" + done + say "To make this permanent in your shell, add:" + for path_note in $PATH_NOTES; do + say " export PATH=$(shell_quote "$path_note"):\$PATH" + done + IFS=$old_ifs +fi + +say "" +say "Next commands:" +say " cd $(shell_quote "$REPO_ROOT")" +say " build-eips serve" +say " build-eips check" +say " build-eips doctor" diff --git a/scripts/dev-setup.ps1 b/scripts/dev-setup.ps1 new file mode 100644 index 000000000..cf87c14ec --- /dev/null +++ b/scripts/dev-setup.ps1 @@ -0,0 +1,517 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Say { + param([string]$Message) + + Write-Host $Message +} + +function Die { + param([string]$Message) + + throw "error: $Message" +} + +function ConvertTo-NormalizedDirectoryPath { + param([string]$Directory) + + $trimmed = $Directory.Trim('"') + $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + + try { + return ([System.IO.Path]::GetFullPath($trimmed)).TrimEnd($trimChars) + } catch { + return $trimmed.TrimEnd($trimChars) + } +} + +function Test-DirectoryOnPath { + param([string]$Directory) + + if ([string]::IsNullOrWhiteSpace($env:Path)) { + return $false + } + + $target = ConvertTo-NormalizedDirectoryPath -Directory $Directory + foreach ($entry in ($env:Path -split ";")) { + if ([string]::IsNullOrWhiteSpace($entry)) { + continue + } + + $candidate = ConvertTo-NormalizedDirectoryPath -Directory $entry + if ([string]::Equals($candidate, $target, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + + return $false +} + +function Add-PathNote { + param([string]$InstallDir) + + $target = ConvertTo-NormalizedDirectoryPath -Directory $InstallDir + foreach ($pathNote in $script:PathNotes) { + $candidate = ConvertTo-NormalizedDirectoryPath -Directory $pathNote + if ([string]::Equals($candidate, $target, [System.StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + $script:PathNotes += $InstallDir +} + +function Move-DirectoryToFrontOfSessionPath { + param([string]$InstallDir) + + $target = ConvertTo-NormalizedDirectoryPath -Directory $InstallDir + $remainingEntries = @() + + if (-not [string]::IsNullOrWhiteSpace($env:Path)) { + foreach ($entry in ($env:Path -split ";")) { + if ([string]::IsNullOrWhiteSpace($entry)) { + continue + } + + $candidate = ConvertTo-NormalizedDirectoryPath -Directory $entry + if ([string]::Equals($candidate, $target, [System.StringComparison]::OrdinalIgnoreCase)) { + continue + } + + $remainingEntries += $entry + } + } + + $newEntries = @($InstallDir) + if ($remainingEntries.Count -gt 0) { + $newEntries += $remainingEntries + } + + $updatedPath = [string]::Join(";", $newEntries) + if (-not [string]::Equals($env:Path, $updatedPath, [System.StringComparison]::Ordinal)) { + $env:Path = $updatedPath + + Add-PathNote -InstallDir $InstallDir + } +} + +function Find-BuildEipsOnPath { + foreach ($commandName in @("build-eips", "build-eips.exe")) { + $commands = @(Get-Command -Name $commandName -CommandType Application -ErrorAction SilentlyContinue) + if ($commands.Count -gt 0) { + return $commands[0].Source + } + } + + return $null +} + +function Find-ZolaOnPath { + foreach ($commandName in @("zola", "zola.exe")) { + $commands = @(Get-Command -Name $commandName -CommandType Application -ErrorAction SilentlyContinue) + if ($commands.Count -gt 0) { + return $commands[0].Source + } + } + + return $null +} + +function Assert-InstallDirWritable { + param([string]$InstallDir) + + $probePath = $null + + try { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + $probeName = ".build-eips-write-test-{0}.tmp" -f ([System.Guid]::NewGuid().ToString("N")) + $probePath = Join-Path -Path $InstallDir -ChildPath $probeName + [System.IO.File]::WriteAllText($probePath, "") + Remove-Item -LiteralPath $probePath -Force + $probePath = $null + } catch { + Die ("install directory cannot be created or written ({0}): {1}" -f $InstallDir, $_.Exception.Message) + } finally { + if (($null -ne $probePath) -and (Test-Path -LiteralPath $probePath)) { + Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue + } + } +} + +function Invoke-ReleaseDownload { + param( + [string]$Url, + [string]$Destination + ) + + $previousProgressPreference = $ProgressPreference + $ProgressPreference = "SilentlyContinue" + try { + Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing + } catch { + Die ("failed to download {0}: {1}" -f $Url, $_.Exception.Message) + } finally { + $ProgressPreference = $previousProgressPreference + } +} + +function Test-AsciiHexHash { + param([string]$Hash) + + return $Hash -match "^[0-9a-fA-F]{64}$" +} + +function Assert-ArchiveChecksum { + param( + [string]$ArchivePath, + [string]$SidecarPath, + [string]$ArchiveName + ) + + if (-not (Test-Path -LiteralPath $SidecarPath -PathType Leaf)) { + Die "missing checksum sidecar: $SidecarPath" + } + + $sidecarText = [System.IO.File]::ReadAllText($SidecarPath) + $checksumLines = @($sidecarText -split "\r?\n" | Where-Object { $_.Trim().Length -gt 0 }) + if ($checksumLines.Count -ne 1) { + Die "checksum sidecar must contain exactly one checksum line" + } + + $fields = @($checksumLines[0].Trim() -split "\s+") + if ($fields.Count -ne 2) { + Die "checksum sidecar must contain only a hash and archive filename" + } + + $expectedHash = $fields[0].ToLowerInvariant() + $expectedName = $fields[1] + + if (-not (Test-AsciiHexHash -Hash $expectedHash)) { + Die "checksum sidecar hash must be 64 hex characters" + } + if ($expectedName -match '[/\\]') { + Die "checksum sidecar filename must be a basename" + } + if ($expectedName -ne $ArchiveName) { + Die ("checksum sidecar filename '{0}' does not match '{1}'" -f $expectedName, $ArchiveName) + } + + $actualHash = (Get-FileHash -Algorithm SHA256 -Path $ArchivePath).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + Die "checksum mismatch for $ArchiveName" + } +} + +function Assert-FileSha256 { + param( + [string]$ArchivePath, + [string]$ExpectedHash, + [string]$ArchiveName + ) + + $actualHash = (Get-FileHash -Algorithm SHA256 -Path $ArchivePath).Hash.ToLowerInvariant() + if ($actualHash -ne $ExpectedHash.ToLowerInvariant()) { + Die "checksum mismatch for $ArchiveName" + } +} + +function Install-BuildEips { + param( + [string]$InstallDir, + [string]$BuildEipsPath + ) + + $archiveName = "build-eips-windows.zip" + $releaseBaseUrl = "https://github.com/eips-wg/preprocessor/releases/latest/download" + $tmpRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("build-eips-" + [System.Guid]::NewGuid().ToString("N")) + $archivePath = Join-Path -Path $tmpRoot -ChildPath $archiveName + $sidecarPath = Join-Path -Path $tmpRoot -ChildPath "$archiveName.sha256" + $extractDir = Join-Path -Path $tmpRoot -ChildPath "extract" + + try { + Assert-InstallDirWritable -InstallDir $InstallDir + + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + Say "Installing build-eips from $releaseBaseUrl/$archiveName" + Invoke-ReleaseDownload -Url "$releaseBaseUrl/$archiveName" -Destination $archivePath + Invoke-ReleaseDownload -Url "$releaseBaseUrl/$archiveName.sha256" -Destination $sidecarPath + Assert-ArchiveChecksum -ArchivePath $archivePath -SidecarPath $sidecarPath -ArchiveName $archiveName + + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDir -Force + + $extractedBuildEips = Join-Path -Path $extractDir -ChildPath "build-eips.exe" + if (-not (Test-Path -LiteralPath $extractedBuildEips -PathType Leaf)) { + Die "release archive did not contain expected build-eips.exe" + } + + try { + Move-Item -LiteralPath $extractedBuildEips -Destination $BuildEipsPath -Force + } catch { + Die ("build-eips.exe is in use. Close any running build-eips process and re-run this script. Details: {0}" -f $_.Exception.Message) + } + + return $BuildEipsPath + } catch { + Die ("failed to install build-eips: {0}" -f $_.Exception.Message) + } finally { + if (Test-Path -LiteralPath $tmpRoot) { + Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +function Get-ZolaReleaseAsset { + $architecture = $env:PROCESSOR_ARCHITECTURE + if ([string]::IsNullOrWhiteSpace($architecture)) { + $architecture = "unknown" + } + if (($architecture -eq "x86") -and (-not [string]::IsNullOrWhiteSpace($env:PROCESSOR_ARCHITEW6432))) { + $architecture = $env:PROCESSOR_ARCHITEW6432 + } + + switch ($architecture.ToUpperInvariant()) { + "AMD64" { + return @{ + ArchiveName = "zola-v0.22.1-x86_64-pc-windows-msvc.zip" + Hash = "2c8b368f5abdf2b2478748f9549a761fd6599238e18948eccb76a7cae51f5dc1" + } + } + default { + Die "Unsupported platform Windows/$architecture for automatic Zola install. Install Zola 0.22.1 manually from https://github.com/getzola/zola/releases and ensure it is on PATH." + } + } +} + +function Install-Zola { + param( + [string]$InstallDir, + [string]$ZolaPath + ) + + $asset = Get-ZolaReleaseAsset + $archiveName = $asset.ArchiveName + $archiveHash = $asset.Hash + $releaseBaseUrl = "https://github.com/getzola/zola/releases/download/v0.22.1" + $tmpRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("zola-" + [System.Guid]::NewGuid().ToString("N")) + $archivePath = Join-Path -Path $tmpRoot -ChildPath $archiveName + $extractDir = Join-Path -Path $tmpRoot -ChildPath "extract" + + try { + Assert-InstallDirWritable -InstallDir $InstallDir + + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + Say "Installing zola 0.22.1 from $releaseBaseUrl/$archiveName" + Invoke-ReleaseDownload -Url "$releaseBaseUrl/$archiveName" -Destination $archivePath + Assert-FileSha256 -ArchivePath $archivePath -ExpectedHash $archiveHash -ArchiveName $archiveName + + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDir -Force + + $extractedZola = Join-Path -Path $extractDir -ChildPath "zola.exe" + if (-not (Test-Path -LiteralPath $extractedZola -PathType Leaf)) { + Die "zola release archive did not contain expected zola.exe" + } + + try { + Move-Item -LiteralPath $extractedZola -Destination $ZolaPath -Force + } catch { + Die ("zola.exe is in use. Close any running zola process and re-run this script. Details: {0}" -f $_.Exception.Message) + } + + return $ZolaPath + } catch { + Die ("failed to install zola: {0}" -f $_.Exception.Message) + } finally { + if (Test-Path -LiteralPath $tmpRoot) { + Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +function Get-ZolaVersionInfo { + param([string]$ZolaPath) + + try { + $output = & $ZolaPath --version 2>$null + if ($LASTEXITCODE -ne 0) { + return $null + } + } catch { + return $null + } + + $fields = @(($output -join " ") -split "\s+" | Where-Object { $_.Length -gt 0 }) + if ($fields.Count -lt 2) { + return $null + } + + $versionToken = $fields[1] + if ($versionToken -notmatch "^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$") { + return $null + } + + return @{ + VersionToken = $versionToken + Version = [version]("{0}.{1}.{2}" -f $Matches[1], $Matches[2], $Matches[3]) + Suffix = $Matches[4] + } +} + +function Get-ZolaVersionRelation { + param([hashtable]$VersionInfo) + + $minimumVersion = [version]"0.22.1" + if ($VersionInfo.Version -lt $minimumVersion) { + return "below" + } + if ($VersionInfo.Version -gt $minimumVersion) { + return "newer" + } + if (-not [string]::IsNullOrEmpty($VersionInfo.Suffix)) { + return "below" + } + + return "equal" +} + +function Install-PinnedZola { + $defaultPaths = Get-DefaultInstallPaths + $installedZola = Install-Zola -InstallDir $defaultPaths.InstallDir -ZolaPath $defaultPaths.ZolaPath + Move-DirectoryToFrontOfSessionPath -InstallDir $defaultPaths.InstallDir + + return $installedZola +} + +function Initialize-Zola { + $zolaPath = Find-ZolaOnPath + if ($null -eq $zolaPath) { + $defaultPaths = Get-DefaultInstallPaths + if (Test-Path -LiteralPath $defaultPaths.ZolaPath -PathType Leaf) { + $zolaPath = $defaultPaths.ZolaPath + Move-DirectoryToFrontOfSessionPath -InstallDir $defaultPaths.InstallDir + } + } + + if ($null -eq $zolaPath) { + return (Install-PinnedZola) + } + + $versionInfo = Get-ZolaVersionInfo -ZolaPath $zolaPath + if ($null -eq $versionInfo) { + Say "Found zola with unparseable version output. Installing zola 0.22.1." + return (Install-PinnedZola) + } + + $relation = Get-ZolaVersionRelation -VersionInfo $versionInfo + switch ($relation) { + "below" { + Say ("Found zola {0} below supported 0.22.1. Installing zola 0.22.1." -f $versionInfo.VersionToken) + return (Install-PinnedZola) + } + "equal" { + Say ("Using existing zola {0} at {1}" -f $versionInfo.VersionToken, $zolaPath) + return $zolaPath + } + "newer" { + Say ("Found zola {0}. build-eips is tested with zola 0.22.1 or newer. Continuing with the installed version." -f $versionInfo.VersionToken) + return $zolaPath + } + } +} + +function ConvertTo-PowerShellQuotedPath { + param([string]$Path) + + return "'{0}'" -f ($Path -replace "'", "''") +} + +function Get-DefaultInstallPaths { + if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + Die "LOCALAPPDATA is not set; cannot determine the user-local install directory" + } + + $installDir = Join-Path -Path (Join-Path -Path $env:LOCALAPPDATA -ChildPath "build-eips") -ChildPath "bin" + $buildEipsPath = Join-Path -Path $installDir -ChildPath "build-eips.exe" + $zolaPath = Join-Path -Path $installDir -ChildPath "zola.exe" + + return @{ + InstallDir = $installDir + BuildEipsPath = $buildEipsPath + ZolaPath = $zolaPath + } +} + +$PathNotes = @() + +$ScriptDir = (Resolve-Path -LiteralPath $PSScriptRoot).ProviderPath +$RepoRoot = (Resolve-Path -LiteralPath (Split-Path -Path $ScriptDir -Parent)).ProviderPath +$WorkspaceRoot = (Resolve-Path -LiteralPath (Split-Path -Path $RepoRoot -Parent)).ProviderPath + +$BuildEipsPath = Find-BuildEipsOnPath +if ($null -ne $BuildEipsPath) { + Say "Using existing build-eips at $BuildEipsPath" +} else { + $defaultPaths = Get-DefaultInstallPaths + $DefaultInstallDir = $defaultPaths.InstallDir + $DefaultBuildEipsPath = $defaultPaths.BuildEipsPath + + if (Test-Path -LiteralPath $DefaultBuildEipsPath -PathType Leaf) { + $BuildEipsPath = $DefaultBuildEipsPath + Move-DirectoryToFrontOfSessionPath -InstallDir $DefaultInstallDir + Say "Using existing build-eips at $BuildEipsPath" + } else { + $BuildEipsPath = Install-BuildEips -InstallDir $DefaultInstallDir -BuildEipsPath $DefaultBuildEipsPath + Move-DirectoryToFrontOfSessionPath -InstallDir $DefaultInstallDir + } +} + +$ZolaPath = Initialize-Zola + +Say "Active proposal repo: $RepoRoot" +Say "Workspace root: $WorkspaceRoot" +Say "If PowerShell blocks this script, run:" +Say " powershell -ExecutionPolicy Bypass -File .\scripts\dev-setup.ps1" + +Say "Bootstrapping workspace at $WorkspaceRoot" +& $BuildEipsPath -C $RepoRoot init $WorkspaceRoot --template --platform-dev +$WorkspaceInitExitCode = $LASTEXITCODE +if ($WorkspaceInitExitCode -ne 0) { + Die "build-eips init failed with exit code $WorkspaceInitExitCode" +} + +Say "Running build-eips doctor" +& $BuildEipsPath -C $RepoRoot doctor +$WorkspaceDoctorExitCode = $LASTEXITCODE +if ($WorkspaceDoctorExitCode -ne 0) { + Say "Warning: build-eips doctor reported issues above. Fix them before relying on direct build-eips commands." +} + +$WorkspaceDocPath = Join-Path -Path $WorkspaceRoot -ChildPath "WORKSPACE.md" +Say "" +if (Test-Path -LiteralPath $WorkspaceDocPath -PathType Leaf) { + Say "Workspace docs: $WorkspaceDocPath (../WORKSPACE.md from this repo)" +} else { + Say "Warning: workspace docs were not found at $WorkspaceDocPath after build-eips init" +} + +if ($PathNotes.Count -gt 0) { + Say "" + Say 'Updated PATH for this PowerShell session only:' + foreach ($pathNote in $PathNotes) { + Say " $pathNote" + } + Say "To make this permanent, add the listed directory or directories to your user Path in Windows Environment Variables." +} + +Say "" +Say "Next commands:" +Say (" cd {0}" -f (ConvertTo-PowerShellQuotedPath -Path $RepoRoot)) +Say " build-eips serve" +Say " build-eips check" +Say " build-eips doctor" From beb2478f73d41f2e12a277a0ac53610d76dcf7a0 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Fri, 8 May 2026 08:11:59 -0400 Subject: [PATCH 2/2] submit sitemap after publish Add post-deploy Google Search Console and IndexNow sitemap notifications to the Pages publishing workflow. The submit script retries each enabled provider once and defaults to a non-blocking retry/warn failure policy so deploys remain green unless strict mode is explicitly enabled. Gate activation behind the ENABLE_SITEMAP_SUBMIT feature flag and require repo or org variables/secrets for activation: GOOGLE_WORKLOAD_IDENTITY_PROVIDER, GOOGLE_SERVICE_ACCOUNT, optional GOOGLE_SEARCH_CONSOLE_SITE_URL, and INDEXNOW_KEY. --- .github/workflows/publish.yml | 57 ++++++ scripts/submit-sitemap.py | 350 ++++++++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 scripts/submit-sitemap.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8bf406b88..f1e061e97 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,6 +22,26 @@ jobs: with: args: build + # Sitemap submission is production-only; do not publish IndexNow keys + # from the eips-wg staging repository even though it can build Pages. + - name: Add IndexNow Key File + if: github.repository == 'ethereum/EIPs' && vars.ENABLE_SITEMAP_SUBMIT == '1' + env: + ARTIFACT_DIR: build/output + INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }} + run: | + if [ -z "$INDEXNOW_KEY" ]; then + echo "::warning title=IndexNow key missing::INDEXNOW_KEY is not configured; skipping key file generation" + exit 0 + fi + + if [[ ! "$INDEXNOW_KEY" =~ ^[A-Za-z0-9-]{8,128}$ ]]; then + echo "::warning title=Invalid IndexNow key::INDEXNOW_KEY must match ^[A-Za-z0-9-]{8,128}$; skipping key file generation" + exit 0 + fi + + printf '%s' "$INDEXNOW_KEY" > "${ARTIFACT_DIR}/${INDEXNOW_KEY}.txt" + - name: Upload Artifact id: artifact uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # 3.0.1 @@ -43,3 +63,40 @@ jobs: - name: Publish Pages id: deployment uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # 4.0.5 + + submit-sitemap: + # Sitemap submission notifies production search providers only. + if: github.repository == 'ethereum/EIPs' && vars.ENABLE_SITEMAP_SUBMIT == '1' + name: Submit Sitemap + runs-on: ubuntu-latest + needs: publish + concurrency: + group: sitemap-submit-production + cancel-in-progress: false + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + + - name: Authenticate to Google + id: google-auth + if: vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER != '' && vars.GOOGLE_SERVICE_ACCOUNT != '' + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + token_format: access_token + workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GOOGLE_SERVICE_ACCOUNT }} + access_token_scopes: https://www.googleapis.com/auth/webmasters + + - name: Submit Sitemap + env: + SITE_URL: https://eips.ethereum.org/ + GOOGLE_ACCESS_TOKEN: ${{ steps.google-auth.outputs.access_token }} + GOOGLE_SEARCH_CONSOLE_SITE_URL: ${{ vars.GOOGLE_SEARCH_CONSOLE_SITE_URL }} + INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }} + INDEXNOW_ENDPOINT: https://api.indexnow.org/indexnow + HTTP_TIMEOUT_SECONDS: "15" + SITEMAP_SUBMIT_STRICT: "0" + run: python3 scripts/submit-sitemap.py diff --git a/scripts/submit-sitemap.py b/scripts/submit-sitemap.py new file mode 100644 index 000000000..c45e3a576 --- /dev/null +++ b/scripts/submit-sitemap.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Submit the production sitemap to configured search providers. + +Configuration comes from environment variables: + +- SITE_URL: required production site URL. +- SITEMAP_URL: optional, defaults to {SITE_URL.rstrip('/')}/sitemap.xml. +- GOOGLE_ACCESS_TOKEN: optional Google access token; enables Google submission. +- GOOGLE_SEARCH_CONSOLE_SITE_URL: optional, defaults to SITE_URL. +- INDEXNOW_KEY: optional IndexNow key; enables IndexNow when valid. +- INDEXNOW_ENDPOINT: optional, defaults to https://api.indexnow.org/indexnow. +- DRY_RUN: set to 1 to print intended requests without making external calls. +- SITEMAP_SUBMIT_STRICT: set to 1 to exit nonzero after enabled provider failures. +- HTTP_TIMEOUT_SECONDS: optional positive integer, defaults to 15. +""" + +from __future__ import annotations + +import json +import os +import re +import socket +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Callable + + +DEFAULT_INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow" +DEFAULT_TIMEOUT_SECONDS = 15 +INDEXNOW_KEY_PATTERN = re.compile(r"[A-Za-z0-9-]{8,128}") +RETRY_DELAY_SECONDS = 10 +RESPONSE_EXCERPT_LENGTH = 300 + + +class SubmissionError(Exception): + """Raised when a provider submission attempt fails.""" + + +@dataclass(frozen=True) +class Config: + site_url: str + sitemap_url: str + google_access_token: str + google_site_url: str + indexnow_key: str + indexnow_endpoint: str + dry_run: bool + strict: bool + timeout_seconds: int + + +def actions_escape(value: str) -> str: + return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def warning(title: str, message: str) -> None: + print( + f"::warning title={actions_escape(title)}::{actions_escape(message)}", + file=sys.stderr, + ) + + +def parse_timeout_seconds() -> int: + raw_timeout = os.environ.get("HTTP_TIMEOUT_SECONDS") + if raw_timeout is None: + return DEFAULT_TIMEOUT_SECONDS + + try: + timeout_seconds = int(raw_timeout) + except ValueError: + warning( + "Invalid HTTP timeout", + f"HTTP_TIMEOUT_SECONDS must be a positive integer; using {DEFAULT_TIMEOUT_SECONDS}", + ) + return DEFAULT_TIMEOUT_SECONDS + + if timeout_seconds <= 0: + warning( + "Invalid HTTP timeout", + f"HTTP_TIMEOUT_SECONDS must be greater than 0; using {DEFAULT_TIMEOUT_SECONDS}", + ) + return DEFAULT_TIMEOUT_SECONDS + + return timeout_seconds + + +def load_config() -> Config: + site_url = os.environ.get("SITE_URL", "").strip() + if not site_url: + print("SITE_URL is required", file=sys.stderr) + raise SystemExit(2) + + sitemap_url = os.environ.get("SITEMAP_URL", "").strip() + if not sitemap_url: + sitemap_url = f"{site_url.rstrip('/')}/sitemap.xml" + + google_site_url = os.environ.get("GOOGLE_SEARCH_CONSOLE_SITE_URL", "").strip() + if not google_site_url: + google_site_url = site_url + + return Config( + site_url=site_url, + sitemap_url=sitemap_url, + google_access_token=os.environ.get("GOOGLE_ACCESS_TOKEN", "").strip(), + google_site_url=google_site_url, + indexnow_key=os.environ.get("INDEXNOW_KEY", "").strip(), + indexnow_endpoint=os.environ.get( + "INDEXNOW_ENDPOINT", DEFAULT_INDEXNOW_ENDPOINT + ).strip() + or DEFAULT_INDEXNOW_ENDPOINT, + dry_run=os.environ.get("DRY_RUN", "").strip() == "1", + strict=os.environ.get("SITEMAP_SUBMIT_STRICT", "").strip() == "1", + timeout_seconds=parse_timeout_seconds(), + ) + + +def response_excerpt(body: bytes) -> str: + text = body.decode("utf-8", errors="replace").strip() + if len(text) <= RESPONSE_EXCERPT_LENGTH: + return text + return f"{text[:RESPONSE_EXCERPT_LENGTH]}..." + + +def redact(value: str, redactions: list[str]) -> str: + redacted = value + for secret in redactions: + if secret: + redacted = redacted.replace(secret, "***") + return redacted + + +def request_failure_message(error: BaseException) -> str: + if isinstance(error, urllib.error.HTTPError): + body = response_excerpt(error.read()) + message = f"HTTP {error.code} {error.reason}" + if body: + message = f"{message}: {body}" + return message + + if isinstance(error, urllib.error.URLError): + if isinstance(error.reason, TimeoutError): + return "request timed out" + return f"URL error: {error.reason}" + + if isinstance(error, socket.timeout | TimeoutError): + return "request timed out" + + return str(error) + + +def open_request( + request: urllib.request.Request, + timeout_seconds: int, + success_statuses: Callable[[int], bool], +) -> None: + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + status = response.status + body = response.read() + except (urllib.error.HTTPError, urllib.error.URLError, socket.timeout, TimeoutError) as error: + raise SubmissionError(request_failure_message(error)) from error + + if not success_statuses(status): + body_excerpt = response_excerpt(body) + message = f"HTTP {status}" + if body_excerpt: + message = f"{message}: {body_excerpt}" + raise SubmissionError(message) + + +def google_request_url(google_site_url: str, sitemap_url: str) -> str: + encoded_site_url = urllib.parse.quote(google_site_url, safe="") + encoded_sitemap_url = urllib.parse.quote(sitemap_url, safe="") + return ( + "https://www.googleapis.com/webmasters/v3/sites/" + f"{encoded_site_url}/sitemaps/{encoded_sitemap_url}" + ) + + +def submit_google(config: Config) -> None: + request_url = google_request_url(config.google_site_url, config.sitemap_url) + request = urllib.request.Request( + request_url, + headers={"Authorization": f"Bearer {config.google_access_token}"}, + method="PUT", + ) + open_request( + request, + config.timeout_seconds, + lambda status: 200 <= status <= 299, + ) + + +def indexnow_key_location(site_url: str, indexnow_key: str) -> str: + return f"{site_url.rstrip('/')}/{indexnow_key}.txt" + + +def build_indexnow_payload(config: Config) -> dict[str, object]: + host = urllib.parse.urlparse(config.site_url).netloc + if not host: + raise SubmissionError("SITE_URL must include a host for IndexNow submission") + + return { + "host": host, + "key": config.indexnow_key, + "keyLocation": indexnow_key_location(config.site_url, config.indexnow_key), + "urlList": [config.sitemap_url], + } + + +def redacted_indexnow_payload(config: Config) -> dict[str, object]: + payload = build_indexnow_payload(config) + payload["key"] = "***" + payload["keyLocation"] = indexnow_key_location(config.site_url, "***") + return payload + + +def submit_indexnow(config: Config) -> None: + payload = build_indexnow_payload(config) + request = urllib.request.Request( + config.indexnow_endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json; charset=utf-8"}, + method="POST", + ) + open_request( + request, + config.timeout_seconds, + lambda status: status in {200, 202}, + ) + + +def submit_with_retry( + provider_name: str, + submit: Callable[[], None], + redactions: list[str] | None = None, +) -> bool: + redactions = redactions or [] + last_error = "" + + for attempt in range(1, 3): + try: + submit() + print(f"{provider_name} sitemap submission succeeded") + return True + except SubmissionError as error: + last_error = redact(str(error), redactions) + if attempt == 1: + print( + f"{provider_name} sitemap submission failed; " + f"retrying in {RETRY_DELAY_SECONDS} seconds: {last_error}", + file=sys.stderr, + ) + time.sleep(RETRY_DELAY_SECONDS) + + warning( + "Sitemap submission failed", + f"{provider_name} failed after retry: {last_error}", + ) + return False + + +def dry_run(config: Config, indexnow_enabled: bool) -> None: + if config.google_access_token: + print("DRY RUN Google request:") + print(f" PUT {google_request_url(config.google_site_url, config.sitemap_url)}") + print(" Authorization: Bearer ***") + else: + warning( + "Sitemap provider skipped", + "GOOGLE_ACCESS_TOKEN is not configured; skipping Google submission", + ) + + if indexnow_enabled: + print("DRY RUN IndexNow request:") + print(f" POST {config.indexnow_endpoint}") + print( + json.dumps( + redacted_indexnow_payload(config), + indent=2, + sort_keys=True, + ) + ) + elif not config.indexnow_key: + warning( + "Sitemap provider skipped", + "INDEXNOW_KEY is not configured; skipping IndexNow submission", + ) + + +def main() -> int: + config = load_config() + failures = 0 + + print(f"SITE_URL={config.site_url}") + print(f"SITEMAP_URL={config.sitemap_url}") + + indexnow_enabled = False + if config.indexnow_key: + if INDEXNOW_KEY_PATTERN.fullmatch(config.indexnow_key): + indexnow_enabled = True + else: + warning( + "Invalid IndexNow key", + "INDEXNOW_KEY must match ^[A-Za-z0-9-]{8,128}$; " + "skipping IndexNow submission", + ) + + if config.dry_run: + dry_run(config, indexnow_enabled) + return 0 + + if config.google_access_token: + if not submit_with_retry( + "Google", + lambda: submit_google(config), + [config.google_access_token], + ): + failures += 1 + else: + warning( + "Sitemap provider skipped", + "GOOGLE_ACCESS_TOKEN is not configured; skipping Google submission", + ) + + if indexnow_enabled: + if not submit_with_retry( + "IndexNow", + lambda: submit_indexnow(config), + [config.indexnow_key], + ): + failures += 1 + elif not config.indexnow_key: + warning( + "Sitemap provider skipped", + "INDEXNOW_KEY is not configured; skipping IndexNow submission", + ) + + if failures and config.strict: + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())