Publish immutable staging candidate artifacts

558d89cd72c2 · Christopher David · · parent d95e3005e659

Publish immutable staging candidate artifacts

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Dockerfile
  • modified docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md
  • added docs/operations/staging-candidate-artifacts.md
  • modified infra/staging/README.md
  • modified infra/staging/main.tf
  • modified infra/staging/tests/safety.tftest.hcl
  • modified lib/openagents_web/components/layouts/root.html.heex
  • modified mix.exs
  • modified ops/ci/contracts.sh
  • modified ops/deploy/build-image.sh
  • modified ops/relup-proof/common.sh
  • modified ops/staging/generate-sbom.sh
  • added ops/staging/publish-candidate.sh
  • modified rel/env.sh.eex
  • added test/openagents/staging_candidate_contract_test.exs

Diff

15 files changed, +781 -54

Dockerfile modified +51 -8

@@ -8,17 +8,42 @@

8 8
ARG ELIXIR_VERSION=1.20.3
9 9
ARG OTP_VERSION=29.0.5
10 10
ARG DEBIAN_VERSION=trixie-20260803-slim
11
12
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
13
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
11
ARG DEBIAN_SNAPSHOT=20260803T000000Z
12
ARG HEX_VERSION=2.5.1
13
ARG REBAR3_VERSION=3.25.1
14
ARG REBAR3_SHA512=69073f6ad163f74971545015238614c327893960c1b3f26df5377df135c773a0716b48b65c2a48cef878f185dd92805abc69894adfa3fd27a90c62a64ba371e2
15
ARG TAILWIND_VERSION=4.3.0
16
ARG TAILWIND_SHA256=73f0e5459054e5cfaa8ab6f3b940f3fbe0f13cc7fd83bc24e7c655033c203400
17
ARG ESBUILD_VERSION=0.25.4
18
ARG ESBUILD_SHA256=93433b456cac3a454ee27403d3de9adce88d83e5439ba37e1471af54730c9ca7
19
20
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}@sha256:ae38be7cb19bffa78adedb04732d9e6ba83a507b4cfb06983cbe711edb49da54"
21
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258"
14 22
15 23
FROM ${BUILDER_IMAGE} AS builder
16 24
17 25
ARG OPENAGENTS_BUILD_REVISION="image"
26
ARG SOURCE_DATE_EPOCH=0
27
ARG DEBIAN_SNAPSHOT
28
ARG HEX_VERSION
29
ARG REBAR3_VERSION
30
ARG REBAR3_SHA512
31
ARG TAILWIND_VERSION
32
ARG TAILWIND_SHA256
33
ARG ESBUILD_VERSION
34
ARG ESBUILD_SHA256
18 35
ENV OPENAGENTS_BUILD_REVISION=${OPENAGENTS_BUILD_REVISION}
36
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
19 37
20 38
# install build dependencies
21
RUN apt-get update \
39
RUN sed -i \
40
      "s|URIs: http://deb.debian.org/debian$|URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|" \
41
      /etc/apt/sources.list.d/debian.sources \
42
  && sed -i \
43
      "s|URIs: http://deb.debian.org/debian-security$|URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|" \
44
      /etc/apt/sources.list.d/debian.sources \
45
  && printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99snapshot \
46
  && apt-get update \
22 47
  && apt-get install -y --no-install-recommends build-essential git \
