Skip to content

Commit 0e8cedd

Browse files
committed
Keep candidate maintenance from moving release tags
1 parent be747c0 commit 0e8cedd

6 files changed

Lines changed: 92 additions & 15 deletions

File tree

.github/workflows/container-candidates.yml

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ name: Mesh container candidates
22

33
on:
44
workflow_dispatch:
5+
inputs:
6+
publish:
7+
description: Publish a new, precomputed candidate digest under a release tag that does not exist yet
8+
required: true
9+
type: boolean
10+
default: false
511
push:
612
branches: [main, beta/production-readiness-2026-08]
713
paths:
@@ -28,7 +34,7 @@ permissions:
2834

2935
jobs:
3036
publish:
31-
name: Publish and verify Mesh container candidate (${{ matrix.name }})
37+
name: Maintain and verify Mesh container candidate (${{ matrix.name }})
3238
runs-on: ubuntu-latest
3339
strategy:
3440
fail-fast: false
@@ -37,11 +43,13 @@ jobs:
3743
- name: caddy
3844
image: ghcr.io/dhawal-ss/mesh-caddy
3945
tag: 2.11.4-mesh.1
46+
digest: sha256:67cf933778f84aadd59f93fc5d2eaf6471df7b415135ae95fc1b1e6e5f4b7e4f
4047
context: mesh/infra/container-candidates/caddy
4148
dockerfile: mesh/infra/container-candidates/caddy/Dockerfile
4249
- name: lk-jwt-service
4350
image: ghcr.io/dhawal-ss/mesh-lk-jwt-service
4451
tag: 0.5.0-mesh.1
52+
digest: sha256:78bf2f1e8535928037abc35a9886614b3f722885b54f1f707a570e6991252710
4553
context: mesh/infra/container-candidates/lk-jwt-service
4654
dockerfile: mesh/infra/container-candidates/lk-jwt-service/Dockerfile
4755
steps:
@@ -54,41 +62,65 @@ jobs:
5462
run: npm run check:container-candidates
5563
- name: Set up Buildx
5664
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
65+
- name: Refuse an existing release tag before publication
66+
if: github.event_name == 'workflow_dispatch' && inputs.publish
67+
shell: bash
68+
run: |
69+
if docker buildx imagetools inspect '${{ matrix.image }}:${{ matrix.tag }}' >/dev/null 2>&1; then
70+
echo 'Candidate release tags are immutable. Choose a new reviewed tag.' >&2
71+
exit 1
72+
fi
5773
- name: Sign in to GitHub Container Registry
74+
if: github.event_name == 'workflow_dispatch' && inputs.publish
5875
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
5976
with:
6077
registry: ghcr.io
6178
username: ${{ github.actor }}
6279
password: ${{ secrets.GITHUB_TOKEN }}
63-
- name: Build and publish exact multi-platform candidate
80+
- name: Build and publish staged multi-platform candidate
81+
if: github.event_name == 'workflow_dispatch' && inputs.publish
6482
id: build
6583
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
6684
with:
6785
context: ${{ matrix.context }}
6886
file: ${{ matrix.dockerfile }}
6987
platforms: linux/amd64,linux/arm64
7088
push: true
71-
tags: ${{ matrix.image }}:${{ matrix.tag }}
72-
provenance: mode=max
89+
tags: ${{ matrix.image }}:staging-${{ github.run_id }}-${{ github.run_attempt }}
90+
provenance: false
7391
sbom: false
7492
cache-from: type=gha,scope=${{ matrix.name }}
7593
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
7694
- name: Resolve exact candidate image
7795
id: exact
7896
shell: bash
7997
run: |
80-
exact_image='${{ matrix.image }}:${{ matrix.tag }}@${{ steps.build.outputs.digest }}'
98+
expected_digest='${{ matrix.digest }}'
99+
built_digest='${{ steps.build.outputs.digest }}'
100+
if [ -n "$built_digest" ]; then
101+
if [ "$built_digest" != "$expected_digest" ]; then
102+
echo "The staged build digest does not match the precomputed reviewed digest." >&2
103+
exit 1
104+
fi
105+
docker buildx imagetools create \
106+
--tag '${{ matrix.image }}:${{ matrix.tag }}' \
107+
'${{ matrix.image }}@${{ matrix.digest }}'
108+
fi
109+
exact_image='${{ matrix.image }}:${{ matrix.tag }}@${{ matrix.digest }}'
110+
docker buildx imagetools inspect "$exact_image" >/dev/null
81111
echo "image=$exact_image" >> "$GITHUB_OUTPUT"
82-
echo "Published \`$exact_image\`" >> "$GITHUB_STEP_SUMMARY"
112+
echo "Verified \`$exact_image\`" >> "$GITHUB_STEP_SUMMARY"
83113
- name: Install pinned Cosign
84114
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
85115
with:
86116
cosign-release: v3.0.6
87117
- name: Sign exact candidate digest with GitHub OIDC
118+
if: github.event_name == 'workflow_dispatch' && inputs.publish
88119
env:
89120
EXACT_IMAGE: ${{ steps.exact.outputs.image }}
90121
run: cosign sign --yes "$EXACT_IMAGE"
91122
- name: Publish registry provenance attestation
123+
if: github.event_name == 'workflow_dispatch' && inputs.publish
92124
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
93125
with:
94126
subject-name: ${{ matrix.image }}
@@ -121,6 +153,7 @@ jobs:
121153
--image '${{ steps.exact.outputs.image }}'
122154
--output "candidate-${{ matrix.name }}-scan-evidence.json"
123155
- name: Attach the pinned CycloneDX SBOM to the exact digest
156+
if: github.event_name == 'workflow_dispatch' && inputs.publish
124157
env:
125158
EXACT_IMAGE: ${{ steps.exact.outputs.image }}
126159
run: >-

