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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# A Linux container carrying every toolchain this repo's dev loop needs: the
# pinned .NET SDK plus wasm-tools, Node, PowerShell, Lua 5.2 and python3.
# pinned Rust toolchain, Node and python3, plus (until the C# is removed) the
# pinned .NET SDK with wasm-tools, PowerShell and Lua 5.2.
# CLAUDE.md's "Devcontainer" section says what it covers and what still needs
# the host (benchmarks and the Factorio oracle capture).
#
Expand Down Expand Up @@ -28,11 +29,17 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0.402@sha256:0d99ebf1ccad15037b5c98c9fe8979
#
# python3 is only for trim-factorio-oracle.py. The capture itself cannot run in
# here - it needs a real Factorio install - but the trim half is worth having.
#
# gcc and libc6-dev are for Rust: cargo links native code (build scripts, proc
# macros, the test binaries) with `cc`. Nothing in rust/Cargo.lock compiles C,
# so the rest of build-essential (g++, make) is not needed.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gcc \
git \
libc6-dev \
lua5.2 \
python3 \
sudo \
Expand Down Expand Up @@ -94,3 +101,51 @@ ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1

USER ubuntu

# Rust, for rust/ and for `npm run build-wasm` in src/vue, which builds the
# planner the site loads. Installed as ubuntu into its home, so cargo can write
# its caches and rustup can add a toolchain without sudo.
#
# rustup-init is pinned and checksummed the same way Node is above. The
# checksums are the .sha256 files published beside each binary, and match a
# download of both. Self-update is off so the pin holds: otherwise
# `rustup toolchain install` checks for a newer rustup and installs it.
#
# rust/rust-toolchain.toml is the authority on the compiler: rustup reads it
# inside rust/, and installs whatever it names on first use. RUST_VERSION only
# decides what is ALREADY in the image, so that creating the container, and
# every run of the CI job, does not download a toolchain. rustfmt, clippy and
# the wasm32 target are what the toolchain file asks for; rust-src is extra,
# for rust-analyzer, which cannot index std without it.
#
# WHEN rust-toolchain.toml MOVES, RUST_VERSION SHOULD MOVE WITH IT. A stale one
# still works - rustup just downloads the new pin on first use - which is
# exactly why nobody would notice. The Devcontainer CI job checks the two agree.
ARG RUSTUP_VERSION=1.29.1
ARG RUSTUP_SHA256_ARM64=15f6e4ce9f583b929c996c91562bad6d4454f3281de858b02cdfdef615fac433
ARG RUSTUP_SHA256_AMD64=dda7234360b7f578ca8b0ddcb80145646fa61a67c1720a5abc7051b35c9fcb71
ENV RUST_VERSION=1.98.1 \
CARGO_HOME=/home/ubuntu/.cargo \
RUSTUP_HOME=/home/ubuntu/.rustup
ENV PATH=${CARGO_HOME}/bin:${PATH}
RUN set -eux; \
case "$(dpkg --print-architecture)" in \
arm64) rust_triple=aarch64-unknown-linux-gnu; rustup_sha="${RUSTUP_SHA256_ARM64}" ;; \
amd64) rust_triple=x86_64-unknown-linux-gnu; rustup_sha="${RUSTUP_SHA256_AMD64}" ;; \
*) echo "unsupported architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \
esac; \
curl -fsSLo /tmp/rustup-init \
"https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/${rust_triple}/rustup-init"; \
echo "${rustup_sha} /tmp/rustup-init" | sha256sum -c -; \
chmod +x /tmp/rustup-init; \
/tmp/rustup-init -y --no-modify-path \
--profile minimal \
--default-toolchain "${RUST_VERSION}" \
--component rustfmt,clippy,rust-src \
--target wasm32-unknown-unknown; \
rm /tmp/rustup-init; \
rustup set auto-self-update disable; \
mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git"; \
rustup --version; \
rustc --version; \
cargo --version
51 changes: 42 additions & 9 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// A Linux container for the whole dev loop: dotnet build/test, the Lua
// transpile and its 5.2 checks, and the Vue front-end including the WASM
// bundle. CLAUDE.md's "Devcontainer" section says what it covers and what
// still needs the host - benchmarks and the Factorio oracle capture.
// A Linux container for the whole dev loop: the Rust planner (cargo build and
// test in rust/, and the WASM build the site loads), the Vue front-end, and,
// until the C# is removed, dotnet build/test and the Lua transpile with its 5.2
// checks. CLAUDE.md's "Devcontainer" section says what it covers and what still
// needs the host - benchmarks and the Factorio oracle capture.
//
// The Dockerfile pins the SDK from global.json by digest. That is the reason
// this exists: `rollForward: latestMajor` only rolls forward, so a host whose
// package manager trails the pin cannot build the repo at all.
// package manager trails the pin cannot build the repo at all. It also carries
// the Rust toolchain rust/rust-toolchain.toml pins, already installed.
{
"name": "FactorioTools",
"build": { "dockerfile": "Dockerfile" },
Expand All @@ -32,6 +34,28 @@
"source": "factoriotools-nuget-${devcontainerId}",
"target": "/home/ubuntu/.cache/nuget",
"type": "volume"
},
// rust/target is the same problem as node_modules: Linux build output
// in the bind mount would sit beside, and invalidate, a Mac host's own
// cargo builds. A volume keeps them apart and survives a rebuild.
{
"source": "factoriotools-rust-target-${devcontainerId}",
"target": "${containerWorkspaceFolder}/rust/target",
"type": "volume"
},
// The crates.io index and downloaded crates, so a rebuilt container
// does not fetch them again. Only these two folders of CARGO_HOME:
// its bin/ holds rustup and cargo from the image, and a volume over
// all of it would keep serving the old ones after an image update.
{
"source": "factoriotools-cargo-registry-${devcontainerId}",
"target": "/home/ubuntu/.cargo/registry",
"type": "volume"
},
{
"source": "factoriotools-cargo-git-${devcontainerId}",
"target": "/home/ubuntu/.cargo/git",
"type": "volume"
}
],
// Vite's dev server. Reaching it from the host needs `--host`, and that is
Expand All @@ -43,21 +67,30 @@
"5173": { "label": "Vite (needs --host)" }
},
// A new volume is root-owned, hence the chown. Submodules are required to
// build at all - FluteSharp and delaunator-sharp are referenced by the core
// build the C# - FluteSharp and delaunator-sharp are referenced by the core
// library, CSharp.lua by the transpile - and a fresh clone has none of them.
// The safe.directory line is a no-op under OrbStack, where the mount already
// appears owned by uid 1000; it is insurance on a Linux host whose own uid
// is not 1000, where git otherwise refuses with "dubious ownership" and
// vite.config.ts's `git describe` call takes the build down with it.
"postCreateCommand": "sudo chown ubuntu:ubuntu src/vue/node_modules /home/ubuntu/.cache/nuget && git config --global --add safe.directory \"${containerWorkspaceFolder}\" && git submodule update --init --recursive && npm --prefix src/vue ci",
// build-wasm runs last because the Vite build and `npm run dev` import the
// planner it produces, and fail without it. It is a release build of the
// planner and its dependencies, so it is the slow step here; the
// Devcontainer section in CLAUDE.md has the measured time.
"postCreateCommand": "sudo chown ubuntu:ubuntu src/vue/node_modules /home/ubuntu/.cache/nuget rust/target /home/ubuntu/.cargo/registry /home/ubuntu/.cargo/git && git config --global --add safe.directory \"${containerWorkspaceFolder}\" && git submodule update --init --recursive && npm --prefix src/vue ci && npm --prefix src/vue run build-wasm",
"customizations": {
"vscode": {
"extensions": [
"ms-dotnettools.csdevkit",
"rust-lang.rust-analyzer",
"Vue.volar",
"ms-dotnettools.csdevkit",
"ms-vscode.powershell",
"sumneko.lua"
]
],
"settings": {
// The Cargo workspace is in rust/, not at the repository root.
"rust-analyzer.linkedProjects": ["rust/Cargo.toml"]
}
}
}
}
8 changes: 3 additions & 5 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@
# produce CRLF and fail that check for no real reason.
*.lua text eol=lf