23 48
  && rm -rf /var/lib/apt/lists/*
24 49

@@ -26,8 +51,11 @@ RUN apt-get update \

26 51
WORKDIR /app
27 52
28 53
# install hex + rebar
29
RUN mix local.hex --force \
30
  && mix local.rebar --force
54
RUN mix local.hex "${HEX_VERSION}" --force \
55
  && mix local.rebar rebar3 \
56
      "https://github.com/erlang/rebar3/releases/download/${REBAR3_VERSION}/rebar3" \
57
      --sha512 "${REBAR3_SHA512}" \
58
      --force
31 59
32 60
# set build ENV
33 61
ENV MIX_ENV="prod"

@@ -44,7 +72,11 @@ COPY config/config.exs config/${MIX_ENV}.exs config/

44 72
RUN mix deps.compile
45 73
46 74
# Install Tailwind and esbuild so assets can be built
47
RUN mix assets.setup
75
RUN mix assets.setup \
76
  && printf '%s  %s\n' \
77
      "${TAILWIND_SHA256}" "/app/_build/tailwind-linux-x64-${TAILWIND_VERSION}" \
78
      "${ESBUILD_SHA256}" "/app/_build/esbuild-linux-x64" \
79
    | sha256sum --check --strict
48 80
49 81
COPY priv priv
50 82

@@ -77,7 +109,18 @@ CMD ["mix", "run", "--no-compile", "--no-start", "ops/forge/build-worker.exs"]

77 109
# the compiled release and other runtime necessities
78 110
FROM ${RUNNER_IMAGE} AS final
79 111
80
RUN apt-get update \
112
ARG DEBIAN_SNAPSHOT
113
ARG SOURCE_DATE_EPOCH=0
114
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
115
116
RUN sed -i \
117
      "s|URIs: http://deb.debian.org/debian$|URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|" \
118
      /etc/apt/sources.list.d/debian.sources \
119
  && sed -i \
120
      "s|URIs: http://deb.debian.org/debian-security$|URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|" \
121
      /etc/apt/sources.list.d/debian.sources \
122
  && printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99snapshot \
123
  && apt-get update \
81 124
  && apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates git \
82 125
  && rm -rf /var/lib/apt/lists/*
83 126
docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md modified +51 -23

@@ -403,9 +403,10 @@ Remove the unused parser and update every related document in the same commit.

403 403
- Reject any component library that emits generic component selectors into the
404 404
  Tailwind utilities layer or otherwise outranks the application component
405 405
  layer. Verify cascade behavior, not only dependency names.
406
- Keep the product deliberately dark-only for staging. Hide or remove the theme
407
  toggle while it has no visual effect; treat a light palette as a separate,
408
  owner-approved design project.
406
- Maintain exactly the two owned light and dark palettes, with a functional
407
  system, light, and dark preference control. Keep explicit choices ahead of
408
  the operating-system fallback, prevent a wrong-theme first paint, and reject
409
  third-party theme tokens or an undeclared third palette.
409 410
- Treat any style-pack rename as a controlled palette migration. Preserve the
410 411
  primitive token contract, run visual regression checks, and do not mistake a
411 412
  load-bearing palette file for a cosmetic filename.

@@ -421,8 +422,9 @@ Remove the unused parser and update every related document in the same commit.

421 422
- Build an SBOM for the staging image and retain it with the staging evidence.
422 423
423 424
**Exit criteria:** The application has one Markdown parser, one component
424
system, one documented two-tier icon policy, no nonfunctional theme control, no
425
unexplained dependency, and complete license records.
425
system, one documented two-tier icon policy, exactly two governed themes with a
426
functional preference control, no unexplained dependency, and complete license
427
records.
426 428
427 429
**Gate 4 status (2026-08-20): complete.**
428 430

@@ -436,11 +438,12 @@ unexplained dependency, and complete license records.

436 438
  Added form-aware input, heading, table, and list primitives; removed the
437 439
  generated compatibility module; and made `/components` an executable
438 440
  inventory of every public component.
439
- Removed the browser theme script, theme control, theme selectors, and retired
440
  palette aliases. The palette remains deliberately dark-only. The Node suite
441
  now compiles Tailwind and proves that Basecoat geometry precedes the
442
  OpenAgents style pack and that all eight governed button variants survive the
443
  cascade.
441
- Removed the retired palette aliases and restored an owner-directed light
442
  palette alongside the neutral dark palette. The system, light, and dark
443
  preference control resolves before first paint. The Node suite compiles
444
  Tailwind and proves that exactly two owned themes and the system fallback
445
  survive, Basecoat geometry precedes the OpenAgents style pack, and all eight
446
  governed button variants survive the cascade.
444 447
- Migrated current glyph uses to the preferred vendored Apps SDK set. Retained
445 448
  Heroicons only as the owner-approved second tier, pinned it to immutable
446 449
  revision `0435d4ca364a608cc75e2f8683d374e55abbae26`, and recorded an empty

@@ -1159,7 +1162,7 @@ Use two staging lanes until the intended fleet replaces the web-only lane:

1159 1162
1160 1163
### Web acceptance lane
1161 1164
1162
Use an isolated staging hostname such as `stage.openagents.com` for browser and
1165
Use the isolated `staging.openagents.com` hostname for browser and
1163 1166
API acceptance. It needs:
1164 1167
1165 1168
- A staging-only GitHub OAuth application and callback.

@@ -1316,12 +1319,30 @@ Implemented locally on 2026-08-20:

1316 1319
  build receipt, and deploy receipt probes remained present. Both the candidate
1317 1320
  application and the last-known-good application started against the migrated
1318 1321
  copy.
1322
- Added an exact-SHA staging publisher for the application image, isolated
1323
  builder image, release tar, CycloneDX SBOM, and one checksummed candidate
1324
  manifest. It resolves registry-reported manifest digests, verifies platform,
1325
  OCI labels, and packaged revisions, supports safe resume after a partial
1326
  push, and never creates a mutable convenience tag.
1327
- Enabled immutable Docker tags and Terraform deletion prevention on the
1328
  staging Artifact Registry. Pinned both base image manifests, Debian package
1329
  snapshots, Hex, Rebar3, Tailwind, esbuild, the target architecture, and the
1330
  build timestamp source. Distributed releases now refuse to start without an
1331
  explicit runtime cookie rather than falling back to the packaged
1332
  non-distributed placeholder.
1333
- Built and inspected both container targets locally. The dated Debian sources
1334
  and all executable checksums passed, both OCI labels and packaged build
1335
  revisions matched the source commit, the builder contained one release
1336
  archive, and a distributed boot without a runtime cookie failed closed. The
1337
  repository-wide precommit completed with 1,378 Elixir tests and 17 JavaScript
1338
  tests passing.
1319 1339
1320 1340
The [staging migration-lineage runbook](operations/staging-migration-lineage.md)
1321 1341
defines classification, copy rehearsal, snapshot, single-job migration,
1322 1342
rollback compatibility, and evidence collection. Gate 13 remains open until
1323
the exact candidate image and release artifacts are retained and the selected
1324
path runs against the isolated staging target.
1343
the [immutable candidate runbook](operations/staging-candidate-artifacts.md)
1344
runs against the isolated staging registry and the selected migration path runs
1345
against the isolated staging target.
1325 1346
1326 1347
## Gate 14: Run the staging regression matrix
1327 1348

@@ -1579,7 +1600,8 @@ each handoff.

1579 1600
- [x] All documentation links and invariant evidence resolve.
1580 1601
- [x] The application has one Markdown parser, component system, and documented
1581 1602
      two-tier icon policy.
1582
- [x] The dark-only palette has no nonfunctional theme control.
1603
- [x] Exactly two owned palettes have a functional system, light, and dark
1604
      preference control and compiled cascade coverage.
1583 1605
- [x] Runtime configuration is typed, redacted, and staging-specific.
1584 1606
- [x] Every route has an explicit authority class.
1585 1607
- [x] GitHub token behavior matches code, UI disclosure, and data rights.

@@ -1615,8 +1637,10 @@ Status: accepted and incorporated into the plan above

1615 1637
The gate structure remains intact. The main plan now incorporates the measured
1616 1638
Gate 0 baseline, the missing JavaScript suite, database isolation, migration
1617 1639
lineage, configuration and authorization evidence, and recovery-test scope.
1618
It also adopts the owner's two-tier icon decision, makes the current dark-only
1619
palette explicit, and treats the style-pack rename as a palette migration.
1640
It also adopts the owner's two-tier icon decision, records the dark-only
1641
palette that existed when the addendum was measured, and treats the style-pack
1642
rename as a palette migration. The later owner-directed light-mode change is
1643
recorded in the current disposition below.
1620 1644
This addendum remains as the measurement record. Everything here was verified
1621 1645
directly at the SHA given, not inferred.
1622 1646

@@ -1631,7 +1655,7 @@ this disposition for the current plan:

1631 1655
| A1, missing browser tests and incomplete baseline | Resolved by Gate 0. The owned baseline now runs the Node suites, merged Elixir coverage, and release smoke against an exact SHA. |
1632 1656
| A2, staging shares a production database failure domain | Open and blocking Gates 12 and 15. Provision a separate staging database instance before any failure injection or soak. |
1633 1657
| A3, incompatible migration lineage on a nonempty prior database | Mapped and rehearsed locally with a fail-closed release command. Each actual prior-lineage target still requires its own snapshot-copy rehearsal before Gate 13 deployment. |
1634
| A4, component, icon, and palette conflicts | Resolved by Gate 4 and its compiled CSS contract tests. |
1658
| A4, component, icon, and palette conflicts | Resolved by Gate 4 and its compiled CSS contract tests. The later owner-directed light-mode change supersedes only the addendum's historical dark-only palette decision. |
1635 1659
| A5, silent runtime configuration failures | Resolved by Gate 5 and its fail-closed runtime readiness checks. |
1636 1660
| A6, repository authorization violations | Resolved by Gate 7 in code, PostgreSQL constraints, and the populated migration rehearsal. |
1637 1661
| A7, missing recovery-worker tests | Resolved by Gate 8 with direct supervised-process recovery tests. |

@@ -1751,17 +1775,21 @@ baseline map and rehearsal before deployment.

1751 1775
## A4. Four places the plan disagrees with a prior decision or current state
1752 1776
1753 1777
These were originally flagged for deliberate choices. Gate 4 now records their
1754
disposition: retain the owner-approved Heroicons fallback, keep staging
1755
deliberately dark-only without a nonfunctional toggle, control the palette-file
1756
rename as a visual migration, and reject conflicting cascade-layer behavior.
1778
disposition: retain the owner-approved Heroicons fallback, govern the two owned
1779
palettes and functional preference control, control the palette-file rename as
1780
a visual migration, and reject conflicting cascade-layer behavior.
1757 1781
1758 1782
**Heroicons, resolved.** Product surfaces now use Apps SDK glyphs. Heroicons
1759 1783
remains the deliberate fallback, is pinned to an immutable revision, and has no
1760 1784
current product call sites. `docs/ICONS.md` governs and inventories exceptions.
1761 1785
1762
**The palette is dark-only, resolved.** The application removed its theme
1763
control, browser theme state, and theme selectors. A light palette remains a
1764
separate owner-approved design project.
1786
**The palette finding is resolved and the original decision is superseded.**
1787
The application was dark-only when this addendum was measured. The later
1788
owner-directed palette change added one governed light palette, restored a
1789
system, light, and dark preference control, and replaced the old negative
1790
theme assertion with compiled positive contracts for both owned themes and the
1791
operating-system fallback. No third-party theme plugin or third palette was
1792
introduced.
1765 1793
1766 1794
**The palette file, resolved.** `assets/css/openagents.css` owns the palette
1767 1795
contract and loads after individually imported Basecoat structure. The retired
docs/operations/staging-candidate-artifacts.md added +133

@@ -0,0 +1,133 @@

1
# Publish an immutable staging candidate
2
3
Publish one exact, gated Git commit as two immutable images and one retained
4
artifact set before you migrate or deploy staging. The application image serves
5
traffic. The separate builder image contains the pinned compiler toolchain and
6
the exact release archive.
7
8
The staging Artifact Registry enables [immutable image tags](https://docs.cloud.google.com/artifact-registry/docs/docker/names).
9
Terraform also prevents deletion of the repository. The publication command
10
uses the full Git SHA as each tag, reads the registry manifest descriptor after
11
the push, and deploys by digest rather than by tag.
12
13
## Build inputs
14
15
The container build pins these external inputs:
16
17
- Elixir and Debian base images by OCI manifest digest.
18
- Debian and Debian Security packages to the dated 2026-08-03 snapshot.
19
- Hex by exact version.
20
- Rebar3 by exact release URL and SHA-512 checksum.
21
- Tailwind and esbuild by exact version and executable SHA-256 checksum.
22
- Application dependencies by `mix.lock` and Hex package checksums.
23
- Build timestamps with the commit timestamp through `SOURCE_DATE_EPOCH`.
24
- The deployment platform as `linux/amd64`, matching the staging fleet.
25
26
The packaged release contains a fixed non-distributed cookie placeholder. A
27
fleet node refuses to start distribution unless the staging runtime provides a
28
separate `RELEASE_COOKIE` with at least 32 bytes. Never put the staging cookie
29
in an image or artifact manifest.
30
31
Update base, snapshot, Hex, or Rebar3 pins in a dedicated dependency change.
32
Run the complete exact-SHA release gate and inspect the resulting SBOM before
33
you publish a candidate with new pins.
34
35
## Check publication prerequisites
36
37
Use a clean `main` worktree whose `HEAD` equals the locally fetched
38
`origin/main`. The exact commit must already have a complete local release-gate
39
receipt. Export only staging and comparison project identifiers:
40
41
```sh
42
export OPENAGENTS_STAGING_PROJECT_ID='openagents-staging-UNIQUE'
43
export OPENAGENTS_PRODUCTION_PROJECT_ID='PRODUCTION_PROJECT_ID'
44
export OPENAGENTS_STAGING_REGION='us-central1'
45
46
ops/staging/publish-candidate.sh check
47
```
48
49
The check is read-only. It verifies the project fence, active Google Cloud
50
authentication, exact local gate receipt, staging Artifact Registry format,
51
and immutable-tag setting. It does not build or push an image.
52
53
Do not continue if the check reports an expired login. Refresh both the Cloud
54
CLI and Application Default Credentials before the broader staging workflow:
55
56
```sh
57
gcloud auth login
58
gcloud auth application-default login
59
```
60
61
## Publish the candidate
62
63
Publish only after the isolated Gate 12 project and registry exist:
64
65
```sh
66
ops/staging/publish-candidate.sh --publish
67
```
68
69
The command performs these operations:
70
71
1. Repeats every preflight check and configures Docker authentication only for
72
   the staging registry host.
73
2. Builds the application and isolated forge-builder targets with the exact Git
74
   SHA, commit timestamp, and `linux/amd64` deployment platform.
75
3. Pushes only full-SHA tags into the immutable staging repository. If an
76
   earlier attempt already pushed a tag, it resolves and verifies that existing
77
   immutable image instead of moving the tag.
78
4. Reads and validates the registry-reported application and builder OCI
79
   manifest digests.
80
5. Pulls both digest references and verifies their OCI revision labels and
81
   packaged `OpenAgents.BuildInfo` revisions.
82
6. Extracts the exact release tar from the builder image.
83
7. Generates a CycloneDX SBOM from the digest-addressed application image by
84
   using the digest-pinned Syft image.
85
8. Writes and hashes one candidate manifest that binds the Git SHA, registry
86
   manifests, local image configurations, release archive, SBOM, release-gate
87
   receipt, Dockerfile, lockfile, migration-lineage map, application spec, and
88
   compiler toolchain.
89
90
The command never creates `latest`, environment, or branch tags. A failed
91
second image push can be resumed because the first full-SHA tag cannot move.
92
An unexpected existing local evidence directory fails closed unless it already
93
contains a valid manifest for the exact SHA.
94
95
## Review the retained artifact set
96
97
The command writes mode-`0600` evidence under:
98
99
```text
100
.git/openagents/staging-candidates/<full-sha>/
101
  candidate-manifest.json
102
  candidate-manifest.sha256
103
  openagents-<release-version>.tar.gz
104
  sbom.cdx.json
105
  sbom.cdx.json.receipt
106
```
107
108
The `.git` location prevents accidental commits. Copy the complete directory
109
to the staging-only versioned evidence bucket after Gate 12, and verify
110
`candidate-manifest.sha256` after upload. Do not upload Docker credentials,
111
Cloud CLI state, environment files, database URLs, or secret values.
112
113
Use only the `images.application.reference` and `images.builder.reference`
114
digest values from `candidate-manifest.json` in deployment configuration. A
115
tag is a review label, not deployment authority. Require every web revision,
116
fleet node, `/status` response, and deployment receipt to report the same Git
117
SHA and application manifest digest.
118
119
## Refuse or recover a candidate
120
121
Refuse publication if any of these conditions occurs:
122
123
- `HEAD` differs from `origin/main` or lacks an exact gate receipt.
124
- The target project is unmarked, equals production, or lacks immutable tags.
125
- A registry label or packaged build revision differs from the Git SHA.
126
- A registry manifest, release, SBOM, or input digest has an invalid shape.
127
- The generated manifest does not validate before its atomic move.
128
129
If publication fails after an immutable tag exists, preserve the local logs and
130
rerun the same exact commit. Do not delete or move the tag. If the existing
131
image fails identity verification, stop and investigate the registry as a
132
security incident. Never recover by publishing a different commit under the
133
same SHA tag.
infra/staging/README.md modified +7 -1

@@ -26,12 +26,18 @@ The configuration creates these staging-only resources:

26 26
  Terraform never creates a secret version or stores a credential in state.
27 27
- Separate buckets for forge artifacts, forge WAL, recordings, and evidence.
28 28
- One Artifact Registry repository for digest-addressed application and builder
29
  images.
29
  images. Full-SHA tags are immutable, and Terraform cannot delete the
30
  repository.
30 31
31 32
The instances start fenced. They do not run an application until an operator
32 33
assigns exact application and builder image digests and creates the required
33 34
staging-only secret versions during Gate 13.
34 35
36
After this boundary exists, use the
37
[immutable candidate runbook](../../docs/operations/staging-candidate-artifacts.md)
38
to publish the exact application image, builder image, release archive, SBOM,
39
and candidate manifest for Gate 13.
40
35 41
## Prerequisites
36 42
37 43
You need these local tools:
infra/staging/main.tf modified +5

@@ -363,6 +363,11 @@ resource "google_artifact_registry_repository" "openagents" {

363 363
  labels        = local.labels
364 364
365 365
  cleanup_policy_dry_run = true
366
  deletion_policy        = "PREVENT"
367
368
  docker_config {
369
    immutable_tags = true
370
  }
366 371
367 372
  depends_on = [google_project_service.required]
368 373
}
infra/staging/tests/safety.tftest.hcl modified +10

@@ -42,6 +42,16 @@ run "isolated_topology" {

42 42
    error_message = "Staging must have a separate application database role."
43 43
  }
44 44
45
  assert {
46
    condition     = google_artifact_registry_repository.openagents.docker_config[0].immutable_tags
47
    error_message = "Staging application and builder image tags must be immutable."
48
  }
49
50
  assert {
51
    condition     = google_artifact_registry_repository.openagents.deletion_policy == "PREVENT"
52
    error_message = "Terraform must not delete the staging artifact repository."
53
  }
54
45 55
  assert {
46 56
    condition     = length(google_secret_manager_secret.runtime) == 12
47 57
    error_message = "Every named staging credential and lane configuration needs its own secret resource."
lib/openagents_web/components/layouts/root.html.heex modified +19 -17

@@ -15,23 +15,25 @@

15 15
    "system" stores nothing and leaves data-theme unset so the CSS
16 16
    prefers-color-scheme fallback governs; an explicit choice sets the
17 17
    attribute, which always wins. --%>
18
    <script phx-no-format>(() => {
19
          const stored = localStorage.getItem("phx:theme");
20
          const apply = (choice) => {
21
            if (choice === "system" || choice === null) {
22
              localStorage.removeItem("phx:theme");
23
              document.documentElement.removeAttribute("data-theme");
24
              document.documentElement.setAttribute("data-theme-choice", "system");
25
            } else {
26
              localStorage.setItem("phx:theme", choice);
27
              document.documentElement.setAttribute("data-theme", choice);
28
              document.documentElement.setAttribute("data-theme-choice", choice);
29
            }
30
          };
31
          apply(stored);
32
          window.addEventListener("phx:set-theme", (e) => apply(e.detail?.theme ?? e.target?.dataset?.theme));
33
          window.addEventListener("storage", (e) => e.key === "phx:theme" && apply(e.newValue));
34
        })();</script>
18
    <script phx-no-curly-interpolation>
19
      (() => {
20
        const stored = localStorage.getItem("phx:theme");
21
        const apply = (choice) => {
22
          if (choice === "system" || choice === null) {
23
            localStorage.removeItem("phx:theme");
24
            document.documentElement.removeAttribute("data-theme");
25
            document.documentElement.setAttribute("data-theme-choice", "system");
26
          } else {
27
            localStorage.setItem("phx:theme", choice);
28
            document.documentElement.setAttribute("data-theme", choice);
29
            document.documentElement.setAttribute("data-theme-choice", choice);
30
          }
31
        };
32
        apply(stored);
33
        window.addEventListener("phx:set-theme", (e) => apply(e.detail?.theme ?? e.target?.dataset?.theme));
34
        window.addEventListener("storage", (e) => e.key === "phx:theme" && apply(e.newValue));
35
      })();
36
    </script>
35 37
    <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
36 38
    </script>
37 39
  </head>
mix.exs modified +1

@@ -47,6 +47,7 @@ defmodule OpenAgents.MixProject do

47 47
      [
48 48
        include_erts: true,
49 49
        include_src: false,
50
        cookie: "openagents-nondistributed-placeholder",
50 51
        steps: [
51 52
          &OpenAgents.ReleaseAssembler.pre_assemble/1,
52 53
          :assemble,
ops/ci/contracts.sh modified +2

@@ -9,12 +9,14 @@ cd "$repo_root"

9 9
ops/ci/reference-check.sh
10 10
sh -n rel/overlays/bin/migration-lineage
11 11
sh -n ops/staging/cleanup-run.sh
12
sh -n ops/staging/publish-candidate.sh
12 13
elixir ops/ci/docs-check.exs
13 14
MIX_ENV=test mix test --warnings-as-errors \
14 15
  test/openagents/log_safety_test.exs \
15 16
  test/openagents/migration_lineage_test.exs \
16 17
  test/openagents/runtime_config_test.exs \
17 18
  test/openagents/staging_cleanup_test.exs \
19
  test/openagents/staging_candidate_contract_test.exs \
18 20
  test/openagents_web/icon_affordances_test.exs \
19 21
  test/openagents_web/icons_test.exs \
20 22
  test/openagents_web/ui_test.exs
ops/deploy/build-image.sh modified +12

@@ -10,6 +10,8 @@ if [ -n "$(git -C "$repo_root" status --porcelain --untracked-files=all)" ]; the

10 10
fi
11 11
12 12
git_sha=$(git -C "$repo_root" rev-parse --verify HEAD)
13
source_date_epoch=$(git -C "$repo_root" show -s --format=%ct "$git_sha")
14
platform=${OPENAGENTS_IMAGE_PLATFORM:-linux/amd64}
13 15
tag=${1:-"openagents:$git_sha"}
14 16
image_root="$repo_root/.git/openagents/images"
15 17
result_path="$image_root/$git_sha.json"

@@ -21,10 +23,17 @@ cleanup() {

21 23
22 24
trap cleanup EXIT INT TERM
23 25
26
if [ "$platform" != "linux/amd64" ]; then
27
  echo "OPENAGENTS_IMAGE_PLATFORM must be linux/amd64 for the staging fleet" >&2
28
  exit 1
29
fi
30
24 31
"$repo_root/ops/ci/gate.sh" --verify
25 32
26 33
docker build \
34
  --platform "$platform" \
27 35
  --build-arg "OPENAGENTS_BUILD_REVISION=$git_sha" \
36
  --build-arg "SOURCE_DATE_EPOCH=$source_date_epoch" \
28 37
  --iidfile "$iid_file" \
29 38
  --label "org.opencontainers.image.revision=$git_sha" \
30 39
  --tag "$tag" \

@@ -62,6 +71,9 @@ cat >"$result_path" <<EOF

62 71
  "schema": "openagents.local-image.v1",
63 72
  "git_sha": "$git_sha",
64 73
  "image_digest": "$image_digest",
74
  "digest_scope": "local_config",
75
  "platform": "$platform",
76
  "source_date_epoch": $source_date_epoch,
65 77
  "tag": "$tag"
66 78
}
67 79
EOF
ops/relup-proof/common.sh modified +2 -1

@@ -29,7 +29,8 @@ proof_key() {

29 29
}
30 30
31 31
proof_root() {
32
  printf '%s\n' "$repo_root/.git/openagents/relup-proof/$(proof_key)"
32
  key=$(proof_key) || return $?
33
  printf '%s\n' "$repo_root/.git/openagents/relup-proof/$key"
33 34
}
34 35
35 36
require_proof_artifacts() {
ops/staging/generate-sbom.sh modified +11 -4

@@ -19,10 +19,17 @@ fi

19 19
20 20
readonly source_commit="$(git rev-parse HEAD)"
21 21
22
readonly image_digest="$(
23
  docker image inspect "$image_reference" \
24
    --format '{{index .RepoDigests 0}}' 2>/dev/null
25
)"
22
case "$image_reference" in
23
  *@sha256:????????????????????????????????????????????????????????????????)
24
    readonly image_digest="$image_reference"
25
    ;;
26
  *)
27
    readonly image_digest="$(
28
      docker image inspect "$image_reference" \
29
        --format '{{index .RepoDigests 0}}' 2>/dev/null
30
    )"
31
    ;;
32
esac
26 33
27 34
if [[ -z "$image_digest" || "$image_digest" == "<no value>" ]]; then
28 35
  echo "image must be present locally with a resolved repository digest: $image_reference" >&2
ops/staging/publish-candidate.sh added +410

@@ -0,0 +1,410 @@

1
#!/bin/sh
2
set -eu
3
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
6
7
mode=${1:-check}
8
staging_project=${OPENAGENTS_STAGING_PROJECT_ID:-}
9
production_project=${OPENAGENTS_PRODUCTION_PROJECT_ID:-}
10
region=${OPENAGENTS_STAGING_REGION:-us-central1}
11
repository_id=openagents-staging
12
platform=linux/amd64
13
14
: "${staging_project:?OPENAGENTS_STAGING_PROJECT_ID is required}"
15
: "${production_project:?OPENAGENTS_PRODUCTION_PROJECT_ID is required}"
16
17
case "$mode" in
18
  check) ;;
19
  --publish) ;;
20
  *) echo "usage: ops/staging/publish-candidate.sh [check|--publish]" >&2; exit 64 ;;
21
esac
22
23
case "$staging_project" in
24
  *stag*) ;;
25
  *) echo "staging project ID must contain 'stag'" >&2; exit 1 ;;
26
esac
27
28
if [ "$staging_project" = "$production_project" ]; then
29
  echo "staging and production project IDs must differ" >&2
30
  exit 1
31
fi
32
33
for command_name in docker gcloud jq sha256sum; do
34
  if ! command -v "$command_name" >/dev/null 2>&1; then
35
    echo "$command_name is required" >&2
36
    exit 1
37
  fi
38
done
39
40
cd "$repo_root"
41
42
if [ -n "$(git status --porcelain --untracked-files=all)" ]; then
43
  echo "candidate publication requires a clean worktree" >&2
44
  exit 1
45
fi
46
47
git_sha=$(git rev-parse --verify HEAD)
48
origin_main=$(git rev-parse --verify refs/remotes/origin/main)
49
source_date_epoch=$(git show -s --format=%ct "$git_sha")
50
51
if [ "$git_sha" != "$origin_main" ]; then
52
  echo "candidate must equal the locally fetched origin/main commit" >&2
53
  exit 1
54
fi
55
56
ops/ci/gate.sh --verify
57
gcloud auth print-access-token >/dev/null
58
59
repository_json=$(
60
  gcloud artifacts repositories describe "$repository_id" \
61
    --project="$staging_project" \
62
    --location="$region" \
63
    --format=json
64
)
65
66
echo "$repository_json" | jq -e '
67
  .format == "DOCKER" and
68
  .dockerConfig.immutableTags == true
69
' >/dev/null || {
70
  echo "staging Artifact Registry must be Docker format with immutable tags" >&2
71
  exit 1
72
}
73
74
if [ "$mode" = check ]; then
75
  echo "Candidate publication preflight passed for $git_sha"
76
  exit 0
77
fi
78
79
registry_host="${region}-docker.pkg.dev"
80
repository="${registry_host}/${staging_project}/${repository_id}"
81
application_repository="${repository}/openagents"
82
builder_repository="${repository}/openagents-builder"
83
application_tag="${application_repository}:${git_sha}"
84
builder_tag="${builder_repository}:${git_sha}"
85
evidence_root="$repo_root/.git/openagents/staging-candidates"
86
candidate_path="$evidence_root/$git_sha"
87
run_root=$(mktemp -d /tmp/openagents-staging-candidate.XXXXXX)
88
candidate_temp=
89
builder_container=
90
91
cleanup() {
92
  if [ -n "$builder_container" ]; then
93
    docker rm "$builder_container" >/dev/null 2>&1 || true
94
  fi
95
96
  if [ -n "$candidate_temp" ] && [ -d "$candidate_temp" ]; then
97
    find "$candidate_temp" -depth -delete 2>/dev/null || true
98
  fi
99
100
  find "$run_root" -depth -delete 2>/dev/null || true
101
}
102
103
trap cleanup EXIT INT TERM
104
105
if [ -e "$candidate_path" ]; then
106
  if jq -e --arg sha "$git_sha" '.git_sha == $sha' \
107
    "$candidate_path/candidate-manifest.json" >/dev/null 2>&1; then
108
    echo "Candidate evidence already exists for $git_sha"
109
    exit 0
110
  fi
111
112
  echo "candidate evidence path exists without a valid exact-SHA manifest" >&2
113
  exit 1
114
fi
115
116
umask 077
117
mkdir -p "$evidence_root"
118
candidate_temp=$(mktemp -d "$evidence_root/.candidate.$git_sha.XXXXXX")
119
120
gcloud auth configure-docker "$registry_host" --quiet >/dev/null
121
122
remote_digest() {
123
  image_tag=$1
124
  receipt_name=$2
125
  descriptor="$run_root/$receipt_name.descriptor.json"
126
  inspect_error="$run_root/$receipt_name.inspect-error"
127
128
  if docker buildx imagetools inspect "$image_tag" \
129
    --format '{{json .Manifest}}' >"$descriptor" 2>"$inspect_error"; then
130
    if jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$"))' "$descriptor"; then
131
      return 0
132
    fi
133
134
    echo "registry returned an invalid manifest descriptor for $image_tag" >&2
135
    cat "$descriptor" >&2
136
    return 2
137
  fi
138
139
  if grep -Eiq 'not found|manifest unknown|does not exist' "$inspect_error"; then
140
    return 1
141
  fi
142
143
  cat "$inspect_error" >&2
144
  return 2
145
}
146
147
verify_platform() {
148
  image_reference=$1
149
  operating_system=$(docker image inspect "$image_reference" --format '{{.Os}}')
150
  architecture=$(docker image inspect "$image_reference" --format '{{.Architecture}}')
151
152
  if [ "$operating_system/$architecture" != "$platform" ]; then
153
    echo "$image_reference resolved to $operating_system/$architecture, expected $platform" >&2
154
    return 1
155
  fi
156
157
  return 0
158
}
159
160
if application_digest=$(remote_digest "$application_tag" application); then
161
  echo "Reusing immutable application tag for $git_sha"
162
else
163
  remote_status=$?
164
  if [ "$remote_status" -ne 1 ]; then
165
    exit "$remote_status"
166
  fi
167
168
  OPENAGENTS_IMAGE_PLATFORM="$platform" ops/deploy/build-image.sh "$application_tag"
169
  docker push "$application_tag" >/dev/null
170
  application_digest=$(remote_digest "$application_tag" application)
171
fi
172
173
if builder_digest=$(remote_digest "$builder_tag" builder); then
174
  echo "Reusing immutable builder tag for $git_sha"
175
else
176
  remote_status=$?
177
  if [ "$remote_status" -ne 1 ]; then
178
    exit "$remote_status"
179
  fi
180
181
  builder_iid="$run_root/builder.iid"
182
183
  docker build \
184
    --platform "$platform" \
185
    --build-arg "OPENAGENTS_BUILD_REVISION=$git_sha" \
186
    --build-arg "SOURCE_DATE_EPOCH=$source_date_epoch" \
187
    --iidfile "$builder_iid" \
188
    --label "org.opencontainers.image.revision=$git_sha" \
189
    --tag "$builder_tag" \
190
    --target forge-builder \
191
    "$repo_root"
192
193
  docker push "$builder_tag" >/dev/null
194
  builder_digest=$(remote_digest "$builder_tag" builder)
195
fi
196
197
case "$application_digest" in
198
  sha256:????????????????????????????????????????????????????????????????) ;;
199
  *) echo "application registry manifest digest is invalid" >&2; exit 1 ;;
200
esac
201
202
case "$builder_digest" in
203
  sha256:????????????????????????????????????????????????????????????????) ;;
204
  *) echo "builder registry manifest digest is invalid" >&2; exit 1 ;;
205
esac
206
207
application_image="${application_repository}@${application_digest}"
208
builder_image="${builder_repository}@${builder_digest}"
209
210
docker pull --platform "$platform" "$application_image" >/dev/null
211
docker pull --platform "$platform" "$builder_image" >/dev/null
212
verify_platform "$application_image"
213
verify_platform "$builder_image"
214
215
application_revision=$(
216
  docker image inspect "$application_image" \
217
    --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'
218
)
219
220
builder_revision=$(
221
  docker image inspect "$builder_image" \
222
    --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'
223
)
224
225
if [ "$application_revision" != "$git_sha" ] || [ "$builder_revision" != "$git_sha" ]; then
226
  echo "registry image revision labels do not match the exact Git SHA" >&2
227
  exit 1
228
fi
229
230
embedded_application_revision=$(
231
  docker run --rm \
232
    --entrypoint /bin/sh \
233
    "$application_image" \
234
    -c 'release_version=$(awk '\''{print $2}'\'' /app/releases/start_erl.data); /app/erts-*/bin/erl -boot_var RELEASE_LIB /app/lib -boot "/app/releases/$release_version/start_clean" -noshell -pa /app/lib/openagents-*/ebin -eval "io:put_chars('\''Elixir.OpenAgents.BuildInfo'\'':revision()), halt()."' \
235
    | tail -n 1
