From 001e62cfdd515e2893b88106b10837f56cfa67c3 Mon Sep 17 00:00:00 2001 From: "Jayper.Z" Date: Fri, 17 Jul 2026 10:19:45 +0800 Subject: [PATCH 1/4] Add VM smoke releases and Pages downloads --- .github/workflows/pages.yml | 67 +++++ .github/workflows/release.yml | 101 ++++++- .github/workflows/vm-smoke.yml | 117 ++++++++ .gitignore | 3 + README.md | 48 +++- docs/RELEASES.md | 120 ++++++++ manifests/vm.lock | 7 + scripts/build-vm-image.sh | 165 +++++++++++ scripts/generate-pages-data.py | 254 +++++++++++++++++ scripts/test-vm-smoke.sh | 267 ++++++++++++++++++ scripts/validate.sh | 8 + site/.nojekyll | 0 site/app.js | 252 +++++++++++++++++ site/index.html | 199 +++++++++++++ site/releases.json | 15 + site/styles.css | 204 +++++++++++++ tests/test_pages_policy.sh | 248 ++++++++++++++++ tests/test_static.sh | 2 + tests/test_vm_policy.sh | 153 ++++++++++ tests/test_workflow_policy.sh | 235 ++++++++++++++- vm-files/etc/banner | 9 + vm-files/etc/nexawrt-vm-smoke | 6 + vm-files/etc/uci-defaults/10-vm-smoke | 19 ++ vm-files/sbin/factoryreset | 3 + vm-files/sbin/firstboot | 3 + vm-files/sbin/jffs2mark | 3 + vm-files/sbin/jffs2reset | 3 + vm-files/sbin/mtd | 3 + vm-files/sbin/sysupgrade | 3 + vm-files/sbin/ubiattach | 3 + vm-files/sbin/ubidetach | 3 + vm-files/sbin/ubiformat | 3 + .../nexawrt-vm-dangerous-command-guard | 4 + 33 files changed, 2502 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/vm-smoke.yml create mode 100644 docs/RELEASES.md create mode 100644 manifests/vm.lock create mode 100755 scripts/build-vm-image.sh create mode 100755 scripts/generate-pages-data.py create mode 100755 scripts/test-vm-smoke.sh create mode 100644 site/.nojekyll create mode 100644 site/app.js create mode 100644 site/index.html create mode 100644 site/releases.json create mode 100644 site/styles.css create mode 100755 tests/test_pages_policy.sh create mode 100755 tests/test_vm_policy.sh create mode 100644 vm-files/etc/banner create mode 100644 vm-files/etc/nexawrt-vm-smoke create mode 100755 vm-files/etc/uci-defaults/10-vm-smoke create mode 100755 vm-files/sbin/factoryreset create mode 100755 vm-files/sbin/firstboot create mode 100755 vm-files/sbin/jffs2mark create mode 100755 vm-files/sbin/jffs2reset create mode 100755 vm-files/sbin/mtd create mode 100755 vm-files/sbin/sysupgrade create mode 100755 vm-files/sbin/ubiattach create mode 100755 vm-files/sbin/ubidetach create mode 100755 vm-files/sbin/ubiformat create mode 100755 vm-files/usr/libexec/nexawrt-vm-dangerous-command-guard diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..7670353 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,67 @@ +name: NexaWrt GitHub Pages + +on: + push: + branches: [main] + paths: + - 'site/**' + - 'scripts/generate-pages-data.py' + - '.github/workflows/pages.yml' + workflow_run: + workflows: ['NexaWrt AX9000 reproducible RAM-test release'] + types: [completed] + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + +permissions: {} + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + if: >- + github.repository == 'tifycloud/NexaWrt' && + (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout trusted site source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: refs/heads/main + persist-credentials: false + - name: Generate allowlisted release index + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + python3 scripts/generate-pages-data.py --output site/releases.json + python3 -m json.tool site/releases.json >/dev/null + if find site \( -type l -o -type f -links +1 \) -print -quit | grep -q .; then + echo 'The Pages artifact must not contain symbolic or hard links.' >&2 + exit 1 + fi + - name: Configure GitHub Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ab5487..2d01bb8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: contents: read outputs: flavor: ${{ steps.release_identity.outputs.flavor }} + release_version: ${{ steps.release_identity.outputs.release_version }} work_basename: ${{ steps.release_identity.outputs.work_basename }} staging_basename: ${{ steps.release_identity.outputs.staging_basename }} steps: @@ -27,21 +28,37 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - name: Verify immutable release preconditions and derive flavor + - name: Require repository immutable releases + env: + IMMUTABLE_RELEASES_READ_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }} + run: | + set -euo pipefail + test -n "$IMMUTABLE_RELEASES_READ_TOKEN" + GH_TOKEN="$IMMUTABLE_RELEASES_READ_TOKEN" gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/immutable-releases" \ + --jq 'select(.enabled == true) | .enabled' | grep -Fxq true + - name: Verify trusted tag, derive flavor, and run policy tests id: release_identity env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail test "$GITHUB_REF_TYPE" = tag test "$GITHUB_REF" = "refs/tags/$GITHUB_REF_NAME" test "$GITHUB_WORKFLOW_REF" = "tifycloud/NexaWrt/.github/workflows/release.yml@$GITHUB_REF" test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" - if [[ "$GITHUB_REF_NAME" =~ ^ram-test-v[0-9][0-9A-Za-z._-]*$ ]]; then + official_tag_pattern='^ram-test-(v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.(0|[1-9][0-9]*))$' + nss_tag_pattern='^ram-test-nss-(v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.(0|[1-9][0-9]*))$' + if [[ "$GITHUB_REF_NAME" =~ $official_tag_pattern ]]; then flavor=official + release_version="${BASH_REMATCH[1]}" work_basename=openwrt staging_basename=dist - elif [[ "$GITHUB_REF_NAME" =~ ^ram-test-nss-v[0-9][0-9A-Za-z._-]*$ ]]; then + elif [[ "$GITHUB_REF_NAME" =~ $nss_tag_pattern ]]; then flavor=nss + release_version="${BASH_REMATCH[1]}" work_basename=openwrt-nss staging_basename=dist-nss else @@ -69,8 +86,8 @@ jobs: return "$status" } release_tag_is_absent "$GITHUB_REF_NAME" - printf 'flavor=%s\nwork_basename=%s\nstaging_basename=%s\n' \ - "$flavor" "$work_basename" "$staging_basename" >> "$GITHUB_OUTPUT" + printf 'flavor=%s\nrelease_version=%s\nwork_basename=%s\nstaging_basename=%s\n' \ + "$flavor" "$release_version" "$work_basename" "$staging_basename" >> "$GITHUB_OUTPUT" NEXAWRT_FLAVOR="$flavor" ./tests/test_static.sh build: @@ -324,6 +341,7 @@ jobs: artifact-metadata: write env: RELEASE_FLAVOR: ${{ needs.preflight.outputs.flavor }} + RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} steps: - name: Checkout verification policy uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -346,7 +364,7 @@ jobs: grep -Fxq "flavor=$RELEASE_FLAVOR" release-staging/verified-dist/BUILD-MANIFEST.txt grep -Fq 'initramfs RAM-boot candidate only.' release-staging/verified-dist/DO-NOT-FLASH.txt source_epoch="$(awk -F= '$1 == "source_date_epoch" { count++; value=$2 } END { if (count != 1 || value !~ /^[1-9][0-9]*$/) exit 1; print value }' release-staging/verified-dist/BUILD-MANIFEST.txt)" - archive_basename="NexaWrt-AX9000-${RELEASE_FLAVOR}-verified-dist.tar.gz" + archive_basename="NexaWrt-AX9000-${RELEASE_FLAVOR}-${RELEASE_VERSION}-verified-dist.tar.gz" mkdir release-staging/publish tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@$source_epoch" \ -C release-staging -cf - verified-dist | gzip -9n > "release-staging/publish/$archive_basename" @@ -378,7 +396,7 @@ jobs: id: archive_attestation uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 with: - subject-path: release-staging/publish/NexaWrt-AX9000-${{ needs.preflight.outputs.flavor }}-verified-dist.tar.gz + subject-path: release-staging/publish/NexaWrt-AX9000-${{ needs.preflight.outputs.flavor }}-${{ needs.preflight.outputs.release_version }}-verified-dist.tar.gz - name: Attach offline attestation bundles outside verified-dist run: | cp '${{ steps.firmware_attestation.outputs.bundle-path }}' release-staging/publish/firmware.provenance.bundle.json @@ -394,13 +412,74 @@ jobs: remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$GITHUB_REF_NAME" --jq '.object.sha')" test "$remote_type" = commit test "$remote_sha" = "$GITHUB_SHA" - gh release create "$GITHUB_REF_NAME" --verify-tag --draft --prerelease \ - --title "$GITHUB_REF_NAME" \ - --notes "Reproducible NexaWrt AX9000 $RELEASE_FLAVOR initramfs RAM-boot candidate. NOT FOR FLASHING. Hardware, UART, recovery, and stress-test approval remain mandatory." + case "$RELEASE_FLAVOR" in + official) release_title="NexaWrt AX9000 $RELEASE_VERSION RAM-test prerelease" ;; + nss) release_title="NexaWrt AX9000 NSS $RELEASE_VERSION RAM-test prerelease" ;; + *) echo "Untrusted release flavor: $RELEASE_FLAVOR" >&2; exit 1 ;; + esac + gh release create "$GITHUB_REF_NAME" --verify-tag --draft --prerelease --latest=false \ + --title "$release_title" \ + --notes "Reproducible NexaWrt AX9000 $RELEASE_FLAVOR $RELEASE_VERSION initramfs RAM-boot candidate. NOT FOR FLASHING. Hardware, UART, recovery, and stress-test approval remain mandatory." while IFS= read -r -d '' asset; do gh release upload "$GITHUB_REF_NAME" "$asset" done < <(find release-staging/publish -maxdepth 1 -type f -print0 | sort -z) expected="$(find release-staging/publish -maxdepth 1 -type f -exec basename {} \; | sort)" actual="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" --jq '.assets[].name' | sort)" test "$actual" = "$expected" - gh release edit "$GITHUB_REF_NAME" --draft=false --prerelease + gh release edit "$GITHUB_REF_NAME" --draft=false --prerelease --latest=false + final_release="$(mktemp)" + trap 'rm -f "$final_release"' EXIT + gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" > "$final_release" + python3 - "$final_release" "$GITHUB_REF_NAME" "$RELEASE_FLAVOR" "$RELEASE_VERSION" <<'PY' + import json + import pathlib + import re + import sys + + release_path, expected_tag, flavor, version = sys.argv[1:] + release = json.loads(pathlib.Path(release_path).read_text(encoding="utf-8")) + if release.get("draft") is not False: + raise SystemExit("published release is still a draft") + if release.get("prerelease") is not True: + raise SystemExit("published release is not marked as a prerelease") + if release.get("immutable") is not True: + raise SystemExit("published release is not immutable") + if release.get("tag_name") != expected_tag: + raise SystemExit("published release tag does not match the triggering tag") + published_at = release.get("published_at") + if not isinstance(published_at, str) or not re.fullmatch( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", published_at + ): + raise SystemExit("published release has no valid published_at timestamp") + + archive = f"NexaWrt-AX9000-{flavor}-{version}-verified-dist.tar.gz" + expected_assets = { + archive, + f"{archive}.sha256", + "archive.provenance.bundle.json", + "checksums.provenance.bundle.json", + "firmware.provenance.bundle.json", + "sbom.provenance.bundle.json", + } + assets = release.get("assets") + if not isinstance(assets, list) or len(assets) != len(expected_assets): + raise SystemExit("published release asset count is not exact") + names = [] + for asset in assets: + if not isinstance(asset, dict): + raise SystemExit("published release contains a malformed asset") + name = asset.get("name") + size = asset.get("size") + if not isinstance(name, str): + raise SystemExit("published release contains an invalid asset name") + if asset.get("state") != "uploaded": + raise SystemExit(f"published release asset is not uploaded: {name}") + if not isinstance(size, int) or isinstance(size, bool) or size <= 0: + raise SystemExit(f"published release asset has invalid size: {name}") + names.append(name) + if len(names) != len(set(names)) or set(names) != expected_assets: + raise SystemExit("published release asset names are not the exact expected set") + PY diff --git a/.github/workflows/vm-smoke.yml b/.github/workflows/vm-smoke.yml new file mode 100644 index 0000000..960178d --- /dev/null +++ b/.github/workflows/vm-smoke.yml @@ -0,0 +1,117 @@ +name: VM smoke (QEMU only) + +on: + push: + branches: [main] + paths: + - manifests/vm.lock + - vm-files/** + - scripts/build-vm-image.sh + - scripts/test-vm-smoke.sh + - tests/test_vm_policy.sh + - .github/workflows/vm-smoke.yml + pull_request: + branches: [main] + paths: + - manifests/vm.lock + - vm-files/** + - scripts/build-vm-image.sh + - scripts/test-vm-smoke.sh + - tests/test_vm_policy.sh + - .github/workflows/vm-smoke.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: vm-smoke-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + policy: + name: VM policy + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Install policy tools + run: sudo apt-get update && sudo apt-get install --yes shellcheck + - name: Check VM shell policy + run: | + shellcheck -S warning \ + scripts/build-vm-image.sh \ + scripts/test-vm-smoke.sh \ + tests/test_vm_policy.sh \ + vm-files/etc/uci-defaults/10-vm-smoke \ + vm-files/usr/libexec/nexawrt-vm-dangerous-command-guard \ + vm-files/sbin/* + ./tests/test_vm_policy.sh + + smoke: + name: QEMU ${{ matrix.target }} + needs: policy + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + target: + - x86-64 + - armsr-armv8 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Install ImageBuilder and QEMU dependencies + run: | + sudo apt-get update + sudo apt-get install --yes \ + build-essential \ + ca-certificates \ + curl \ + file \ + gawk \ + gzip \ + libncurses-dev \ + openssh-client \ + python3 \ + qemu-efi-aarch64 \ + qemu-system-arm \ + qemu-system-x86 \ + rsync \ + unzip \ + wget \ + xz-utils \ + zstd + - name: Create ephemeral smoke-test SSH key + run: ssh-keygen -q -t ed25519 -N '' -f "$RUNNER_TEMP/vm-smoke-key" + - name: Build VM-only image + id: build + env: + VM_SMOKE_AUTHORIZED_KEY_FILE: ${{ runner.temp }}/vm-smoke-key.pub + VM_WORK_DIR: ${{ github.workspace }}/.work/vm/${{ matrix.target }} + VM_OUTPUT_DIR: ${{ github.workspace }}/dist/vm + run: ./scripts/build-vm-image.sh '${{ matrix.target }}' + - name: Run QEMU smoke test + env: + VM_SMOKE_OUTPUT_DIR: ${{ github.workspace }}/vm-smoke-results/${{ matrix.target }} + run: ./scripts/test-vm-smoke.sh '${{ matrix.target }}' '${{ steps.build.outputs.image }}' "$RUNNER_TEMP/vm-smoke-key" + - name: Upload VM image, logs, and report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: vm-smoke-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + dist/vm/${{ matrix.target }}/ + vm-smoke-results/${{ matrix.target }}/ + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index 073c812..8b8d004 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ ax9000-backup-*/ hardware-evidence/ ax9000-hardware-evidence-*/ release-staging/ + +# Local QEMU smoke-test logs and decompressed VM disks. +vm-smoke-results/ diff --git a/README.md b/README.md index 992e8aa..5260ab3 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ MTD 备份包含整个 UBI 区域,可能间接包含 `rootfs_data` 中的配 - [initramfs 测试流程](docs/TESTING.md) - [实验性 NSS flavor、限制与诊断](docs/NSS.md) - [生产就绪门禁与真机证据格式](docs/PRODUCTION-READINESS.md) +- [版本化 RAM-test prerelease 发布说明](docs/RELEASES.md) ## 构建 NexaWrt @@ -216,8 +217,9 @@ macOS 自带的 Bash、Make 和默认大小写不敏感文件系统通常不满 必须能追溯到 flavor,镜像文件名必须能追溯到 profile。当前 profile 明确关闭 sysupgrade 和 factory 产物,产物门检会拒绝任何可刷写镜像。 -候选发布有两个互不重叠的 tag 家族:`ram-test-v*` 只对应 `official`, -`ram-test-nss-v*` 只对应实验性 `nss`。preflight 会从严格受信任的 tag 名 fail-closed 派生 +候选发布有两个互不重叠且严格版本化的 tag 家族:`ram-test-vMAJOR.MINOR.PATCH-rc.N` 只对应 +`official`,`ram-test-nss-vMAJOR.MINOR.PATCH-rc.N` 只对应实验性 `nss`。例如首个 NSS 测试版本可使用 +`ram-test-nss-v0.1.0-rc.1`。preflight 会从严格受信任的 tag 名 fail-closed 派生 flavor,不允许未知 tag 回退到默认构建。每个 flavor 都执行两个无共享下载缓存、使用独立 flavor/replica `WORK_DIR`、构建日志和 staging 目录的干净构建,再把正确 flavor 传给比较器, 比较精确 ITB、package manifest、解析配置、buildinfo、输入摘要及规范化 CycloneDX SBOM。 @@ -241,6 +243,42 @@ initramfs RAM-boot 候选,不上传整个 OpenWrt target 输出,也不开放 > sysupgrade/factory 文件,产物门检必须失败。当前候选产物尚未获得真机 RAM 启动批准。 +## 虚拟机测试、Releases 与下载网站 + +仓库提供独立的 **VM-only** QEMU 冒烟测试,覆盖 `x86-64` 与 `armsr-armv8` 两个架构。它使用 +SHA-256 锁定的 OpenWrt ImageBuilder,检查启动、SSH、LuCI HTTP、`ubus`、UCI、网络和核心服务, +并把 VM 镜像、串口日志与结构化报告上传到 Actions。VM 产物会明确标记 +`NOT_AX9000_FIRMWARE=1`、`HARDWARE_VALIDATION=0`、`NSS_VALIDATION=0`:**虚拟机通过只能证明通用 +OpenWrt 用户空间和自动化流程可运行,不能证明 AX9000、Qualcomm NSS、交换芯片、Wi-Fi、温度、 +断电恢复或持久存储安全。** + +在浏览器中打开 **Actions → VM smoke (QEMU only) → Run workflow** 即可重新运行;Pull Request 修改 +VM 相关文件时也会自动执行。测试阶段的 AX9000 候选则通过轻量 tag 发布为 GitHub prerelease: + +```sh +tag=ram-test-nss-v0.1.0-rc.1 +commit="$(git rev-parse origin/main)" +git merge-base --is-ancestor "$commit" origin/main +git tag "$tag" "$commit" +git push origin "refs/tags/$tag" +``` + +发布流程仍需通过双副本可复现门禁和 `ram-test-release` Environment 审批。仓库必须先启用 GitHub +Immutable Releases,并配置仅限此仓库、具备 Administration(read) 的 Actions Secret +`IMMUTABLE_RELEASES_READ_TOKEN`;发布 preflight 会在任何构建开始前调用官方 API 并 fail closed, +发布后还会验证 `immutable=true`、六个资产名称集合完全一致、状态均为 `uploaded` 且大小有效。最终归档名包含版本, +例如 `NexaWrt-AX9000-nss-v0.1.0-rc.1-verified-dist.tar.gz`,并附带 SHA-256、SBOM 与 GitHub +provenance。所有 RC 都是 prerelease 且不会被标记为 Latest。完整规则与失败恢复步骤见 +[版本化发布说明](docs/RELEASES.md)。 + +GitHub Pages 站点由仓库内 `site/` 提供,合并并启用 Pages 后地址为 +。网站只展示 `immutable=true` 且远端资产集合严格等于六个预期文件的 GitHub prerelease;任何额外、 +缺失、重复、非 `uploaded` 或大小无效的资产都会使整个 Release 被拒绝。站点会在 `main` 更新、 +发布工作流成功后以及每 6 小时周期复验并重新部署。Official 与 NSS 分频道,Release 尚不存在时 +显示安全空状态;它还提供不包含密码、密钥或 Token 的 +RAM 会话 UCI 配置片段生成器。网站不是刷机工具,也不会把配置烘焙进镜像。 + + ## License 除另有明确标注的上游或第三方组件外,本仓库采用 **GNU General Public License v2.0 only @@ -256,12 +294,16 @@ initramfs RAM-boot 候选,不上传整个 OpenWrt target 输出,也不开放 configs/ 单设备、按 flavor 隔离的最小包配置 files/ 两个 flavor 共用、不含密码或订阅的基础 overlay files-nss/ 仅 NSS flavor 应用的运行时 overlay -manifests/ OpenWrt/feeds/layout 与 NSS 来源锁定信息 +manifests/ OpenWrt/feeds/layout、NSS 与 VM ImageBuilder 来源锁定信息 patches/ RAM-only 安全补丁(含只读 uboot-envtools patch 003) scripts/backup-router.sh 只读备份 scripts/nss-diagnostics.sh NSS/ECM 只读运行时诊断 scripts/prepare.sh 获取、锁定并校验上游 scripts/build.sh Linux 干净构建 +scripts/build-vm-image.sh 构建 x86_64/ARM64 VM-only 测试镜像 +scripts/test-vm-smoke.sh QEMU 启动、网络、SSH 与 LuCI 冒烟测试 +scripts/generate-pages-data.py 生成严格白名单的 Pages Release 索引 +site/ GitHub Pages 下载与安全配置站点 scripts/check-kernel-build-identity.sh Kconfig 构建身份与带产品前缀的 source-lock revision 门禁 tests/test_openwrt_defconfig_version.sh 锁定 OpenWrt Kconfig defconfig 保留测试 scripts/collect-build-evidence.sh 构建输入、环境与日志证据 diff --git a/docs/RELEASES.md b/docs/RELEASES.md new file mode 100644 index 0000000..c16b0c7 --- /dev/null +++ b/docs/RELEASES.md @@ -0,0 +1,120 @@ +# Versioned RAM-test prereleases + +The RAM-test release workflow publishes reproducible **prereleases** for the NexaWrt AX9000. These artifacts are initramfs RAM-boot candidates only; they are not approved for flashing. Hardware, UART, recovery, and stress-test approval remain separate mandatory gates. + +## Accepted tags + +GitHub Actions keeps broad tag globs so both release families trigger the workflow, but preflight rejects every tag except these complete forms: + +- Official: `ram-test-vMAJOR.MINOR.PATCH-rc.N` +- NSS: `ram-test-nss-vMAJOR.MINOR.PATCH-rc.N` + +`MAJOR`, `MINOR`, `PATCH`, and `N` are decimal integers. Each is either `0` or begins with `1`-`9`; leading zeroes are forbidden. Examples: + +- Accepted: `ram-test-v1.4.0-rc.1` +- Accepted: `ram-test-nss-v1.4.0-rc.2` +- Rejected: `ram-test-v1.4.0` +- Rejected: `ram-test-v01.4.0-rc.1` +- Rejected: `ram-test-nss-v1.4.0-rc.01` + +The workflow derives `release_version` by removing only the flavor prefix. For example, both `ram-test-v1.4.0-rc.1` and `ram-test-nss-v1.4.0-rc.1` derive `v1.4.0-rc.1`. + +Tags must be **lightweight tags** that point directly to a commit. Annotated and signed tag objects are rejected. The tagged commit must be an ancestor of `origin/main`, the workflow definition must come from that same tagged commit, and no GitHub release may already exist for the tag. + +GitHub **Immutable Releases** is a hard release gate. Before either build replica starts, preflight calls the official `repos/{owner}/{repo}/immutable-releases` API with `X-GitHub-Api-Version: 2026-03-10` and fails closed unless `enabled` is exactly `true`. The endpoint requires repository Administration read access, which the normal workflow `GITHUB_TOKEN` does not request; configure `IMMUTABLE_RELEASES_READ_TOKEN` as a repository Actions secret containing a short-lived fine-grained token restricted to this repository with **Administration: read**. Do not reuse a broadly scoped personal token, do not grant write administration, and do not bypass this gate. + +## Release procedure + +1. Update local `main` from the canonical repository and choose the exact commit to release. Do not release an unmerged side-branch commit. +2. Create or rotate the short-lived fine-grained read token, save it as the repository Actions secret `IMMUTABLE_RELEASES_READ_TOKEN`, and confirm immutable releases are enabled using that token: + + ```sh + gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + repos/tifycloud/NexaWrt/immutable-releases --jq '.enabled' + ``` + + The command must print exactly `true`. Authentication, authorization, missing-secret, network, malformed-response, and disabled-state results all block release. The secret is used only for this read-only preflight request; the workflow returns to its normal `GITHUB_TOKEN` for other GitHub API operations. + +3. Run the repository release-policy tests before tagging: + + ```sh + tests/test_workflow_policy.sh + tests/test_static.sh + ``` + +4. Choose the next release-candidate version and construct exactly one accepted tag. For example: + + ```sh + tag=ram-test-v1.4.0-rc.1 + # NSS alternative: + # tag=ram-test-nss-v1.4.0-rc.1 + ``` + +5. Confirm that the tag does not already exist locally or remotely and that no release exists: + + ```sh + ! git show-ref --verify --quiet "refs/tags/$tag" + ! git ls-remote --exit-code --tags origin "refs/tags/$tag" + ! gh release view "$tag" + ``` + + The final command should fail specifically because the release is absent. Authentication, network, rate-limit, and other API failures are not evidence that a release is absent. + +6. Create a lightweight tag at the intended commit. Do not use `git tag -a` or `git tag -s`: + + ```sh + commit=$(git rev-parse origin/main) + git merge-base --is-ancestor "$commit" origin/main + git tag "$tag" "$commit" + test "$(git cat-file -t "refs/tags/$tag")" = commit + ``` + +7. Push only the selected tag: + + ```sh + git push origin "refs/tags/$tag" + ``` + +8. Monitor the `NexaWrt AX9000 reproducible RAM-test release` workflow. The workflow builds two independent replicas, enforces exact reproducibility, verifies the immutable `verified-dist`, creates a deterministic archive and checksum, attests the release subjects, uploads the exact publish directory, and checks the exact remote asset-name set before publishing. +9. After completion, verify the release is published as a prerelease, is not a draft, uses the triggering tag, has a publication timestamp, is not marked latest, and reports `immutable=true`. The workflow also requires exactly six remote assets: the versioned archive, its checksum, and four provenance bundles. Any extra, missing, duplicate, non-`uploaded`, zero-sized, negative-sized, or otherwise malformed asset fails final verification. + +The GitHub Pages index independently repeats the immutable and exact-asset checks. It refreshes on `main` updates, after a successful release workflow, on manual dispatch, and every six hours. This periodic revalidation is defense in depth: a release that no longer satisfies the contract disappears from the generated download index instead of remaining labeled verified. + +## Published names + +For `release_version=v1.4.0-rc.1`, the archive and checksum names are: + +- Official archive: `NexaWrt-AX9000-official-v1.4.0-rc.1-verified-dist.tar.gz` +- Official checksum: `NexaWrt-AX9000-official-v1.4.0-rc.1-verified-dist.tar.gz.sha256` +- NSS archive: `NexaWrt-AX9000-nss-v1.4.0-rc.1-verified-dist.tar.gz` +- NSS checksum: `NexaWrt-AX9000-nss-v1.4.0-rc.1-verified-dist.tar.gz.sha256` + +The archive provenance attestation subject is the exact version-qualified archive path. Release titles are human-readable and include the flavor where needed, for example `NexaWrt AX9000 v1.4.0-rc.1 RAM-test prerelease` or `NexaWrt AX9000 NSS v1.4.0-rc.1 RAM-test prerelease`. + +## Draft recovery + +The publish job intentionally creates a draft first, uploads every asset, compares the complete remote asset-name set with the local publish directory, and only then publishes it. A failed publish job can therefore leave a partial draft. + +Do **not** manually publish a partial draft and do not upload replacement assets by hand. Once published, an immutable release cannot be repaired in place; use a new release-candidate tag for corrections. First inspect the release and the failed workflow logs: + +```sh +tag=ram-test-v1.4.0-rc.1 +gh release view "$tag" --json isDraft,isPrerelease,tagName,publishedAt,url +gh release view "$tag" --json assets --jq '.assets[].name' +``` + +If and only if the release is still a draft for the exact triggering tag: + +1. Preserve the failed workflow logs and note the failure cause. +2. Delete the draft without deleting the Git tag: + + ```sh + gh release delete "$tag" --yes + test "$(git ls-remote --tags origin "refs/tags/$tag" | wc -l | tr -d ' ')" = 1 + ``` + +3. Correct external conditions if needed, then use GitHub Actions to rerun the failed publish job. Its retained verified artifact will be downloaded and reverified before a new draft is created. If the artifacts have expired or the rerun cannot reuse them, delete the draft as above and rerun the complete workflow for the unchanged tag. + +If the release is already published (`isDraft=false` or `publishedAt` is set), stop. Do not delete or overwrite it as routine draft recovery. Investigate the final-state verification failure and treat any correction as an explicit release-management incident; normally a new release-candidate tag is required. diff --git a/manifests/vm.lock b/manifests/vm.lock new file mode 100644 index 0000000..5468c90 --- /dev/null +++ b/manifests/vm.lock @@ -0,0 +1,7 @@ +# shellcheck shell=sh +# OpenWrt ImageBuilders used only for architecture-neutral VM smoke images. +VM_OPENWRT_VERSION="25.12.5" +VM_X86_64_IMAGEBUILDER_URL="https://downloads.openwrt.org/releases/25.12.5/targets/x86/64/openwrt-imagebuilder-25.12.5-x86-64.Linux-x86_64.tar.zst" +VM_X86_64_IMAGEBUILDER_SHA256="313221253d9bac534e4a4ee6492a4941b4ba0f43200eceb8d16a4785470ae9df" +VM_ARMSR_ARMV8_IMAGEBUILDER_URL="https://downloads.openwrt.org/releases/25.12.5/targets/armsr/armv8/openwrt-imagebuilder-25.12.5-armsr-armv8.Linux-x86_64.tar.zst" +VM_ARMSR_ARMV8_IMAGEBUILDER_SHA256="225243a1963f05c98f6d0f3a0c4b62c5a267ef505fc6df0c262a588603f53c4c" diff --git a/scripts/build-vm-image.sh b/scripts/build-vm-image.sh new file mode 100755 index 0000000..2b1851b --- /dev/null +++ b/scripts/build-vm-image.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOCK_FILE="$ROOT_DIR/manifests/vm.lock" +OVERLAY_DIR="$ROOT_DIR/vm-files" +WORK_DIR="${VM_WORK_DIR:-$ROOT_DIR/.work/vm}" +OUTPUT_ROOT="${VM_OUTPUT_DIR:-$ROOT_DIR/dist/vm}" +ROOTFS_PARTSIZE="${VM_ROOTFS_PARTSIZE:-256}" + +usage() { + cat >&2 <<'USAGE' +Usage: build-vm-image.sh + +Build a VM-only OpenWrt smoke image from a SHA256-pinned ImageBuilder. +VM_SMOKE_AUTHORIZED_KEY_FILE must name the public SSH key injected for the test. +USAGE +} + +fail() { + printf 'build-vm-image: %s\n' "$*" >&2 + exit 1 +} + +[[ $# -eq 1 ]] || { usage; exit 2; } +TARGET="$1" +[[ -f "$LOCK_FILE" ]] || fail "missing lock file: $LOCK_FILE" +[[ -d "$OVERLAY_DIR" ]] || fail "missing VM overlay: $OVERLAY_DIR" +# vm.lock is repository-controlled and policy-tested as declarative assignments only. +# shellcheck source=../manifests/vm.lock +source "$LOCK_FILE" + +case "$TARGET" in + x86-64) + TARGET_PATH="x86/64" + PROFILE="generic" + IMAGEBUILDER_URL="$VM_X86_64_IMAGEBUILDER_URL" + IMAGEBUILDER_SHA256="$VM_X86_64_IMAGEBUILDER_SHA256" + UPSTREAM_IMAGE="openwrt-${VM_OPENWRT_VERSION}-x86-64-generic-ext4-combined.img.gz" + ;; + armsr-armv8) + TARGET_PATH="armsr/armv8" + PROFILE="generic" + IMAGEBUILDER_URL="$VM_ARMSR_ARMV8_IMAGEBUILDER_URL" + IMAGEBUILDER_SHA256="$VM_ARMSR_ARMV8_IMAGEBUILDER_SHA256" + UPSTREAM_IMAGE="openwrt-${VM_OPENWRT_VERSION}-armsr-armv8-generic-ext4-combined-efi.img.gz" + ;; + *) + usage + fail "unsupported target: $TARGET" + ;; +esac + +for command_name in curl sha256sum tar make find install cp tee; do + command -v "$command_name" >/dev/null 2>&1 || fail "required command is missing: $command_name" +done +[[ "$ROOTFS_PARTSIZE" =~ ^[1-9][0-9]*$ ]] || fail "VM_ROOTFS_PARTSIZE must be a positive integer" + +AUTHORIZED_KEY_FILE="${VM_SMOKE_AUTHORIZED_KEY_FILE:-}" +[[ -n "$AUTHORIZED_KEY_FILE" ]] || fail "VM_SMOKE_AUTHORIZED_KEY_FILE is required" +[[ -f "$AUTHORIZED_KEY_FILE" && ! -L "$AUTHORIZED_KEY_FILE" ]] || fail "authorized key must be a regular file" +[[ "$(wc -l < "$AUTHORIZED_KEY_FILE" | tr -d ' ')" == 1 ]] || fail "authorized key must contain exactly one line" +grep -Eq '^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521))[[:space:]]+[A-Za-z0-9+/=]+' "$AUTHORIZED_KEY_FILE" || + fail "authorized key is not a supported OpenSSH public key" + +ARCHIVE_NAME="${IMAGEBUILDER_URL##*/}" +DOWNLOAD_DIR="$WORK_DIR/downloads" +BUILDER_ROOT="$WORK_DIR/imagebuilders/$TARGET" +OVERLAY_WORK="$WORK_DIR/overlay-$TARGET" +OUTPUT_DIR="$OUTPUT_ROOT/$TARGET" +ARCHIVE_PATH="$DOWNLOAD_DIR/$ARCHIVE_NAME" +BUILD_LOG="$OUTPUT_DIR/build.log" +mkdir -p "$DOWNLOAD_DIR" "$OUTPUT_DIR" + +if [[ ! -f "$ARCHIVE_PATH" ]] || ! printf '%s %s\n' "$IMAGEBUILDER_SHA256" "$ARCHIVE_PATH" | sha256sum --check --status; then + rm -f "$ARCHIVE_PATH" + download_tmp="$ARCHIVE_PATH.part" + rm -f "$download_tmp" + curl --fail --location --retry 5 --retry-all-errors --connect-timeout 30 \ + --output "$download_tmp" "$IMAGEBUILDER_URL" + printf '%s %s\n' "$IMAGEBUILDER_SHA256" "$download_tmp" | sha256sum --check --status || { + rm -f "$download_tmp" + fail "ImageBuilder SHA256 mismatch for $TARGET" + } + mv "$download_tmp" "$ARCHIVE_PATH" +fi + +rm -rf "$BUILDER_ROOT" +mkdir -p "$BUILDER_ROOT" +tar --zstd -xf "$ARCHIVE_PATH" -C "$BUILDER_ROOT" +BUILDER_DIR="$(find "$BUILDER_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'openwrt-imagebuilder-*' -print -quit)" +[[ -n "$BUILDER_DIR" ]] || fail "ImageBuilder archive did not contain the expected directory" + +rm -rf "$OVERLAY_WORK" +mkdir -p "$OVERLAY_WORK" +cp -a "$OVERLAY_DIR/." "$OVERLAY_WORK/" +mkdir -p "$OVERLAY_WORK/etc/dropbear" +install -m 0600 "$AUTHORIZED_KEY_FILE" "$OVERLAY_WORK/etc/dropbear/authorized_keys" + +# Keep this package set identical for both VM architectures. It intentionally +# contains LuCI, Dropbear SSH, and the utility baseline shared by release flavors. +VM_PACKAGES=( + luci + luci-ssl + dropbear + ca-bundle + curl + ethtool + htop + iperf3 + nano + tcpdump-mini +) +printf -v PACKAGE_LIST ' %q' "${VM_PACKAGES[@]}" +PACKAGE_LIST="${PACKAGE_LIST# }" + +rm -rf "$BUILDER_DIR/bin/targets/$TARGET_PATH" +{ + printf 'VM-only OpenWrt %s build for %s\n' "$VM_OPENWRT_VERSION" "$TARGET" + printf 'ImageBuilder: %s\n' "$IMAGEBUILDER_URL" + printf 'ImageBuilder SHA256: %s\n' "$IMAGEBUILDER_SHA256" + printf 'Validation scope: QEMU boot/userspace only; not hardware or NSS validation.\n' + make -C "$BUILDER_DIR" image \ + PROFILE="$PROFILE" \ + PACKAGES="$PACKAGE_LIST" \ + FILES="$OVERLAY_WORK" \ + ROOTFS_PARTSIZE="$ROOTFS_PARTSIZE" +} 2>&1 | tee "$BUILD_LOG" + +BUILT_IMAGE="$BUILDER_DIR/bin/targets/$TARGET_PATH/$UPSTREAM_IMAGE" +[[ -f "$BUILT_IMAGE" ]] || fail "expected image was not produced: $BUILT_IMAGE" +ARTIFACT_BASENAME="nexawrt-vm-smoke-openwrt-${VM_OPENWRT_VERSION}-${TARGET}.img.gz" +ARTIFACT_PATH="$OUTPUT_DIR/$ARTIFACT_BASENAME" +cp "$BUILT_IMAGE" "$ARTIFACT_PATH" + +MANIFEST_SOURCE="${BUILT_IMAGE%.img.gz}.manifest" +if [[ -f "$MANIFEST_SOURCE" ]]; then + cp "$MANIFEST_SOURCE" "$OUTPUT_DIR/${ARTIFACT_BASENAME%.img.gz}.manifest" +fi + +cat > "$OUTPUT_DIR/artifact-labels.env" < SHA256SUMS +) + +printf 'Built VM-only smoke image: %s\n' "$ARTIFACT_PATH" +printf 'This artifact is not hardware validation and not NSS validation.\n' +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + printf 'image=%s\n' "$ARTIFACT_PATH" + printf 'artifact_dir=%s\n' "$OUTPUT_DIR" + } >> "$GITHUB_OUTPUT" +fi diff --git a/scripts/generate-pages-data.py b/scripts/generate-pages-data.py new file mode 100755 index 0000000..e5a7cd1 --- /dev/null +++ b/scripts/generate-pages-data.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Generate the allowlisted GitHub release data consumed by the NexaWrt site.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import tempfile +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import quote + +REPOSITORY = "tifycloud/NexaWrt" +API_URL = f"https://api.github.com/repos/{REPOSITORY}/releases?per_page=100" +WEB_ROOT = f"https://github.com/{REPOSITORY}" +MAX_RESPONSE_BYTES = 5 * 1024 * 1024 +DEFAULT_HISTORY_LIMIT = 12 + +VERSION_PATTERN = r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-rc\.(?:0|[1-9][0-9]*)" +TAG_PATTERNS = { + "official": re.compile(rf"^ram-test-(?P{VERSION_PATTERN})$"), + "nss": re.compile(rf"^ram-test-nss-(?P{VERSION_PATTERN})$"), +} + +PROVENANCE_ASSETS = { + "archive": "archive.provenance.bundle.json", + "checksums": "checksums.provenance.bundle.json", + "firmware": "firmware.provenance.bundle.json", + "sbom": "sbom.provenance.bundle.json", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--input", + type=Path, + help="read a saved GitHub releases API response instead of making a request", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("site/releases.json"), + help="output path (default: site/releases.json)", + ) + parser.add_argument( + "--history-limit", + type=int, + default=DEFAULT_HISTORY_LIMIT, + choices=range(1, 21), + metavar="1..20", + ) + return parser.parse_args() + + +def read_limited(stream: Any) -> bytes: + payload = stream.read(MAX_RESPONSE_BYTES + 1) + if len(payload) > MAX_RESPONSE_BYTES: + raise ValueError("GitHub releases response exceeds the size limit") + return payload + + +def fetch_releases(token: str | None) -> Any: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "NexaWrt-Pages-Release-Index/1", + "X-GitHub-Api-Version": "2026-03-10", + } + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(API_URL, headers=headers, method="GET") + try: + with urllib.request.urlopen(request, timeout=20) as response: + if response.status != 200: + raise ValueError(f"GitHub API returned HTTP {response.status}") + return json.loads(read_limited(response)) + except urllib.error.HTTPError as exc: + raise ValueError(f"GitHub API returned HTTP {exc.code}") from exc + except urllib.error.URLError as exc: + raise ValueError(f"GitHub API request failed: {exc.reason}") from exc + + +def load_fixture(path: Path) -> Any: + if path.is_symlink() or not path.is_file(): + raise ValueError("fixture input must be a regular file, not a symlink") + with path.open("rb") as stream: + return json.loads(read_limited(stream)) + + +def normalize_timestamp(value: Any) -> str | None: + if not isinstance(value, str) or len(value) > 40: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def release_identity(tag: Any) -> tuple[str, str] | None: + if not isinstance(tag, str): + return None + for flavor in ("official", "nss"): + match = TAG_PATTERNS[flavor].fullmatch(tag) + if match: + return flavor, match.group("version") + return None + + +def expected_assets(flavor: str, version: str) -> dict[str, str]: + archive = f"NexaWrt-AX9000-{flavor}-{version}-verified-dist.tar.gz" + return { + "archive": archive, + "checksum": f"{archive}.sha256", + **{f"provenance_{key}": name for key, name in PROVENANCE_ASSETS.items()}, + } + + +def safe_download_url(tag: str, asset_name: str) -> str: + return f"{WEB_ROOT}/releases/download/{quote(tag, safe='')}/{quote(asset_name, safe='')}" + + +def sanitize_release(raw: Any) -> tuple[str, dict[str, Any]] | None: + if ( + not isinstance(raw, dict) + or raw.get("draft") is not False + or raw.get("prerelease") is not True + or raw.get("immutable") is not True + ): + return None + + tag = raw.get("tag_name") + identity = release_identity(tag) + published_at = normalize_timestamp(raw.get("published_at")) + if identity is None or published_at is None: + return None + flavor, version = identity + + assets = raw.get("assets") + if not isinstance(assets, list) or len(assets) > 100: + return None + + allowed = expected_assets(flavor, version) + allowed_by_name = {name: key for key, name in allowed.items()} + if len(assets) != len(allowed): + return None + + present: dict[str, dict[str, Any]] = {} + remote_names: set[str] = set() + for asset in assets: + if not isinstance(asset, dict): + return None + name = asset.get("name") + if not isinstance(name, str) or name in remote_names: + return None + remote_names.add(name) + key = allowed_by_name.get(name) + if key is None or asset.get("state") != "uploaded": + return None + size = asset.get("size") + if not isinstance(size, int) or isinstance(size, bool) or size <= 0: + return None + present[key] = { + "name": name, + "url": safe_download_url(tag, name), + "size": size, + } + + # Fail closed unless the remote release contains exactly the six expected assets. + if remote_names != set(allowed.values()) or set(present) != set(allowed): + return None + + return flavor, { + "tag": tag, + "version": version, + "published_at": published_at, + "url": f"{WEB_ROOT}/releases/tag/{quote(tag, safe='')}", + "assets": {key: present[key] for key in allowed}, + } + + +def build_document(raw_releases: Any, history_limit: int) -> dict[str, Any]: + if not isinstance(raw_releases, list) or len(raw_releases) > 100: + raise ValueError("GitHub releases payload must be a list of at most 100 entries") + + grouped: dict[str, list[dict[str, Any]]] = {"official": [], "nss": []} + seen_tags: set[str] = set() + for raw in raw_releases: + sanitized = sanitize_release(raw) + if sanitized is None: + continue + flavor, release = sanitized + if release["tag"] in seen_tags: + raise ValueError(f"duplicate allowlisted release tag: {release['tag']}") + seen_tags.add(release["tag"]) + grouped[flavor].append(release) + + for releases in grouped.values(): + releases.sort(key=lambda release: (release["published_at"], release["tag"]), reverse=True) + del releases[history_limit:] + + generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + return { + "schema_version": 1, + "repository": REPOSITORY, + "generated_at": generated_at, + "flavors": { + flavor: { + "latest": releases[0] if releases else None, + "history": releases, + } + for flavor, releases in grouped.items() + }, + } + + +def write_document(path: Path, document: dict[str, Any]) -> None: + if path.exists() and path.is_symlink(): + raise ValueError("output path must not be a symlink") + path.parent.mkdir(parents=True, exist_ok=True) + serialized = json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream: + stream.write(serialized) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def main() -> int: + args = parse_args() + try: + raw_releases = load_fixture(args.input) if args.input else fetch_releases(os.environ.get("GITHUB_TOKEN")) + write_document(args.output, build_document(raw_releases, args.history_limit)) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"generate-pages-data: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-vm-smoke.sh b/scripts/test-vm-smoke.sh new file mode 100755 index 0000000..7f63441 --- /dev/null +++ b/scripts/test-vm-smoke.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: test-vm-smoke.sh + +Boot a VM-only image in QEMU and validate networking, SSH, HTTP, ubus, UCI, +core services, and the dangerous-command guards. This is not hardware or NSS +validation. +USAGE +} + +fail() { + printf 'test-vm-smoke: %s\n' "$*" >&2 + return 1 +} + +[[ $# -eq 3 ]] || { usage; exit 2; } +TARGET="$1" +IMAGE_GZ="$(cd "$(dirname "$2")" 2>/dev/null && pwd)/$(basename "$2")" +SSH_KEY="$(cd "$(dirname "$3")" 2>/dev/null && pwd)/$(basename "$3")" +OUTPUT_DIR="${VM_SMOKE_OUTPUT_DIR:-$(pwd)/vm-smoke-results/$TARGET}" +BOOT_TIMEOUT="${VM_SMOKE_BOOT_TIMEOUT:-300}" +mkdir -p "$OUTPUT_DIR" +SERIAL_LOG="$OUTPUT_DIR/serial.log" +SSH_LOG="$OUTPUT_DIR/ssh-checks.log" +HTTP_HEADERS="$OUTPUT_DIR/http-headers.txt" +HTTP_BODY="$OUTPUT_DIR/http-body.html" +REPORT="$OUTPUT_DIR/smoke-report.txt" +DISK_IMAGE="$OUTPUT_DIR/disk.img" +QEMU_PID="" +SMOKE_STATUS="FAIL" + +cleanup() { + rc=$? + if [[ -n "$QEMU_PID" ]] && kill -0 "$QEMU_PID" 2>/dev/null; then + kill "$QEMU_PID" 2>/dev/null || true + for _ in {1..20}; do + kill -0 "$QEMU_PID" 2>/dev/null || break + sleep 0.25 + done + kill -9 "$QEMU_PID" 2>/dev/null || true + wait "$QEMU_PID" 2>/dev/null || true + fi + if [[ ! -f "$REPORT" ]]; then + printf 'status=%s\ntarget=%s\n' "$SMOKE_STATUS" "$TARGET" > "$REPORT" + fi + if [[ "$SMOKE_STATUS" != PASS ]]; then + { + printf 'status=FAIL\n' + printf 'target=%s\n' "$TARGET" + printf 'vm_only=true\n' + printf 'not_ax9000_firmware=true\n' + printf 'hardware_validation=false\n' + printf 'nss_validation=false\n' + printf 'serial_log=%s\n' "$SERIAL_LOG" + printf 'ssh_log=%s\n' "$SSH_LOG" + } > "$REPORT" + if [[ -s "$SERIAL_LOG" ]]; then + printf '%s\n' '--- QEMU serial tail ---' >&2 + tail -n 80 "$SERIAL_LOG" >&2 || true + fi + fi + trap - EXIT + exit "$rc" +} +trap cleanup EXIT + +case "$TARGET" in + x86-64) QEMU_BIN="qemu-system-x86_64" ;; + armsr-armv8) QEMU_BIN="qemu-system-aarch64" ;; + *) usage; fail "unsupported target: $TARGET"; exit 2 ;; +esac + +[[ -f "$IMAGE_GZ" && ! -L "$IMAGE_GZ" ]] || { fail "image must be a regular file: $IMAGE_GZ"; exit 1; } +[[ -f "$SSH_KEY" && ! -L "$SSH_KEY" ]] || { fail "SSH key must be a regular file: $SSH_KEY"; exit 1; } +case "$(basename "$IMAGE_GZ" | tr '[:upper:]' '[:lower:]')" in + *ax9000*) fail "VM image filename must not identify itself as AX9000 firmware"; exit 1 ;; +esac +[[ "$BOOT_TIMEOUT" =~ ^[1-9][0-9]*$ ]] || { fail "VM_SMOKE_BOOT_TIMEOUT must be a positive integer"; exit 1; } +for command_name in "$QEMU_BIN" curl gzip python3 ssh timeout; do + command -v "$command_name" >/dev/null 2>&1 || { fail "required command is missing: $command_name"; exit 1; } +done + +gzip -dc "$IMAGE_GZ" > "$DISK_IMAGE" + +allocate_port() { + python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} +SSH_PORT="$(allocate_port)" +HTTP_PORT="$(allocate_port)" +[[ "$SSH_PORT" != "$HTTP_PORT" ]] || HTTP_PORT="$(allocate_port)" + +QEMU_ARGS=( + -m 512 + -smp 2 + -display none + -monitor none + -serial stdio + -no-reboot + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22,hostfwd=tcp:127.0.0.1:${HTTP_PORT}-:80" +) +if [[ "$TARGET" == x86-64 ]]; then + QEMU_ARGS+=( + -machine "q35,accel=tcg" + -drive "file=$DISK_IMAGE,format=raw,if=ide" + -device "e1000,netdev=net0" + ) +else + AARCH64_EFI="${VM_AARCH64_EFI:-}" + if [[ -z "$AARCH64_EFI" ]]; then + for candidate in \ + /usr/share/AAVMF/AAVMF_CODE.fd \ + /usr/share/qemu-efi-aarch64/QEMU_EFI.fd \ + /usr/share/edk2/aarch64/QEMU_EFI.fd; do + if [[ -f "$candidate" ]]; then + AARCH64_EFI="$candidate" + break + fi + done + fi + [[ -n "$AARCH64_EFI" && -f "$AARCH64_EFI" ]] || { fail "AArch64 QEMU EFI firmware was not found"; exit 1; } + QEMU_ARGS+=( + -machine "virt,accel=tcg" + -cpu cortex-a57 + -bios "$AARCH64_EFI" + -drive "file=$DISK_IMAGE,format=raw,if=virtio" + -device "virtio-net-pci,netdev=net0" + ) +fi + +printf 'Starting %s for VM-only smoke validation (not hardware/NSS validation).\n' "$TARGET" +"$QEMU_BIN" "${QEMU_ARGS[@]}" > "$SERIAL_LOG" 2>&1 & +QEMU_PID=$! + +SSH_OPTIONS=( + -i "$SSH_KEY" + -p "$SSH_PORT" + -o BatchMode=yes + -o ConnectTimeout=4 + -o ConnectionAttempts=1 + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o ServerAliveInterval=5 + -o ServerAliveCountMax=3 +) + +deadline=$((SECONDS + BOOT_TIMEOUT)) +ssh_ready=false +while (( SECONDS < deadline )); do + if ! kill -0 "$QEMU_PID" 2>/dev/null; then + wait "$QEMU_PID" || true + fail "QEMU exited before SSH became ready" + exit 1 + fi + if timeout 10 ssh "${SSH_OPTIONS[@]}" root@127.0.0.1 true >/dev/null 2>&1; then + ssh_ready=true + break + fi + sleep 3 +done +[[ "$ssh_ready" == true ]] || { fail "SSH did not become ready within ${BOOT_TIMEOUT}s"; exit 1; } + +if ! timeout 120 ssh "${SSH_OPTIONS[@]}" root@127.0.0.1 'sh -s' > "$SSH_LOG" 2>&1 <<'REMOTE_CHECKS' +set -eu + +pass() { + printf 'PASS %s\n' "$1" +} + +label=/etc/nexawrt-vm-smoke +grep -qx 'ARTIFACT_CLASS=VM_SMOKE_IMAGE' "$label" +grep -qx 'VM_ONLY=1' "$label" +grep -qx 'NOT_AX9000_FIRMWARE=1' "$label" +grep -qx 'HARDWARE_VALIDATION=0' "$label" +grep -qx 'NSS_VALIDATION=0' "$label" +grep -q 'VM ONLY.*NOT-AX9000' /etc/banner +pass labels + +ubus call system board +ubus call network.interface.lan status | grep -q '"up": true' +pass ubus + +test "$(uci -q get system.@system[0].hostname)" = 'nexawrt-vm-smoke' +test "$(uci -q get network.lan.proto)" = 'dhcp' +test "$(uci -q get uhttpd.main.redirect_https)" = '0' +pass uci + +ip -4 address show dev eth0 | grep -q 'inet ' +ip -4 route show | grep -q '^default ' +gateway="$(ip -4 route show default | awk 'NR == 1 { print $3 }')" +test -n "$gateway" +ping -c 1 -W 5 "$gateway" >/dev/null +pass network + +for service_name in ubus dropbear rpcd uhttpd; do + "/etc/init.d/$service_name" running + printf 'service %s running\n' "$service_name" +done +pass services + +wget -qO /tmp/vm-smoke-luci.html http://127.0.0.1/cgi-bin/luci/ +grep -Eqi 'luci|&1)" + guard_status=$? + set -e + test "$guard_status" -eq 74 + printf '%s\n' "$guard_output" | grep -q 'VM-only safety guard' + printf '%s\n' "$guard_output" | grep -q 'not hardware/NSS validation' +} +for guard_path in \ + /sbin/factoryreset \ + /sbin/firstboot \ + /sbin/jffs2mark \ + /sbin/jffs2reset \ + /sbin/mtd \ + /sbin/sysupgrade \ + /sbin/ubiattach \ + /sbin/ubidetach \ + /sbin/ubiformat; do + check_guard "$guard_path" +done +pass dangerous-command-guards +REMOTE_CHECKS +then + fail "SSH/ubus/UCI/service/guard checks failed" + exit 1 +fi + +http_status="$(curl --silent --show-error --max-time 20 \ + --dump-header "$HTTP_HEADERS" --output "$HTTP_BODY" --write-out '%{http_code}' \ + "http://127.0.0.1:${HTTP_PORT}/cgi-bin/luci/")" +[[ "$http_status" =~ ^(200|30[1278])$ ]] || { fail "unexpected LuCI HTTP status: $http_status"; exit 1; } +grep -Eqi 'luci| "$REPORT" +printf 'VM smoke test PASS for %s. This is not hardware or NSS validation.\n' "$TARGET" diff --git a/scripts/validate.sh b/scripts/validate.sh index 8d17fdc..42bdb6e 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -827,9 +827,13 @@ credential_scan_paths=( "$ROOT_DIR/files" "$ROOT_DIR/manifests/feeds.lock" "$ROOT_DIR/manifests/upstream.lock" + "$ROOT_DIR/manifests/vm.lock" "$ROOT_DIR/patches" "$ROOT_DIR/scripts/backup-router.sh" "$ROOT_DIR/scripts/build.sh" + "$ROOT_DIR/scripts/build-vm-image.sh" + "$ROOT_DIR/scripts/test-vm-smoke.sh" + "$ROOT_DIR/scripts/generate-pages-data.py" "$ROOT_DIR/scripts/check-kernel-build-identity.sh" "$ROOT_DIR/scripts/collect-build-evidence.sh" "$ROOT_DIR/scripts/collect-production-state.sh" @@ -862,7 +866,11 @@ credential_scan_paths=( "$ROOT_DIR/tests/test_reproducibility_policy.sh" "$ROOT_DIR/tests/test_runtime_guards.sh" "$ROOT_DIR/tests/test_static.sh" + "$ROOT_DIR/tests/test_vm_policy.sh" + "$ROOT_DIR/tests/test_pages_policy.sh" "$ROOT_DIR/tests/test_workflow_policy.sh" + "$ROOT_DIR/site" + "$ROOT_DIR/vm-files" "$ROOT_DIR"/*.md "$ROOT_DIR/LICENSE" "$ROOT_DIR/Makefile" diff --git a/site/.nojekyll b/site/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..514d220 --- /dev/null +++ b/site/app.js @@ -0,0 +1,252 @@ +'use strict'; + +const REPOSITORY = 'tifycloud/NexaWrt'; +const FLAVORS = ['official', 'nss']; +const PROVENANCE_LABELS = { + provenance_archive: 'Archive bundle', + provenance_checksums: 'Checksums bundle', + provenance_firmware: 'Firmware bundle', + provenance_sbom: 'SBOM bundle' +}; + +function makeLink(label, url, className = '') { + const link = document.createElement('a'); + link.textContent = label; + link.href = url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + if (className) link.className = className; + return link; +} + +function isSafeGitHubUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'https:' && url.hostname === 'github.com' && + url.pathname.startsWith(`/${REPOSITORY}/releases/`); + } catch { + return false; + } +} + +function validRelease(release, flavor) { + if (!release || typeof release !== 'object') return false; + const versionPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-rc\.(?:0|[1-9]\d*)$/; + const tagPattern = flavor === 'nss' + ? /^ram-test-nss-v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-rc\.(?:0|[1-9]\d*)$/ + : /^ram-test-v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-rc\.(?:0|[1-9]\d*)$/; + const expectedTag = flavor === 'nss' ? `ram-test-nss-${release.version}` : `ram-test-${release.version}`; + if (!tagPattern.test(release.tag) || typeof release.version !== 'string' || + !versionPattern.test(release.version) || release.tag !== expectedTag || !isSafeGitHubUrl(release.url)) return false; + if (!release.assets || typeof release.assets !== 'object') return false; + const archive = `NexaWrt-AX9000-${flavor}-${release.version}-verified-dist.tar.gz`; + const expectedNames = { + archive, + checksum: `${archive}.sha256`, + provenance_archive: 'archive.provenance.bundle.json', + provenance_checksums: 'checksums.provenance.bundle.json', + provenance_firmware: 'firmware.provenance.bundle.json', + provenance_sbom: 'sbom.provenance.bundle.json' + }; + return Object.entries(expectedNames).every(([key, expectedName]) => { + const asset = release.assets[key]; + return asset && asset.name === expectedName && isSafeGitHubUrl(asset.url) && + new URL(asset.url).pathname.endsWith(`/${expectedName}`); + }); +} + +function formatDate(timestamp) { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return '—'; + return new Intl.DateTimeFormat(['zh-CN', 'en'], { + year: 'numeric', month: 'short', day: '2-digit', timeZone: 'UTC' + }).format(date); +} + +function showUnavailable(card) { + const downloads = card.querySelector('[data-field="downloads"]'); + const message = document.createElement('span'); + message.className = 'unavailable'; + message.textContent = '暂无完整已验证资产 / No complete verified release yet'; + downloads.replaceChildren(message); + card.querySelector('details').hidden = true; +} + +function renderCard(flavor, release) { + const card = document.querySelector(`[data-flavor="${flavor}"]`); + if (!card) return; + if (!validRelease(release, flavor)) { + showUnavailable(card); + return; + } + + card.querySelector('[data-field="version"]').textContent = release.version; + const date = card.querySelector('[data-field="date"]'); + date.textContent = formatDate(release.published_at); + date.dateTime = release.published_at; + + const downloads = card.querySelector('[data-field="downloads"]'); + downloads.replaceChildren( + makeLink('下载已验证归档 ↓', release.assets.archive.url), + makeLink('SHA-256', release.assets.checksum.url) + ); + + const provenance = card.querySelector('[data-field="provenance"]'); + provenance.replaceChildren(...Object.entries(PROVENANCE_LABELS).map(([key, label]) => + makeLink(label, release.assets[key].url) + )); + + const releaseUrl = card.querySelector('[data-field="release-url"]'); + releaseUrl.href = release.url; + releaseUrl.hidden = false; +} + +function renderHistory(flavorData) { + const history = document.querySelector('#release-history'); + const rows = []; + for (const flavor of FLAVORS) { + const releases = Array.isArray(flavorData[flavor]?.history) ? flavorData[flavor].history : []; + for (const release of releases) { + if (validRelease(release, flavor)) rows.push({ flavor, release }); + } + } + rows.sort((left, right) => right.release.published_at.localeCompare(left.release.published_at)); + + if (!rows.length) { + const empty = document.createElement('p'); + empty.className = 'empty-state'; + empty.textContent = '暂无完整已验证历史版本 / No complete verified history'; + history.replaceChildren(empty); + return; + } + + const fragment = document.createDocumentFragment(); + for (const { flavor, release } of rows) { + const row = document.createElement('article'); + row.className = 'history-item'; + + const flavorLabel = document.createElement('span'); + flavorLabel.className = `history-flavor ${flavor}`; + flavorLabel.textContent = flavor === 'nss' ? 'NSS · EXP' : 'OFFICIAL'; + + const tag = document.createElement('strong'); + tag.className = 'history-tag'; + tag.textContent = release.tag; + + const date = document.createElement('time'); + date.dateTime = release.published_at; + date.textContent = formatDate(release.published_at); + + row.append(flavorLabel, tag, date, makeLink('Archive ↓', release.assets.archive.url)); + fragment.append(row); + } + history.replaceChildren(fragment); +} + +async function loadReleases() { + const status = document.querySelector('#data-status'); + try { + const response = await fetch('releases.json', { cache: 'no-store', credentials: 'same-origin' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (data.schema_version !== 1 || data.repository !== REPOSITORY || !data.flavors) { + throw new Error('unexpected release index schema'); + } + for (const flavor of FLAVORS) renderCard(flavor, data.flavors[flavor]?.latest); + renderHistory(data.flavors); + status.textContent = data.generated_at === '1970-01-01T00:00:00Z' + ? '尚未发布版本 / No release index has been published yet' + : `索引更新 / Index generated: ${formatDate(data.generated_at)} UTC`; + } catch (error) { + for (const flavor of FLAVORS) renderCard(flavor, null); + renderHistory({}); + status.classList.add('error'); + status.textContent = '发布索引暂不可用;请勿猜测下载地址。 / Release index unavailable; never guess asset URLs.'; + console.error('Unable to load the allowlisted release index:', error); + } +} + +const TIMEZONES = { + 'Asia/Shanghai': 'CST-8', + 'Asia/Tokyo': 'JST-9', + 'Asia/Singapore': '<+08>-8', + 'Europe/London': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Berlin': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'America/New_York': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Los_Angeles': 'PST8PDT,M3.2.0,M11.1.0', + 'Australia/Sydney': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'UTC': 'UTC0' +}; +const COUNTRIES = new Set(['CN', 'US', 'GB', 'DE', 'JP', 'AU', 'SG']); + +function isPrivateIPv4(value) { + const parts = value.split('.'); + if (parts.length !== 4 || parts.some((part) => !/^(?:0|[1-9]\d{0,2})$/.test(part))) return false; + const octets = parts.map(Number); + if (octets.some((part) => part > 255)) return false; + const [a, b, , d] = octets; + const privateRange = a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); + return privateRange && d !== 0 && d !== 255; +} + +function generateSnippet(event) { + event.preventDefault(); + const form = event.currentTarget; + const error = document.querySelector('#config-error'); + const hostname = form.elements.hostname.value.trim().toLowerCase(); + const lanIp = form.elements['lan-ip'].value.trim(); + const zonename = form.elements.timezone.value; + const country = form.elements.country.value; + const hostnamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + + let problem = ''; + if (!hostnamePattern.test(hostname)) problem = '主机名必须为 1–63 个字母、数字或中划线,且不能以中划线开头或结尾。'; + else if (!isPrivateIPv4(lanIp)) problem = 'LAN IP 必须是有效的 RFC1918 私有 IPv4 主机地址。'; + else if (!Object.hasOwn(TIMEZONES, zonename)) problem = '请选择列表中的时区。'; + else if (!COUNTRIES.has(country)) problem = '请选择列表中的无线国家码。'; + + if (problem) { + error.textContent = problem; + error.hidden = false; + document.querySelector('#copy-snippet').disabled = true; + return; + } + error.hidden = true; + + const snippet = `# NexaWrt RAM-session configuration — review before running +# Volatile runtime settings only; this does not modify or rebuild the image. +uci -q batch <<'NEXAWRT_SAFE_CONFIG' +set system.@system[0].hostname='${hostname}' +set system.@system[0].zonename='${zonename}' +set system.@system[0].timezone='${TIMEZONES[zonename]}' +set network.lan.ipaddr='${lanIp}' +set wireless.radio0.country='${country}' +set wireless.radio1.country='${country}' +set wireless.radio2.country='${country}' +commit system +commit network +commit wireless +NEXAWRT_SAFE_CONFIG + +/etc/init.d/system reload +/etc/init.d/network reload +wifi reload +`; + document.querySelector('#config-output').textContent = snippet; + document.querySelector('#copy-snippet').disabled = false; +} + +async function copySnippet() { + const button = document.querySelector('#copy-snippet'); + try { + await navigator.clipboard.writeText(document.querySelector('#config-output').textContent); + button.textContent = '已复制 / Copied'; + window.setTimeout(() => { button.textContent = '复制 / Copy'; }, 1800); + } catch { + button.textContent = '请手动复制 / Select manually'; + } +} + +document.querySelector('#config-form').addEventListener('submit', generateSnippet); +document.querySelector('#copy-snippet').addEventListener('click', copySnippet); +loadReleases(); diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..b943313 --- /dev/null +++ b/site/index.html @@ -0,0 +1,199 @@ + + + + + + + + + NexaWrt · AX9000 RAM Test + + + + + + + + +
+
+ + +
+
+