# Same deal for the other two committed generated files CI now checks against a
# fresh regeneration: swagger.json (Swashbuckle CLI) and the TS client generated
# from it (swagger-typescript-api). Both are LF today; pinning them keeps a regen
# on a Windows checkout from failing those checks on line endings alone.
# Same deal for swagger.json (Swashbuckle CLI), another committed generated file
# CI checks against a fresh regeneration. It is LF today; pinning it keeps a regen
# on a Windows checkout from failing that check on line endings alone.
src/WebApp/swagger.json text eol=lf
src/vue/src/lib/FactorioToolsApi.ts text eol=lf

*.verified.txt text eol=lf working-tree-encoding=UTF-8
*.verified.xml text eol=lf working-tree-encoding=UTF-8
Expand Down
16 changes: 0 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,6 @@ jobs:
run: npm run build
working-directory: ${{ env.BUILD_PATH }}

# "npm run build" runs swagger-gen first, so the build above has already
# regenerated the committed TS client from the committed swagger.json.
# This is the second link in the chain the swagger.json check below guards:
# WebApp C# -> swagger.json -> src/vue/src/lib -> the deployed front-end.
# Without this, the client could keep describing an older contract than the
# swagger.json sitting next to it. Only FactorioToolsApi.ts is generated -
# everything else in src/lib is hand-written.
- name: Check the committed API client is up to date
run: |
if [ -n "$(git status --porcelain -- src/vue/src/lib/FactorioToolsApi.ts)" ]; then
echo "::error::src/vue/src/lib/FactorioToolsApi.ts does not match what swagger.json generates. Run 'npm run swagger-gen' in src/vue and commit the result."
git diff -- src/vue/src/lib/FactorioToolsApi.ts
exit 1
fi
echo "src/vue/src/lib/FactorioToolsApi.ts matches the generated client."

