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
59 changes: 59 additions & 0 deletions .github/workflows/intake-landing-hold.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Intake landing hold

# Keeps fork pull requests from merging while an intake candidate runs Fork CI
# or waits for promotion, so main cannot move under it. The status this sets is
# one of main's required checks; auto-merge lands held pull requests once it
# turns green. Every run recomputes the hold from workflow runs, so a missed
# event is fixed by the next one or by dispatching this workflow.
on:
pull_request_target:
types: [opened, reopened, synchronize]
branches: [main]
workflow_run:
workflows: ["Fork CI", "Promote upstream intake"]
types: [requested, completed]
workflow_dispatch:

permissions:
contents: read

jobs:
hold:
name: Set intake landing hold
if: >-
github.repository == 'incognitojam/styal' &&
(github.event_name != 'workflow_run' ||
github.event.workflow_run.name == 'Promote upstream intake' ||
startsWith(github.event.workflow_run.head_branch, 'intake/'))
runs-on: ubuntu-24.04
timeout-minutes: 5
# One queue for every event, at job level so skipped runs never take a slot. Each run
# recomputes every pull request, so a newer run may replace one still waiting.
concurrency:
group: intake-landing-hold
cancel-in-progress: false
permissions:
actions: read
contents: read
pull-requests: read
statuses: write
steps:
# pull_request_target runs main's script and never checks out pull request code.
- name: Checkout main
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: |
/scripts/upstream/
sparse-checkout-cone-mode: false

- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "24"

- name: Set statuses
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: node scripts/upstream/landing-hold.ts | tee -a "$GITHUB_STEP_SUMMARY"
6 changes: 3 additions & 3 deletions docs/operations/upstream-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Use an `intake/<batch>` branch based on fork `main`. Preserve upstream authorshi

