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
1 change: 1 addition & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
build-args: BUILD_REVISION=${{ github.sha }}
context: .
file: ./Dockerfile
push: true
Expand Down
68 changes: 48 additions & 20 deletions .github/workflows/nightly-corpus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,16 @@ jobs:
retention-days: 14

browsers-shard:
name: Cross-browser (${{ matrix.browser }})
name: Cross-browser (${{ matrix.browser }}, shard ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
timeout-minutes: 90
timeout-minutes: 60
needs: gate
if: needs.gate.outputs.browsers == 'true'
strategy:
fail-fast: false
matrix:
browser: [webkit, firefox]
shard: [1, 2, 3]

steps:
- name: Checkout code
Expand All @@ -179,33 +180,59 @@ jobs:
with:
browsers: ${{ matrix.browser }}

# The same PR-time suites (minus the opt-in sweeps) on the two engines
# the gate does not cover. Same-realm/L0 rules apply unchanged.
- name: Run E2E on ${{ matrix.browser }}
# Per-engine measured file weights, assigned with LPT; unknown specs use
# that engine's median. Keep two workers and the existing retry budget.
- name: Run ordinary E2E on ${{ matrix.browser }}
env:
E2E_REPORT_DIR: playwright-report-browsers/normal
E2E_RESULTS_DIR: test-results-browsers/normal
E2E_SHARD: ${{ matrix.shard }}/3
E2E_BROWSER: ${{ matrix.browser }}
E2E_REPORT_DIR: playwright-report-browsers/${{ matrix.browser }}-${{ matrix.shard }}-normal
E2E_RESULTS_DIR: test-results-browsers/${{ matrix.browser }}-${{ matrix.shard }}-normal
run: pnpm exec playwright test -c playwright.browsers.config.ts --project=${{ matrix.browser }} --grep-invert "api surface|corpus|@serial" --workers=2

# The other half of that `--grep-invert`, exactly as ci.yml's e2e job
# pairs them: an @serial case is one that cannot share the run. Without
# this split sw-silent-update.spec.ts rewrites the served sw.js while
# other specs are being served from it, replacing THEIR worker mid-test
# -- which is where sw-warm's "runtime-e2e-next" and cache-first's
# missing sentinel came from on 2026-08-25. Without the second pass
# nothing runs them at all and the job stays green regardless.
- name: Upload report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report-browsers-${{ matrix.browser }}-shard-${{ matrix.shard }}-normal
path: |
playwright-report-browsers/
test-results-browsers/
retention-days: 14

browsers-serial:
name: Cross-browser (${{ matrix.browser }}, serial)
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [gate, browsers-shard]
if: ${{ !cancelled() && needs.gate.outputs.browsers == 'true' }}
strategy:
fail-fast: false
matrix:
browser: [webkit, firefox]

steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Set up the toolchain
uses: ./.github/actions/setup
with:
browsers: ${{ matrix.browser }}

# Exactly once per engine, on its own runner, even after ordinary failures.
# sw-silent-update rewrites the served SW; budgets also need exclusive CPU.
- name: Run the cases that cannot share the run
if: ${{ !cancelled() }}
env:
E2E_REPORT_DIR: playwright-report-browsers/serial
E2E_RESULTS_DIR: test-results-browsers/serial
E2E_REPORT_DIR: playwright-report-browsers/${{ matrix.browser }}-serial
E2E_RESULTS_DIR: test-results-browsers/${{ matrix.browser }}-serial
run: pnpm exec playwright test -c playwright.browsers.config.ts --project=${{ matrix.browser }} --grep @serial --workers=1

- name: Upload report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report-browsers-${{ matrix.browser }}
name: playwright-report-browsers-${{ matrix.browser }}-serial
path: |
playwright-report-browsers/
test-results-browsers/
Expand All @@ -215,13 +242,14 @@ jobs:
name: Cross-browser (WebKit + Firefox)
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [gate, browsers-shard]
needs: [gate, browsers-shard, browsers-serial]
if: always() && needs.gate.outputs.browsers == 'true'
steps:
- name: Report the browser matrix verdict
env:
RESULT: ${{ needs.browsers-shard.result }}
run: test "$RESULT" = "success"
SERIAL_RESULT: ${{ needs.browsers-serial.result }}
run: test "$RESULT" = "success" && test "$SERIAL_RESULT" = "success"

budgets:
name: Slow-network budgets
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/preview-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ jobs:
with:
browsers: chromium

- name: Verify the deployed preview identity
env:
PREVIEW_URL: ${{ steps.preview.outputs.url }}
EXPECTED_REVISION: ${{ github.event.pull_request.head.sha }}
run: node bin/wait-for-deploy.mjs "$PREVIEW_URL" "$EXPECTED_REVISION" 120000 2000

- name: Smoke the preview
env:
PROD_URL: ${{ steps.preview.outputs.url }}
Expand Down
34 changes: 5 additions & 29 deletions .github/workflows/prod-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,39 +37,15 @@ jobs:
with:
browsers: chromium

# On push: build the same commit and wait until production serves its
# hashed entry asset (Vite hashes are content-derived, so an identical
# build here yields the same asset name as the Pages build). Skipped for
# schedule/dispatch runs, which smoke whatever is live.
#
# The asset to watch is the EDITOR's, taken from editor.html. `/` has
# shipped no bundle since the route split (2026-08-16), so the old
# `assets/index-*.js` grep against `dist/index.html` found nothing --
# and an empty expectation matched an empty answer on the first poll,
# so this step reported "live after 20s" without waiting for anything.
# Every smoke since has run against whatever happened to be deployed;
# it only surfaced once a case could tell the builds apart.
#
# An empty ASSET is now a hard error rather than a vacuous pass: this
# gate is worthless if it cannot name what it is waiting for.
# The final build stamps its source revision and served-tree digest. This
# covers vendor, SW and static-only commits whose editor JS hash is stable.
# Missing/stale identities and transport errors exhaust a bounded wait and
# fail the job: smoking the previous deployment would be a false green.
- name: Wait for the deploy of this commit
if: github.event_name == 'push'
env:
PROD_URL: ${{ github.event.inputs.url || 'https://edit.chaxus.com' }}
run: |
sh ./bin/build.sh >/dev/null 2>&1
ASSET=$(grep -o 'assets/editor-[A-Za-z0-9_-]*\.js' dist/editor.html | head -1)
if [ -z "$ASSET" ]; then
echo "::error::no editor entry asset in dist/editor.html; the deploy gate cannot tell builds apart"
exit 1
fi
echo "expecting $ASSET"
for i in $(seq 1 60); do
LIVE=$(curl -s "$PROD_URL/editor" | grep -o 'assets/editor-[A-Za-z0-9_-]*\.js' | head -1)
if [ "$LIVE" = "$ASSET" ]; then echo "live after $((i*20))s"; exit 0; fi
sleep 20
done
echo "::warning::production still serves $LIVE after 20 min; smoking what is live"
run: node bin/wait-for-deploy.mjs "$PROD_URL" "$GITHUB_SHA"

- name: Smoke production
env:
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ notes. Entries describe what users experience, not internal refactors.

### Fixed

- Reloading after a completed save restores the saved document instead of an
older recovery snapshot or the original file.
- Opening a copied editor tab no longer removes the original tab's reload
source.
- Document links preserve encoded path and query characters, including signed
URLs with encoded delimiters.
- Gemini artifact revisions accept normally completed responses while retaining
incomplete-response protection.
- Using a downloaded model from a custom mirror preserves its source and runtime
settings; deleting another source of the same model keeps the active one loaded.
- Editing a document after more than seven idle days keeps the new recovery
bytes instead of leaving an empty history record.
- Turning off recovery, or stopping it after a storage failure, keeps ordinary
Expand Down
7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
# Copy source code
COPY . .

# .git is excluded from Docker contexts. CI/self-hosted callers can preserve
# exact source identity with --build-arg BUILD_REVISION=<git SHA>. Archives
# without a revision get an explicit content:<sha256> marker instead.
ARG BUILD_REVISION

# Build the workspace packages, encode WASM for plain HTTP, then build the app.
# Do this before build.sh hashes the vendor tree, so sw.js names the served bytes.
RUN pnpm -r run prepare && node bin/prepare-docker-wasm.mjs public && pnpm run build
RUN pnpm -r run prepare && node bin/prepare-docker-wasm.mjs public && BUILD_REVISION="$BUILD_REVISION" pnpm run build

#FROM nginxinc/nginx-unprivileged:stable-alpine
#COPY --from=builder /app/dist /usr/share/nginx/html
Expand Down
43 changes: 43 additions & 0 deletions bin/build-identity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// Stamp the final served output, after every build transform (including sw.js).
// Git archives/Docker contexts omit .git: pass BUILD_REVISION to preserve source
// identity there. Without source metadata the content identity remains explicit.
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { readdir, writeFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';

const root = resolve(process.argv[2] || 'dist');
const files = (await readdir(root, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) =>
resolve(entry.parentPath, entry.name)
.slice(root.length + 1)
.replaceAll('\\', '/'),
)
.filter((name) => name !== 'build-identity.json')
.sort();
if (!files.length) throw new Error('Cannot identify an empty build');
const tree = createHash('sha256');
for (const name of files) {
const hash = createHash('sha256');
for await (const chunk of createReadStream(resolve(root, name))) hash.update(chunk);
tree.update(name + '\0' + hash.digest('hex') + '\n');
}
const contentSha256 = tree.digest('hex');
let revision = process.env.BUILD_REVISION;
if (!revision) {
try {
revision = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: resolve(import.meta.dirname, '..'),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
revision = process.env.CF_PAGES_COMMIT_SHA || `content:${contentSha256}`;
}
}
if (!/^(?:[a-f0-9]{40,64}|content:[a-f0-9]{64})$/.test(revision)) throw new Error('Invalid build revision');
await writeFile(resolve(root, 'build-identity.json'), JSON.stringify({ schema: 1, revision, contentSha256 }) + '\n');
console.log(`Build identity: ${revision} (${contentSha256})`);
4 changes: 4 additions & 0 deletions bin/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,8 @@ else
echo "Warning: $DIST_DIR/sw.js not found, skipping version injection."
fi

# This must remain the final output transform: identity covers the exact served
# vendor, static pages and stamped worker, not only the editor entry asset.
node bin/build-identity.mjs "$DIST_DIR"

echo "Build completed successfully!"
Loading
Loading