mesh/docs/operations/CONTAINER_CANDIDATE_MAINTENANCE.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,25 @@ exception or unsupported dependency combination.
1616
## Build and trust contract
1717

1818
The workflow at `.github/workflows/container-candidates.yml` binds every build
19-
to an upstream release tag and commit. It builds Linux AMD64 and ARM64 images
20-
from a pinned builder, publishes only non-root scratch runtimes, emits a
21-
CycloneDX SBOM and registry provenance, rejects fixable High or Critical
22-
findings, and signs the exact multi-platform digest with GitHub Actions OIDC.
19+
to an upstream release tag and commit. An explicit manual publication builds
20+
Linux AMD64 and ARM64 images from a pinned builder, first pushes a run-specific
21+
staging tag, rejects any digest other than the precomputed reviewed digest, and
22+
refuses to overwrite an existing release tag. It publishes only non-root
23+
scratch runtimes, emits a CycloneDX SBOM and registry provenance, rejects
24+
fixable High or Critical findings, and signs the exact multi-platform digest
25+
with GitHub Actions OIDC.
2326

2427
Candidate packages must be public. A private package is not eligible for a
2528
Compose or release-policy pin because community operators must not need a Mesh
2629
credential to retrieve an optional service image.
2730

2831
## Maintenance response
2932

30-
- The protected workflow scans candidates every week and whenever an image,
31-
policy, validation script, or workflow changes.
33+
- Pushes, scheduled maintenance, and ordinary manual runs only scan and verify
34+
the policy-pinned exact digest. They do not build, push, sign, attest, or
35+
change a registry tag.
36+
- Publication requires an explicit manual `publish` input, a new release tag,
37+
and a precomputed digest. Release tags are immutable after that operation.
3238
- A fixable High or Critical finding blocks the candidate immediately. Begin a
3339
dependency update or rebuild within 24 hours.
3440
- Review an unfixed Critical finding within 24 hours. Do not add an exception

