diff --git a/.github/workflows/commit-tests.yml b/.github/workflows/commit-tests.yml index 721ad91..e97a073 100644 --- a/.github/workflows/commit-tests.yml +++ b/.github/workflows/commit-tests.yml @@ -7,6 +7,15 @@ name: Commit tests on: push: workflow_dispatch: + inputs: + architecture_mode: + description: Run the fast amd64 gate or the final amd64 plus arm64 gate + type: choice + required: false + default: amd64 + options: + - amd64 + - multiarch permissions: contents: read @@ -22,3 +31,4 @@ jobs: uses: ./.github/workflows/quality-gates.yml with: context: commit + architecture_mode: ${{ inputs.architecture_mode || 'amd64' }} diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 831b298..581e585 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -12,6 +12,15 @@ on: - reopened - ready_for_review workflow_dispatch: + inputs: + architecture_mode: + description: Run the fast amd64 gate or the final amd64 plus arm64 gate + type: choice + required: false + default: amd64 + options: + - amd64 + - multiarch permissions: contents: read @@ -25,3 +34,4 @@ jobs: uses: ./.github/workflows/quality-gates.yml with: context: pull-request + architecture_mode: ${{ inputs.architecture_mode || 'amd64' }} diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 1eec725..2f417e2 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -11,6 +11,11 @@ on: description: Execution context for the quality gates required: true type: string + architecture_mode: + description: Fast amd64 validation by default; select multiarch for the final arm64 gate + required: false + default: amd64 + type: string permissions: contents: read @@ -22,6 +27,9 @@ jobs: name: Repository quality gates (${{ inputs.context }}) runs-on: ubuntu-latest timeout-minutes: 60 + env: + ARCHITECTURE_MODE: ${{ inputs.architecture_mode || 'amd64' }} + BUILD_PLATFORMS: ${{ inputs.architecture_mode == 'multiarch' && 'linux/arm64' || 'linux/amd64' }} steps: - name: Checkout uses: actions/checkout@v6 @@ -59,6 +67,7 @@ jobs: shell: bash run: | set -euo pipefail + [[ "$ARCHITECTURE_MODE" == "amd64" || "$ARCHITECTURE_MODE" == "multiarch" ]] test -f LICENSE grep -Fq 'MIT License' LICENSE test -f README.md @@ -145,6 +154,9 @@ jobs: test -f .github/workflows/commit-tests.yml test -f .github/workflows/pr-validation.yml test -f .github/workflows/quality-gates.yml + grep -Fq 'architecture_mode' .github/workflows/commit-tests.yml + grep -Fq 'architecture_mode' .github/workflows/pr-validation.yml + grep -Fq 'BUILD_PLATFORMS' .github/workflows/quality-gates.yml grep -Fq 'uses: actions/attest-build-provenance@v4' .github/workflows/quality-gates.yml grep -Fq 'id-token: write' .github/workflows/quality-gates.yml grep -Fq 'attestations: write' .github/workflows/quality-gates.yml @@ -476,7 +488,7 @@ jobs: node -e "const t=require('./release/local-proof-topology.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP1' || t.networkPolicy !== 'offline_runtime' || t.internalNetwork !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.dockerSocketMounted !== false) process.exit(1)" node -e "const t=require('./release/lp2-local-services.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP2' || t.proofType !== 'local_synthetic' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.hostPortsPublished !== false || t.dockerSocketMounted !== false) process.exit(1)" node -e "const t=require('./release/lp3-local-mail.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP3' || t.proofType !== 'local_synthetic_mail' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.syntheticDataOnly !== true || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.externalDeliveryEnabled !== false || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.policy?.catchAll !== false || t.policy?.automaticForwarding !== false || t.policy?.scanFailureMode !== 'fail_closed' || t.policy?.trashRetentionDays !== 28 || t.protocols?.imap?.idle !== true || t.protocols?.lmtp?.quotaReservationBeforeAck !== true || t.protocols?.sieve?.redirect !== false) process.exit(1)" - node -e "const t=require('./release/lp4-local-web.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP4' || t.proofType !== 'local_synthetic_web_dav' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.syntheticDataOnly !== true || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.credentialsCommitted !== false || t.web?.sameOriginOnly !== true || t.session?.csrfOnAuthenticatedMutations !== true || t.dav?.tenantBoundEtags !== true || t.dav?.tenantBoundSyncTokens !== true || t.dav?.masterContentAccess !== false || t.discovery?.tenantBound !== true) process.exit(1)" + node -e "const t=require('./release/lp4-local-web.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP4' || t.proofType !== 'local_synthetic_web_dav' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.syntheticDataOnly !== true || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.credentialsCommitted !== false || t.web?.sameOriginOnly !== true || t.session?.csrfOnAuthenticatedMutations !== true || t.dav?.tenantBoundEtags !== true || t.dav?.tenantBoundSyncTokens !== true || t.dav?.masterContentAccess !== false || t.discovery?.tenantBound !== true || t.architectureValidation?.defaultWorkflowMode !== 'amd64' || t.architectureValidation?.finalWorkflowMode !== 'multiarch' || t.architectureValidation?.finalModePlatforms?.join(',') !== 'linux/arm64' || t.architectureValidation?.arm64RequiredBeforeMergeOrRelease !== true || t.architectureValidation?.composeProofPlatform !== 'linux/amd64') process.exit(1)" bash -n docker/lp1-network/entrypoint-ca.sh bash -n docker/lp1-network/entrypoint-dns.sh bash -n docker/lp2-tls/entrypoint-tls.sh @@ -493,14 +505,14 @@ jobs: bash -n docker/lp3-clamav/healthcheck-clamav.sh - name: Set up Node.js when package metadata exists - if: hashFiles('package.json') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('package.json') != '' uses: actions/setup-node@v6 with: node-version: latest check-latest: true - name: Install dependencies and run the test script when available - if: hashFiles('package.json') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('package.json') != '' shell: bash run: | set -euo pipefail @@ -525,12 +537,12 @@ jobs: fi - name: Run Docker Compose M0 smoke checks - if: hashFiles('compose.yaml') != '' || hashFiles('compose.yml') != '' || hashFiles('docker-compose.yml') != '' || hashFiles('docker-compose.yaml') != '' + if: inputs.architecture_mode != 'multiarch' && (hashFiles('compose.yaml') != '' || hashFiles('compose.yml') != '' || hashFiles('docker-compose.yml') != '' || hashFiles('docker-compose.yaml') != '') shell: pwsh run: ./scripts/m0-smoke.ps1 - name: Set up QEMU for Ubuntu multi-architecture builds - if: hashFiles('Dockerfile') != '' + if: hashFiles('Dockerfile') != '' && inputs.architecture_mode == 'multiarch' uses: docker/setup-qemu-action@v4.2.0 with: platforms: amd64,arm64 @@ -539,14 +551,14 @@ jobs: if: hashFiles('Dockerfile') != '' uses: docker/setup-buildx-action@v4.2.0 - - name: Validate Ubuntu 26.04 LTS amd64 and arm64 images + - name: Validate Ubuntu 26.04 LTS images (${{ inputs.architecture_mode || 'amd64' }}) if: hashFiles('Dockerfile') != '' shell: bash run: | set -euo pipefail output="$RUNNER_TEMP/gulogulo-ubuntu-26.04.oci.tar" docker buildx build \ - --platform linux/amd64,linux/arm64 \ + --platform "$BUILD_PLATFORMS" \ --pull \ --build-arg NODE_VERSION=26.7.0 \ --provenance=false \ @@ -556,14 +568,14 @@ jobs: . test -s "$output" - - name: Validate LP1 network utility image on amd64 and arm64 + - name: Validate LP1 network utility image (${{ inputs.architecture_mode || 'amd64' }}) if: hashFiles('docker/lp1-network/Dockerfile') != '' shell: bash run: | set -euo pipefail output="$RUNNER_TEMP/gulogulo-lp1-network-ubuntu-26.04.oci.tar" docker buildx build \ - --platform linux/amd64,linux/arm64 \ + --platform "$BUILD_PLATFORMS" \ --pull \ --provenance=false \ --sbom=false \ @@ -574,7 +586,7 @@ jobs: test -s "$output" - name: Run Compose test profile - if: hashFiles('compose.yaml') != '' || hashFiles('compose.yml') != '' || hashFiles('docker-compose.yml') != '' || hashFiles('docker-compose.yaml') != '' + if: inputs.architecture_mode != 'multiarch' && (hashFiles('compose.yaml') != '' || hashFiles('compose.yml') != '' || hashFiles('docker-compose.yml') != '' || hashFiles('docker-compose.yaml') != '') shell: bash run: | set -euo pipefail @@ -592,16 +604,16 @@ jobs: "${compose[@]}" --profile test run --rm --no-deps gulogulo-test - name: Run LP1 isolated local proof topology - if: hashFiles('docker/lp1-network/Dockerfile') != '' && hashFiles('scripts/lp1-proof-smoke.mjs') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('docker/lp1-network/Dockerfile') != '' && hashFiles('scripts/lp1-proof-smoke.mjs') != '' shell: bash run: npm run test:lp1:docker - name: Run LP2 static Compose audit - if: hashFiles('scripts/lp2-compose-audit.mjs') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp2-compose-audit.mjs') != '' shell: bash run: node scripts/lp2-compose-audit.mjs - - name: Validate LP2 dependency images on amd64 and arm64 + - name: Validate LP2 dependency images (${{ inputs.architecture_mode || 'amd64' }}) if: hashFiles('docker/lp2-tls/Dockerfile') != '' && hashFiles('docker/lp2-ldap/Dockerfile') != '' && hashFiles('docker/lp2-postgres/Dockerfile') != '' shell: bash run: | @@ -609,7 +621,7 @@ jobs: while IFS='|' read -r name dockerfile context; do output="$RUNNER_TEMP/gulogulo-${name}-ubuntu-26.04.oci.tar" docker buildx build \ - --platform linux/amd64,linux/arm64 \ + --platform "$BUILD_PLATFORMS" \ --pull \ --provenance=false \ --sbom=false \ @@ -625,17 +637,17 @@ jobs: EOF - name: Run LP2 synthetic LDAP and PostgreSQL proof - if: hashFiles('scripts/lp2-compose-smoke.mjs') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp2-compose-smoke.mjs') != '' shell: bash run: npm run test:lp2:docker - name: Run LP3 static Compose and TypeScript audit - if: hashFiles('scripts/lp3-compose-audit.mjs') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp3-compose-audit.mjs') != '' shell: bash run: node scripts/lp3-compose-audit.mjs - name: Run LP3 typed mail contract gates - if: hashFiles('src/mail/mail-core.test.ts') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('src/mail/mail-core.test.ts') != '' shell: bash run: | set -euo pipefail @@ -643,27 +655,26 @@ jobs: npm run test:lp3 - name: Run LP4 static and typed web/DAV contract gates - if: hashFiles('scripts/lp4-compose-audit.ts') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp4-compose-audit.ts') != '' shell: bash run: npm run test:lp4 - # The Compose proof runs on the amd64 GitHub runner. Keep it before the - # emulated arm64 build so a functional regression fails quickly without - # spending another long QEMU build first. + # In the default amd64 mode, the Compose proofs run on the amd64 GitHub + # runner before any architecture image work that could be expensive. - name: Run LP3 local mail Compose proof (amd64) - if: hashFiles('scripts/lp3-compose-smoke.mjs') != '' && hashFiles('compose.yaml') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp3-compose-smoke.mjs') != '' && hashFiles('compose.yaml') != '' shell: bash run: node scripts/lp3-compose-smoke.mjs - # LP4 remains an amd64 live proof on GitHub; the root image is already - # validated for both architectures by the multi-architecture build. + # LP4 remains an amd64 live proof on GitHub. The explicit multiarch mode + # is a short arm64 artifact/attestation gate after this proof is green. - name: Run LP4 local web and DAV Compose proof (amd64) - if: hashFiles('scripts/lp4-compose-smoke.ts') != '' && hashFiles('compose.yaml') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp4-compose-smoke.ts') != '' && hashFiles('compose.yaml') != '' shell: bash run: npm run test:lp4:docker - name: Validate LP3 mail images on amd64 - if: hashFiles('docker/lp3-tls/Dockerfile') != '' && hashFiles('docker/lp3-postfix/Dockerfile') != '' && hashFiles('docker/lp3-dovecot/Dockerfile') != '' && hashFiles('docker/lp3-rspamd/Dockerfile') != '' && hashFiles('docker/lp3-clamav/Dockerfile') != '' && hashFiles('docker/lp3-proof/Dockerfile') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('docker/lp3-tls/Dockerfile') != '' && hashFiles('docker/lp3-postfix/Dockerfile') != '' && hashFiles('docker/lp3-dovecot/Dockerfile') != '' && hashFiles('docker/lp3-rspamd/Dockerfile') != '' && hashFiles('docker/lp3-clamav/Dockerfile') != '' && hashFiles('docker/lp3-proof/Dockerfile') != '' shell: bash run: | set -euo pipefail @@ -688,8 +699,8 @@ jobs: lp3-proof|docker/lp3-proof/Dockerfile|docker/lp3-proof EOF - - name: Validate LP3 mail images on arm64 - if: hashFiles('docker/lp3-tls/Dockerfile') != '' && hashFiles('docker/lp3-postfix/Dockerfile') != '' && hashFiles('docker/lp3-dovecot/Dockerfile') != '' && hashFiles('docker/lp3-rspamd/Dockerfile') != '' && hashFiles('docker/lp3-clamav/Dockerfile') != '' && hashFiles('docker/lp3-proof/Dockerfile') != '' + - name: Validate LP3 mail images on arm64 (final multiarch gate) + if: inputs.architecture_mode == 'multiarch' && hashFiles('docker/lp3-tls/Dockerfile') != '' && hashFiles('docker/lp3-postfix/Dockerfile') != '' && hashFiles('docker/lp3-dovecot/Dockerfile') != '' && hashFiles('docker/lp3-rspamd/Dockerfile') != '' && hashFiles('docker/lp3-clamav/Dockerfile') != '' && hashFiles('docker/lp3-proof/Dockerfile') != '' shell: bash run: | set -euo pipefail @@ -736,7 +747,7 @@ jobs: done - name: Run disposable PostgreSQL M2 integration contract - if: hashFiles('src/integrations/postgres.integration.test.ts') != '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('src/integrations/postgres.integration.test.ts') != '' shell: bash run: | set -euo pipefail @@ -756,16 +767,16 @@ jobs: GULOGULO_M2_POSTGRES_DSN="postgresql://gulogulo@$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container"):5432/gulogulo" npm run test:m2:postgres - name: Run M1 fixture, health, and metrics smoke checks - if: hashFiles('compose.yaml') != '' || hashFiles('compose.yml') != '' || hashFiles('docker-compose.yml') != '' || hashFiles('docker-compose.yaml') != '' + if: inputs.architecture_mode != 'multiarch' && (hashFiles('compose.yaml') != '' || hashFiles('compose.yml') != '' || hashFiles('docker-compose.yml') != '' || hashFiles('docker-compose.yaml') != '') shell: pwsh run: ./scripts/m1-fixture-smoke.ps1 -RequireMetrics - name: Report Docker smoke-check status when Compose is not defined - if: hashFiles('compose.yaml') == '' && hashFiles('compose.yml') == '' && hashFiles('docker-compose.yml') == '' && hashFiles('docker-compose.yaml') == '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('compose.yaml') == '' && hashFiles('compose.yml') == '' && hashFiles('docker-compose.yml') == '' && hashFiles('docker-compose.yaml') == '' shell: bash run: echo "No Compose file exists yet; M0 Docker smoke checks are deferred until the Docker-first scaffold is added." - name: Report package-test status when no package exists - if: hashFiles('package.json') == '' + if: inputs.architecture_mode != 'multiarch' && hashFiles('package.json') == '' shell: bash run: echo "No package.json exists yet; package tests are deferred until implementation is added." diff --git a/README.md b/README.md index 114a131..458edd3 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ where the item depends on external infrastructure. - [x] offline synthetic LP2 LDAP and PostgreSQL dependency proof with verified TLS; - [x] offline synthetic mail proof with Postfix, Dovecot, Rspamd, and ClamAV; - [x] offline synthetic web/session/DAV/discovery proof with restart continuity; +- [x] fast amd64-first CI with an explicit multiarch amd64+arm64 final gate; - [x] tenant-bound DAV ETags and sync tokens; - [x] OCI build-provenance attestations generated and verified; - [x] log rotation; diff --git a/doc/lp4-local-web.md b/doc/lp4-local-web.md index 8177d72..a3ef489 100644 --- a/doc/lp4-local-web.md +++ b/doc/lp4-local-web.md @@ -87,6 +87,28 @@ The intended integrated npm entry points are `npm run test:lp4` and checks, all typed web and DAV tests, and the static audit. The second runs `scripts/lp4-compose-smoke.ts`. +## Architecture-gated CI + +The normal pull-request and push path uses the fast `amd64` architecture mode. +This keeps the functional LP4 static, typed, and Compose gates quick and avoids +starting QEMU when a change is still being iterated. The reusable quality gate +accepts `architecture_mode=amd64` (the default) or `architecture_mode=multiarch`. + +The final LP4 verification uses the explicit `multiarch` mode. It is deliberately +the short final architecture gate: it skips the already-green functional suite, +builds the arm64 artifacts, and verifies their OCI attestations. The ARM64 pass +is therefore a release/merge gate, not a hidden best-effort job. The Compose +proof itself remains an amd64 GitHub runner proof; the prior amd64 run plus this +arm64 artifact pass cover both target architectures. + +For the manual commit workflow, choose **multiarch** only after the default +amd64 run is green. This is the intended two-stage path: + +1. run the default amd64 quality gate; +2. after it passes, dispatch the same commit with `architecture_mode=multiarch` + for the short arm64-only final gate; +3. merge or publish only after that final run is green. + The live smoke builds the application image, starts `gulogulo-lp4-web`, runs the internal proof client, inspects network/container/volume safety, restarts the web service, and repeats the proof against the preserved DAV continuity diff --git a/release/lp4-local-web.json b/release/lp4-local-web.json index d632dc7..fa2890e 100644 --- a/release/lp4-local-web.json +++ b/release/lp4-local-web.json @@ -20,6 +20,13 @@ "credentialsCommitted": false, "credentialsGeneratedAtRuntime": true, "targetPlatforms": ["linux/amd64", "linux/arm64"], + "architectureValidation": { + "defaultWorkflowMode": "amd64", + "finalWorkflowMode": "multiarch", + "finalModePlatforms": ["linux/arm64"], + "arm64RequiredBeforeMergeOrRelease": true, + "composeProofPlatform": "linux/amd64" + }, "web": { "staticEntry": "/", "apiEntry": "/api/session",