Skip to content
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,12 @@ jobs:
- name: Check ASF source headers
run: npm run check:asf-headers

# Everything below needs an installed toolchain. A plan that selected no
# surface stops at the install-free checks above.
# Everything below this line may need an installed toolchain, so each
# step names the selections it belongs to. `setup-node` itself is
# unconditional: it costs seconds on a runner the job is already holding,
# and gating it needed a twelve-term disjunction over every other
# selection that a new lane had to remember to join (#4475).
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.plan.outputs.heavy == 'true'
with:
node-version: '24'
cache: npm
Expand Down
75 changes: 48 additions & 27 deletions .github/workflows/cli-package-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ on:
value: ${{ jobs.build.outputs.release_candidate_run_attempt }}
release_predecessor_version:
description: Exact npm Nightly version qualified against this candidate
value: ${{ jobs.build.outputs.release_predecessor_version }}
value: ${{ jobs.state-root-qualification.outputs.release_predecessor_version }}
release_predecessor_tarball_url:
description: Exact npm Nightly tarball qualified against this candidate
value: ${{ jobs.build.outputs.release_predecessor_tarball_url }}
value: ${{ jobs.state-root-qualification.outputs.release_predecessor_tarball_url }}
release_predecessor_integrity:
description: npm SHA-512 integrity of the Nightly tarball qualified against this candidate
value: ${{ jobs.build.outputs.release_predecessor_integrity }}
value: ${{ jobs.state-root-qualification.outputs.release_predecessor_integrity }}
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -199,9 +199,6 @@ jobs:
outputs:
release_candidate_artifact_id: ${{ steps.release-candidate.outputs.artifact-id }}
release_candidate_run_attempt: ${{ github.run_attempt }}
release_predecessor_version: ${{ steps.predecessor.outputs.version }}
release_predecessor_tarball_url: ${{ steps.predecessor.outputs.tarball_url }}
release_predecessor_integrity: ${{ steps.predecessor.outputs.integrity }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -213,12 +210,6 @@ jobs:
cache: npm
- name: Select the release npm toolchain
run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')"
# Resolving one npm version is seconds of work against a registry this
# job already reaches, so it rides the runner that waits on the addon
# builds instead of holding a second one for the whole wait.
- name: Resolve the current npm Nightly as immutable evidence
id: predecessor
run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT"
- name: Install cargo-deny
uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2
with:
Expand Down Expand Up @@ -302,7 +293,13 @@ jobs:
with:
artifact-ids: ${{ needs.build.outputs.release_candidate_artifact_id }}
path: packages/cli/release
# The two supported Node versions share a runner, so a failure on the
# first would otherwise mean the second never runs — which is the
# independence the matrix these replaced bought with `fail-fast: false`.
# The outcome is re-raised below, once both have had their turn.
- name: Validate the installed tarball
id: first-node-smoke
continue-on-error: true
run: node scripts/smoke-release-cli-package.mjs
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: matrix.second_node != ''
Expand All @@ -314,12 +311,24 @@ jobs:
- name: Validate the installed tarball on the second Node
if: matrix.second_node != ''
run: node scripts/smoke-release-cli-package.mjs
# `always()` because the second Node failing must not swallow the first.
- name: Report the first Node result
if: always() && steps.first-node-smoke.outcome != 'success'
env:
NODE_VERSION: ${{ matrix.node }}
run: |
echo "The installed tarball failed on Node $NODE_VERSION" >&2
exit 1

state-root-qualification:
name: Qualify released State Roots
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 45
outputs:
release_predecessor_version: ${{ steps.predecessor.outputs.version }}
release_predecessor_tarball_url: ${{ steps.predecessor.outputs.tarball_url }}
release_predecessor_integrity: ${{ steps.predecessor.outputs.integrity }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -340,15 +349,26 @@ jobs:
with:
artifact-ids: ${{ needs.build.outputs.release_candidate_artifact_id }}
path: packages/cli/release
# Resolved here rather than on `build`, which is the most expensive job in
# the workflow: a registry blip used to forfeit the tarball build and
# every job downstream of it, and this is the only job that reads the
# answer. It still costs no runner of its own, and the exported identity
# is now this job's output.
- name: Resolve the current npm Nightly as immutable evidence
id: predecessor
run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT"
# Three runs of one script against one sandbox, not three runners. Two of
# these transitions are between tarballs that were published and frozen,
# so nothing in a pull request can change their outcome except the
# qualifier itself, and the third reads the candidate this run built.
# qualifier itself, and the third reads the candidate this run built —
# which is why it goes first. Sharing one `set -e` with the frozen pair
# meant a flaky `curl` on either of them left the only transition a pull
# request can influence unexecuted.
- name: Qualify the released State Root transitions
env:
MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1'
PREDECESSOR_TARBALL_URL: ${{ needs.build.outputs.release_predecessor_tarball_url }}
PREDECESSOR_INTEGRITY: ${{ needs.build.outputs.release_predecessor_integrity }}
PREDECESSOR_TARBALL_URL: ${{ steps.predecessor.outputs.tarball_url }}
PREDECESSOR_INTEGRITY: ${{ steps.predecessor.outputs.integrity }}
run: |
set -euo pipefail
evidence_root="$RUNNER_TEMP/released-state-root"
Expand Down Expand Up @@ -393,6 +413,10 @@ jobs:
echo "::endgroup::"
}

qualify current-nightly-predecessor-to-candidate \
"$PREDECESSOR_TARBALL_URL" '' "$PREDECESSOR_INTEGRITY" \
candidate '' '' any

qualify cross-epoch-74-to-76 \
https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.3.20260830.tgz \
66b1ce9307c9d5c06eaa7a6cbf533d4747d02caf71c1776c69c7dbfa12c3f414 '' \
Expand All @@ -404,29 +428,26 @@ jobs:
b7d48adb466e16be7ffefbda3a0fcd833cc4108ea502b27778d0f4da680e1fc0 '' \
published https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.5.20260830.tgz \
e7a682157c6899fc7f1be86a2d7b0bd0696195a5771d8cc97bd1389a5b74989f same

qualify current-nightly-predecessor-to-candidate \
"$PREDECESSOR_TARBALL_URL" '' "$PREDECESSOR_INTEGRITY" \
candidate '' '' any
# The three transitions used to be three matrix jobs, so one failing left
# the others to upload their own reports. Folded into one step they share
# a `set -e`, and the reports are wanted most on the run that failed —
# `tee` has already written the failing transition's own output by then.
# `if-no-files-found` stays `error` so a broken path is still caught on a
# green run.
# a `set -e`, and `tee` has already written the failing transition's own
# output by then. `if-no-files-found` stays `error` on a green run, where
# an empty directory means a broken path; on a red one it drops to `warn`,
# because a `curl` that failed before any `tee` would otherwise add a
# second, unrelated red to a job that already reported the real one.
- name: Preserve the qualification reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: released-state-root
path: ${{ runner.temp }}/released-state-root/*-report.json
if-no-files-found: error
if-no-files-found: ${{ job.status == 'success' && 'error' || 'warn' }}
retention-days: 7
- name: Require the qualified Nightly predecessor to remain current
env:
PREDECESSOR_VERSION: ${{ needs.build.outputs.release_predecessor_version }}
PREDECESSOR_TARBALL_URL: ${{ needs.build.outputs.release_predecessor_tarball_url }}
PREDECESSOR_INTEGRITY: ${{ needs.build.outputs.release_predecessor_integrity }}
PREDECESSOR_VERSION: ${{ steps.predecessor.outputs.version }}
PREDECESSOR_TARBALL_URL: ${{ steps.predecessor.outputs.tarball_url }}
PREDECESSOR_INTEGRITY: ${{ steps.predecessor.outputs.integrity }}
run: |
node scripts/release-cli-publication.mjs assert-nightly-predecessor \
"$PREDECESSOR_VERSION" \
Expand Down
21 changes: 3 additions & 18 deletions scripts/ci-test-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ const RELEASE_CONTRACT_FILES = new Set([
'scripts/windows-upgrade-baseline.json',
'scripts/windows-package-source-closure.mjs',
'scripts/windows-package-source-closure.test.mjs',
// Reads the filter that closure test compares against, and `check:release`
// is the only gate that runs it against `release-windows-check.yml`.
'scripts/workflow-pull-request-paths.mjs',
]);

// What decides whether a build can read durable state an earlier release wrote.
Expand Down Expand Up @@ -531,23 +534,6 @@ export function planTests(changedFiles, options = {}) {
};
}

export function requiresHeavyValidation(plan) {
return Boolean(
plan.appIcons ||
plan.asfSource ||
plan.astryxSurface ||
plan.cliPackage ||
plan.code ||
plan.e2e ||
plan.releaseContract ||
plan.runtimeHost ||
plan.runtimeSandbox ||
plan.stateRootCompat ||
plan.storybook ||
plan.standardWorkspaces.length > 0,
);
}

export function formatGitHubOutputs(plan) {
return [
`app_icons=${plan.appIcons}`,
Expand All @@ -556,7 +542,6 @@ export function formatGitHubOutputs(plan) {
`cli_package=${plan.cliPackage}`,
`code=${plan.code}`,
`e2e=${plan.e2e}`,
`heavy=${requiresHeavyValidation(plan)}`,
`runtime_host=${plan.runtimeHost}`,
`runtime_sandbox=${plan.runtimeSandbox}`,
`release_contract=${plan.releaseContract}`,
Expand Down
44 changes: 24 additions & 20 deletions scripts/ci-test-plan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,21 @@ import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import test from 'node:test';

import {
changedFilesBetween,
formatGitHubOutputs,
planTests,
requiresHeavyValidation,
} from './ci-test-plan.mjs';
import { changedFilesBetween, formatGitHubOutputs, planTests } from './ci-test-plan.mjs';

/**
* Every surface a plan selected, read off the plan itself rather than from a
* list of the ones worth naming. `assert.deepEqual(selections(plan), [])` is
* therefore the whole "this change costs nothing" claim, and a selection added
* later joins it without anyone editing these tests.
*/
function selections(plan) {
return Object.entries(plan)
.filter(([key]) => key !== 'workspaces')
.filter(([, value]) => (Array.isArray(value) ? value.length > 0 : Boolean(value)))
.map(([key]) => key)
.sort();
}

const dirs = [
'packages/core',
Expand All @@ -62,34 +71,29 @@ const graph = {
testDirs: new Set(dirs),
};

test('documentation-only changes do not select code validation', () => {
test('documentation-only changes select nothing at all', () => {
const plan = planTests(['docs/ci.md'], { graph });

assert.equal(plan.code, false);
assert.equal(plan.asfSource, false);
assert.equal(plan.astryxSurface, false);
assert.equal(requiresHeavyValidation(plan), false);
assert.deepEqual(selections(plan), []);
assert.deepEqual(plan.workspaces, []);
});

test('documentation inside workspaces does not select heavy validation', () => {
test('documentation inside workspaces selects nothing at all', () => {
for (const path of ['packages/runtime/README.md', 'apps/desktop/README.md']) {
const plan = planTests([path], { graph });

assert.equal(plan.code, false, path);
assert.equal(requiresHeavyValidation(plan), false, path);
assert.deepEqual(selections(plan), [], path);
assert.deepEqual(plan.workspaces, [], path);
}
});

test('mixed documentation and code changes still select heavy validation', () => {
test('mixed documentation and code changes still select code validation', () => {
const plan = planTests(['README.md', 'packages/core/src/index.ts'], { graph });

assert.equal(plan.code, true);
assert.equal(requiresHeavyValidation(plan), true);
});

test('documentation with a dedicated contract still selects heavy validation', () => {
test('documentation with a dedicated contract still selects that contract', () => {
for (const path of [
'LICENSE',
'docs/astryx-surface-file-inventory.md',
Expand All @@ -98,7 +102,7 @@ test('documentation with a dedicated contract still selects heavy validation', (
const plan = planTests([path], { graph });

assert.equal(plan.code, false, path);
assert.equal(requiresHeavyValidation(plan), true, path);
assert.notDeepEqual(selections(plan), [], path);
}
});

Expand All @@ -117,7 +121,7 @@ test('changed files are derived from the PR merge base', () => {
const changedFiles = changedFilesBetween('main-now', 'pr-head', exec);

assert.deepEqual(changedFiles, ['packages/runtime/README.md']);
assert.equal(requiresHeavyValidation(planTests(changedFiles, { graph })), false);
assert.deepEqual(selections(planTests(changedFiles, { graph })), []);
assert.deepEqual(calls, [
['merge-base', 'main-now', 'pr-head'],
['diff', '--no-renames', '--name-only', '--diff-filter=ACMRDT', 'fork-point', 'pr-head'],
Expand All @@ -134,7 +138,7 @@ test('type changes remain in the PR-owned delta', () => {
const changedFiles = changedFilesBetween('main-now', 'pr-head', exec);

assert.deepEqual(changedFiles, ['packages/runtime/src/runtime.ts']);
assert.equal(requiresHeavyValidation(planTests(changedFiles, { graph })), true);
assert.equal(planTests(changedFiles, { graph }).code, true);
});

test('the Astryx inventory can run without selecting the code suite', () => {
Expand Down
Loading
Loading