XIAOMI AX9000 · IPQ807X · INITRAMFS

+

先验证,再启动。
Verify first. Boot from RAM.

+

面向 Xiaomi AX9000 的可复现、可追溯 OpenWrt RAM 测试候选。每个可下载版本必须同时具备归档摘要与 GitHub 构建来源证明。

+ +
+ +
+
+ +
+
01

只读边界

保持持久 UBI 未附加、未挂载、未写入。

+
02

先验摘要

下载归档后,使用配套 SHA-256 文件核验。

+
03

来源证明

使用 GitHub provenance bundle 检查构建来源。

+
+ +
+
+
+

VERIFIED RELEASES · 已验证发布

+

选择构建分支

+
+

正在读取发布索引… / Loading release index…

+
+ +
+
+
+ + OFFICIAL · 官方 + RAM ONLY +
+

官方 OpenWrt 路径

+

基于锁定上游输入的标准软件转发版本。适合作为首选验证基线。

+
+
最新版本 / Latest—
+ +
+
+
+ Provenance · 来源证明 + +
+ +
+ +
+
+ + NSS · 实验性 + EXPERIMENTAL +
+

NSS 加速路径

+

包含锁定的 NSS 组件,供受控网络性能实验。风险与验证范围不同于官方版本。

+
+
最新版本 / Latest—
+ +
+
+
+ Provenance · 来源证明 + +
+ +
+
+ +
+
+

