diff --git a/.changeset/reproducible-builds-parity.md b/.changeset/reproducible-builds-parity.md new file mode 100644 index 000000000..588eff47e --- /dev/null +++ b/.changeset/reproducible-builds-parity.md @@ -0,0 +1,10 @@ +--- +"lingo.dev": patch +"@lingo.dev/_compiler": patch +"@lingo.dev/_locales": patch +"@lingo.dev/_sdk": patch +"@lingo.dev/_spec": patch +"@lingo.dev/_react": patch +--- + +Adopt fully hermetic, reproducible builds with containerized local/CI parity; deterministic packaging and checksums; containerized PR/release workflows. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..c92b9a52d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,162 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + types: [opened, edited, synchronize] + branches: [main] + +jobs: + format: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build hermetic image + run: docker build -f Dockerfile.repro -t lingo-repro:20.12.2 . + + - name: Prepare pnpm store path + run: echo "REPRO_PNPM_STORE=${{ runner.temp }}/pnpm-store" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v3 + with: + path: ${{ env.REPRO_PNPM_STORE }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install deps (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm install --frozen-lockfile + + - name: Check formatting (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm prettier . --check + + typecheck: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build hermetic image + run: docker build -f Dockerfile.repro -t lingo-repro:20.12.2 . + + - name: Prepare pnpm store path + run: echo "REPRO_PNPM_STORE=${{ runner.temp }}/pnpm-store" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v3 + with: + path: ${{ env.REPRO_PNPM_STORE }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install deps (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm install --frozen-lockfile + + - name: Typecheck (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm turbo typecheck + + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build hermetic image + run: docker build -f Dockerfile.repro -t lingo-repro:20.12.2 . + + - name: Prepare pnpm store path + run: echo "REPRO_PNPM_STORE=${{ runner.temp }}/pnpm-store" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v3 + with: + path: ${{ env.REPRO_PNPM_STORE }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install deps (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm install --frozen-lockfile + + - name: Build (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm turbo build --force + + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build hermetic image + run: docker build -f Dockerfile.repro -t lingo-repro:20.12.2 . + + - name: Prepare pnpm store path + run: echo "REPRO_PNPM_STORE=${{ runner.temp }}/pnpm-store" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v3 + with: + path: ${{ env.REPRO_PNPM_STORE }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install deps (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm install --frozen-lockfile + + - name: Test (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm turbo test + + changeset: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build hermetic image + run: docker build -f Dockerfile.repro -t lingo-repro:20.12.2 . + + - name: Prepare pnpm store path + run: echo "REPRO_PNPM_STORE=${{ runner.temp }}/pnpm-store" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v3 + with: + path: ${{ env.REPRO_PNPM_STORE }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Mark repo as safe for git inside container + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh git config --global --add safe.directory /workspace + + - name: Install deps (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm install --frozen-lockfile + + - name: Require changeset to be present in PR (container) + if: github.event.pull_request.user.login != 'dependabot[bot]' + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm changeset status --since origin/main diff --git a/.github/workflows/lingodotdev.yml b/.github/workflows/lingodotdev.yml index 7f8099a6e..109bbc1f3 100644 --- a/.github/workflows/lingodotdev.yml +++ b/.github/workflows/lingodotdev.yml @@ -45,21 +45,31 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v2 - with: - node-version: "20" + - name: Build hermetic image + run: docker build -f Dockerfile.repro -t lingo-repro:20.12.2 . + + - name: Prepare pnpm store path + run: echo "REPRO_PNPM_STORE=${{ runner.temp }}/pnpm-store" >> $GITHUB_ENV - - name: Lingo.dev - uses: ./ + - name: Cache pnpm store + uses: actions/cache@v3 with: - api-key: ${{ secrets.LINGODOTDEV_API_KEY }} - version: ${{ inputs.version }} - pull-request: ${{ inputs['pull-request'] }} - commit-message: ${{ inputs['commit-message'] }} - pull-request-title: ${{ inputs['pull-request-title'] }} - working-directory: ${{ inputs['working-directory'] }} - process-own-commits: ${{ inputs['process-own-commits'] }} - parallel: ${{ inputs.parallel }} + path: ${{ env.REPRO_PNPM_STORE }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Lingo.dev (container) env: GH_TOKEN: ${{ github.token }} + LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }} + run: | + REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh \ + npx lingo.dev@${{ inputs.version }} ci \ + --api-key "$LINGODOTDEV_API_KEY" \ + --pull-request "${{ inputs['pull-request'] }}" \ + --commit-message "${{ inputs['commit-message'] }}" \ + --pull-request-title "${{ inputs['pull-request-title'] }}" \ + --working-directory "${{ inputs['working-directory'] }}" \ + --process-own-commits "${{ inputs['process-own-commits'] }}" \ + --parallel ${{ inputs.parallel }} diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml deleted file mode 100644 index cd98f40b1..000000000 --- a/.github/workflows/pr-check.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Check PR - -on: - workflow_dispatch: - pull_request: - types: - - opened - - edited - - synchronize - branches: - - main - -jobs: - check: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{github.event.pull_request.head.sha}} - fetch-depth: 0 - - - name: Check for [skip i18n] - run: | - COMMIT_MESSAGE=$(git log -1 --pretty=%B) - if echo "$COMMIT_MESSAGE" | grep -iq '\[skip i18n\]'; then - echo "Skipping i18n checks due to [skip i18n] in commit message." - exit 0 - fi - - - name: Use Node.js - uses: actions/setup-node@v2 - with: - node-version: 20.12.2 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - id: pnpm-install - with: - version: 9.12.3 - run_install: false - - - name: Configure pnpm cache - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install deps - run: pnpm install - - - name: Setup - run: | - pnpm turbo telemetry disable - - - name: Configure Turbo cache - uses: dtinth/setup-github-actions-caching-for-turbo@v1 - - - name: Check formatting - run: pnpm format:check - - - name: Build - run: pnpm turbo build --force - - - name: Test - run: pnpm turbo test --force - - - name: Require changeset to be present in PR - if: github.event.pull_request.user.login != 'dependabot[bot]' - run: pnpm changeset status --since origin/main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17d09495a..51aa42f0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,61 +35,55 @@ jobs: exit 0 fi - - name: Use Node.js - uses: actions/setup-node@v2 - with: - node-version: 20.12.2 + - name: Build hermetic image + run: docker build -f Dockerfile.repro -t lingo-repro:20.12.2 . - - name: Install pnpm - uses: pnpm/action-setup@v4 - id: pnpm-install - with: - version: 9.12.3 - run_install: false + - name: Prepare pnpm store path + run: echo "REPRO_PNPM_STORE=${{ runner.temp }}/pnpm-store" >> $GITHUB_ENV - - name: Configure pnpm cache - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - name: Cache pnpm store + uses: actions/cache@v3 with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + path: ${{ env.REPRO_PNPM_STORE }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - - name: Install deps - run: pnpm install + - name: Install deps (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm install --frozen-lockfile - - name: Lingo.dev + - name: Lingo.dev (container) if: ${{ !inputs.skip_lingo }} - uses: ./ - with: - api-key: ${{ secrets.LINGODOTDEV_API_KEY }} - pull-request: true - parallel: true env: GH_TOKEN: ${{ github.token }} - - - name: Setup + LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }} run: | - pnpm turbo telemetry disable + REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh \ + npx lingo.dev@latest ci \ + --api-key "$LINGODOTDEV_API_KEY" \ + --pull-request "true" \ + --commit-message "feat: update translations via @LingoDotDev" \ + --pull-request-title "feat: update translations via @LingoDotDev" \ + --working-directory "." \ + --process-own-commits "false" \ + --parallel true - - name: Configure Turbo cache - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + - name: Disable turbo telemetry (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm turbo telemetry disable - - name: Build - run: pnpm turbo build --force + - name: Build (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm turbo build --force - - name: Test - run: pnpm turbo test --force + - name: Test (container) + run: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm turbo test - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 with: title: "chore: bump package versions" - version: pnpm changeset version - publish: pnpm changeset publish + version: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm changeset version + publish: REPRO_PNPM_STORE="${{ env.REPRO_PNPM_STORE }}" bash scripts/repro/exec.sh pnpm changeset publish commit: "chore: bump package version" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ce8901476..a9191cbf2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules .pnp .pnp.js +.pnpm-store/ # Local env files .env diff --git a/.prettierignore b/.prettierignore index 857f16ab4..226393bff 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,7 @@ pnpm-lock.yaml packages/cli/demo/ build/ dist/ +.pnpm-store/ .react-router/ .turbo/ .next/ diff --git a/Dockerfile.repro b/Dockerfile.repro new file mode 100644 index 000000000..da4114405 --- /dev/null +++ b/Dockerfile.repro @@ -0,0 +1,26 @@ +FROM node:20.12.2-bookworm-slim + +# Pinned, hermetic build environment for reproducible builds +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=UTC \ + LC_ALL=C \ + LANG=C \ + PNPM_HOME=/usr/local/pnpm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash git ca-certificates tzdata coreutils findutils gawk curl xz-utils \ + tar gzip bzip2 \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH=$PNPM_HOME:$PATH + +# Pin pnpm via corepack +RUN corepack enable \ + && corepack prepare pnpm@9.12.3 --activate + +WORKDIR /workspace + +# Intentionally no COPY; repository will be bind-mounted at runtime + +ENTRYPOINT ["bash", "-lc"] diff --git a/package.json b/package.json index 13fe1c4c1..8d1ec6afd 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,22 @@ "type": "module", "scripts": { "prepare": "husky", - "build": "turbo build", - "typecheck": "turbo typecheck", - "test": "turbo test", + "build": "bash scripts/repro/exec.sh pnpm turbo build", + "typecheck": "bash scripts/repro/exec.sh pnpm turbo typecheck", + "test": "bash scripts/repro/exec.sh pnpm turbo test", "new": "changeset", "new:empty": "changeset --empty", - "format": "prettier . --write", - "format:check": "prettier . --check" + "format": "bash scripts/repro/exec.sh pnpm prettier . --write", + "format:check": "bash scripts/repro/exec.sh pnpm prettier . --check", + "build:canonical": "bash scripts/repro/local.sh", + "shell": "bash scripts/repro/exec.sh", + "ci:install": "bash scripts/repro/exec.sh pnpm install --frozen-lockfile", + "turbo:telemetry:disable": "bash scripts/repro/exec.sh pnpm turbo telemetry disable", + "host:build": "turbo build", + "host:typecheck": "turbo typecheck", + "host:test": "turbo test", + "host:format": "prettier . --write", + "host:format:check": "prettier . --check" }, "devDependencies": { "@babel/generator": "^7.27.1", diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 7f00c50dd..4a7dcf482 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -20,6 +20,10 @@ export default defineConfig({ splitting: true, bundle: true, sourcemap: true, + esbuildOptions(options) { + options.legalComments = "none"; + options.absWorkingDir = process.cwd(); + }, external: ["readline/promises", "@babel/traverse", "node-machine-id"], outExtension: (ctx) => ({ js: ctx.format === "cjs" ? ".cjs" : ".mjs", diff --git a/packages/compiler/tsup.config.ts b/packages/compiler/tsup.config.ts index 24b199870..9b2506852 100644 --- a/packages/compiler/tsup.config.ts +++ b/packages/compiler/tsup.config.ts @@ -9,6 +9,10 @@ export default defineConfig({ dts: true, cjsInterop: true, splitting: true, + esbuildOptions(options) { + options.legalComments = "none"; + options.absWorkingDir = process.cwd(); + }, outExtension: (ctx) => ({ js: ctx.format === "cjs" ? ".cjs" : ".mjs", }), diff --git a/packages/locales/tsup.config.ts b/packages/locales/tsup.config.ts index c3b516243..72c63ae30 100644 --- a/packages/locales/tsup.config.ts +++ b/packages/locales/tsup.config.ts @@ -9,6 +9,10 @@ export default defineConfig({ dts: true, cjsInterop: true, splitting: false, + esbuildOptions(options) { + options.legalComments = "none"; + options.absWorkingDir = process.cwd(); + }, outExtension: (ctx) => ({ js: ctx.format === "cjs" ? ".cjs" : ".mjs", }), diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts index 2d13ece73..c607f18b6 100644 --- a/packages/sdk/tsup.config.ts +++ b/packages/sdk/tsup.config.ts @@ -9,6 +9,10 @@ export default defineConfig({ dts: true, cjsInterop: true, splitting: true, + esbuildOptions(options) { + options.legalComments = "none"; + options.absWorkingDir = process.cwd(); + }, outExtension: (ctx) => ({ js: ctx.format === "cjs" ? ".cjs" : ".mjs", }), diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts index 297ea8cb4..c8bb05d90 100644 --- a/packages/spec/tsup.config.ts +++ b/packages/spec/tsup.config.ts @@ -10,6 +10,10 @@ export default defineConfig({ dts: true, cjsInterop: true, splitting: true, + esbuildOptions(options) { + options.legalComments = "none"; + options.absWorkingDir = process.cwd(); + }, outExtension: (ctx) => ({ js: ctx.format === "cjs" ? ".cjs" : ".mjs", }), diff --git a/readme/reproducible-builds.md b/readme/reproducible-builds.md new file mode 100644 index 000000000..690f6715e --- /dev/null +++ b/readme/reproducible-builds.md @@ -0,0 +1,117 @@ +## Reproducible, Hermetic Builds (Full Local ↔ CI Parity) + +This guide explains how to run all dev and CI tasks inside the same pinned container and how to verify bit-for-bit identical artifacts. + +### What you get + +- One pinned container (`Dockerfile.repro`) used everywhere (local and CI) +- A parity runner (`scripts/repro/exec.sh`) for any command and a build entrypoint (`scripts/repro/build.sh`) +- Canonical packaging + checksums (`out/canonical.tar`, `out/SHA256SUMS`) and a manifest + +### Prerequisites + +- Docker installed and running + +--- + +## Local: run everything inside the container (default) + +- Open a shell in the container at the repo root: + +```bash +pnpm run shell +``` + +- Common tasks (all run inside the container): + +```bash +pnpm typecheck # containerized typecheck +pnpm test # containerized tests +pnpm format:check # containerized formatting check +pnpm build -- --force # force rebuild inside container +``` + +- Hermetic build that creates canonical artifacts: + +```bash +pnpm build:canonical +``` + +Outputs: + +- `out/canonical.tar` — canonical, deterministically packaged bundle +- `out/SHA256SUMS` — SHA-256 checksum for verification +- `out/BUILD-MANIFEST.json` — metadata (commit, SDE, tool versions) + +How it works: + +1. Builds the pinned image (Node 20.12.2, pnpm 9.12.3, GNU tar, git) +2. Mounts your repo at `/workspace`, sets HOME/cache inside the container +3. Sets `SOURCE_DATE_EPOCH` from the commit time to stabilize timestamps +4. Installs deps with `pnpm install --frozen-lockfile` +5. Runs `pnpm turbo build` with deterministic env (no telemetry/daemon/remote cache) +6. Packages canonical artifacts with sorted names, fixed mtimes, numeric ownership + +Optional: speed up pnpm by caching the store locally across runs: + +```bash +export REPRO_PNPM_STORE="$HOME/.pnpm-store-lingo" +pnpm ci:install +``` + +Common tokens are passed through automatically if set in your shell: `GH_TOKEN`, `GITHUB_TOKEN`, `NPM_TOKEN`, `LINGODOTDEV_API_KEY`. + +--- + +## CI: full parity with local + +All workflows now run inside the same container via `scripts/repro/exec.sh`: + +- PR checks: `.github/workflows/pr-check.yml` +- Release: `.github/workflows/release.yml` (including changesets version/publish) +- Lingo.dev: `.github/workflows/lingodotdev.yml` +- Reproducible build: `.github/workflows/reproducible-build.yml` + +CI also uses a cached pnpm store mounted into the container to keep builds fast. + +--- + +## Verify local vs CI artifacts + +1. Trigger or wait for the "Reproducible Build" workflow +2. Download the artifact bundle (`canonical.tar`, `SHA256SUMS`) +3. Compare with your local build: + +```bash +pnpm repro:build +cat out/SHA256SUMS +sha256sum out/canonical.tar # optional local recompute +``` + +The checksums must match for the same commit. + +--- + +## Notes on determinism + +- Time: `SOURCE_DATE_EPOCH` comes from the commit time +- Packaging: `gnu tar --sort=name --mtime=@$SOURCE_DATE_EPOCH --owner=0 --group=0 --numeric-owner` +- Dependencies: `pnpm install --frozen-lockfile` fails on drift +- Build tools: tsup/esbuild remove legal comments and normalize working directory + +--- + +## Updating pins + +1. Edit `Dockerfile.repro` (Node image or pnpm version via corepack) +2. Update image tag references if needed +3. Run `pnpm build:canonical` locally; verify checksum stability for the same commit +4. Open a PR; CI will rebuild and upload artifacts for verification + +--- + +## Troubleshooting + +- Docker not running: Ensure your Docker daemon is running +- Lockfile errors: Only update dependencies intentionally, commit lockfile changes +- No build outputs: Ensure packages emit `packages/*/build`; the packager collects these directories diff --git a/scripts/repro/build.sh b/scripts/repro/build.sh new file mode 100644 index 000000000..9b768be0c --- /dev/null +++ b/scripts/repro/build.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +export TZ="UTC" +export LC_ALL="C" +export LANG="C" +export CI="${CI:-1}" + +# Ensure writable cache/home inside container regardless of UID:GID +export HOME="/tmp" +export XDG_CACHE_HOME="/tmp/.cache" +export COREPACK_HOME="/tmp/.corepack" + +GIT_COMMIT=${GIT_COMMIT:-"$(git rev-parse HEAD)"} +export SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-"$(git log -1 --pretty=%ct "${GIT_COMMIT}")"} + +echo "Using commit: ${GIT_COMMIT}" +echo "SOURCE_DATE_EPOCH: ${SOURCE_DATE_EPOCH}" + +# Ensure pnpm is the pinned version via corepack +corepack enable >/dev/null 2>&1 || true +pnpm -v + +# Guardrails: lockfile must be respected +pnpm install --frozen-lockfile + +# Disable remote cache/telemetry for determinism +export TURBO_TELEMETRY_DISABLE=1 +export TURBO_REMOTE_CACHE_DISABLE=1 +export TURBO_NO_DAEMON=1 +export TURBO_CONCURRENCY=1 + +# Build +pnpm turbo build --force + +# Package canonical artifacts +mkdir -p out + +# Collect build directories +mapfile -t BUILD_DIRS < <(find packages -maxdepth 2 -type d -name build | sort) +if [ ${#BUILD_DIRS[@]} -eq 0 ]; then + echo "No build directories found under packages/*/build" >&2 + exit 1 +fi + +echo "Packaging canonical bundle from:" +printf ' - %s\n' "${BUILD_DIRS[@]}" + +CANONICAL_TAR="out/canonical.tar" + +tar --create \ + --file "${CANONICAL_TAR}" \ + --sort=name \ + --mtime=@"${SOURCE_DATE_EPOCH}" \ + --owner=0 --group=0 --numeric-owner \ + "${BUILD_DIRS[@]}" + +# Checksums and manifest +sha256sum "${CANONICAL_TAR}" > out/SHA256SUMS + +cat > out/BUILD-MANIFEST.json </dev/null 2>&1; then + echo "Building reproducible build image: ${IMAGE_TAG}" + docker build -f "${DOCKERFILE}" -t "${IMAGE_TAG}" . +fi + +UID_GID="$(id -u):$(id -g)" +GIT_COMMIT=${GIT_COMMIT:-"$(git rev-parse HEAD)"} + +if [ $# -eq 0 ]; then + echo "Opening interactive shell in container..." + DOCKER_ARGS=( + --rm -it + --user "${UID_GID}" + -e HOME="/tmp" + -e XDG_CACHE_HOME="/tmp/.cache" + -e COREPACK_HOME="/tmp/.corepack" + -e GIT_COMMIT="${GIT_COMMIT}" + -v "$(pwd)":/workspace + -w /workspace + --entrypoint bash + ) + # Pass through common tokens if present + [ -n "${GH_TOKEN:-}" ] && DOCKER_ARGS+=( -e GH_TOKEN ) + [ -n "${GITHUB_TOKEN:-}" ] && DOCKER_ARGS+=( -e GITHUB_TOKEN ) + [ -n "${NPM_TOKEN:-}" ] && DOCKER_ARGS+=( -e NPM_TOKEN ) + [ -n "${LINGODOTDEV_API_KEY:-}" ] && DOCKER_ARGS+=( -e LINGODOTDEV_API_KEY ) + if [ -n "${REPRO_PNPM_STORE:-}" ]; then + mkdir -p "${REPRO_PNPM_STORE}" + DOCKER_ARGS+=( -e PNPM_STORE_DIR=/pnpm-store -v "${REPRO_PNPM_STORE}:/pnpm-store" ) + fi + exec docker run "${DOCKER_ARGS[@]}" \ + "${IMAGE_TAG}" +else + CMD="$*" + echo "Running in container: ${CMD}" + DOCKER_ARGS=( + --rm + --user "${UID_GID}" + -e HOME="/tmp" + -e XDG_CACHE_HOME="/tmp/.cache" + -e COREPACK_HOME="/tmp/.corepack" + -e GIT_COMMIT="${GIT_COMMIT}" + -v "$(pwd)":/workspace + -w /workspace + --entrypoint bash + ) + # Pass through common tokens if present + [ -n "${GH_TOKEN:-}" ] && DOCKER_ARGS+=( -e GH_TOKEN ) + [ -n "${GITHUB_TOKEN:-}" ] && DOCKER_ARGS+=( -e GITHUB_TOKEN ) + [ -n "${NPM_TOKEN:-}" ] && DOCKER_ARGS+=( -e NPM_TOKEN ) + [ -n "${LINGODOTDEV_API_KEY:-}" ] && DOCKER_ARGS+=( -e LINGODOTDEV_API_KEY ) + if [ -n "${REPRO_PNPM_STORE:-}" ]; then + mkdir -p "${REPRO_PNPM_STORE}" + DOCKER_ARGS+=( -e PNPM_STORE_DIR=/pnpm-store -v "${REPRO_PNPM_STORE}:/pnpm-store" ) + fi + exec docker run "${DOCKER_ARGS[@]}" \ + "${IMAGE_TAG}" -lc "${CMD}" +fi diff --git a/scripts/repro/local.sh b/scripts/repro/local.sh new file mode 100644 index 000000000..70c6229e2 --- /dev/null +++ b/scripts/repro/local.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_TAG="lingo-repro:20.12.2" +DOCKERFILE="Dockerfile.repro" + +echo "Building reproducible build image: ${IMAGE_TAG}" +docker build -f "${DOCKERFILE}" -t "${IMAGE_TAG}" . + +GIT_COMMIT=$(git rev-parse HEAD) + +echo "Running hermetic build in container" +UID_GID="$(id -u):$(id -g)" +DOCKER_ARGS=( + --rm + --user "${UID_GID}" + -e GIT_COMMIT="${GIT_COMMIT}" + -v "$(pwd)":/workspace + -w /workspace + --entrypoint bash +) +if [ -n "${REPRO_PNPM_STORE:-}" ]; then + mkdir -p "${REPRO_PNPM_STORE}" + DOCKER_ARGS+=( -e PNPM_STORE_DIR=/pnpm-store -v "${REPRO_PNPM_STORE}:/pnpm-store" ) +fi +docker run "${DOCKER_ARGS[@]}" \ + "${IMAGE_TAG}" \ + -lc "scripts/repro/build.sh" + +echo "Done. Artifacts in ./out"