- name: Test
run: npm run test
working-directory: ${{ env.BUILD_PATH }}
Expand Down
76 changes: 59 additions & 17 deletions .github/workflows/devcontainer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ on:
- '.devcontainer/**'
- '.github/workflows/devcontainer.yml'
- 'global.json'
# Weekly, because two of this image's pins rot without anything under
# .devcontainer/ changing, so the paths filter above cannot catch either:
# - the Node tarball and its two checksums, if a release is ever re-cut
# - the SDK digest, once global.json moves (that one IS covered by the
# paths filter now, but only if the bump goes through a pull request)
# Not all of rust/: ci.yml's Rust job already builds and tests every Rust
# change. What this job adds is the container, and the only Rust file the
# container has to agree with is the toolchain pin.
- 'rust/rust-toolchain.toml'
# Weekly, because some of this image's pins rot without anything under
# .devcontainer/ changing, so the paths filter above cannot catch them:
# - the Node tarball and the rustup-init binaries, and their checksums, if
# a release is ever re-cut
# - the SDK digest and RUST_VERSION, once global.json or
# rust/rust-toolchain.toml moves (both ARE covered by the paths filter
# now, but only if the bump goes through a pull request)
# A rotted pin fails the build for everyone who opens the container, so it is
# better found here.
schedule:
Expand All @@ -35,7 +41,7 @@ jobs:
- name: Create the development container
run: npx --yes @devcontainers/cli@0.89.0 up --workspace-folder "$GITHUB_WORKSPACE"