RELEASE HISTORY · 历史版本

已完成完整资产校验的候选

+ 全部 GitHub Releases ↗ +
+
+

正在加载历史版本… / Loading history…

+
+
+
+ +
+
+

THREE-STEP CHECK · 三步核验

下载不等于批准启动

+
+
    +
  1. 1

    核对归档

    仅下载卡片中的 verified-dist.tar.gz 与同名 .sha256。

  2. +
  3. 2

    验证摘要

    sha256sum -c NexaWrt-AX9000-*-verified-dist.tar.gz.sha256
  4. +
  5. 3

    检查来源并遵循真机门禁

    下载 archive/checksums/firmware/SBOM provenance;仍需 UART、恢复、运行时与压力测试批准。

  6. +
+
+ +
+
+

VOLATILE SETUP · 临时配置

+

生成安全的 RAM 会话配置片段

+

此工具仅生成主机名、LAN IP、时区和无线国家码。不收集、不生成密码或密钥,不会把任何设置烘焙进下载镜像。

+

复制后由你在已启动的 RAM 测试会话中审阅并执行。重启后不应依赖这些临时设置;该片段不执行刷写或持久分区操作。

+
+ +
+ + +
+ + +
+ + +
+ +
+
nexawrt-session-setup.sh
+
# 填写并验证上方字段后生成 / Complete the safe fields above
+
+
+
+ +
+
NexaWrt
+