Nothing automatically syncs upstream. Pushing an intake branch starts promotion once Fork CI passes, and a candidate that clears every automatic gate lands without approval (see [step 4](#4-promote-without-a-pr)), so push a candidate only when it is ready to land. Dispatch promotion by hand only when the maintainer explicitly requests it.

Before applying a batch, check for other active agent threads working on fork PRs with `t3_thread_list`. If any may land during intake, ask the maintainer whether to coordinate a landing hold, naming the affected threads and explaining that changes to `main` can invalidate candidate validation. Send no coordination messages without approval. With approval, use `t3_thread_send` to ask those agents to wait for intake promotion before landing their PRs; development and testing can continue. Release the hold after promotion as described in [step 4](#4-promote-without-a-pr). If intake is deferred or cancelled, notify them that the hold is released.
While an intake branch runs Fork CI or waits for promotion, the **Intake landing hold** workflow sets a pending `Intake landing hold` status on every open pull request into `main`. The status is one of `main`'s required checks, so those pull requests cannot merge and move `main` under the candidate; pull requests set to auto-merge land once the hold lifts. The hold lifts when Fork CI fails or the promotion run finishes, however it ends. To lift it for a candidate you will not land, cancel its Fork CI run or reject its waiting promotion. Each run of the workflow recomputes the hold from workflow runs; dispatch it if a status looks stale. Pushes that bypass pull requests, such as the nightly mobile version bump, are not held.

## 1. Choose the next batch

Expand Down Expand Up @@ -60,7 +60,7 @@ node scripts/upstream/intake-run.ts verify

`apply` cherry-picks each commit with diff3 conflict markers and its provenance trailer. It resolves a hunk only when the fork's side is explained: the fork's text is upstream's base with styal renames, upstream's side only renames identifiers the fork's side also uses, or one or both sides only add lines around an unchanged base. It also regenerates and pins the lockfile, gives new service keys the fork's package name, points ledger paths at files the pick moves, and drafts the `Fork adaptation:` note and `Fork-Feature` trailer for those changes. A hunk that combines both sides' added lines is reported for review, because about one in seven combines two versions of the same change. `apply` stops on any other hunk with the conflict in place; resolve and stage it, then `continue` with the note and features it needs. It also lists added lines that name upstream's product in agent- or user-facing code. When a hunk still conflicts and upstream's commit renames identifiers in that file, `apply` merges the file again with those renames applied to the fork's version and the base, and keeps the result if fewer hunks remain. `fix-references` typechecks the packages the batch touches and fixes fork references to names and modules the batch's upstream commits renamed or moved, committing each fix into the pick that made it necessary and noting the files; renames to a bare `layer` without an import, and names the file already declares, stay for a person. `fix-trailers` drops `Fork-Feature` from commits that turn out to match upstream and lists differing commits without a note. `verify` installs once, then typechecks every package the batch touches, lints and format-checks changed files, runs the whole test suite of every package the batch touches (`--changed-tests` runs only changed tests), and checks the ledger, without the server settings a styal session passes to its agents. Whole suites catch fork tests that an upstream change breaks without editing them. Last, it compares the mobile native fingerprint before and after the batch, and `auto` repeats the result when Fork CI passes; `fingerprint [--base <ref>] [--json]` runs that comparison alone.

`intake-run.ts auto --branch intake/<batch> [--max <n>] [--push]` picks the batch itself: it applies the queue in order from `origin/main` and ends the batch before the first commit that needs a person, or once it holds `--max` commits (30 by default). A commit needs a person when `apply` would stop on it or ask to review it, when it adds code outside comments and test titles, or user guide text, that names upstream's product, or when it differs from upstream without a fork note. A batch never ends inside a pull request. It then runs `fix-references`, `fix-trailers`, and `verify`, and with `--push` pushes the branch and waits for Fork CI, after which promotion starts by itself. When the first commit already needs a person, it is left staged for `continue`, as `apply` leaves a stop. Check for active agent threads before running it, as for any batch.
`intake-run.ts auto --branch intake/<batch> [--max <n>] [--push]` picks the batch itself: it applies the queue in order from `origin/main` and ends the batch before the first commit that needs a person, or once it holds `--max` commits (30 by default). A commit needs a person when `apply` would stop on it or ask to review it, when it adds code outside comments and test titles, or user guide text, that names upstream's product, or when it differs from upstream without a fork note. A batch never ends inside a pull request. It then runs `fix-references`, `fix-trailers`, and `verify`, and with `--push` pushes the branch and waits for Fork CI, after which promotion starts by itself. When the first commit already needs a person, it is left staged for `continue`, as `apply` leaves a stop.

`intake-run.ts survey [--base <ref>] [--count <n>]` applies the next queue entries from `--base` (default `HEAD`, such as the top of an unpromoted stack) in a scratch worktree and lists the commits that will need a person, with the reasons `auto` would stop for and the fork features each touches. A stopped pick takes upstream's side so the survey continues, and rerere stays off so none of those guesses is recorded. Use it to start conflict work on later commits, for example in separate worktrees, before the batch reaches them.

Expand Down Expand Up @@ -130,7 +130,7 @@ The successful local audit inspects the remote branch and reports the next actio
2. When Fork CI passes, the workflow audits the exact candidate SHA again with `main`'s code and chooses a lane:
- **Automatic**: the audit reports "Eligible for automatic promotion" (every commit matches upstream, none carries a `Fork adaptation:` note, and no sensitive path, dependency, or tracked fork feature changes) and the native fingerprint is unchanged. The promote job runs in the `upstream-intake-automatic` environment without approval.
- **Manual**: everything else, including a moved or unknown fingerprint. The promote job waits in `upstream-intake-manual`. Its name says when the fingerprint moved, and the run summary shows the audit, the fingerprint report, and the lane. Review it, then approve.
3. The promote job rechecks both tips, then fast-forwards `main` without force. Verify the resulting SHA and ensuing CI. If you coordinated a landing hold, notify those threads of the new `main` SHA and ask them to rebase and rerun affected checks before landing.
3. The promote job rechecks both tips, then fast-forwards `main` without force. Verify the resulting SHA and ensuing CI.

Production mobile builds use a fingerprint runtime version, so an OTA from **Mobile EAS Production** reaches installed builds only when the Expo fingerprint is unchanged. Dependency changes can move it without any native code change. Intake batches land without a pull request, so the Mobile Fingerprint Check never labels them; Fork CI's fingerprint job compares it the same way. If it moved, the batch's JavaScript changes reach users only through new production builds, from upstream's next mobile version bump or a `mode=build` dispatch. Approving such a batch is that choice.

Expand Down
113 changes: 113 additions & 0 deletions scripts/upstream/landing-hold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { assert, describe, it } from "@effect/vitest";
import { holdingCandidates, pullRequestStatus, type WorkflowRun } from "./landing-hold.ts";

const NOW = Date.parse("2026-10-20T12:00:00Z");
const SHA_A = "a".repeat(40);
const SHA_B = "b".repeat(40);

function forkCi(overrides: Partial<WorkflowRun>): WorkflowRun {
return {
event: "push",
status: "completed",
conclusion: "success",
head_branch: "intake/batch",
head_sha: SHA_A,
display_title: "Fork CI",
html_url: "https://example.test/ci",
updated_at: "2026-10-20T11:00:00Z",
...overrides,
};
}

function promotion(sha: string, overrides: Partial<WorkflowRun>): WorkflowRun {
return {
event: "workflow_run",
status: "completed",
conclusion: "success",
head_branch: "main",
head_sha: "c".repeat(40),
display_title: `Promote intake/batch at ${sha}`,
html_url: "https://example.test/promotion",
updated_at: "2026-10-20T11:30:00Z",
...overrides,
};
}

describe("holdingCandidates", () => {
it("holds while an intake branch runs Fork CI", () => {
const holding = holdingCandidates(
[forkCi({ status: "in_progress", conclusion: null })],
[],
NOW,
);
assert.deepStrictEqual(holding, [
{ branch: "intake/batch", sha: SHA_A, url: "https://example.test/ci" },
]);
});

it("ignores Fork CI on pull requests and main", () => {
const holding = holdingCandidates(
[
forkCi({ status: "in_progress", event: "pull_request", head_branch: "intake/batch" }),
forkCi({ status: "in_progress", head_branch: "main" }),
],
[],
NOW,
);
assert.deepStrictEqual(holding, []);
});

it("holds while the promotion waits for approval", () => {
const holding = holdingCandidates(
[forkCi({})],
[promotion(SHA_A, { status: "waiting", conclusion: null })],
NOW,
);
assert.deepStrictEqual(holding, [
{ branch: "intake/batch", sha: SHA_A, url: "https://example.test/promotion" },
]);
});

it("holds between Fork CI passing and its promotion run appearing", () => {
const holding = holdingCandidates([forkCi({ updated_at: "2026-10-20T11:55:00Z" })], [], NOW);
assert.strictEqual(holding.length, 1);
});

it("releases once the promotion finishes, however it ends", () => {
for (const conclusion of ["success", "failure", "cancelled"]) {
const holding = holdingCandidates(
[forkCi({ updated_at: "2026-10-20T11:55:00Z" })],
[promotion(SHA_A, { conclusion, updated_at: "2026-10-20T11:58:00Z" })],
NOW,
);
assert.deepStrictEqual(holding, [], conclusion);
}
});

it("releases when Fork CI fails or passed long ago without a promotion", () => {
const holding = holdingCandidates(
[
forkCi({ conclusion: "failure", updated_at: "2026-10-20T11:58:00Z" }),
forkCi({ head_sha: SHA_B, head_branch: "intake/old", updated_at: "2026-10-19T08:00:00Z" }),
],
[],
NOW,
);
assert.deepStrictEqual(holding, []);
});
});

describe("pullRequestStatus", () => {
it("is success with nothing landing and pending with a link otherwise", () => {
assert.strictEqual(pullRequestStatus([]).state, "success");
const held = pullRequestStatus([
{ branch: "intake/batch", sha: SHA_A, url: "https://example.test/ci" },
{ branch: "intake/other", sha: SHA_B, url: "https://example.test/other" },
]);
assert.deepStrictEqual(held, {
state: "pending",
description: "Waiting for intake/batch and 1 more to land",
target_url: "https://example.test/ci",
});
});
});
Loading
Loading