- name: Every toolchain resolves, and the SDK matches global.json
- name: Every toolchain resolves, and the SDK and Rust match their pins
run: |
npx --yes @devcontainers/cli@0.89.0 exec --workspace-folder "$GITHUB_WORKSPACE" bash -ec '
# The check that matters: global.json pins an exact SDK and
Expand All @@ -62,6 +68,40 @@ jobs:
echo "::error::wasm-tools workload missing - BrowserWasm cannot publish."
exit 1
}

# The same idea for Rust, with one difference. A stale RUST_VERSION
# in the Dockerfile does not fail anything: rustup reads
# rust/rust-toolchain.toml and downloads the pin on first use. So
# checking what rustc resolves to proves nothing on its own; compare
# what the image pre-installed against what the file asks for.
rust_pinned=$(python3 -c "import tomllib;print(tomllib.load(open(\"rust/rust-toolchain.toml\",\"rb\"))[\"toolchain\"][\"channel\"])")
echo "rust-toolchain.toml pins $rust_pinned, the image pre-installs $RUST_VERSION"
[ "$rust_pinned" = "$RUST_VERSION" ] || {
echo "::error::The devcontainer pre-installs Rust $RUST_VERSION but rust/rust-toolchain.toml pins $rust_pinned. Update RUST_VERSION in .devcontainer/Dockerfile."
exit 1
}
rustup --version
rustup toolchain list
# Run inside rust/, so these resolve through the toolchain file the
# way cargo does there.
rustc_version=$(cd rust && rustc --version)
cargo_version=$(cd rust && cargo --version)
echo "$rustc_version"
echo "$cargo_version"
case "$rustc_version" in "rustc $rust_pinned "*) ;; *)
echo "::error::rustc in rust/ is not $rust_pinned."
exit 1 ;;
esac
case "$cargo_version" in "cargo $rust_pinned "*) ;; *)
echo "::error::cargo in rust/ is not $rust_pinned."
exit 1 ;;
esac
# build-wasm needs this target, and the planner the site loads is
# built with it.
rustup target list --installed --toolchain "$rust_pinned" | grep -qx wasm32-unknown-unknown || {
echo "::error::The wasm32-unknown-unknown target is missing - npm run build-wasm cannot build the planner."
exit 1
}
'

- name: The core dev loop runs
Expand All @@ -83,23 +123,25 @@ jobs:
echo "Transpile reproduced the committed output, and it parses and runs under 5.2."
'

# The site now loads the Rust planner (rust/planner-wasm), and the Vite build
# imports it from src/vue/src/lib/planner-wasm. The devcontainer image has no
# Rust toolchain yet (adding one belongs with stage 13 of #138, when .NET
# leaves the image), so build it on the runner, whose rustup honours
# rust/rust-toolchain.toml. The checkout is mounted into the container, so the
# copied files are visible there. This is what `npm run build-wasm` does,
# minus shx, which the runner does not have outside the container.
- name: Build the Rust planner on the runner
working-directory: rust
# Debug only. The release gates (every planner snapshot, the contract
# replay) stay in ci.yml's Rust job: they test the planner, not the
# container, and would add their release build to this job's time.
- name: The Rust tests pass
run: |
cargo build --release -p planner-wasm --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/planner_wasm.wasm planner-wasm/js/planner.js ../src/vue/src/lib/planner-wasm/
npx --yes @devcontainers/cli@0.89.0 exec --workspace-folder "$GITHUB_WORKSPACE" bash -ec '
cd rust
cargo test --all-targets
'

# The site loads the Rust planner (rust/planner-wasm), and the Vite build
# imports it from src/vue/src/lib/planner-wasm, so build-wasm comes first.
# postCreateCommand already ran it, so here it only confirms the output is
# up to date, and keeps this step honest if postCreate ever stops running it.
- name: The front-end builds and tests
run: |
npx --yes @devcontainers/cli@0.89.0 exec --workspace-folder "$GITHUB_WORKSPACE" bash -ec '
cd src/vue
npm run build-wasm
npm run build
npm run test
'
Loading
Loading