mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-01 04:58:23 +00:00
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:
parent
d5e4322d49
commit
614f559915
14 changed files with 804 additions and 157 deletions
3
.github/actionlint.yaml
vendored
3
.github/actionlint.yaml
vendored
|
|
@ -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
124
.github/workflows/linux-app-mirror.yml
vendored
Normal 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
|
||||
199
.github/workflows/linux-app-release.yml
vendored
199
.github/workflows/linux-app-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/linux-app.yml
vendored
7
.github/workflows/linux-app.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
14
docs/ci.md
14
docs/ci.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
130
scripts/frv.mjs
130
scripts/frv.mjs
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue