name: MCP isolated acceptance preview images 'on': workflow_dispatch: inputs: expected_sha: description: Exact reviewed full commit SHA on the selected ref; includes all accepted source changes required: true type: string auth_portal_origin: description: Canonical isolated browser-facing Auth Portal HTTP(S) origin; no path, credentials or query required: true type: string default: http://127.0.0.1:55175 permissions: contents: read packages: write jobs: backend-preview: if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }} runs-on: ubuntu-latest timeout-minutes: 40 env: COMPONENT: backend EXPECTED_SHA: ${{ inputs.expected_sha }} DISPATCH_SHA: ${{ gitea.sha }} DISPATCH_EVENT: ${{ gitea.event_name }} DISPATCH_REF: ${{ gitea.ref }} REPOSITORY: ${{ gitea.repository }} RUN_ID: ${{ gitea.run_id }} RUN_ATTEMPT: ${{ gitea.run_attempt }} steps: - name: Checkout exact dispatch commit uses: actions/checkout@v4 with: ref: ${{ gitea.sha }} persist-credentials: false - name: Use existing Linux Node22 build toolchain uses: actions/setup-node@v4 with: node-version: '22' - name: Verify source and isolated preview destination shell: bash run: | set -euo pipefail test "$DISPATCH_EVENT" = workflow_dispatch test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103 [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] test "$EXPECTED_SHA" = "$DISPATCH_SHA" test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" git diff --exit-code git diff --cached --exit-code [[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]] owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]') name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]') [[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]] stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//') echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV" echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV" echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV" mkdir -p .ci/acceptance-evidence node <<'NODE' const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto'); const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean); const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}}); fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n'); NODE - name: Prepare existing scoped Docker context shell: bash run: | set -euo pipefail STAGING=".ci/docker/backend" rm -rf "$STAGING" mkdir -p "$STAGING/packages/contracts" "$STAGING/backend" cp package-lock.json "$STAGING/" node <<'NODE' const fs = require("node:fs") const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")) pkg.workspaces = ["packages/*", "backend"] pkg.dependencies = {} pkg.devDependencies = {} delete pkg.scripts fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`) NODE cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/" cp -R packages/contracts/src "$STAGING/packages/contracts/" cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/" cp -R backend/src "$STAGING/backend/" mkdir -p "$STAGING/deploy/netflow-agent" cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/" if [ -d backend/drizzle ]; then cp -R backend/drizzle "$STAGING/backend/" fi mkdir -p "$STAGING/backend/scripts/writer-profile" cp backend/scripts/build-database-operator.mjs backend/scripts/database-writer-bootstrap-operator.mjs backend/scripts/database-writer-profile-operator.mts "$STAGING/backend/scripts/" cp backend/scripts/writer-profile/operator-files.mjs backend/scripts/writer-profile/operator-contract.mjs backend/scripts/writer-profile/publisher.mjs backend/scripts/writer-profile/context.mjs backend/scripts/writer-profile/catalogue.mts backend/scripts/writer-profile/catalogue-value-bridge.ts backend/scripts/writer-profile/catalogue-projection.ts backend/scripts/writer-profile/catalogue-leaves.ts backend/scripts/writer-profile/application-writer-profile-catalogue.ts backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts backend/scripts/writer-profile/application-writer-profile-catalogue-parent-policy.ts backend/scripts/writer-profile/application-writer-profile-canonical.ts backend/scripts/writer-profile/fixed44-build-sql.ts backend/scripts/writer-profile/fixed44-build-definition.ts backend/scripts/writer-profile/expanded44-data-policy.ts "${STAGING}/backend/scripts/writer-profile/" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to existing Gitea registry uses: docker/login-action@v3 with: registry: git.shx.one username: ${{ gitea.actor }} password: ${{ secrets.ACTIONS_PAT }} - name: Build and push isolated acceptance image id: build uses: docker/build-push-action@v5 with: context: .ci/docker/backend file: backend/Dockerfile platforms: linux/amd64 push: true tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }} build-args: APP_VERSION=${{ env.PREVIEW_TAG }} cache-from: type=gha,scope=mcp-acceptance-backend cache-to: type=gha,scope=mcp-acceptance-backend,mode=max provenance: mode=min sbom: true labels: |- org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.version=${{ env.PREVIEW_TAG }} io.mmapp.acceptance-only=true - name: Record exact digest and build metadata shell: bash env: IMAGE_DIGEST: ${{ steps.build.outputs.digest }} BUILD_METADATA: ${{ steps.build.outputs.metadata }} run: | set -euo pipefail node <<'NODE' const fs=require('node:fs'),crypto=require('node:crypto'); const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required'); const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json'); fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n'); console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest); NODE - name: Retain source and image binding artifacts uses: actions/upload-artifact@v3 with: name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }} path: .ci/acceptance-evidence/*.json if-no-files-found: error retention-days: 14 frontend-preview: if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }} runs-on: ubuntu-latest timeout-minutes: 40 env: COMPONENT: frontend EXPECTED_SHA: ${{ inputs.expected_sha }} DISPATCH_SHA: ${{ gitea.sha }} DISPATCH_EVENT: ${{ gitea.event_name }} DISPATCH_REF: ${{ gitea.ref }} REPOSITORY: ${{ gitea.repository }} RUN_ID: ${{ gitea.run_id }} RUN_ATTEMPT: ${{ gitea.run_attempt }} AUTH_PORTAL_ORIGIN_REQUEST: ${{ inputs.auth_portal_origin }} steps: - name: Checkout exact dispatch commit uses: actions/checkout@v4 with: ref: ${{ gitea.sha }} persist-credentials: false - name: Use existing Linux Node22 build toolchain uses: actions/setup-node@v4 with: node-version: '22' - name: Validate explicit isolated portal origin shell: bash run: | set -euo pipefail node <<'NODE' const fs=require('node:fs'),assert=require('node:assert/strict'); const raw=process.env.AUTH_PORTAL_ORIGIN_REQUEST; assert(typeof raw==='string'&&raw.length>0&&raw.length<=2048&&!/[\x00-\x20\x7f]/.test(raw),'Bounded canonical HTTP(S) origin required'); const origin=new URL(raw); assert(['http:','https:'].includes(origin.protocol)&&!origin.username&&!origin.password&&origin.pathname==='/'&&!origin.search&&!origin.hash&&origin.origin!=='null'&&origin.origin===raw,'Canonical origin only; credentials/path/query/hash refused'); fs.appendFileSync(process.env.GITHUB_ENV,'AUTH_PORTAL_ORIGIN='+origin.origin+'\n'); NODE - name: Verify source and isolated preview destination shell: bash run: | set -euo pipefail test "$DISPATCH_EVENT" = workflow_dispatch test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103 [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] test "$EXPECTED_SHA" = "$DISPATCH_SHA" test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" git diff --exit-code git diff --cached --exit-code [[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]] owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]') name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]') [[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]] stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//') echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV" echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV" echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV" mkdir -p .ci/acceptance-evidence node <<'NODE' const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto'); const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean); const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}}); fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n'); NODE - name: Prepare existing scoped Docker context shell: bash run: | set -euo pipefail STAGING=".ci/docker/frontend" rm -rf "$STAGING" mkdir -p "$STAGING/packages/contracts" "$STAGING/public" cp package-lock.json next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs components.json "$STAGING/" node <<'NODE' const fs = require("node:fs") const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")) pkg.workspaces = ["packages/*"] fs.writeFileSync(".ci/docker/frontend/package.json", `${JSON.stringify(pkg, null, 2)}\n`) NODE cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/" cp -R packages/contracts/src "$STAGING/packages/contracts/" for dir in app components lib shared entities public hooks; do if [ -d "$dir" ]; then cp -R "$dir" "$STAGING/" fi done test -f "$STAGING/public/release-manifest.json" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to existing Gitea registry uses: docker/login-action@v3 with: registry: git.shx.one username: ${{ gitea.actor }} password: ${{ secrets.ACTIONS_PAT }} - name: Build and push isolated acceptance image id: build uses: docker/build-push-action@v5 with: context: .ci/docker/frontend file: Dockerfile.frontend platforms: linux/amd64 push: true tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }} build-args: |- BACKEND_INTERNAL_URL=http://backend:8000 NEXT_PUBLIC_BACKEND_URL=same-origin NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live NEXT_PUBLIC_ALLOW_MOCK_DATA=false NEXT_PUBLIC_AUTH_PORTAL_URL=${{ env.AUTH_PORTAL_ORIGIN }} NEXT_PUBLIC_APP_VERSION=${{ env.PREVIEW_TAG }} NEXT_PUBLIC_RELEASE_URL= cache-from: type=gha,scope=mcp-acceptance-frontend cache-to: type=gha,scope=mcp-acceptance-frontend,mode=max provenance: mode=min sbom: true labels: |- org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.version=${{ env.PREVIEW_TAG }} io.mmapp.acceptance-only=true - name: Record exact digest and build metadata shell: bash env: IMAGE_DIGEST: ${{ steps.build.outputs.digest }} BUILD_METADATA: ${{ steps.build.outputs.metadata }} run: | set -euo pipefail node <<'NODE' const fs=require('node:fs'),crypto=require('node:crypto'); const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required'); const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json'); fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n'); console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest); NODE - name: Retain source and image binding artifacts uses: actions/upload-artifact@v3 with: name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }} path: .ci/acceptance-evidence/*.json if-no-files-found: error retention-days: 14