mesh/infra/container-candidates/candidate-policy.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
{
4242
"name": "caddy",
4343
"image": "ghcr.io/dhawal-ss/mesh-caddy:2.11.4-mesh.1",
44+
"publishedDigest": "sha256:67cf933778f84aadd59f93fc5d2eaf6471df7b415135ae95fc1b1e6e5f4b7e4f",
4445
"context": "infra/container-candidates/caddy",
4546
"dockerfile": "infra/container-candidates/caddy/Dockerfile",
4647
"sourceRepository": "https://github.com/caddyserver/caddy.git",
@@ -57,6 +58,7 @@
5758
{
5859
"name": "lk-jwt-service",
5960
"image": "ghcr.io/dhawal-ss/mesh-lk-jwt-service:0.5.0-mesh.1",
61+
"publishedDigest": "sha256:78bf2f1e8535928037abc35a9886614b3f722885b54f1f707a570e6991252710",
6062
"context": "infra/container-candidates/lk-jwt-service",
6163
"dockerfile": "infra/container-candidates/lk-jwt-service/Dockerfile",
6264
"sourceRepository": "https://github.com/element-hq/lk-jwt-service.git",

mesh/reports/TRACK_A_REPORT.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Mesh remains `NO_GO_PUBLIC_BETA`. Track A is not complete until the pull request
3434
| The release rung could not be fully green before Track A merge | Seven of nine release commands pass. `check:release-sboms` rejects the preceding text-build reachability file because it is not a Matrix voice artifact, and `release:preflight` stops on D7. | Record both failures. Do not run the Track E voice rung or mark protected gates from local output. |
3535
| Protected security evidence failed after the first push | Caddy, Synapse, and `lk-jwt-service` contained fixable High or Critical findings. Newer upstream-only tags still failed with 8, 32, and 3 High or Critical findings respectively. | Do not waive the findings. Maintain hardened Mesh candidates only for the two dependency graphs that pass upstream tests and the zero-finding scan. Keep Synapse blocked. |
3636
| The first exact-digest R3 run reached the new image but stopped before strict identity verification | Cosign `v3.0.6` returned one direct image signature together with the signed CycloneDX and SLSA referrers. The discovery normalizer correctly rejected the attestation payload types under its former direct-signature-only shape. | Keep unknown payloads rejected, require at least one direct image signature, separately count only the two reviewed attestation types, and retain the strict Mesh workflow-identity verification. |
37+
| A source-validator change retriggered candidate publication under the existing release tags | Cancellation stopped Caddy before publication, but the faster `lk-jwt-service` job had already moved the human-readable tag to a new same-source digest. The reviewed exact digest remained public, signed, zero-finding, and unchanged in every production reference. | Treat the digest as authoritative. Make push, schedule, and ordinary manual runs scan-only; require an explicit manual publish input, a precomputed digest, a staging tag, and refusal of any existing release tag. Do not use a human-readable tag as release trust. |
3738

3839
## Post-push container remediation
3940

@@ -67,7 +68,7 @@ The owner approved a zero-cost public GHCR maintenance path on 2026-08-10. The i
6768
| 4, end to end | Pass, 3 of 3 commands | Chromium installation passed. Playwright: 87 of 87 cases. Production preview performance: interactive median 75 ms and worst observed 205 ms across three local samples. |
6869
| 5, release gates | Partial, 7 of 9 commands | Owner, beta, operations, readiness structure, protected-evidence verifier tests, installer payload, and coexistence passed. Release SBOM generation failed because the current `dist/mesh-reachability.json` is the text build and omits `livekit-client`. Preflight failed on D7. |
6970
| Additional commit contract | Pass | `cargo clippy --all-targets --locked -- -D warnings` passed with one build job. `git diff --check` passed. No secret-bearing filenames or high-confidence private-key and token patterns were found. |
70-
| Candidate container contract | Pass locally | 67 supply-chain tests pass. Both candidate Dockerfiles build from exact upstream commits. Caddy configuration and HTTP health pass; `lk-jwt-service` `/healthz` passes. Both run as `65532:65532` with read-only filesystems, dropped capabilities, and no new privileges. The complete local prototype run built both images reproducibly with zero fixable findings and reproduced the two blocked Synapse scans. Real Cosign output for both public digests normalizes to one direct signature and two reviewed attestations while rejecting attestation-only and unknown-type payloads. |
71+
| Candidate container contract | Pass locally | 68 supply-chain tests pass. Both candidate Dockerfiles build from exact upstream commits. Caddy configuration and HTTP health pass; `lk-jwt-service` `/healthz` passes. Both run as `65532:65532` with read-only filesystems, dropped capabilities, and no new privileges. The complete local prototype run built both images reproducibly with zero fixable findings and reproduced the two blocked Synapse scans. Real Cosign output for both public digests normalizes to one direct signature and two reviewed attestations while rejecting attestation-only and unknown-type payloads. Scheduled and push-triggered candidate maintenance is scan-only; publication is manual, precomputed-digest-bound, staged, and existing-tag-refusing. |
7172
| Candidate publication | Pass on pull request source | Workflow run `31377423123` built, published, signed, attested, scanned, verified, and retained evidence for both public multi-architecture images. Independent exact-digest verification accepted only the repository workflow identity and found zero fixable High or Critical findings on Linux AMD64 and ARM64. Anonymous manifest inspection passed for both packages. |
7273
| 6, voice specific | Not run | The work order limits this rung to Track E. Track A does not use it to claim voice readiness. |
7374

mesh/scripts/check-container-candidates.mjs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
66
const SHA40 = /^[0-9a-f]{40}$/u
77
const PINNED_BUILDER = /^[A-Za-z0-9./_-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$/u
88
const CANDIDATE_IMAGE = /^ghcr\.io\/dhawal-ss\/[a-z0-9-]+:[A-Za-z0-9._-]+$/u
9+
const OCI_DIGEST = /^sha256:[0-9a-f]{64}$/u
910

1011
function hash(contents) {
1112
return createHash('sha256').update(contents).digest('hex')
@@ -31,6 +32,7 @@ export function validateContainerCandidates({ policy, workflow, dockerfiles, pat
3132
if (images.map((entry) => entry.name).join(',') !== 'caddy,lk-jwt-service') fail('candidate policy must contain exactly Caddy and lk-jwt-service')
3233
for (const image of images) {
3334
if (!CANDIDATE_IMAGE.test(image?.image ?? '')) fail(`${image?.name ?? 'candidate'} image tag is invalid`)
35+
if (!OCI_DIGEST.test(image?.publishedDigest ?? '')) fail(`${image?.name ?? 'candidate'} published digest is invalid`)
3436
if (!SHA40.test(image?.sourceCommit ?? '')) fail(`${image?.name ?? 'candidate'} source commit is invalid`)
3537
if (!image?.sourceRelease?.startsWith('v')) fail(`${image?.name ?? 'candidate'} source release is invalid`)
3638
if (!image?.context?.startsWith(`infra/container-candidates/${image.name}`)
@@ -55,19 +57,26 @@ export function validateContainerCandidates({ policy, workflow, dockerfiles, pat
5557
if (!dockerfile.includes(path.posix.basename(patch.path)) || !dockerfile.includes(patch.sha256) || !dockerfile.includes('git apply --check')) fail(`${image.name} source patch is not checksum-bound and preflighted`)
5658
}
5759
const [repository, tag] = image.image.split(/:(?=[^/]+$)/u)
58-
if (!workflow.includes(`image: ${repository}`) || !workflow.includes(`tag: ${tag}`)) fail(`${image.name} is missing from the publishing matrix`)
60+
if (!workflow.includes(`image: ${repository}`)
61+
|| !workflow.includes(`tag: ${tag}`)
62+
|| !workflow.includes(`digest: ${image.publishedDigest}`)) fail(`${image.name} is missing from the publishing matrix`)
5963
}
6064
if ((policy?.blocked ?? []).length !== 1 || policy.blocked[0]?.name !== 'synapse' || !policy.blocked[0]?.reason) fail('Synapse must remain an explicit blocked candidate')
6165

6266
const workflowRequirements = [
6367
'branches: [main, beta/production-readiness-2026-08]',
68+
'default: false',
6469
'packages: write',
6570
'id-token: write',
6671
'attestations: write',
6772
'platforms: linux/amd64,linux/arm64',
6873
'push: true',
69-
'provenance: mode=max',
74+
'provenance: false',
7075
'sbom: false',
76+
'staging-${{ github.run_id }}-${{ github.run_attempt }}',
77+
'Candidate release tags are immutable',
78+
'The staged build digest does not match the precomputed reviewed digest.',
79+
'docker buildx imagetools create',
7180
'cosign attest --yes --type cyclonedx',
7281
'cosign sign --yes',
7382
'actions/attest-build-provenance@',
@@ -80,6 +89,20 @@ export function validateContainerCandidates({ policy, workflow, dockerfiles, pat
8089
'if: always()',
8190
]
8291
for (const required of workflowRequirements) if (!workflow.includes(required)) fail(`candidate workflow is missing ${required}`)
92+
const manualPublishGuard = "if: github.event_name == 'workflow_dispatch' && inputs.publish"
93+
for (const stepName of [
94+
'Refuse an existing release tag before publication',
95+
'Sign in to GitHub Container Registry',
96+
'Build and publish staged multi-platform candidate',
97+
'Sign exact candidate digest with GitHub OIDC',
98+
'Publish registry provenance attestation',
99+
'Attach the pinned CycloneDX SBOM to the exact digest',
100+
]) {
101+
const start = workflow.indexOf(`- name: ${stepName}`)
102+
const next = workflow.indexOf('\n - name:', start + 1)
103+
const block = start >= 0 ? workflow.slice(start, next >= 0 ? next : undefined) : ''
104+
if (!block.includes(manualPublishGuard)) fail(`${stepName} must remain manual-publication-only`)
105+
}
83106
for (const action of ['actions/checkout', 'actions/setup-node', 'docker/setup-buildx-action', 'docker/login-action', 'docker/build-push-action', 'sigstore/cosign-installer', 'actions/attest-build-provenance', 'anchore/sbom-action', 'anchore/scan-action', 'actions/upload-artifact']) {
84107
if (!new RegExp(`${action.replace('/', '\\/')}@[0-9a-f]{40}`, 'u').test(workflow)) fail(`${action} must be SHA pinned`)
85108
}

mesh/scripts/check-container-candidates.test.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,15 @@ test('rejects root runtimes and source patch drift', async () => {
4646
assert.match(errors, /USER 65532:65532/u)
4747
assert.match(errors, /source patch hash drifted/u)
4848
})
49+
50+
test('rejects mutable tags, digest drift, and scheduled publication', async () => {
51+
const input = await fixture()
52+
input.policy.images[0].publishedDigest = 'sha256:invalid'
53+
input.workflow = input.workflow
54+
.replace('Candidate release tags are immutable', 'Candidate tags may move')
55+
.replace("if: github.event_name == 'workflow_dispatch' && inputs.publish\n uses: docker/login-action", 'uses: docker/login-action')
56+
const errors = validateContainerCandidates(input).join('; ')
57+
assert.match(errors, /published digest is invalid/u)
58+
assert.match(errors, /Candidate release tags are immutable/u)
59+
assert.match(errors, /Sign in to GitHub Container Registry must remain manual-publication-only/u)
60+
})

0 commit comments

Comments
 (0)