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
82 changes: 66 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
name: CI
run-name: "CI ${{ github.event_name }} PR #${{ github.event.pull_request.number }} head ${{ github.event.pull_request.head.sha }} base ${{ github.event.pull_request.base.sha }} merge ${{ github.sha }}"

on:
pull_request:
branches:
- lastcode/main
push:
branches:
- main
- lastcode/main

permissions:
contents: read

concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
Expand All @@ -13,20 +19,27 @@ concurrency:
jobs:
check:
name: Check
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# The pull-request merge checkout plus both parents are enough to
# compare the exact event head and base without fetching full history.
fetch-depth: 2
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false

- name: Reject repository-owned PR assets
if: ${{ github.event_name == 'pull_request' }}
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
files="$(git ls-files .github/pr-assets)"
files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- .github/pr-assets)"
if test -n "$files"; then
printf 'PR evidence must be uploaded to GitHub, not committed:\n%s\n' "$files" >&2
exit 1
Expand Down Expand Up @@ -61,10 +74,10 @@ jobs:
# dependency ordering that `vp run` applies by default: these `test` tasks
# declare no `dependsOn` and resolve workspace deps from source, so ordering
# only bought us idle runners between dependency layers. The concurrency
# limit stays at the default 4 so peak load per runner is unchanged.
# limit stays at 4 until standard-runner measurements justify a change.
test:
name: Test
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -94,7 +107,7 @@ jobs:
# isolation that flag buys is preserved exactly.
test_server:
name: Test Server ${{ matrix.shard }}
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
strategy:
fail-fast: false
Expand Down Expand Up @@ -157,11 +170,10 @@ jobs:
if-no-files-found: ignore
retention-days: 30

# Split out of Check and Test: both paid ~7-9s to install a Rust toolchain
# for checks that take under 3s, on the critical path of every PR.
# Keep the Rust toolchain and checks off the JavaScript jobs' critical paths.
rust:
name: Rust
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
Expand All @@ -183,13 +195,12 @@ jobs:
- name: Test resource monitor
run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml

# The static analysis below needs a macOS runner, which bills ~6.7x a Linux
# minute, so gate it on the native sources it actually lints instead of paying
# for it on every push. Detection is API-only (no checkout) and fails open: if
# the diff cannot be resolved, the lint runs.
# The static analysis below needs a macOS runner, so gate it on the native
# sources it actually lints. Detection is API-only (no checkout) and fails
# open: if the diff cannot be resolved, the lint runs.
mobile_native_changes:
name: Mobile Native Changes
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
Expand Down Expand Up @@ -267,7 +278,7 @@ jobs:
# Skip only on an explicit "no": a gate job that failed or errored leaves the
# output empty, and that must run the lint rather than silently skip it.
if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }}
runs-on: blacksmith-6vcpu-macos-26
runs-on: macos-26
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -295,7 +306,7 @@ jobs:

release_smoke:
name: Release Smoke
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
Expand All @@ -317,3 +328,42 @@ jobs:

- name: Exercise release-only workflow steps
run: node scripts/release-smoke.ts