Xiaomi AX9000 initramfs RAM-test project · Not affiliated with Xiaomi or OpenWrt.

+ Build status ↗ +
+ + diff --git a/site/releases.json b/site/releases.json new file mode 100644 index 0000000..2289e34 --- /dev/null +++ b/site/releases.json @@ -0,0 +1,15 @@ +{ + "flavors": { + "nss": { + "history": [], + "latest": null + }, + "official": { + "history": [], + "latest": null + } + }, + "generated_at": "1970-01-01T00:00:00Z", + "repository": "tifycloud/NexaWrt", + "schema_version": 1 +} diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..63cde2e --- /dev/null +++ b/site/styles.css @@ -0,0 +1,204 @@ +:root { + --bg: #090b0d; + --surface: #111519; + --surface-2: #171c21; + --line: #293139; + --text: #f3f6f8; + --muted: #9da8b0; + --lime: #c9ff3d; + --lime-soft: rgba(201, 255, 61, 0.11); + --orange: #ff9f43; + --danger: #ff4d43; + --danger-bg: #3a0d0a; + --max: 1180px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + color: var(--text); + background: var(--bg); + color-scheme: dark; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; background: var(--bg); line-height: 1.65; } +a { color: inherit; text-decoration: none; } +button, input, select { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +code, pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } + +.skip-link { position: fixed; top: 8px; left: 8px; z-index: 100; padding: 8px 12px; color: #000; background: var(--lime); transform: translateY(-150%); } +.skip-link:focus { transform: translateY(0); } + +.safety-banner { display: grid; grid-template-columns: auto minmax(0, var(--max)); justify-content: center; gap: 18px; padding: 18px 24px; border-bottom: 2px solid var(--danger); background: repeating-linear-gradient(135deg, var(--danger-bg), var(--danger-bg) 14px, #320b09 14px, #320b09 28px); } +.safety-banner h1, .safety-banner p { margin: 0; } +.safety-banner h1 { font-size: clamp(1.15rem, 2.3vw, 1.65rem); letter-spacing: .02em; } +.safety-banner p:last-child { max-width: 980px; color: #ffd9d6; } +.safety-mark { align-self: center; display: grid; place-items: center; width: 46px; height: 46px; border: 2px solid #fff; border-radius: 50%; font: 900 1.6rem/1 monospace; color: #fff; background: var(--danger); } +.eyebrow { margin: 0 0 8px; color: var(--lime); font: 700 .72rem/1.2 "SFMono-Regular", Consolas, monospace; letter-spacing: .14em; text-transform: uppercase; } +.safety-banner .eyebrow { color: #ffaba6; } + +.hero { position: relative; overflow: hidden; border-bottom: 1px solid var(--line); background: radial-gradient(circle at 78% 48%, rgba(201,255,61,.10), transparent 26%), linear-gradient(180deg, #101418 0%, #090b0d 100%); } +.hero::before { content: ""; position: absolute; inset: 0; opacity: .17; pointer-events: none; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 42px 42px; mask-image: linear-gradient(to bottom, black, transparent 85%); } +.topbar, .hero-grid, .section, .boundary, footer { width: min(calc(100% - 40px), var(--max)); margin-inline: auto; } +.topbar { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: 24px 0; border-bottom: 1px solid var(--line); } +.brand, footer > div { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: .02em; } +.brand-glyph { display: grid; place-items: center; width: 30px; height: 30px; color: #050600; background: var(--lime); font: 900 1rem/1 monospace; clip-path: polygon(15% 0, 100% 0, 85% 100%, 0 100%); } +.nav-links { display: flex; gap: 28px; color: var(--muted); font-size: .9rem; } +.nav-links a:hover, .nav-links a:focus-visible, footer a:hover { color: var(--lime); } + +.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: 1.45fr .75fr; gap: clamp(40px, 8vw, 110px); align-items: center; padding: 92px 0 105px; } +.kicker { color: var(--muted); font: 700 .76rem/1.4 monospace; letter-spacing: .16em; } +.hero-copy h2 { max-width: 780px; margin: 18px 0 24px; font-size: clamp(2.6rem, 6.5vw, 5.8rem); line-height: .98; letter-spacing: -.065em; } +.hero-copy h2 span { color: var(--lime); } +.lede { max-width: 700px; color: #c3cbd0; font-size: clamp(1rem, 1.8vw, 1.18rem); } +.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 34px; } +.button { display: inline-flex; min-height: 48px; align-items: center; justify-content: center; padding: 11px 20px; border: 1px solid transparent; border-radius: 2px; cursor: pointer; font-weight: 750; transition: transform .15s ease, border-color .15s ease, background .15s ease; } +.button:hover { transform: translateY(-2px); } +.button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible, .snippet-toolbar button:focus-visible, a:focus-visible { outline: 2px solid var(--lime); outline-offset: 3px; } +.button-primary { color: #0a0c08; background: var(--lime); } +.button-secondary { border-color: #56616b; background: rgba(255,255,255,.03); } +.button-secondary:hover { border-color: var(--lime); } + +.device-card { position: relative; padding: 28px; border: 1px solid #414b53; background: rgba(12,15,17,.87); box-shadow: 14px 14px 0 rgba(201,255,61,.08); } +.device-card::before { content: "SUPPORTED HARDWARE"; position: absolute; top: 18px; right: 18px; color: #67727a; font: 650 .61rem/1 monospace; letter-spacing: .12em; } +.device-orbit { display: grid; place-items: center; width: 152px; height: 152px; margin: 18px auto 32px; border: 1px solid #48535a; border-radius: 50%; background: radial-gradient(circle, var(--lime-soft), transparent 62%); box-shadow: inset 0 0 0 24px rgba(255,255,255,.015); } +.device-orbit::before, .device-orbit::after { content: ""; position: absolute; border-radius: 50%; } +.device-orbit::before { width: 112px; height: 112px; border: 1px dashed #69737b; } +.device-orbit::after { width: 8px; height: 8px; transform: translate(73px, -28px); background: var(--lime); box-shadow: 0 0 16px var(--lime); } +.device-orbit span { z-index: 1; font: 900 2.4rem/1 monospace; letter-spacing: -.12em; } +dl { margin: 0; } +dl div { display: grid; grid-template-columns: 1fr 1.45fr; gap: 12px; padding: 10px 0; border-top: 1px solid var(--line); } +dt { color: var(--muted); font-size: .78rem; } +dd { margin: 0; font: 650 .78rem/1.5 monospace; } +.warning-text { color: var(--orange); } + +.boundary { display: grid; grid-template-columns: repeat(3, 1fr); border-inline: 1px solid var(--line); } +.boundary article { min-height: 170px; padding: 30px; border-right: 1px solid var(--line); background: #0d1013; } +.boundary article:last-child { border-right: 0; } +.boundary span { color: var(--lime); font: 700 .72rem monospace; } +.boundary h3 { margin: 14px 0 4px; font-size: 1rem; } +.boundary p { margin: 0; color: var(--muted); font-size: .9rem; } + +.section { padding: 100px 0; } +.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 32px; margin-bottom: 36px; } +.section-heading h2 { margin: 0; font-size: clamp(2rem, 4vw, 3.4rem); line-height: 1.1; letter-spacing: -.04em; } +.section-heading.compact { margin-bottom: 24px; } +.data-status { max-width: 420px; margin: 0; color: var(--muted); font: .76rem/1.6 monospace; text-align: right; } +.data-status.error { color: #ff8982; } + +.releases-section { padding-bottom: 70px; } +.release-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; } +.release-card { position: relative; display: flex; flex-direction: column; min-height: 510px; padding: 30px; overflow: hidden; border: 1px solid var(--line); background: linear-gradient(145deg, var(--surface-2), var(--surface)); } +.release-card::after { content: ""; position: absolute; right: -90px; bottom: -90px; width: 220px; height: 220px; border: 1px solid rgba(201,255,61,.14); border-radius: 50%; box-shadow: 0 0 0 28px rgba(201,255,61,.025), 0 0 0 58px rgba(201,255,61,.018); pointer-events: none; } +.release-card.nss::after { border-color: rgba(255,159,67,.18); box-shadow: 0 0 0 28px rgba(255,159,67,.03), 0 0 0 58px rgba(255,159,67,.018); } +.release-card-top { display: flex; align-items: center; gap: 9px; } +.flavor-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 10px rgba(201,255,61,.7); } +.nss .flavor-dot { background: var(--orange); box-shadow: 0 0 10px rgba(255,159,67,.7); } +.flavor-label { font: 750 .73rem/1 monospace; letter-spacing: .09em; } +.release-badge { margin-left: auto; padding: 5px 8px; border: 1px solid #526222; color: var(--lime); font: 700 .62rem/1 monospace; } +.release-badge.experimental { border-color: #704619; color: var(--orange); } +.release-card h3 { margin: 32px 0 8px; font-size: 1.55rem; } +.release-card > p { min-height: 58px; margin: 0; color: var(--muted); } +.version-row { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 36px 0 26px; padding: 22px 0; border-block: 1px solid var(--line); } +.version-row div { display: flex; flex-direction: column; } +.version-row span, .version-row time { color: var(--muted); font: .72rem/1.4 monospace; } +.version-row strong { font: 800 1.65rem/1.3 monospace; } +.download-actions { display: grid; grid-template-columns: 1fr auto; gap: 9px; min-height: 48px; } +.download-actions a { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; padding: 11px 14px; border: 1px solid #58636a; font-size: .82rem; font-weight: 700; } +.download-actions a:first-child { color: #080a06; border-color: var(--lime); background: var(--lime); } +.nss .download-actions a:first-child { border-color: var(--orange); background: var(--orange); } +.download-actions a:hover { border-color: #fff; } +.download-actions .unavailable { grid-column: 1 / -1; color: var(--muted); font: .78rem/1.5 monospace; } +details { position: relative; z-index: 1; margin-top: 18px; } +summary { color: var(--muted); cursor: pointer; font-size: .8rem; } +.provenance-links { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; margin-top: 10px; } +.provenance-links a { padding: 7px 9px; border: 1px solid var(--line); color: #bdc6cc; font: .7rem/1.3 monospace; } +.provenance-links a:hover { border-color: var(--lime); color: var(--text); } +.release-notes { position: relative; z-index: 1; margin-top: auto; padding-top: 22px; color: var(--muted); font-size: .78rem; } +.release-notes:hover { color: var(--lime); } + +.history-panel { margin-top: 20px; border: 1px solid var(--line); background: #0d1013; } +.history-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 24px 28px; border-bottom: 1px solid var(--line); } +.history-heading h3 { margin: 0; font-size: 1rem; } +.history-heading a { color: var(--muted); font-size: .78rem; } +.history-list { padding: 0 28px; } +.history-item { display: grid; grid-template-columns: 100px 1fr 160px auto; align-items: center; gap: 16px; padding: 17px 0; border-bottom: 1px solid var(--line); } +.history-item:last-child { border-bottom: 0; } +.history-flavor { color: var(--lime); font: 700 .68rem/1 monospace; letter-spacing: .08em; } +.history-flavor.nss { color: var(--orange); } +.history-tag { font: 750 .82rem/1.4 monospace; overflow-wrap: anywhere; } +.history-item time { color: var(--muted); font: .7rem/1 monospace; } +.history-item a { padding: 5px 0; color: var(--muted); font-size: .75rem; } +.history-item a:hover { color: var(--lime); } +.empty-state { color: var(--muted); font: .78rem/1.5 monospace; } + +.verify-section { width: 100%; max-width: none; padding-inline: max(20px, calc((100% - var(--max)) / 2)); border-block: 1px solid var(--line); background: #0d1013; } +.verify-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin: 0; padding: 1px; list-style: none; background: var(--line); } +.verify-steps li { display: flex; gap: 17px; min-width: 0; padding: 26px; background: var(--surface); } +.verify-steps li > span { flex: 0 0 auto; display: grid; place-items: center; width: 30px; height: 30px; color: #050600; background: var(--lime); font: 800 .8rem/1 monospace; } +.verify-steps h3 { margin: 2px 0 8px; font-size: .95rem; } +.verify-steps p { margin: 0; color: var(--muted); font-size: .82rem; } +.verify-steps pre { max-width: 100%; margin: 8px 0 0; overflow-x: auto; color: var(--lime); font-size: .68rem; } + +.configure-section { display: grid; grid-template-columns: .8fr 1fr; gap: 28px 60px; } +.config-copy h2 { margin: 0 0 18px; font-size: clamp(2rem, 4vw, 3.5rem); line-height: 1.05; letter-spacing: -.04em; } +.config-copy > p:not(.eyebrow) { color: var(--muted); } +.not-baked { display: flex; gap: 12px; margin-top: 28px; padding: 16px; border-left: 3px solid var(--orange); background: rgba(255,159,67,.07); } +.not-baked span { color: var(--orange); font-size: 1.5rem; } +.not-baked p { margin: 0; color: #d5c2ad; font-size: .82rem; } +.config-form { display: grid; gap: 16px; padding: 26px; border: 1px solid var(--line); background: var(--surface); } +.config-form label { display: grid; gap: 7px; color: #c9d0d4; font-size: .78rem; font-weight: 700; } +.config-form input, .config-form select { width: 100%; min-width: 0; padding: 11px 12px; border: 1px solid #424c54; border-radius: 0; color: var(--text); background: #0a0d0f; } +.config-form input:invalid:not(:focus):not(:placeholder-shown) { border-color: var(--danger); } +.config-form small { color: var(--muted); font-weight: 400; } +.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.form-error { margin: 0; color: #ff8d86; font-size: .78rem; } +.config-form .button { width: 100%; border: 0; } +.snippet-panel { grid-column: 1 / -1; min-width: 0; border: 1px solid var(--line); background: #07090a; } +.snippet-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--line); color: var(--muted); font: .7rem/1 monospace; } +.snippet-toolbar button { border: 0; color: var(--lime); background: transparent; cursor: pointer; } +.snippet-toolbar button:disabled { color: #5d666c; cursor: default; } +.snippet-panel pre { min-height: 190px; margin: 0; padding: 22px; overflow: auto; color: #d8e0e4; font-size: .78rem; line-height: 1.7; } + +footer { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 26px; padding: 34px 0 44px; border-top: 1px solid var(--line); color: var(--muted); font-size: .75rem; } +footer p { margin: 0; text-align: center; } + +@media (max-width: 850px) { + .hero-grid, .configure-section { grid-template-columns: 1fr; } + .hero-grid { padding: 65px 0 75px; } + .device-card { max-width: 520px; } + .boundary, .verify-steps { grid-template-columns: 1fr; } + .boundary article { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); } + .release-grid { grid-template-columns: 1fr; } + .history-item { grid-template-columns: 90px 1fr auto; } + .history-item time { display: none; } +} + +@media (max-width: 600px) { + .topbar, .hero-grid, .section, .boundary, footer { width: min(calc(100% - 28px), var(--max)); } + .safety-banner { grid-template-columns: 1fr; padding: 16px; } + .safety-mark { display: none; } + .nav-links a:not(:last-child) { display: none; } + .hero-copy h2 { font-size: clamp(2.35rem, 14vw, 4rem); } + .section { padding: 70px 0; } + .section-heading, .history-heading { align-items: flex-start; flex-direction: column; } + .data-status { text-align: left; } + .release-card { min-height: 0; padding: 22px; } + .version-row { align-items: flex-start; flex-direction: column; } + .download-actions, .provenance-links, .field-grid { grid-template-columns: 1fr; } + .history-list { padding: 0 18px; } + .history-item { grid-template-columns: 1fr auto; gap: 7px 14px; } + .history-flavor { grid-column: 1; } + .history-tag { grid-column: 1; } + .history-item a { grid-column: 2; grid-row: 1 / 3; } + footer { grid-template-columns: 1fr; text-align: center; } + footer > div { justify-content: center; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { transition: none !important; } +} + +/* Keep long verification commands inside their own scroll containers on phones. */ +.verify-steps li > div { min-width: 0; } +.verify-steps pre, .snippet-panel pre { width: 100%; min-width: 0; } diff --git a/tests/test_pages_policy.sh b/tests/test_pages_policy.sh new file mode 100755 index 0000000..e4c0980 --- /dev/null +++ b/tests/test_pages_policy.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="$ROOT_DIR/.github/workflows/pages.yml" +GENERATOR="$ROOT_DIR/scripts/generate-pages-data.py" +SITE="$ROOT_DIR/site" + +for path in "$WORKFLOW" "$GENERATOR" "$SITE/index.html" "$SITE/styles.css" "$SITE/app.js" "$SITE/releases.json" "$SITE/.nojekyll"; do + test -f "$path" || { echo "missing Pages file: $path" >&2; exit 1; } +done + +test -x "$GENERATOR" +python3 -m py_compile "$GENERATOR" +grep -Fq '"X-GitHub-Api-Version": "2026-03-10"' "$GENERATOR" +if command -v node >/dev/null 2>&1; then + node --check "$SITE/app.js" +fi + +# The public page must make the hardware and non-flashable safety boundary unmistakable. +grep -Fq 'Xiaomi AX9000' "$SITE/index.html" +grep -Fq '仅限 RAM 测试 · RAM TEST ONLY' "$SITE/index.html" +grep -Fq '严禁刷写 · DO NOT FLASH' "$SITE/index.html" +grep -Fq '不是 sysupgrade / factory 固件' "$SITE/index.html" +grep -Fq 'https://github.com/tifycloud/NexaWrt/actions/workflows/build.yml' "$SITE/index.html" +grep -Fq '不收集、不生成密码或密钥,不会把任何设置烘焙进下载镜像' "$SITE/index.html" +if grep -Eiq ']+(password|secret|token|key)' "$SITE/index.html"; then + echo 'secret-bearing configuration field found in site UI' >&2 + exit 1 +fi +if grep -Eiq '(sysupgrade|factory).*(href|download=)|(href|download=).*(sysupgrade|factory)' "$SITE/index.html"; then + echo 'flash image download link found in site UI' >&2 + exit 1 +fi +grep -Fq "default-src 'self'" "$SITE/index.html" + +# Pages deployment uses only official actions pinned to immutable commit SHAs. +grep -Fq 'permissions: {}' "$WORKFLOW" +grep -Fq "workflows: ['NexaWrt AX9000 reproducible RAM-test release']" "$WORKFLOW" +grep -Fq 'types: [completed]' "$WORKFLOW" +grep -Fq 'schedule:' "$WORKFLOW" +grep -Fq "cron: '17 */6 * * *'" "$WORKFLOW" +grep -Fq 'workflow_dispatch:' "$WORKFLOW" +grep -Fq "github.repository == 'tifycloud/NexaWrt' &&" "$WORKFLOW" +grep -Fq "github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'" "$WORKFLOW" +grep -Fq 'ref: refs/heads/main' "$WORKFLOW" +grep -Fq 'contents: read' "$WORKFLOW" +grep -Fq 'pages: write' "$WORKFLOW" +grep -Fq 'id-token: write' "$WORKFLOW" +grep -Fq 'name: github-pages' "$WORKFLOW" +# GitHub expression is intentionally matched literally. +# shellcheck disable=SC2016 +grep -Fq 'url: ${{ steps.deployment.outputs.page_url }}' "$WORKFLOW" +grep -Fq 'python3 scripts/generate-pages-data.py --output site/releases.json' "$WORKFLOW" +grep -Fq 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' "$WORKFLOW" +grep -Fq 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' "$WORKFLOW" +grep -Fq 'actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128' "$WORKFLOW" +while IFS= read -r action; do + [[ "$action" =~ ^actions/(checkout|configure-pages|upload-pages-artifact|deploy-pages)@[0-9a-f]{40}$ ]] || { + echo "unapproved or unpinned Pages action: $action" >&2 + exit 1 + } +done < <(sed -nE 's/^[[:space:]]*uses:[[:space:]]*([^[:space:]#]+).*/\1/p' "$WORKFLOW") + +# Exercise immutable releases against exact, extra, missing, duplicate, non-uploaded, and invalid-size assets. +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +fixture="$tmp_dir/releases.json" +output="$tmp_dir/pages.json" +cat > "$fixture" <<'JSON' +[ + { + "tag_name": "ram-test-v2.0.0-rc.2", + "draft": false, + "prerelease": true, + "immutable": true, + "published_at": "2026-07-02T12:00:00Z", + "assets": [ + {"name": "NexaWrt-AX9000-official-v2.0.0-rc.2-verified-dist.tar.gz", "state": "uploaded", "size": 100}, + {"name": "NexaWrt-AX9000-official-v2.0.0-rc.2-verified-dist.tar.gz.sha256", "state": "uploaded", "size": 101}, + {"name": "archive.provenance.bundle.json", "state": "uploaded", "size": 102}, + {"name": "checksums.provenance.bundle.json", "state": "uploaded", "size": 103}, + {"name": "firmware.provenance.bundle.json", "state": "uploaded", "size": 104}, + {"name": "sbom.provenance.bundle.json", "state": "uploaded", "size": 105}, + {"name": "openwrt-ax9000-sysupgrade.bin", "state": "uploaded", "size": 999} + ] + }, + { + "tag_name": "ram-test-v1.9.0-rc.1", + "draft": false, + "prerelease": true, + "immutable": true, + "published_at": "2026-06-01T12:00:00Z", + "html_url": "https://attacker.invalid/release", + "assets": [ + {"name": "NexaWrt-AX9000-official-v1.9.0-rc.1-verified-dist.tar.gz", "state": "uploaded", "size": 90, "browser_download_url": "https://attacker.invalid/archive"}, + {"name": "NexaWrt-AX9000-official-v1.9.0-rc.1-verified-dist.tar.gz.sha256", "state": "uploaded", "size": 91}, + {"name": "archive.provenance.bundle.json", "state": "uploaded", "size": 92}, + {"name": "checksums.provenance.bundle.json", "state": "uploaded", "size": 93}, + {"name": "firmware.provenance.bundle.json", "state": "uploaded", "size": 94}, + {"name": "sbom.provenance.bundle.json", "state": "uploaded", "size": 95} + ] + }, + { + "tag_name": "ram-test-nss-v3.0.0-rc.1", + "draft": false, + "prerelease": true, + "immutable": true, + "published_at": "2026-07-03T12:00:00+00:00", + "assets": [ + {"name": "NexaWrt-AX9000-nss-v3.0.0-rc.1-verified-dist.tar.gz", "state": "uploaded", "size": 200}, + {"name": "NexaWrt-AX9000-nss-v3.0.0-rc.1-verified-dist.tar.gz.sha256", "state": "uploaded", "size": 201}, + {"name": "archive.provenance.bundle.json", "state": "uploaded", "size": 202}, + {"name": "checksums.provenance.bundle.json", "state": "uploaded", "size": 203}, + {"name": "firmware.provenance.bundle.json", "state": "uploaded", "size": 204}, + {"name": "sbom.provenance.bundle.json", "state": "uploaded", "size": 205} + ] + }, + { + "tag_name": "ram-test-v4.0.0-rc.1", + "draft": false, + "prerelease": true, + "immutable": true, + "published_at": "2026-07-04T12:00:00Z", + "assets": [ + {"name": "NexaWrt-AX9000-official-v4.0.0-rc.1-verified-dist.tar.gz", "state": "uploaded", "size": 300} + ] + }, + { + "tag_name": "ram-test-v4.1.0-rc.1", + "draft": false, + "prerelease": true, + "immutable": true, + "published_at": "2026-07-04T13:00:00Z", + "assets": [ + {"name": "NexaWrt-AX9000-official-v4.1.0-rc.1-verified-dist.tar.gz", "state": "uploaded", "size": 300}, + {"name": "NexaWrt-AX9000-official-v4.1.0-rc.1-verified-dist.tar.gz.sha256", "state": "uploaded", "size": 301}, + {"name": "archive.provenance.bundle.json", "state": "uploaded", "size": 302}, + {"name": "checksums.provenance.bundle.json", "state": "uploaded", "size": 303}, + {"name": "firmware.provenance.bundle.json", "state": "uploaded", "size": 304}, + {"name": "firmware.provenance.bundle.json", "state": "uploaded", "size": 305} + ] + }, + { + "tag_name": "ram-test-v4.2.0-rc.1", + "draft": false, + "prerelease": true, + "immutable": true, + "published_at": "2026-07-04T14:00:00Z", + "assets": [ + {"name": "NexaWrt-AX9000-official-v4.2.0-rc.1-verified-dist.tar.gz", "state": "uploaded", "size": 300}, + {"name": "NexaWrt-AX9000-official-v4.2.0-rc.1-verified-dist.tar.gz.sha256", "state": "uploaded", "size": 301}, + {"name": "archive.provenance.bundle.json", "state": "uploaded", "size": 302}, + {"name": "checksums.provenance.bundle.json", "state": "uploaded", "size": 303}, + {"name": "firmware.provenance.bundle.json", "state": "new", "size": 304}, + {"name": "sbom.provenance.bundle.json", "state": "uploaded", "size": 305} + ] + }, + { + "tag_name": "ram-test-v4.3.0-rc.1", + "draft": false, + "prerelease": true, + "immutable": true, + "published_at": "2026-07-04T15:00:00Z", + "assets": [ + {"name": "NexaWrt-AX9000-official-v4.3.0-rc.1-verified-dist.tar.gz", "state": "uploaded", "size": 300}, + {"name": "NexaWrt-AX9000-official-v4.3.0-rc.1-verified-dist.tar.gz.sha256", "state": "uploaded", "size": 301}, + {"name": "archive.provenance.bundle.json", "state": "uploaded", "size": 302}, + {"name": "checksums.provenance.bundle.json", "state": "uploaded", "size": 303}, + {"name": "firmware.provenance.bundle.json", "state": "uploaded", "size": 0}, + {"name": "sbom.provenance.bundle.json", "state": "uploaded", "size": 305} + ] + }, + { + "tag_name": "ram-test-v4.4.0-rc.1", + "draft": false, + "prerelease": true, + "immutable": false, + "published_at": "2026-07-04T16:00:00Z", + "assets": [] + }, + { + "tag_name": "ram-test-v2.0.0", + "draft": false, + "prerelease": true, + "immutable": true, + "published_at": "2026-07-05T11:00:00Z", + "assets": [] + }, + { + "tag_name": "evil-v9