236
)
237
238
embedded_builder_revision=$(
239
  docker run --rm \
240
    --entrypoint /bin/sh \
241
    "$builder_image" \
242
    -c 'elixir -pa /app/_build/prod/lib/openagents/ebin -e "IO.write(OpenAgents.BuildInfo.revision())"'
243
)
244
245
if [ "$embedded_application_revision" != "$git_sha" ] ||
246
  [ "$embedded_builder_revision" != "$git_sha" ]; then
247
  echo "registry image packaged revisions do not match the exact Git SHA" >&2
248
  exit 1
249
fi
250
251
application_config_digest=$(docker image inspect "$application_image" --format '{{.Id}}')
252
builder_config_digest=$(docker image inspect "$builder_image" --format '{{.Id}}')
253
254
archive_name=$(
255
  docker run --rm \
256
    --entrypoint /bin/sh \
257
    "$builder_image" \
258
    -c 'set -- /app/_build/prod/openagents-*.tar.gz; [ "$#" -eq 1 ]; basename "$1"'
259
)
260
261
case "$archive_name" in
262
  openagents-*.tar.gz) ;;
263
  *) echo "builder image does not contain one release archive" >&2; exit 1 ;;
264
esac
265
266
release_version=${archive_name#openagents-}
267
release_version=${release_version%.tar.gz}
268
builder_container=$(docker create "$builder_image")
269
docker cp "$builder_container:/app/_build/prod/$archive_name" "$candidate_temp/$archive_name" >/dev/null
270
docker rm "$builder_container" >/dev/null
271
builder_container=
272
273
ops/staging/generate-sbom.sh \
274
  "$application_image" \
275
  "$candidate_temp/sbom.cdx.json"
276
277
release_sha256=$(sha256sum "$candidate_temp/$archive_name" | cut -d ' ' -f 1)
278
sbom_sha256=$(sha256sum "$candidate_temp/sbom.cdx.json" | cut -d ' ' -f 1)
279
sbom_receipt_sha256=$(sha256sum "$candidate_temp/sbom.cdx.json.receipt" | cut -d ' ' -f 1)
280
gate_receipt="$repo_root/.git/openagents/release-gate-receipts/$git_sha.json"
281
gate_receipt_sha256=$(sha256sum "$gate_receipt" | cut -d ' ' -f 1)
282
dockerfile_sha256=$(sha256sum "$repo_root/Dockerfile" | cut -d ' ' -f 1)
283
mix_lock_sha256=$(sha256sum "$repo_root/mix.lock" | cut -d ' ' -f 1)
284
lineage_map_sha256=$(sha256sum "$repo_root/priv/migration_lineages/prior-2026-08-19.json" | cut -d ' ' -f 1)
285
application_spec_sha256=$(
286
  docker run --rm \
287
    --entrypoint /bin/sh \
288
    "$builder_image" \
289
    -c 'sha256sum /app/_build/prod/lib/openagents/ebin/openagents.app' \
290
    | cut -d ' ' -f 1
291
)
292
elixir_version=$(docker run --rm --entrypoint elixir "$builder_image" --version | awk '/^Elixir / {print $2}')
293
otp_release=$(docker run --rm --entrypoint elixir "$builder_image" --version | awk '/^Erlang\/OTP / {print $2}')
294
erts_version=$(
295
  docker run --rm --entrypoint erl "$builder_image" \
296
    -noshell -eval 'io:put_chars(erlang:system_info(version)), halt().'
297
)
298
generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
299
manifest_temp="$candidate_temp/candidate-manifest.json.tmp"
300
301
jq -n \
302
  --arg git_sha "$git_sha" \
303
  --arg generated_at "$generated_at" \
304
  --argjson source_date_epoch "$source_date_epoch" \
305
  --arg staging_project "$staging_project" \
306
  --arg region "$region" \
307
  --arg platform "$platform" \
308
  --arg application_tag "$application_tag" \
309
  --arg application_image "$application_image" \
310
  --arg application_digest "$application_digest" \
311
  --arg application_config_digest "$application_config_digest" \
312
  --arg builder_tag "$builder_tag" \
313
  --arg builder_image "$builder_image" \
314
  --arg builder_digest "$builder_digest" \
315
  --arg builder_config_digest "$builder_config_digest" \
316
  --arg release_version "$release_version" \
317
  --arg release_file "$archive_name" \
318
  --arg release_sha256 "$release_sha256" \
319
  --arg sbom_sha256 "$sbom_sha256" \
320
  --arg sbom_receipt_sha256 "$sbom_receipt_sha256" \
321
  --arg gate_receipt_sha256 "$gate_receipt_sha256" \
322
  --arg dockerfile_sha256 "$dockerfile_sha256" \
323
  --arg mix_lock_sha256 "$mix_lock_sha256" \
324
  --arg lineage_map_sha256 "$lineage_map_sha256" \
325
  --arg elixir_version "$elixir_version" \
326
  --arg otp_release "$otp_release" \
327
  --arg erts_version "$erts_version" \
328
  --arg application_spec_sha256 "$application_spec_sha256" '
329
  {
330
    schema: "openagents.staging-candidate.v1",
331
    git_sha: $git_sha,
332
    branch: "main",
333
    generated_at: $generated_at,
334
    source_date_epoch: $source_date_epoch,
335
    target: {
336
      environment: "staging",
337
      project: $staging_project,
338
      region: $region,
339
      platform: $platform,
340
      immutable_tags: true
341
    },
342
    images: {
343
      application: {
344
        tag: $application_tag,
345
        reference: $application_image,
346
        manifest_digest: $application_digest,
347
        config_digest: $application_config_digest
348
      },
349
      builder: {
350
        tag: $builder_tag,
351
        reference: $builder_image,
352
        manifest_digest: $builder_digest,
353
        config_digest: $builder_config_digest
354
      }
355
    },
356
    release: {
357
      version: $release_version,
358
      file: $release_file,
359
      sha256: $release_sha256
360
    },
361
    sbom: {
362
      file: "sbom.cdx.json",
363
      sha256: $sbom_sha256,
364
      receipt_sha256: $sbom_receipt_sha256
365
    },
366
    receipts: {
367
      release_gate_sha256: $gate_receipt_sha256
368
    },
369
    inputs: {
370
      dockerfile_sha256: $dockerfile_sha256,
371
      mix_lock_sha256: $mix_lock_sha256,
372
      migration_lineage_map_sha256: $lineage_map_sha256,
373
      application_spec_sha256: $application_spec_sha256
374
    },
375
    toolchain: {
376
      elixir: $elixir_version,
377
      otp: $otp_release,
378
      erts: $erts_version
379
    }
380
  }
381
' >"$manifest_temp"
382
383
mv "$manifest_temp" "$candidate_temp/candidate-manifest.json"
384
manifest_sha256=$(sha256sum "$candidate_temp/candidate-manifest.json" | cut -d ' ' -f 1)
385
printf '%s  candidate-manifest.json\n' "$manifest_sha256" \
386
  >"$candidate_temp/candidate-manifest.sha256"
387
388
jq -e \
389
  --arg sha "$git_sha" \
390
  --arg application_digest "$application_digest" \
391
  --arg builder_digest "$builder_digest" '
392
  .schema == "openagents.staging-candidate.v1" and
393
  .git_sha == $sha and
394
  .target.environment == "staging" and
395
  .target.immutable_tags == true and
396
  .images.application.manifest_digest == $application_digest and
397
  .images.builder.manifest_digest == $builder_digest and
398
  (.release.sha256 | test("^[0-9a-f]{64}$")) and
399
  (.sbom.sha256 | test("^[0-9a-f]{64}$"))
400
' "$candidate_temp/candidate-manifest.json" >/dev/null
401
402
mv "$candidate_temp" "$candidate_path"
403
candidate_temp=
404
trap - EXIT INT TERM
405
find "$run_root" -depth -delete
406
407
echo "Published immutable staging candidate $git_sha"
408
echo "Application: $application_image"
409
echo "Builder: $builder_image"
410
echo "Evidence: .git/openagents/staging-candidates/$git_sha"
rel/env.sh.eex modified +1

@@ -8,6 +8,7 @@

8 8
# all that is needed to form a BEAM cluster.
9 9
10 10
if [ -n "${OPENAGENTS_NODE_HOST:-}" ]; then
11
  : "${RELEASE_COOKIE:?RELEASE_COOKIE is required for a distributed node}"
11 12
  export RELEASE_DISTRIBUTION="name"
12 13
  export RELEASE_NODE="${RELEASE_NODE:-openagents@${OPENAGENTS_NODE_HOST}}"
13 14
  # Pin Erlang distribution ports to a fixed range so firewall rules and
test/openagents/staging_candidate_contract_test.exs added +66

@@ -0,0 +1,66 @@

1
defmodule OpenAgents.StagingCandidateContractTest do
2
  use ExUnit.Case, async: true
3
4
  test "the staging registry prevents tag movement and Terraform deletion" do
5
    terraform = File.read!("infra/staging/main.tf")
6
    safety_test = File.read!("infra/staging/tests/safety.tftest.hcl")
7
8
    assert terraform =~ "immutable_tags = true"
9
    assert terraform =~ ~s(deletion_policy        = "PREVENT")
10
    assert safety_test =~ "docker_config[0].immutable_tags"
11
    assert safety_test =~ "deletion_policy == \"PREVENT\""
12
  end
13
14
  test "the container build pins external inputs and distributed cookie admission" do
15
    dockerfile = File.read!("Dockerfile")
16
    release_config = File.read!("mix.exs")
17
    release_environment = File.read!("rel/env.sh.eex")
18
19
    assert dockerfile =~ ~r/BUILDER_IMAGE=.*@sha256:[0-9a-f]{64}/
20
    assert dockerfile =~ ~r/RUNNER_IMAGE=.*@sha256:[0-9a-f]{64}/
21
    assert dockerfile =~ "snapshot.debian.org/archive/debian/"
22
    assert dockerfile =~ ~s(mix local.hex "${HEX_VERSION}" --force)
23
    assert dockerfile =~ "--sha512 \"${REBAR3_SHA512}\""
24
25
    assert dockerfile =~
26
             ~s("${TAILWIND_SHA256}" "/app/_build/tailwind-linux-x64-${TAILWIND_VERSION}")
27
28
    assert dockerfile =~ ~s("${ESBUILD_SHA256}" "/app/_build/esbuild-linux-x64")
29
    assert dockerfile =~ "sha256sum --check --strict"
30
    assert dockerfile =~ "SOURCE_DATE_EPOCH"
31
    assert release_config =~ ~s(cookie: "openagents-nondistributed-placeholder")
32
33
    assert release_environment =~
34
             ~s(RELEASE_COOKIE:?RELEASE_COOKIE is required for a distributed node)
35
  end
36
37
  test "candidate publication binds exact immutable registry and artifact identities" do
38
    publisher = File.read!("ops/staging/publish-candidate.sh")
39
    sbom = File.read!("ops/staging/generate-sbom.sh")
40
41
    assert publisher =~ "refs/remotes/origin/main"
42
    assert publisher =~ "ops/ci/gate.sh --verify"
43
    assert publisher =~ "platform=linux/amd64"
44
    assert publisher =~ ~s(docker pull --platform "$platform")
45
    assert publisher =~ ".dockerConfig.immutableTags == true"
46
    assert publisher =~ ~s(application_tag="${application_repository}:${git_sha}")
47
    assert publisher =~ ~s(builder_tag="${builder_repository}:${git_sha}")
48
    assert publisher =~ "docker buildx imagetools inspect"
49
    assert publisher =~ ~s(--format '{{json .Manifest}}')
50
    assert publisher =~ "verify_platform"
51
    assert publisher =~ "/app/_build/prod/lib/openagents/ebin"
52
    refute publisher =~ "/app/_build/prod/lib/openagents-*/ebin"
53
    assert publisher =~ "openagents.staging-candidate.v1"
54
    assert publisher =~ "candidate-manifest.sha256"
55
    refute publisher =~ ":latest"
56
    assert sbom =~ "*@sha256:"
57
  end
58
59
  test "relup proof paths propagate worktree admission failures" do
60
    common = File.read!("ops/relup-proof/common.sh")
61
62
    assert common =~ "key=$(proof_key) || return $?"
63
    assert common =~ ~s|$repo_root/.git/openagents/relup-proof/$key|
64
    refute common =~ ~s|$repo_root/.git/openagents/relup-proof/$(proof_key)|
65
  end
66
end

This page updates live while a promote is in flight · changelog