ci_gate:
name: CI Gate
if: ${{ always() }}
needs:
- check
- test
- test_server
- rust
- mobile_native_changes
- mobile_native_static_analysis
- release_smoke
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
steps:
- name: Verify required jobs
env:
CHECK_RESULT: ${{ needs.check.result }}
TEST_RESULT: ${{ needs.test.result }}
TEST_SERVER_RESULT: ${{ needs.test_server.result }}
RUST_RESULT: ${{ needs.rust.result }}
MOBILE_CHANGES_RESULT: ${{ needs.mobile_native_changes.result }}
MOBILE_CHANGED: ${{ needs.mobile_native_changes.outputs.changed }}
MOBILE_STATIC_RESULT: ${{ needs.mobile_native_static_analysis.result }}
RELEASE_SMOKE_RESULT: ${{ needs.release_smoke.result }}
run: |
set -euo pipefail
test "$CHECK_RESULT" = success
test "$TEST_RESULT" = success
test "$TEST_SERVER_RESULT" = success
test "$RUST_RESULT" = success
test "$MOBILE_CHANGES_RESULT" = success
test "$RELEASE_SMOKE_RESULT" = success
if test "$MOBILE_CHANGED" = false; then
test "$MOBILE_STATIC_RESULT" = skipped
else
test "$MOBILE_STATIC_RESULT" = success
fi
8 changes: 5 additions & 3 deletions docs/lastcode/nightly-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,9 +419,11 @@ Configure the fork so that:
- checkpoint, revision, and build tags cannot be modified or deleted;
- only the owner or automation identity can force-push `lastcode/main`;
- ordinary LastCode changes arrive through PRs targeting `lastcode/main`; and
- GitHub Actions are enabled only for the manually dispatched
[LastCode Intel artifact workflow](release.md#intel-build-publication), while
ordinary CI remains disabled because local CI is authoritative.
- GitHub Actions are enabled for the manually dispatched
[LastCode Intel artifact workflow](release.md#intel-build-publication) and the
pull-request CI workflow. During the hosted-CI proof stage, guarded merge still
requires the exact local Full CI stamp; the hosted run is observed and measured
before it replaces that local PR authority.

Branch protection must permit the intentional force-with-lease promotion model.
If GitHub cannot express that narrowly enough for a personal repository, rely on
Expand Down
100 changes: 100 additions & 0 deletions scripts/lastcode-ci-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import { describe, expect, it } from "vite-plus/test";

const workflow = NodeFS.readFileSync(
NodePath.resolve(import.meta.dirname, "../.github/workflows/ci.yml"),
"utf8",
);

const gateBlock = /^ ci_gate:\n(?<body>[\s\S]*)$/mu.exec(workflow)?.groups?.body;
if (!gateBlock) throw new Error("CI workflow is missing the ci_gate job.");

const gateScriptBody = / run: \|\n(?<body>(?: .*\n?)*)$/u.exec(gateBlock)?.groups
?.body;
if (!gateScriptBody) throw new Error("CI workflow is missing the CI Gate decision script.");

const gateScript = gateScriptBody
.split("\n")
.map((line) => line.replace(/^ {10}/u, ""))
.join("\n");

const successfulGateEnvironment = {
CHECK_RESULT: "success",
TEST_RESULT: "success",
TEST_SERVER_RESULT: "success",
RUST_RESULT: "success",
MOBILE_CHANGES_RESULT: "success",
MOBILE_CHANGED: "false",
MOBILE_STATIC_RESULT: "skipped",
RELEASE_SMOKE_RESULT: "success",
};

const runGate = (overrides: Readonly<Record<string, string>> = {}): number | null =>
NodeChildProcess.spawnSync("bash", ["-c", gateScript], {
env: { ...process.env, ...successfulGateEnvironment, ...overrides },
stdio: "ignore",
}).status;

describe("LastCode GitHub CI workflow", () => {
it("targets the downstream branch on standard GitHub runners", () => {
expect(workflow).toContain(
'run-name: "CI ${{ github.event_name }} PR #${{ github.event.pull_request.number }} head ${{ github.event.pull_request.head.sha }} base ${{ github.event.pull_request.base.sha }} merge ${{ github.sha }}"',
);
expect(workflow).toContain("pull_request:\n branches:\n - lastcode/main");
expect(workflow).toContain("push:\n branches:\n - lastcode/main");
expect(workflow).toContain("permissions:\n contents: read");
expect(workflow).toContain("fetch-depth: 2");
expect(workflow).toContain('files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA"');
expect(workflow).not.toContain("blacksmith-");
expect(workflow).toContain("runs-on: ubuntu-24.04");
expect(workflow).toContain("runs-on: macos-26");
});

it("makes the stable gate depend on every validation job", () => {
for (const job of [
"check",
"test",
"test_server",
"rust",
"mobile_native_changes",
"mobile_native_static_analysis",
"release_smoke",
]) {
expect(gateBlock).toContain(` - ${job}`);
}
expect(gateBlock).toContain("name: CI Gate");
expect(gateBlock).toContain("if: ${{ always() }}");
});

it("passes when mandatory jobs succeed and irrelevant mobile analysis is skipped", () => {
expect(runGate()).toBe(0);
});

it("passes when required mobile analysis succeeds", () => {
expect(runGate({ MOBILE_CHANGED: "true", MOBILE_STATIC_RESULT: "success" })).toBe(0);
});

it("fails closed for every unsuccessful mandatory result", () => {
for (const variable of [
"CHECK_RESULT",
"TEST_RESULT",
"TEST_SERVER_RESULT",
"RUST_RESULT",
"MOBILE_CHANGES_RESULT",
"RELEASE_SMOKE_RESULT",
]) {
expect(runGate({ [variable]: "failure" })).not.toBe(0);
expect(runGate({ [variable]: "cancelled" })).not.toBe(0);
expect(runGate({ [variable]: "skipped" })).not.toBe(0);
}
});

it("accepts a skipped mobile job only after an explicit no-change result", () => {
expect(runGate({ MOBILE_CHANGED: "", MOBILE_STATIC_RESULT: "skipped" })).not.toBe(0);
expect(runGate({ MOBILE_CHANGED: "true", MOBILE_STATIC_RESULT: "skipped" })).not.toBe(0);
expect(runGate({ MOBILE_CHANGED: "false", MOBILE_STATIC_RESULT: "success" })).not.toBe(0);
});
});
Loading