fix(release): expose Linux builder admission and retain failed bundles (#170297)

* fix(release): expose Linux builder admission and retain failed bundles

* fix(release): preserve unconfirmed Linux dispatch status

* fix(ci): make Linux build event admission explicit

* fix(ci): separate Linux mirror dispatch from release builds

* fix(ci): budget cold Linux source package builds
This commit is contained in:
Dallin Romney 2026-10-14 18:14:52 -07:00 • committed by GitHub
parent d5e4322d49
commit 614f559915
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 804 additions and 157 deletions

View file

@ -51,6 +51,9 @@ paths:
.github/workflows/docker-channel-promote.yml:
ignore:
- 'unexpected key "queue" for "concurrency" section'
.github/workflows/linux-app-mirror.yml:
ignore:
- 'unexpected key "queue" for "concurrency" section'
.github/workflows/linux-app-release.yml:
ignore:
- 'unexpected key "queue" for "concurrency" section'

124
.github/workflows/linux-app-mirror.yml vendored Normal file
View file

@ -0,0 +1,124 @@
name: Linux App Mirror
run-name: Linux App Release mirror=${{ inputs.release_publish_run_id }}
on:
workflow_dispatch:
inputs:
release_tag:
description: "Published core tag to reconcile (mirror only; no native build)"
required: true
type: string
source_sha:
description: "Exact published core source SHA"
required: true
type: string
tooling_sha:
description: "Exact protected parent workflow SHA"
required: true
type: string
release_publish_run_id:
description: "Original core publication run"
required: true
type: string
release_publish_run_attempt:
description: "Exact original core publication attempt"
required: true
type: string
permissions: {}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
mirror_legacy:
name: Reconcile legacy Linux updater metadata
if: ${{ github.event_name == 'workflow_dispatch' && github.repository == 'openclaw/openclaw' }}
# The core parent dispatches and exits; this queue never holds that parent.
concurrency:
group: linux-app-release-publish
queue: max
cancel-in-progress: false
runs-on: ubuntu-22.04
timeout-minutes: 10
permissions:
actions: read
contents: write
steps:
- name: Checkout trusted Linux mirror tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.workflow_sha }}
fetch-depth: 1
persist-credentials: false
sparse-checkout: |
apps/linux/src-tauri/tauri.conf.json
scripts/linux-app-channel.mjs
scripts/lib/release-version.mjs
scripts/lib/record-shared.mjs
scripts/release-tooling-identity.mjs
sparse-checkout-cone-mode: false
- name: Setup supported Node runtime
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24.21.0"
package-manager-cache: false
- name: Verify detached mirror dispatch identity
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_TOOLING_SHA: ${{ inputs.tooling_sha }}
PARENT_RUN_ID: ${{ inputs.release_publish_run_id }}
PARENT_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }}
WORKFLOW_FULL_REF: ${{ github.ref }}
WORKFLOW_REF: ${{ github.ref_name }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
[[ "$WORKFLOW_FULL_REF" == refs/tags/release-publish/* && "$EXPECTED_TOOLING_SHA" == "$WORKFLOW_SHA" ]]
node scripts/release-tooling-identity.mjs verify \
--repository "$GITHUB_REPOSITORY" \
--workflow-ref "$WORKFLOW_REF" --workflow-full-ref "$WORKFLOW_FULL_REF" \
--workflow-sha "$WORKFLOW_SHA" \
--release-publish-run-id "$PARENT_RUN_ID" \
--release-publish-run-attempt "$PARENT_RUN_ATTEMPT" \
--release-publish-ref "$WORKFLOW_REF" --release-publish-full-ref "$WORKFLOW_FULL_REF" \
--release-publish-parent-state-policy active-or-success
- name: Mirror verified canonical bytes to the actual core latest
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
TAG_SHA: ${{ inputs.source_sha }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
WORKFLOW_REF: ${{ github.ref_name }}
WORKFLOW_FULL_REF: ${{ github.ref }}
PARENT_RUN_ID: ${{ inputs.release_publish_run_id }}
PARENT_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }}
run: |
set -euo pipefail
node scripts/linux-app-channel.mjs mirror \
--tag "$RELEASE_TAG" --source-sha "$TAG_SHA" --tooling-sha "$WORKFLOW_SHA" \
--workflow-ref "$WORKFLOW_REF" --workflow-full-ref "$WORKFLOW_FULL_REF" \
--release-publish-run-id "$PARENT_RUN_ID" --release-publish-run-attempt "$PARENT_RUN_ATTEMPT" \
--release-publish-ref "$WORKFLOW_REF" --release-publish-full-ref "$WORKFLOW_FULL_REF" \
--public-key-config apps/linux/src-tauri/tauri.conf.json \
2> "$RUNNER_TEMP/linux-mirror.log" | tee "$RUNNER_TEMP/linux-mirror.json"
- name: Record degraded legacy Linux bridge
if: ${{ failure() }}
run: |
echo "::warning::Legacy Linux update delivery is degraded; core publication remains complete. Reconcile canonical/latest identity before recovering this mirror-only dispatch."
echo "- Legacy Linux bridge: incomplete. Dispatch acceptance was not mirror success; inspect this run before recovery." >> "$GITHUB_STEP_SUMMARY"
- name: Retain Linux mirror result
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: linux-mirror-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ runner.temp }}/linux-mirror.json
${{ runner.temp }}/linux-mirror.log
if-no-files-found: warn

View file

@ -1,28 +1,8 @@
name: Linux App Release
run-name: Linux App Release request=${{ github.event.workflow_run.id }} attempt=${{ github.event.workflow_run.run_attempt }}
on:
workflow_dispatch:
inputs:
release_tag:
description: "Published core tag to reconcile (mirror only; no native build)"
required: true
type: string
source_sha:
description: "Exact published core source SHA"
required: true
type: string
tooling_sha:
description: "Exact protected parent workflow SHA"
required: true
type: string
release_publish_run_id:
description: "Original core publication run"
required: true
type: string
release_publish_run_attempt:
description: "Exact original core publication attempt"
required: true
type: string
workflow_run: # zizmor: ignore[dangerous-triggers] main-only release builder; exact successful request/main identity is validated before selected-tag execution, while signing and publication stay in fresh jobs
workflows: ["Linux App Release Request"]
branches: [main]
@ -32,7 +12,7 @@ permissions: {}
# Serial admission lets later requests reuse the first completed publication.
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('linux-app-mirror-{0}', github.run_id) || 'linux-app-release-build' }}
group: linux-app-release-build
cancel-in-progress: false
queue: max
@ -246,6 +226,40 @@ jobs:
echo "- Linux: existing same-tag bundles and signed updater metadata verified; no rebuild needed. Current updater publication follows." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Record Linux builder admission
if: ${{ always() }}
env:
REQUEST_RUN_ID: ${{ github.event.workflow_run.id }}
REQUEST_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
REQUEST_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
RELEASE_TAG: ${{ steps.request.outputs.release_tag }}
TAG_SHA: ${{ steps.ancestry.outputs.tag_sha }}
ADMISSION_STATUS: ${{ job.status }}
run: |
set -euo pipefail
jq -n --arg repository "$GITHUB_REPOSITORY" \
--arg requestRunId "$REQUEST_RUN_ID" --arg requestRunAttempt "$REQUEST_RUN_ATTEMPT" \
--arg requestSha "$REQUEST_HEAD_SHA" --arg builderRunId "$GITHUB_RUN_ID" \
--arg builderRunAttempt "$GITHUB_RUN_ATTEMPT" --arg toolingSha "$WORKFLOW_SHA" \
--arg tag "$RELEASE_TAG" --arg sourceSha "$TAG_SHA" --arg status "$ADMISSION_STATUS" \
'{schemaVersion: 1, repository: $repository,
request: {runId: $requestRunId, runAttempt: $requestRunAttempt, headSha: $requestSha},
builder: {runId: $builderRunId, runAttempt: $builderRunAttempt, toolingSha: $toolingSha},
tag: (if $tag == "" then null else $tag end),
sourceSha: (if $sourceSha == "" then null else $sourceSha end),
admission: (if $status == "success" then "admitted" else "rejected" end)}' \
> "$RUNNER_TEMP/linux-release-admission.json"
echo "- Linux builder: $ADMISSION_STATUS; request https://github.com/$GITHUB_REPOSITORY/actions/runs/$REQUEST_RUN_ID; builder https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID." >> "$GITHUB_STEP_SUMMARY"
- name: Retain Linux builder admission
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: linux-release-admission-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/linux-release-admission.json
if-no-files-found: error
- name: Retain Linux completion evidence
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@ -357,6 +371,7 @@ jobs:
.release-tooling/apps/linux/scripts/tauri-appimage-tools.sh verify pre-build
- name: Build Linux companion bundles
id: bundle-build
working-directory: apps/linux/src-tauri
env:
# appimagetool strips fail on CI runners; Tauri documents NO_STRIP for Actions.
@ -374,6 +389,7 @@ jobs:
--config "{\"version\":\"${version}\",\"bundle\":{\"createUpdaterArtifacts\":false,\"useLocalToolsDir\":false}}"
- name: Finalize AppImage
id: finalize-appimage
env:
XDG_CACHE_HOME: ${{ runner.temp }}/openclaw-tauri-cache
run: |
@ -382,6 +398,7 @@ jobs:
.release-tooling/apps/linux/scripts/finalize-appimage.sh "$bundle_dir"
- name: Verify and stage unsigned Linux bundles
id: stage-bundles
env:
RELEASE_TAG: ${{ needs.validate_release.outputs.release_tag }}
run: |
@ -434,6 +451,51 @@ jobs:
--shell-container \
"archlinux@sha256:82b1b08faae9d61e3e7e13d562f4d09114d939105b0d59ff34140f3bd418593a"
# Diagnostic bytes never use the qualified artifact IDs consumed by signing.
- name: Retain completed unqualified Linux bundles
if: ${{ failure() && steps.bundle-build.outcome == 'success' }}
env:
RELEASE_TAG: ${{ needs.validate_release.outputs.release_tag }}
TAG_SHA: ${{ needs.validate_release.outputs.tag_sha }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
FINALIZE_OUTCOME: ${{ steps.finalize-appimage.outcome }}
STAGING_OUTCOME: ${{ steps.stage-bundles.outcome }}
RUNTIME_OUTCOME: ${{ steps.packaged-runtime.outcome }}
run: |
set -euo pipefail
shopt -s nullglob
output="$RUNNER_TEMP/linux-unqualified-bundles"
mkdir -p "$output/bundles"
bundles=(apps/linux/src-tauri/target/release/bundle/deb/*.deb apps/linux/src-tauri/target/release/bundle/appimage/*.AppImage)
for bundle in "${bundles[@]}"; do
[[ -f "$bundle" && ! -L "$bundle" ]]
cp "$bundle" "$output/bundles/"
done
if [[ ${#bundles[@]} -eq 0 ]]; then
echo "::warning::No completed Linux bundle files remain for diagnostics."
exit 0
fi
(cd "$output" && sha256sum bundles/* > SHA256SUMS)
jq -n --arg repository "$GITHUB_REPOSITORY" --arg tag "$RELEASE_TAG" \
--arg sourceSha "$TAG_SHA" --arg toolingSha "$WORKFLOW_SHA" \
--arg runId "$GITHUB_RUN_ID" --arg runAttempt "$GITHUB_RUN_ATTEMPT" \
--arg finalize "$FINALIZE_OUTCOME" --arg staging "$STAGING_OUTCOME" --arg runtime "$RUNTIME_OUTCOME" \
'{schemaVersion: 1, qualification: "unqualified", repository: $repository, tag: $tag,
sourceSha: $sourceSha, toolingSha: $toolingSha, runId: $runId, runAttempt: $runAttempt,
checks: {finalize: $finalize, staging: $staging, packagedRuntime: $runtime}}' \
> "$output/identity.json"
echo "- Linux: completed bundles retained for diagnostics only; not eligible for signing or publication. Verify identity.json and SHA256SUMS before rerunning the packaging checker on these exact bytes." >> "$GITHUB_STEP_SUMMARY"
- name: Upload unqualified Linux bundle diagnostics
if: ${{ failure() && steps.bundle-build.outcome == 'success' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: linux-app-release-unqualified-${{ github.run_id }}-${{ github.run_attempt }}
retention-days: 14
compression-level: 0
if-no-files-found: warn
path: ${{ runner.temp }}/linux-unqualified-bundles
- name: Upload packaged runtime failure evidence
if: failure() && steps.packaged-runtime.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@ -1131,94 +1193,3 @@ jobs:
${{ runner.temp }}/linux-publication.log
${{ runner.temp }}/linux-release-before-upload
if-no-files-found: ignore
mirror_legacy:
name: Reconcile legacy Linux updater metadata
if: ${{ github.event_name == 'workflow_dispatch' && github.repository == 'openclaw/openclaw' }}
# The core parent dispatches and exits; this queue never holds that parent.
concurrency:
group: linux-app-release-publish
queue: max
cancel-in-progress: false
runs-on: ubuntu-22.04
timeout-minutes: 10
permissions:
actions: read
contents: write
steps:
- name: Checkout trusted Linux mirror tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.workflow_sha }}
fetch-depth: 1
persist-credentials: false
sparse-checkout: |
apps/linux/src-tauri/tauri.conf.json
scripts/linux-app-channel.mjs
scripts/lib/release-version.mjs
scripts/lib/record-shared.mjs
scripts/release-tooling-identity.mjs
sparse-checkout-cone-mode: false
- name: Setup supported Node runtime
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24.21.0"
package-manager-cache: false
- name: Verify detached mirror dispatch identity
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_TOOLING_SHA: ${{ inputs.tooling_sha }}
PARENT_RUN_ID: ${{ inputs.release_publish_run_id }}
PARENT_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }}
WORKFLOW_FULL_REF: ${{ github.ref }}
WORKFLOW_REF: ${{ github.ref_name }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
[[ "$WORKFLOW_FULL_REF" == refs/tags/release-publish/* && "$EXPECTED_TOOLING_SHA" == "$WORKFLOW_SHA" ]]
node scripts/release-tooling-identity.mjs verify \
--repository "$GITHUB_REPOSITORY" \
--workflow-ref "$WORKFLOW_REF" --workflow-full-ref "$WORKFLOW_FULL_REF" \
--workflow-sha "$WORKFLOW_SHA" \
--release-publish-run-id "$PARENT_RUN_ID" \
--release-publish-run-attempt "$PARENT_RUN_ATTEMPT" \
--release-publish-ref "$WORKFLOW_REF" --release-publish-full-ref "$WORKFLOW_FULL_REF" \
--release-publish-parent-state-policy active-or-success
- name: Mirror verified canonical bytes to the actual core latest
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
TAG_SHA: ${{ inputs.source_sha }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
WORKFLOW_REF: ${{ github.ref_name }}
WORKFLOW_FULL_REF: ${{ github.ref }}
PARENT_RUN_ID: ${{ inputs.release_publish_run_id }}
PARENT_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }}
run: |
set -euo pipefail
node scripts/linux-app-channel.mjs mirror \
--tag "$RELEASE_TAG" --source-sha "$TAG_SHA" --tooling-sha "$WORKFLOW_SHA" \
--workflow-ref "$WORKFLOW_REF" --workflow-full-ref "$WORKFLOW_FULL_REF" \
--release-publish-run-id "$PARENT_RUN_ID" --release-publish-run-attempt "$PARENT_RUN_ATTEMPT" \
--release-publish-ref "$WORKFLOW_REF" --release-publish-full-ref "$WORKFLOW_FULL_REF" \
--public-key-config apps/linux/src-tauri/tauri.conf.json \
2> "$RUNNER_TEMP/linux-mirror.log" | tee "$RUNNER_TEMP/linux-mirror.json"
- name: Record degraded legacy Linux bridge
if: ${{ failure() }}
run: |
echo "::warning::Legacy Linux update delivery is degraded; core publication remains complete. Reconcile canonical/latest identity before recovering this mirror-only dispatch."
echo "- Legacy Linux bridge: incomplete. Dispatch acceptance was not mirror success; inspect this run before recovery." >> "$GITHUB_STEP_SUMMARY"
- name: Retain Linux mirror result
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: linux-mirror-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ runner.temp }}/linux-mirror.json
${{ runner.temp }}/linux-mirror.log
if-no-files-found: warn

View file

@ -28,8 +28,8 @@ jobs:
name: Build Linux companion
# Match the release build base for native tests and manually built bundles.
runs-on: ubuntu-22.04
# Source runtime staging includes the canonical package/declaration build.
timeout-minutes: 60
# Leave room for cold source packaging and the native desktop proof afterward.
timeout-minutes: 105
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -138,6 +138,9 @@ jobs:
- name: Build native inline browser candidate
working-directory: apps/linux/src-tauri
env:
# Cold Linux declarations also exceed the package builder's 45-minute default.
OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS: "3600000"
run: |
node ../scripts/stage-runtime.mjs
# The native fixture supplies its CLI at the same build-time boundary.

View file

@ -18,13 +18,15 @@ agent-turn prefixes. See [prompt-cache regression coverage](/help/testing/suites
Native video smoke coverage uses four shards of four providers. Each provider has a ten-minute operation timeout plus 30 seconds of test overhead; each shard has a 50-minute job budget, leaving eight minutes for setup. These shards keep full-mode video testing disabled.
The `Linux App` Windows installer job allows 105 minutes for source staging and
packaging. Its canonical core build uses the existing
The `Linux App` Linux companion and Windows installer jobs allow 105 minutes for
source staging, packaging, and native proof. Their canonical core builds use the existing
`OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS` override with a 60-minute allowance:
cold Windows declarations exceeded the default 45-minute package deadline, while
a complete installer job previously took 78 minutes. The remaining job budget
covers dependency installation, Rust compilation, and NSIS packaging. Linux and
release-workflow budgets are unchanged.
cold declarations exceeded the default 45-minute package deadline. A Linux run
completed its package declarations in 38 minutes before the deadline interrupted
the following declaration phase; a complete Windows installer job previously took
78 minutes. The remaining job budget covers dependency installation, Rust
compilation, desktop tests, and installer packaging. The override stays on each
source-build step; release-workflow budgets and package-integrity checks are unchanged.
Broad PRs retain their compact selected-owner Node plan when time-based splitting
would exceed the 130-row matrix cap. See [Node test lanes](/ci/scope-and-routing/node-test-lanes).

View file

@ -263,7 +263,21 @@ registry readback, binding and asset checks remain separate. Failed publishers
can retain successful readback and partial package results. Docker and VCR
remain parent jobs; VCR copy, smoke and alias values are API step conclusions.
Detached Windows acknowledgement, its pre-upload marker and the current child
conclusion are separate observations. The normal ClawHub dispatch record is not
conclusion are separate observations. Linux requests likewise remain
`awaiting-admission` until the exact downstream builder records admission. The
reader binds the request attempt, builder identity, selected tag, and source SHA;
main advancing can produce an explicit receiver rejection, not a silent pending
request. Builder completion and admission are not publication qualification.
The `Linux App Release` workflow accepts only completed release requests through
`workflow_run`. Detached mirror-only dispatches use `Linux App Mirror`
(`linux-app-mirror.yml`); they never select or build release-tag code.
If Linux packaging checks fail after bundles finish building, the builder retains
`linux-app-release-unqualified-<run-id>-<attempt>` for 14 days. Its `identity.json`
records source, tooling, and failed checks; `SHA256SUMS` binds the retained bundle
bytes. Verify those facts before rerunning the packaging checker on downloaded
outputs. These diagnostics cannot enter signing or publication; there is no
shortcut that converts a retained failed bundle into a qualified release. The normal ClawHub dispatch record is not
a complete child inventory. Mac assets/feeds and website deployment are not
collected by this reader. Use the native publication and website deployment
owners for their current readbacks. The [website owner's download checks](https://github.com/openclaw/openclaw.ai#companion-downloads)

View file

@ -106,7 +106,8 @@ const runSchema = z.object({
head_branch: ref,
path: z.string().max(500),
display_title: z.string().max(500).optional(),
event: z.literal("workflow_dispatch"),
created_at: z.iso.datetime().optional(),
event: z.enum(["workflow_dispatch", "workflow_run"]),
status: runState,
conclusion: conclusion.nullable(),
actor: actor.optional(),
@ -114,12 +115,18 @@ const runSchema = z.object({
});
export type PublicationRun = z.infer<typeof runSchema>;
export function parsePublicationRun(value: unknown, repo: string, runId: string): PublicationRun {
export function parsePublicationRun(
value: unknown,
repo: string,
runId: string,
event: PublicationRun["event"] = "workflow_dispatch",
): PublicationRun {
const parsed = runSchema.safeParse(value);
requirePublication(parsed.success);
const run = parsed.data;
requirePublication(
String(run.id) === runId &&
run.event === event &&
run.repository.full_name === repo &&
(!run.head_repository || run.head_repository.full_name === repo),
);
@ -162,6 +169,7 @@ export function assertPublicationRunUnchanged(before: PublicationRun, after: Pub
run.head_branch,
run.path.split("@")[0],
run.event,
run.created_at ?? null,
];
// Lifecycle, display and actor-login observations can change without a new identity.
requirePublication(isDeepStrictEqual(identity(before), identity(after)));
@ -322,6 +330,7 @@ const surfaces = [
"activation",
"vcr",
"nativeWindows",
"nativeLinux",
"nativeAndroid",
"nativeMac",
"npmTelegram",
@ -366,7 +375,7 @@ export type PublicationObservation = {
binding: Phase;
assets: Phase;
dispatches: {
scope: "normal-clawhub" | "windows";
scope: "normal-clawhub" | "windows" | "linux";
state: "acknowledged" | "not-dispatched" | "unknown";
runId: string;
runAttempt: number;
@ -410,9 +419,14 @@ export function newPublicationObservation(
verification: phase(),
registryObservation: phase(),
terminalMarker: phase(),
advisory: ["vcr", "nativeWindows", "nativeAndroid", "nativeMac", "npmTelegram"].includes(
name,
),
advisory: [
"vcr",
"nativeWindows",
"nativeLinux",
"nativeAndroid",
"nativeMac",
"npmTelegram",
].includes(name),
jobs: [],
children: [],
verificationError: null,
@ -673,6 +687,83 @@ export function observePublicationChild(
});
}
export function parseLinuxDispatch(value: unknown, sourceSha: string) {
const parsed = z
.object({
tag: z
.string()
.max(81)
.regex(/^v[0-9]{4}\.[0-9]+\.[0-9]+(?:-[0-9]+)?$/u),
sourceSha: sha.optional(),
state: z.enum([
"request-dispatched",
"request-reused",
"published-assets-reused",
"dispatch-unconfirmed",
]),
requestRunId: decimal.optional(),
requestRunAttempt: decimal.optional(),
workflowSha: sha.optional(),
})
.safeParse(value);
requirePublication(parsed.success, "malformed-evidence");
requirePublication(!parsed.data.sourceSha || parsed.data.sourceSha === sourceSha);
if (["request-dispatched", "request-reused"].includes(parsed.data.state)) {
requirePublication(parsed.data.requestRunId && parsed.data.workflowSha);
}
return parsed.data;
}
export function observeLinuxAdmission(
observation: PublicationObservation,
value: unknown,
request: PublicationRun,
builder: PublicationRun,
tag: string,
sourceSha: string,
artifactId: number,
) {
const parsed = z
.object({
schemaVersion: z.literal(1),
repository,
request: z.object({ runId: decimal, runAttempt: decimal, headSha: sha }),
builder: z.object({ runId: decimal, runAttempt: decimal, toolingSha: sha }),
tag: z.string().nullable(),
sourceSha: sha.nullable(),
admission: z.enum(["admitted", "rejected"]),
})
.safeParse(value);
requirePublication(parsed.success, "malformed-evidence");
const record = parsed.data;
requirePublication(
record.repository === request.repository.full_name &&
record.request.runId === String(request.id) &&
record.request.runAttempt === String(request.run_attempt) &&
record.request.headSha === request.head_sha &&
record.builder.runId === String(builder.id) &&
record.builder.runAttempt === String(builder.run_attempt) &&
record.builder.toolingSha === builder.head_sha,
);
requirePublication(
record.admission !== "admitted" ||
(record.tag === tag &&
record.sourceSha === sourceSha &&
record.builder.toolingSha === request.head_sha),
);
observation.surfaces.nativeLinux.terminalMarker = {
state: record.admission,
evidence: [
{
kind: "terminal-marker",
runId: String(builder.id),
runAttempt: builder.run_attempt,
artifactId,
},
],
};
}
export function parseWindowsDispatch(value: unknown) {
const parsed = z
.object({

View file

@ -2060,7 +2060,9 @@ async function createPublicationReader(repository) {
const ids = new Set();
let total;
for (let page = 1; page <= limits.pages; page += 1) {
const value = json(`${path}?per_page=${limits.pageSize}&page=${page}`);
const value = json(
`${path}${path.includes("?") ? "&" : "?"}per_page=${limits.pageSize}&page=${page}`,
);
requirePublication(
Number.isSafeInteger(value?.total_count) &&
value.total_count >= 0 &&
@ -2090,13 +2092,15 @@ async function createPublicationReader(repository) {
}
throw new PublicationStatusError("limits");
}
async function getRun(runId) {
async function getRun(runId, options = {}) {
const event = options.event ?? "workflow_dispatch";
const key = String(runId);
requirePublication(/^[1-9][0-9]*$/u.test(key) && Number.isSafeInteger(Number(key)), "limits");
if (!runs.has(key)) {
requirePublication(runs.size < limits.runs, "limits");
runs.set(key, parsePublicationRun(json(`actions/runs/${key}`), repository, key));
runs.set(key, parsePublicationRun(json(`actions/runs/${key}`), repository, key, event));
}
requirePublication(runs.get(key).event === event);
return runs.get(key);
}
async function authenticate(run, path) {
@ -2114,6 +2118,7 @@ async function createPublicationReader(repository) {
json(`actions/runs/${runId}/attempts/${attempt}`),
repository,
String(runId),
current.event,
);
requirePublication(
observed.run_attempt === attempt &&
@ -2201,6 +2206,17 @@ async function createPublicationReader(repository) {
authenticate,
readArtifact,
listArtifacts,
findLinuxBuilders(request) {
requirePublication(request.created_at, "incomplete");
const title = `Linux App Release request=${request.id} attempt=${request.run_attempt}`;
// workflow_run uses current main, which can reject an older request.
// Filter by request creation, not SHA, so that rejection remains visible.
const created = encodeURIComponent(`>=${request.created_at}`);
return collection(
`actions/workflows/linux-app-release.yml/runs?event=workflow_run&branch=main&created=${created}`,
"workflow_runs",
).filter((run) => run.display_title === title);
},
async getAttemptJobs(runId, attempt) {
return parsePublicationJobs(
collection(`actions/runs/${runId}/attempts/${attempt}/jobs`, "jobs"),
@ -2213,7 +2229,7 @@ async function createPublicationReader(repository) {
try {
assertPublicationRunUnchanged(
run,
parsePublicationRun(json(`actions/runs/${runId}`), repository, runId),
parsePublicationRun(json(`actions/runs/${runId}`), repository, runId, run.event),
);
} catch (error) {
throw new PublicationStatusError(publicationFailure(error), runId);
@ -2243,6 +2259,8 @@ async function inspectPublicationStatus(options) {
joinPublicationValidation,
observePublicationChild,
parseWindowsDispatch,
parseLinuxDispatch,
observeLinuxAdmission,
observeWindowsMarker,
parseClawHubDispatch,
} = policy;
@ -2429,6 +2447,110 @@ async function inspectPublicationStatus(options) {
);
}
}
const linux = await reader.readArtifact(
publisher,
`linux-release-dispatch-${publisher.id}-${publisher.run_attempt}`,
"linux-dispatch.json",
PUBLICATION_LIMITS.diagnosticBytes,
);
if (linux.state === "available") {
requireArtifactUploadJob(
jobs,
"Request Linux assets after publication",
"Retain Linux request evidence",
);
const dispatch = parseLinuxDispatch(linux.value, plan.targetSha);
requirePublication(
!diagnostic?.context.releaseTag || dispatch.tag === diagnostic.context.releaseTag,
);
const surface = publication.surfaces.nativeLinux;
surface.selection = "selected";
surface.operation.state =
dispatch.state === "request-dispatched" || dispatch.state === "request-reused"
? "awaiting-admission"
: dispatch.state;
publication.dispatches.push({
scope: "linux",
state: dispatch.requestRunId ? "acknowledged" : "unknown",
runId: String(publisher.id),
runAttempt: publisher.run_attempt,
artifactId: linux.artifactId,
});
if (dispatch.requestRunId && !dispatch.requestRunAttempt) {
surface.operation.state = "request-attempt-unknown";
}
if (dispatch.requestRunId && dispatch.requestRunAttempt) {
const currentRequest = await reader.getRun(dispatch.requestRunId);
await reader.authenticate(
currentRequest,
".github/workflows/linux-app-release-request.yml",
);
const request = await reader.getRunAttempt(
dispatch.requestRunId,
Number(dispatch.requestRunAttempt),
);
requirePublication(
request.head_branch === "main" &&
request.head_sha === dispatch.workflowSha &&
[
`Linux App Release Request [${dispatch.tag}] desktop=false`,
`Linux App Release Request [${dispatch.tag}] desktop=true`,
].includes(request.display_title),
);
const builders = reader.findLinuxBuilders(request);
requirePublication(builders.length <= 1);
if (
!builders.length &&
request.status === "completed" &&
request.conclusion !== "success"
) {
surface.operation.state = `request-${request.conclusion ?? "unknown"}`;
}
if (builders.length) {
const builder = await reader.getRun(String(builders[0].id), { event: "workflow_run" });
await reader.authenticate(builder, ".github/workflows/linux-app-release.yml");
requirePublication(
builder.head_branch === "main" &&
builder.display_title ===
`Linux App Release request=${request.id} attempt=${request.run_attempt}`,
);
observePublicationChild(publication, "nativeLinux", builder, null, "dispatch-record");
surface.operation.state =
builder.status === "completed"
? (builder.conclusion ?? "unknown")
: "awaiting-admission";
const admission = await reader.readArtifact(
builder,
`linux-release-admission-${request.id}-${request.run_attempt}-${builder.id}-${builder.run_attempt}`,
"linux-release-admission.json",
PUBLICATION_LIMITS.diagnosticBytes,
);
if (admission.state === "available") {
const builderJobs = await reader.getAttemptJobs(
String(builder.id),
builder.run_attempt,
);
requireArtifactUploadJob(
builderJobs,
"Validate release tag",
"Retain Linux builder admission",
);
observeLinuxAdmission(
publication,
admission.value,
request,
builder,
dispatch.tag,
plan.targetSha,
admission.artifactId,
);
if (builder.status !== "completed") {
surface.operation.state = surface.terminalMarker.state;
}
}
}
}
}
const windows = await reader.readArtifact(
publisher,
`windows-release-dispatch-${publisher.id}-${publisher.run_attempt}`,

View file

@ -923,7 +923,7 @@ dispatch_linux_mirror() {
--release-publish-run-id "$parent_run" --release-publish-run-attempt "$parent_attempt" \
--release-publish-ref "$parent_ref" --release-publish-full-ref "$parent_full_ref" \
--release-publish-parent-state-policy active-or-success || return 1
mirror_run_id="$(dispatch_workflow_at_ref "$parent_ref" "$parent_sha" linux-app-release.yml \
mirror_run_id="$(dispatch_workflow_at_ref "$parent_ref" "$parent_sha" linux-app-mirror.yml \
-f release_tag="$RELEASE_TAG" -f source_sha="$TARGET_SHA" -f tooling_sha="$parent_sha" \
-f release_publish_run_id="$parent_run" -f release_publish_run_attempt="$parent_attempt")" || return 1
jq --arg runId "$mirror_run_id" '. + {state: "dispatched", childRunId: $runId}' \
@ -946,7 +946,7 @@ NODE
if [[ "${release_train}" != "stable" || "${RELEASE_NPM_DIST_TAG}" == "extended-stable" ]]; then
return 0
fi
jq -n --arg tag "$RELEASE_TAG" '{tag: $tag, state: "dispatch-unconfirmed"}' \
jq -n --arg tag "$RELEASE_TAG" --arg sourceSha "$TARGET_SHA" '{tag: $tag, sourceSha: $sourceSha, state: "dispatch-unconfirmed"}' \
> "$RUNNER_TEMP/linux-dispatch.json"
release_json="$(gh_read release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease)" || return 1
if ! jq -e '.isDraft == false and .isPrerelease == false' <<< "$release_json" >/dev/null; then
@ -960,7 +960,7 @@ NODE
if [[ "$(jq -er '.state' <<< "$publication_state")" == published &&
"$(jq -r '.needsUpdaterPublication' <<< "$publication_state")" != true &&
"$(jq -r '.needsChannelPublication' <<< "$publication_state")" == false ]]; then
jq -n --arg tag "$RELEASE_TAG" '{tag: $tag, state: "published-assets-reused"}' \
jq -n --arg tag "$RELEASE_TAG" --arg sourceSha "$TARGET_SHA" '{tag: $tag, sourceSha: $sourceSha, state: "published-assets-reused"}' \
> "$RUNNER_TEMP/linux-dispatch.json"
echo "- Linux: existing same-tag AppImage, Debian package, signed updater manifest, and checksums verified; no build requested." >> "$GITHUB_STEP_SUMMARY"
return 0
@ -981,8 +981,8 @@ NODE
done
if [[ -n "$existing_request" ]]; then
request_run_id="$(jq -r '.id' <<< "$existing_request")"
jq --arg tag "$RELEASE_TAG" \
'{tag: $tag, state: "request-reused", requestRunId: (.id | tostring), workflowSha: .head_sha}' \
jq --arg tag "$RELEASE_TAG" --arg sourceSha "$TARGET_SHA" \
'{tag: $tag, sourceSha: $sourceSha, state: "request-reused", requestRunId: (.id | tostring), requestRunAttempt: (.run_attempt | tostring), workflowSha: .head_sha}' \
<<< "$existing_request" > "$RUNNER_TEMP/linux-dispatch.json"
echo "- Linux: existing same-tag request reused; no duplicate build requested. Request: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${request_run_id}; inspect Linux App Release for publication status and recovery." >> "$GITHUB_STEP_SUMMARY"
return 0
@ -994,10 +994,10 @@ NODE
# validates the title, exact main SHA, release ancestry, and signing trust.
request_run_id="$(dispatch_workflow_at_ref main "$workflow_sha" linux-app-release-request.yml \
-f tag="$RELEASE_TAG" -f desktop-test-bundles=false)" || return 1
jq -n --arg tag "$RELEASE_TAG" --arg requestRunId "$request_run_id" --arg workflowSha "$workflow_sha" \
'{tag: $tag, state: "request-dispatched", requestRunId: $requestRunId, workflowSha: $workflowSha}' \
jq -n --arg tag "$RELEASE_TAG" --arg sourceSha "$TARGET_SHA" --arg requestRunId "$request_run_id" --arg workflowSha "$workflow_sha" \
'{tag: $tag, sourceSha: $sourceSha, state: "request-dispatched", requestRunId: $requestRunId, requestRunAttempt: "1", workflowSha: $workflowSha}' \
> "$RUNNER_TEMP/linux-dispatch.json"
echo "- Linux: request dispatched; release processing is pending and verified existing bundles will be reused. Request: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${request_run_id}; publication: https://github.com/${GITHUB_REPOSITORY}/actions/workflows/linux-app-release.yml" >> "$GITHUB_STEP_SUMMARY"
echo "- Linux: dispatched, awaiting builder admission; release processing is pending and verified existing bundles will be reused. Request: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${request_run_id}; publication: https://github.com/${GITHUB_REPOSITORY}/actions/workflows/linux-app-release.yml" >> "$GITHUB_STEP_SUMMARY"
}
promote_windows_release_assets() {

View file

@ -146,7 +146,9 @@ function authorizeWrite(mode, options) {
workflowPath:
mode === "finalize-core"
? ".github/workflows/openclaw-release-publish.yml"
: ".github/workflows/linux-app-release.yml",
: mode === "publish"
? ".github/workflows/linux-app-release.yml"
: ".github/workflows/linux-app-mirror.yml",
workflowEvent: mode === "publish" ? "workflow_run" : "workflow_dispatch",
runStatePolicy: "active",
...(runGh ? { runGh } : {}),

View file

@ -6647,7 +6647,8 @@ it("serializes Linux manifests with stable activation and reuses completed Linux
["openclaw-release-promote.yml", "finalize"],
] as const
).map(([file, job]) => readWorkflow(`.github/workflows/${file}`).jobs[job]);
for (const job of [...finalizers, linux.jobs.publish, linux.jobs.mirror_legacy]) {
const mirror = readWorkflow(".github/workflows/linux-app-mirror.yml");
for (const job of [...finalizers, linux.jobs.publish, mirror.jobs.mirror_legacy]) {
expect(job.concurrency).toEqual({
group: "linux-app-release-publish",
"cancel-in-progress": false,
@ -6820,16 +6821,20 @@ fi
it("detaches Linux mirror-only writers from both completed core finalizers", () => {
const linux = readWorkflow(".github/workflows/linux-app-release.yml");
expect(Object.keys(linux.on)).toEqual(["workflow_run"]);
const mirror = readWorkflow(".github/workflows/linux-app-mirror.yml");
expect(Object.keys(mirror.on)).toEqual(["workflow_dispatch"]);
expect(Object.keys(mirror.jobs)).toEqual(["mirror_legacy"]);
for (const event_name of ["push", "pull_request", "workflow_run", "workflow_dispatch"]) {
expect(
runInNewContext(linux.jobs.mirror_legacy.if.slice(3, -2), {
runInNewContext(mirror.jobs.mirror_legacy.if.slice(3, -2), {
github: { repository: "openclaw/openclaw", event_name },
}),
).toBe(event_name === "workflow_dispatch");
}
expect(linux.on.push).toBeUndefined();
expect(linux.on.pull_request).toBeUndefined();
const mirrorSteps = linux.jobs.mirror_legacy.steps as WorkflowStep[];
const mirrorSteps = mirror.jobs.mirror_legacy.steps as WorkflowStep[];
expect(JSON.stringify(mirrorSteps)).not.toContain("TAURI_SIGNING_PRIVATE_KEY");
expect(JSON.stringify(mirrorSteps)).not.toContain("linux-app-channel.mjs publish");
expect(JSON.stringify(mirrorSteps)).not.toContain("cargo");
@ -6866,6 +6871,63 @@ it("detaches Linux mirror-only writers from both completed core finalizers", ()
expect(preparedDispatch).toContain(".releaseRunAttempt");
expect(preparedDispatch).toContain(".tooling.fullRef");
expect(preparedDispatch).not.toContain("$GITHUB_RUN_ID");
const directory = tempDirs.make("openclaw-linux-mirror-dispatch-");
const toolingSha = "a".repeat(40);
const sourceSha = "b".repeat(40);
const toolingRef = `release-publish/${toolingSha.slice(0, 12)}-123`;
const result = spawnSync(
"bash",
[
"-c",
`source scripts/lib/release-publish-children.sh
verify_release_tag_target() { return 0; }
node() { return 0; }
dispatch_workflow_at_ref() {
printf '%s\\n' "$@" > "$RUNNER_TEMP/dispatch-args"
printf '456\\n'
}
dispatch_linux_mirror "$GITHUB_REF_NAME" "$GITHUB_REF" "$PARENT_WORKFLOW_SHA" 123 2`,
],
{
encoding: "utf8",
env: {
PATH: process.env.PATH,
GITHUB_REF_NAME: toolingRef,
GITHUB_REF: `refs/tags/${toolingRef}`,
PARENT_WORKFLOW_SHA: toolingSha,
GITHUB_REPOSITORY: "openclaw/openclaw",
GITHUB_STEP_SUMMARY: path.join(directory, "summary"),
RUNNER_TEMP: directory,
RELEASE_TAG: "v2026.8.2",
TARGET_SHA: sourceSha,
},
},
);
expect(result.status, `${result.stdout}${result.stderr}`).toBe(0);
expect(readFileSync(path.join(directory, "dispatch-args"), "utf8").trim().split("\n")).toEqual([
toolingRef,
toolingSha,
"linux-app-mirror.yml",
"-f",
"release_tag=v2026.8.2",
"-f",
`source_sha=${sourceSha}`,
"-f",
`tooling_sha=${toolingSha}`,
"-f",
"release_publish_run_id=123",
"-f",
"release_publish_run_attempt=2",
]);
expect(
JSON.parse(readFileSync(path.join(directory, "linux-mirror-dispatch.json"), "utf8")),
).toEqual({
tag: "v2026.8.2",
sourceSha,
state: "dispatched",
mirrorVerified: false,
childRunId: "456",
});
});
it("reports stale Linux release requests before selected code runs", () => {
@ -6929,6 +6991,126 @@ it("reports stale Linux release requests before selected code runs", () => {
}
});
it.each(["success", "failure"])(
"records the exact Linux request and builder admission (%s)",
(status) => {
const workflow = parse(readFileSync(".github/workflows/linux-app-release.yml", "utf8"));
const step = (workflow.jobs.validate_release.steps as WorkflowStep[]).find(
({ name }) => name === "Record Linux builder admission",
);
const directory = tempDirs.make("openclaw-linux-admission-");
const summary = path.join(directory, "summary");
const result = spawnSync("bash", ["-c", expectDefined(step?.run, "admission owner")], {
encoding: "utf8",
env: {
PATH: process.env.PATH,
RUNNER_TEMP: directory,
GITHUB_STEP_SUMMARY: summary,
GITHUB_REPOSITORY: "openclaw/openclaw",
GITHUB_RUN_ID: "456",
GITHUB_RUN_ATTEMPT: "2",
REQUEST_RUN_ID: "123",
REQUEST_RUN_ATTEMPT: "1",
REQUEST_HEAD_SHA: "a".repeat(40),
WORKFLOW_SHA: "a".repeat(40),
RELEASE_TAG: status === "success" ? "v2026.8.2" : "",
TAG_SHA: status === "success" ? "b".repeat(40) : "",
ADMISSION_STATUS: status,
},
});
expect(result.status, `${result.stdout}${result.stderr}`).toBe(0);
expect(
JSON.parse(readFileSync(path.join(directory, "linux-release-admission.json"), "utf8")),
).toEqual({
schemaVersion: 1,
repository: "openclaw/openclaw",
request: { runId: "123", runAttempt: "1", headSha: "a".repeat(40) },
builder: { runId: "456", runAttempt: "2", toolingSha: "a".repeat(40) },
tag: status === "success" ? "v2026.8.2" : null,
sourceSha: status === "success" ? "b".repeat(40) : null,
admission: status === "success" ? "admitted" : "rejected",
});
},
);
it("retains exact completed Linux bundle bytes after packaging fails without admitting signing", () => {
const workflow = parse(readFileSync(".github/workflows/linux-app-release.yml", "utf8"));
const steps = workflow.jobs.build_linux.steps as WorkflowStep[];
const step = steps.find(({ name }) => name === "Retain completed unqualified Linux bundles");
const directory = tempDirs.make("openclaw-linux-unqualified-");
const runner = path.join(directory, "runner");
mkdirSync(runner);
for (const [kind, filename, bytes] of [
["deb", "OpenClaw.deb", "completed deb bytes"],
["appimage", "OpenClaw.AppImage", "completed appimage bytes"],
] as const) {
const bundleDirectory = path.join(
directory,
"apps/linux/src-tauri/target/release/bundle",
kind,
);
mkdirSync(bundleDirectory, { recursive: true });
writeFileSync(path.join(bundleDirectory, filename), bytes);
}
const result = spawnSync("bash", ["-c", expectDefined(step?.run, "diagnostic retention")], {
cwd: directory,
encoding: "utf8",
env: {
PATH: process.env.PATH,
RUNNER_TEMP: runner,
GITHUB_STEP_SUMMARY: path.join(runner, "summary"),
GITHUB_REPOSITORY: "openclaw/openclaw",
GITHUB_RUN_ID: "456",
GITHUB_RUN_ATTEMPT: "1",
RELEASE_TAG: "v2026.8.2",
TAG_SHA: "b".repeat(40),
WORKFLOW_SHA: "a".repeat(40),
FINALIZE_OUTCOME: "success",
STAGING_OUTCOME: "success",
RUNTIME_OUTCOME: "failure",
},
});
expect(result.status, `${result.stdout}${result.stderr}`).toBe(0);
const retained = path.join(runner, "linux-unqualified-bundles");
expect(readFileSync(path.join(retained, "bundles/OpenClaw.deb"), "utf8")).toBe(
"completed deb bytes",
);
expect(readFileSync(path.join(retained, "bundles/OpenClaw.AppImage"), "utf8")).toBe(
"completed appimage bytes",
);
expect(JSON.parse(readFileSync(path.join(retained, "identity.json"), "utf8"))).toMatchObject({
qualification: "unqualified",
sourceSha: "b".repeat(40),
toolingSha: "a".repeat(40),
runId: "456",
runAttempt: "1",
checks: { packagedRuntime: "failure" },
});
const hashes = spawnSync("sha256sum", ["--check", "SHA256SUMS"], {
cwd: retained,
encoding: "utf8",
});
expect(hashes.status, hashes.stderr).toBe(0);
// Model the Actions failure boundary: normal uploads skip, and failed build
// blocks the signing dependency. Diagnostic retention has no promoted outputs.
const context = {
eventName: "workflow_run" as const,
repository: "openclaw/openclaw",
runAttempt: 1,
failed: true,
steps: { "bundle-build": { outcome: "success" as const, outputs: {} } },
};
expect(evaluateWorkflowExpression(expectDefined(step?.if, "retention condition"), context)).toBe(
true,
);
for (const name of ["Upload Debian bundle", "Upload finalized unsigned AppImage"]) {
expect(steps.find((entry) => entry.name === name)?.if).toBeUndefined();
}
expect(workflow.jobs.sign_linux.needs).toContain("build_linux");
expect(workflow.jobs.sign_linux.if).toBeUndefined();
expect(workflow.jobs.build_linux.outputs).not.toHaveProperty("unqualified_artifact_id");
});
it("isolates release credentials and verifies trusted AppImage and signing tools", () => {
const pinned = {
name: "Prepare Git owner",
@ -7000,7 +7182,7 @@ it("isolates release credentials and verifies trusted AppImage and signing tools
(job as { permissions?: { contents?: string } }).permissions?.contents === "write",
)
.map(([name]) => name),
).toEqual(["mirror_legacy"]);
).toEqual([]);
expect(linux.jobs.publish.permissions).toEqual({ actions: "read", contents: "read" });
expect(
Object.entries(linux.jobs)

View file

@ -1386,6 +1386,139 @@ describe("publication status real CLI", () => {
},
);
it.each([
["unconfirmed", "dispatch-unconfirmed"],
["awaiting", "awaiting-admission"],
["admitted", "success"],
["rejected", "failure"],
["wrong-source", "success"],
["foreign-request", "success"],
["legacy-attempt", "request-attempt-unknown"],
])("correlates the Linux builder through its owner admission (%s)", async (kind, operation) => {
const fixture = publicationFixture();
fixture.publisherJobs.push({
...job(
"Request Linux assets after publication",
kind === "unconfirmed" ? "failure" : "success",
),
id: 8810,
run_id: 88,
run_attempt: 1,
steps: [
{
number: 1,
name: "Retain Linux request evidence",
status: "completed",
conclusion: "success",
},
],
});
const request = {
...fixture.publisher,
id: 909,
workflow_id: 9090,
head_branch: "main",
head_sha: SHA,
created_at: "2026-08-02T00:00:00Z",
path: ".github/workflows/linux-app-release-request.yml",
run_attempt: 1,
display_title: "Linux App Release Request [v2026.8.2] desktop=false",
conclusion: "success",
};
const builder = {
...request,
id: 910,
workflow_id: 9100,
event: "workflow_run",
path: ".github/workflows/linux-app-release.yml",
display_title: "Linux App Release request=909 attempt=1",
head_sha: kind === "rejected" ? "f".repeat(40) : SHA,
conclusion: kind === "rejected" ? "failure" : "success",
};
const result = await runPublicationCli(fixture, undefined, async (responses, artifact) => {
const tag = fixture.diagnostic.context.releaseTag!;
request.display_title = `Linux App Release Request [${tag}] desktop=false`;
await artifact(4, fixture.publisher, "linux-release-dispatch-88-1", "linux-dispatch.json", {
tag,
sourceSha: TARGET_SHA,
state: kind === "unconfirmed" ? "dispatch-unconfirmed" : "request-dispatched",
requestRunId: kind === "unconfirmed" ? undefined : "909",
requestRunAttempt: ["unconfirmed", "legacy-attempt"].includes(kind) ? undefined : "1",
workflowSha: kind === "unconfirmed" ? undefined : SHA,
});
if (kind === "unconfirmed") {
return;
}
responses[`repos/${REPOSITORY}/actions/runs/909`] = request;
responses[`repos/${REPOSITORY}/actions/runs/909/attempts/1`] = request;
responses[`repos/${REPOSITORY}/actions/workflows/9090`] = { id: 9090, path: request.path };
responses[
`repos/${REPOSITORY}/actions/workflows/linux-app-release.yml/runs?event=workflow_run&branch=main&created=%3E%3D2026-08-02T00%3A00%3A00Z&per_page=100&page=1`
] = {
total_count: kind === "awaiting" ? 1 : 2,
workflow_runs: [
{ ...builder, id: 999, display_title: "Linux App Release request=908 attempt=1" },
...(kind === "awaiting" ? [] : [builder]),
],
};
if (kind === "awaiting") {
return;
}
responses[`repos/${REPOSITORY}/actions/runs/910`] = builder;
responses[`repos/${REPOSITORY}/actions/workflows/9100`] = { id: 9100, path: builder.path };
await artifact(
5,
builder,
"linux-release-admission-909-1-910-1",
"linux-release-admission.json",
{
schemaVersion: 1,
repository: REPOSITORY,
request: {
runId: kind === "foreign-request" ? "908" : "909",
runAttempt: "1",
headSha: SHA,
},
builder: { runId: "910", runAttempt: "1", toolingSha: builder.head_sha },
tag: kind === "rejected" ? null : tag,
sourceSha:
kind === "rejected" ? null : kind === "wrong-source" ? "e".repeat(40) : TARGET_SHA,
admission: kind === "rejected" ? "rejected" : "admitted",
},
);
responses[`repos/${REPOSITORY}/actions/runs/910/attempts/1/jobs?per_page=100&page=1`] = {
total_count: 1,
jobs: [
{
...job("Validate release tag", builder.conclusion),
id: 9101,
run_id: 910,
run_attempt: 1,
steps: [
{
number: 1,
name: "Retain Linux builder admission",
status: "completed",
conclusion: "success",
},
],
},
],
};
});
const surface = JSON.parse(result.stdout).publication.surfaces.nativeLinux;
const mismatch = ["wrong-source", "foreign-request"].includes(kind);
expect(result.status, result.stderr + result.stdout).toBe(mismatch ? 1 : 0);
expect(surface.operation.state).toBe(operation);
expect(surface.children.map((child: { runId: string }) => child.runId)).toEqual(
["unconfirmed", "awaiting", "legacy-attempt"].includes(kind) ? [] : ["910"],
);
expect(surface.terminalMarker.state).toBe(
kind === "rejected" ? "rejected" : kind === "admitted" ? "admitted" : "unknown",
);
expect(surface.verification.state).toBe("unknown");
});
it.each(["complete", "denied", "duplicate", "changed-total"])(
"handles a second artifact page: %s",
async (kind) => {

View file

@ -564,7 +564,7 @@ function fixture(workflowRef = toolingRef, desktop = false, windows = false) {
writerConclusion: null,
writerRef: workflowRef,
writerFullRef: workflowFullRef,
writerPath: ".github/workflows/linux-app-release.yml",
writerPath: ".github/workflows/linux-app-mirror.yml",
writerEvent: "workflow_dispatch",
},
} satisfies State),
@ -694,7 +694,9 @@ function fixture(workflowRef = toolingRef, desktop = false, windows = false) {
value.authority.writerPath =
mode === "finalize-core"
? ".github/workflows/openclaw-release-publish.yml"
: ".github/workflows/linux-app-release.yml";
: mode === "publish"
? ".github/workflows/linux-app-release.yml"
: ".github/workflows/linux-app-mirror.yml";
value.authority.writerEvent = mode === "publish" ? "workflow_run" : "workflow_dispatch";
});
if (mode === "publish") {

View file

@ -37,18 +37,16 @@ it.each(["build_linux", "build_windows", "build_macos"])(
},
);
it("budgets Windows source packaging within the desktop job", () => {
it.each([
["build", "Build native inline browser candidate"],
["test-windows-power", "Build Windows installer without publishing"],
])("budgets source packaging within desktop job %s", (jobName, stepName) => {
const workflow = parse(readFileSync(".github/workflows/linux-app.yml", "utf8"));
const windows = workflow.jobs["test-windows-power"];
const build = windows.steps.find(
(step: { name: string }) => step.name === "Build Windows installer without publishing",
);
const job = workflow.jobs[jobName];
const build = job.steps.find((step: { name: string }) => step.name === stepName);
const packageMinutes = Number(build.env?.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS) / 60_000;
expect(packageMinutes).toBeGreaterThanOrEqual(60);
expect(windows["timeout-minutes"] - packageMinutes).toBeGreaterThanOrEqual(40);
expect(job["timeout-minutes"] - packageMinutes).toBeGreaterThanOrEqual(40);
expect(workflow.env.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS).toBeUndefined();
expect(JSON.stringify(workflow.jobs.build)).not.toContain(
"OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS",
);
});