feat(timeline): full state history and quieter chart cyan (#111) #451
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| runner: | |
| description: 'Runner to use (manual runs only)' | |
| type: choice | |
| options: | |
| - arc-runner | |
| - ubuntu-latest | |
| default: arc-runner | |
| concurrency: | |
| group: release-${{ github.ref_name }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| packages: write | |
| id-token: write # Required for cosign keyless signing | |
| attestations: write # Required for actions/attest-build-provenance (OPS-08) | |
| env: | |
| REGISTRY: ghcr.io | |
| BACKEND_IMAGE: ghcr.io/${{ github.repository }}-api | |
| WEB_IMAGE: ghcr.io/${{ github.repository }}-web | |
| NOTIFICATION_IMAGE: ghcr.io/${{ github.repository }}-notification-worker | |
| EXPORT_IMAGE: ghcr.io/${{ github.repository }}-export-worker | |
| AUTOMATION_IMAGE: ghcr.io/${{ github.repository }}-automation-worker | |
| jobs: | |
| version: | |
| name: Compute version | |
| runs-on: ${{ inputs.runner || 'arc-runner' }} | |
| permissions: | |
| contents: read | |
| outputs: | |
| new_tag: ${{ steps.bump.outputs.new_tag }} | |
| version: ${{ steps.bump.outputs.version }} | |
| is_prerelease: ${{ steps.bump.outputs.is_prerelease }} | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Determine version bump | |
| id: bump | |
| run: | | |
| LATEST=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) | |
| if [ -z "$LATEST" ]; then | |
| LATEST="v0.0.0" | |
| fi | |
| echo "Latest tag: $LATEST" | |
| MAJOR=$(echo "$LATEST" | sed 's/v//' | cut -d. -f1) | |
| MINOR=$(echo "$LATEST" | sed 's/v//' | cut -d. -f2) | |
| PATCH=$(echo "$LATEST" | sed 's/v//' | cut -d. -f3) | |
| if [ "$LATEST" = "v0.0.0" ]; then | |
| COMMITS=$(git log --oneline) | |
| else | |
| COMMITS=$(git log "${LATEST}..HEAD" --oneline) | |
| fi | |
| if echo "$COMMITS" | grep -qiE '^[a-f0-9]+ (feat!|BREAKING)'; then | |
| MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 | |
| elif echo "$COMMITS" | grep -qiE '^[a-f0-9]+ feat'; then | |
| MINOR=$((MINOR + 1)); PATCH=0 | |
| else | |
| PATCH=$((PATCH + 1)) | |
| fi | |
| BASE_VERSION="${MAJOR}.${MINOR}.${PATCH}" | |
| # Non-main branches get a prerelease suffix | |
| if [ "${{ github.ref_name }}" != "main" ]; then | |
| BRANCH=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') | |
| SHORT_SHA=$(git rev-parse --short HEAD) | |
| VERSION="${BASE_VERSION}-rc.${BRANCH}.g${SHORT_SHA}" | |
| NEW_TAG="v${VERSION}" | |
| IS_PRERELEASE="true" | |
| else | |
| VERSION="${BASE_VERSION}" | |
| NEW_TAG="v${VERSION}" | |
| IS_PRERELEASE="false" | |
| fi | |
| echo "New tag: $NEW_TAG" | |
| echo "new_tag=$NEW_TAG" >> "$GITHUB_OUTPUT" | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" | |
| # NOTE: the git tag is NOT pushed here. Tagging is a persistent, | |
| # public release side effect, so it happens in `tag-and-release` | |
| # AFTER the vulnerability gate. Computing the version early is | |
| # side-effect-free and lets the build jobs label their artifacts. | |
| # ββ Stage 1: BUILD AND SCAN β no publish side effects ββββββββββββββ | |
| # | |
| # Builds every image locally (`push: false`, `load: true`) and scans | |
| # the loaded image. Nothing leaves the runner. The layers land in the | |
| # GHA cache, so the publish stage re-uses them and the second build is | |
| # a cache hit rather than a rebuild. | |
| # | |
| # WHY: previously the docker job pushed :version AND :latest, signed, | |
| # attested, and published the Helm chart, and only THEN did | |
| # vulnerability-report run β where it could block nothing but the | |
| # release notes. A fixable CRITICAL still shipped to every consumer | |
| # pulling :latest. The gate has to precede the publish, not the | |
| # paperwork. | |
| build-scan: | |
| name: Build & scan Β· ${{ matrix.image }} | |
| needs: version | |
| runs-on: ${{ inputs.runner || 'arc-runner' }} | |
| permissions: | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - image: api | |
| file: Dockerfile | |
| build-args: | | |
| VERSION=${{ needs.version.outputs.new_tag }} | |
| COMMIT=${{ github.sha }} | |
| - image: web | |
| file: Dockerfile.web | |
| # The SPA's build identity. Threaded HERE, in the one build step | |
| # whose output is the promoted archive, so the exact bytes that are | |
| # scanned and published carry the release identity β nothing is | |
| # rebuilt later. | |
| # | |
| # VITE_APP_VERSION: without it the SPA fell back to package.json's | |
| # `2.0.0` while the API reported its git tag, so at v2.1 the PWA | |
| # handshake read client 2.0.0 < server 2.1.0 as `assets-stale` | |
| # and raised a NON-DISMISSIBLE update prompt that reloading could | |
| # never clear (the next build embedded 2.0.0 again). | |
| # VITE_GIT_SHA: BUILD_ID is `<version>+<sha>` and suffixes every | |
| # versioned Cache Storage bucket. The image has no `.git`, so | |
| # without this arg every deploy reused `β¦+dev` buckets and the | |
| # previous build's chunks survived `activate()`. | |
| # VITE_RELEASE_BUILD: asserts release intent. Only here. It turns a | |
| # half-configured identity (version supplied, SHA missing) from a | |
| # safe degradation into a hard build failure, so a publish can | |
| # never ship `<version>+dev` β while an operator's own | |
| # `docker compose build` stays unbreakable. | |
| build-args: | | |
| VITE_APP_VERSION=${{ needs.version.outputs.version }} | |
| VITE_GIT_SHA=${{ github.sha }} | |
| VITE_RELEASE_BUILD=1 | |
| - image: notification-worker | |
| file: Dockerfile.notification | |
| build-args: | | |
| VERSION=${{ needs.version.outputs.new_tag }} | |
| - image: export-worker | |
| file: Dockerfile.export-worker | |
| build-args: | | |
| VERSION=${{ needs.version.outputs.new_tag }} | |
| - image: automation-worker | |
| file: Dockerfile.automation | |
| build-args: | | |
| VERSION=${{ needs.version.outputs.new_tag }} | |
| - image: fleet-telemetry | |
| file: Dockerfile.fleet-telemetry | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 | |
| - name: Prepare the promotion directory | |
| run: | | |
| set -euo pipefail | |
| mkdir -p /tmp/promote | |
| # BUILD ONCE. The output is a docker-archive on disk β no push, no | |
| # registry side effect of any kind. Every later stage consumes THIS | |
| # tarball; nothing is ever rebuilt, so "the bytes that were scanned" | |
| # and "the bytes that were published" are the same bytes by | |
| # construction rather than by hoping two builds agree. | |
| # | |
| # Rebuilding in the publish stage (the previous design) was not | |
| # sound: unpinned base tags, cache eviction, and any non-reproducible | |
| # layer could make the published image differ from the scanned one, | |
| # and the signature/attestation would then describe an image nobody | |
| # had scanned. | |
| - name: Build ${{ matrix.image }} to a local archive (no push) | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 | |
| with: | |
| context: . | |
| file: ${{ matrix.file }} | |
| push: false | |
| tags: teslasync-scan/${{ matrix.image }}:candidate | |
| outputs: type=docker,dest=/tmp/promote/${{ matrix.image }}.tar | |
| cache-from: type=gha,scope=${{ matrix.image }} | |
| cache-to: type=gha,mode=max,scope=${{ matrix.image }} | |
| build-args: ${{ matrix.build-args }} | |
| # The config digest commits to the layer diff_ids, so it uniquely | |
| # identifies the image content. Recording it here is what makes the | |
| # later "DIGEST CONTINUITY" assertion meaningful. | |
| - name: Record the scanned config digest | |
| id: scanned | |
| env: | |
| IMAGE: ${{ matrix.image }} | |
| run: | | |
| set -euo pipefail | |
| TAR="/tmp/promote/${IMAGE}.tar" | |
| test -s "$TAR" | |
| CONFIG=$(tar -xOf "$TAR" manifest.json | jq -r '.[0].Config') | |
| # docker-archive names the config blob after its own digest, | |
| # either `<sha256>.json` or `blobs/sha256/<sha256>`. | |
| CONFIG_DIGEST="sha256:$(basename "$CONFIG" .json)" | |
| LAYERS=$(tar -xOf "$TAR" manifest.json | jq -r '.[0].Layers | length') | |
| TAR_SHA=$(sha256sum "$TAR" | cut -d' ' -f1) | |
| echo "config_digest=${CONFIG_DIGEST}" >> "$GITHUB_OUTPUT" | |
| echo "layers=${LAYERS}" >> "$GITHUB_OUTPUT" | |
| printf '%s\n' "$CONFIG_DIGEST" > "/tmp/promote/${IMAGE}.config-digest" | |
| printf '%s\n' "$LAYERS" > "/tmp/promote/${IMAGE}.layers" | |
| printf '%s\n' "$TAR_SHA" > "/tmp/promote/${IMAGE}.tar-sha256" | |
| echo "scanned config digest: ${CONFIG_DIGEST} (${LAYERS} layers, archive ${TAR_SHA})" | |
| # Build identity of the SPA, asserted against the promoted archive. | |
| # | |
| # Read-only: the tarball is loaded into the local daemon (no rebuild, no | |
| # push, no registry reference) and the archive file itself is untouched, | |
| # so the digest recorded above and the bytes published later are still | |
| # the same bytes. The archive SHA is re-checked afterwards to make that | |
| # explicit rather than assumed. | |
| # | |
| # Without this, two silent failures shipped a *successful* build of a | |
| # *wrong* image: the SPA reported package.json's version forever (pinning | |
| # a non-dismissible "update required" prompt from the first minor release | |
| # onward) and BUILD_ID stayed `β¦+dev` on every deploy (so versioned Cache | |
| # Storage buckets never rotated and stale chunks survived `activate()`). | |
| - name: Verify the promoted web archive carries the release build identity | |
| if: matrix.image == 'web' | |
| env: | |
| EXPECTED_VERSION: ${{ needs.version.outputs.version }} | |
| EXPECTED_SHA: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| TAR=/tmp/promote/web.tar | |
| BEFORE=$(sha256sum "$TAR" | cut -d' ' -f1) | |
| docker load --input "$TAR" >/dev/null | |
| SW=$(docker run --rm --entrypoint cat teslasync-scan/web:candidate /usr/share/nginx/html/sw.js) | |
| fail=0 | |
| if ! printf '%s' "$SW" | grep -qF "$EXPECTED_VERSION"; then | |
| echo "::error::dist/sw.js does not embed release version ${EXPECTED_VERSION} β VITE_APP_VERSION did not reach the build" | |
| fail=1 | |
| fi | |
| if ! printf '%s' "$SW" | grep -qF "$EXPECTED_SHA"; then | |
| echo "::error::dist/sw.js does not embed build sha ${EXPECTED_SHA} β VITE_GIT_SHA did not reach the build" | |
| fail=1 | |
| fi | |
| # `<version>+dev` is the shape that looks like a release but never | |
| # rotates its caches. Reject it outright. | |
| if printf '%s' "$SW" | grep -qF "${EXPECTED_VERSION}+dev"; then | |
| echo "::error::build id ${EXPECTED_VERSION}+dev is not publishable β cache buckets would never rotate" | |
| fail=1 | |
| fi | |
| AFTER=$(sha256sum "$TAR" | cut -d' ' -f1) | |
| if [ "$BEFORE" != "$AFTER" ]; then | |
| echo "::error::the promotion archive changed during identity verification" | |
| fail=1 | |
| fi | |
| docker image rm teslasync-scan/web:candidate >/dev/null 2>&1 || true | |
| test "$fail" -eq 0 | |
| echo "web build identity verified in the promoted archive: ${EXPECTED_VERSION}+${EXPECTED_SHA} (archive ${AFTER} unchanged)" | |
| # Scans the ARCHIVE, not a registry reference, so the scan target | |
| # is unambiguously the artifact that will be promoted. | |
| # | |
| # `exit-code: 0` on purpose: this step REPORTS. The gating decision | |
| # is made once, in `vulnerability-report`, from the aggregated JSON | |
| # β so one unfixable base-image CVE cannot silently block every | |
| # release without anyone seeing why. | |
| - name: Scan the candidate archive | |
| uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 | |
| with: | |
| # The action's v0.65.0 default no longer has downloadable | |
| # release assets. Keep this aligned with security.yml. | |
| version: v0.74.0 | |
| input: /tmp/promote/${{ matrix.image }}.tar | |
| format: json | |
| output: vuln-${{ matrix.image }}.json | |
| severity: CRITICAL,HIGH,MEDIUM | |
| ignore-unfixed: true | |
| exit-code: "0" | |
| timeout: 30m | |
| - name: Upload vulnerability scan | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: vuln-${{ matrix.image }}-${{ needs.version.outputs.version }} | |
| path: vuln-${{ matrix.image }}.json | |
| retention-days: 90 | |
| # One artifact per matrix leg, named after the image AND the | |
| # version, so legs cannot collide and a publish job cannot pick up | |
| # another image's bytes. | |
| - name: Upload the promotable image archive | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: image-${{ matrix.image }}-${{ needs.version.outputs.version }} | |
| path: | | |
| /tmp/promote/${{ matrix.image }}.tar | |
| /tmp/promote/${{ matrix.image }}.config-digest | |
| /tmp/promote/${{ matrix.image }}.layers | |
| /tmp/promote/${{ matrix.image }}.tar-sha256 | |
| retention-days: 7 | |
| compression-level: 0 | |
| if-no-files-found: error | |
| # ββ Stage 2: THE GATE βββββββββββββββββββββββββββββββββββββββββββββββ | |
| vulnerability-report: | |
| name: Vulnerability status | |
| needs: [version, build-scan] | |
| runs-on: ${{ inputs.runner || 'arc-runner' }} | |
| permissions: | |
| contents: read | |
| outputs: | |
| summary: ${{ steps.aggregate.outputs.summary }} | |
| blocking: ${{ steps.aggregate.outputs.blocking }} | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Download vulnerability scans | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| pattern: vuln-*-${{ needs.version.outputs.version }} | |
| merge-multiple: true | |
| path: vuln | |
| - name: Aggregate and apply policy | |
| id: aggregate | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| declare -A CRIT HIGH MED | |
| TOTAL_CRIT=0 | |
| for f in vuln/*.json; do | |
| image=$(basename "$f" .json | sed 's/^vuln-//') | |
| c=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' "$f") | |
| h=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' "$f") | |
| m=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="MEDIUM")] | length' "$f") | |
| CRIT[$image]=$c; HIGH[$image]=$h; MED[$image]=$m | |
| TOTAL_CRIT=$((TOTAL_CRIT + c)) | |
| done | |
| # An empty scan set is a failure, not a clean bill of health: | |
| # it means the build stage produced no artifact and nobody | |
| # actually looked at the images. | |
| if [ "${#CRIT[@]}" -eq 0 ]; then | |
| echo "::error::no vulnerability scan artifacts were found; refusing to publish an unscanned release" | |
| exit 1 | |
| fi | |
| { | |
| echo "| Image | Critical | High | Medium |" | |
| echo "|-------|---------:|-----:|-------:|" | |
| for image in "${!CRIT[@]}"; do | |
| echo "| \`$image\` | ${CRIT[$image]} | ${HIGH[$image]} | ${MED[$image]} |" | |
| done | |
| } > vulnerability-status.md | |
| cat vulnerability-status.md >> "$GITHUB_STEP_SUMMARY" | |
| { | |
| echo "summary<<VULN_EOF" | |
| cat vulnerability-status.md | |
| echo "VULN_EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| if [ "$TOTAL_CRIT" -gt 0 ]; then | |
| echo "blocking=true" >> "$GITHUB_OUTPUT" | |
| echo "::error::${TOTAL_CRIT} fixable CRITICAL vulnerabilities across the release images; nothing will be published (ops/release/supply-chain.yaml vulnerability_policy.fail_on)" | |
| exit 1 | |
| fi | |
| echo "blocking=false" >> "$GITHUB_OUTPUT" | |
| - name: Upload vulnerability status | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: vulnerability-status-${{ needs.version.outputs.version }} | |
| path: vulnerability-status.md | |
| if-no-files-found: warn | |
| # ββ Stage 3: PUBLISH β gated on the vulnerability verdict βββββββββββ | |
| docker: | |
| name: Publish Β· ${{ matrix.image }} | |
| needs: [version, vulnerability-report] | |
| runs-on: ${{ inputs.runner || 'arc-runner' }} | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write | |
| attestations: write | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # No `file` / `build-args` here: this stage does NOT build. | |
| image: [api, web, notification-worker, export-worker, automation-worker, fleet-telemetry] | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 | |
| with: | |
| go-version: '1.25' | |
| # crane pushes a docker-archive to a registry blob-for-blob. It is | |
| # the promotion mechanism: no rebuild, no re-resolution of base | |
| # images, no cache dependency. | |
| - name: Install crane | |
| run: go install github.com/google/go-containerregistry/cmd/crane@v0.20.2 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # Only this image's artifact, so a matrix leg can never promote | |
| # another leg's bytes. | |
| - name: Download the scanned image archive | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| name: image-${{ matrix.image }}-${{ needs.version.outputs.version }} | |
| path: /tmp/promote | |
| - name: Determine image tags | |
| id: tags | |
| env: | |
| IMAGE: ${{ matrix.image }} | |
| VERSION: ${{ needs.version.outputs.version }} | |
| IS_PRERELEASE: ${{ needs.version.outputs.is_prerelease }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| BASE="ghcr.io/${REPO}-${IMAGE}" | |
| echo "base=${BASE}" >> "$GITHUB_OUTPUT" | |
| echo "primary=${BASE}:${VERSION}" >> "$GITHUB_OUTPUT" | |
| if [ "$IS_PRERELEASE" = "true" ]; then | |
| echo "tags=${BASE}:${VERSION}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "tags=${BASE}:${VERSION},${BASE}:latest" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Confirms the artifact that arrived is byte-identical to the one | |
| # that was scanned, before anything is pushed. | |
| - name: Verify the downloaded archive is the scanned archive | |
| env: | |
| IMAGE: ${{ matrix.image }} | |
| run: | | |
| set -euo pipefail | |
| TAR="/tmp/promote/${IMAGE}.tar" | |
| test -s "$TAR" | |
| EXPECTED_TAR_SHA=$(cat "/tmp/promote/${IMAGE}.tar-sha256") | |
| ACTUAL_TAR_SHA=$(sha256sum "$TAR" | cut -d' ' -f1) | |
| if [ "$EXPECTED_TAR_SHA" != "$ACTUAL_TAR_SHA" ]; then | |
| echo "::error::archive checksum mismatch: scanned ${EXPECTED_TAR_SHA}, downloaded ${ACTUAL_TAR_SHA}" | |
| exit 1 | |
| fi | |
| echo "archive checksum verified: ${ACTUAL_TAR_SHA}" | |
| # PROMOTE. `crane push` uploads the archive's existing blobs and | |
| # config; it does not rebuild anything, so the published content is | |
| # the scanned content. | |
| - name: Promote the scanned archive to GHCR | |
| id: promote | |
| env: | |
| IMAGE: ${{ matrix.image }} | |
| PRIMARY: ${{ steps.tags.outputs.primary }} | |
| BASE: ${{ steps.tags.outputs.base }} | |
| IS_PRERELEASE: ${{ needs.version.outputs.is_prerelease }} | |
| run: | | |
| set -euo pipefail | |
| crane push "/tmp/promote/${IMAGE}.tar" "$PRIMARY" | |
| PUSHED_DIGEST=$(crane digest "$PRIMARY") | |
| echo "digest=${PUSHED_DIGEST}" >> "$GITHUB_OUTPUT" | |
| echo "pushed ${PRIMARY}@${PUSHED_DIGEST}" | |
| # `latest` is a TAG on the same manifest, never a second build. | |
| if [ "$IS_PRERELEASE" != "true" ]; then | |
| crane tag "$PRIMARY" latest | |
| LATEST_DIGEST=$(crane digest "${BASE}:latest") | |
| if [ "$LATEST_DIGEST" != "$PUSHED_DIGEST" ]; then | |
| echo "::error::latest resolves to ${LATEST_DIGEST}, not the promoted ${PUSHED_DIGEST}" | |
| exit 1 | |
| fi | |
| echo "tagged ${BASE}:latest -> ${PUSHED_DIGEST}" | |
| fi | |
| # DIGEST CONTINUITY β the assertion the whole stage exists for. | |
| # | |
| # The config digest commits to every layer diff_id, so equality | |
| # here means the published image is the image that was scanned. If | |
| # it ever diverges, the signature and the SBOM below would be | |
| # describing bytes nobody assessed. | |
| - name: Assert DIGEST CONTINUITY from scan to push | |
| env: | |
| IMAGE: ${{ matrix.image }} | |
| PRIMARY: ${{ steps.tags.outputs.primary }} | |
| PUSHED_DIGEST: ${{ steps.promote.outputs.digest }} | |
| run: | | |
| set -euo pipefail | |
| SCANNED_CONFIG=$(cat "/tmp/promote/${IMAGE}.config-digest") | |
| SCANNED_LAYERS=$(cat "/tmp/promote/${IMAGE}.layers") | |
| PUSHED_CONFIG=$(crane manifest "${PRIMARY}@${PUSHED_DIGEST}" | jq -r '.config.digest') | |
| PUSHED_LAYERS=$(crane manifest "${PRIMARY}@${PUSHED_DIGEST}" | jq -r '.layers | length') | |
| echo "scanned config: ${SCANNED_CONFIG} (${SCANNED_LAYERS} layers)" | |
| echo "pushed config: ${PUSHED_CONFIG} (${PUSHED_LAYERS} layers)" | |
| if [ "$SCANNED_CONFIG" != "$PUSHED_CONFIG" ]; then | |
| echo "::error::DIGEST CONTINUITY BROKEN β published config ${PUSHED_CONFIG} != scanned ${SCANNED_CONFIG}; the signature would describe unscanned bytes" | |
| exit 1 | |
| fi | |
| if [ "$SCANNED_LAYERS" != "$PUSHED_LAYERS" ]; then | |
| echo "::error::DIGEST CONTINUITY BROKEN β layer count ${PUSHED_LAYERS} != scanned ${SCANNED_LAYERS}" | |
| exit 1 | |
| fi | |
| echo "β DIGEST CONTINUITY verified: published bytes == scanned bytes" | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 | |
| - name: Sign image with cosign (keyless) | |
| env: | |
| DIGEST: ${{ steps.promote.outputs.digest }} | |
| PRIMARY: ${{ steps.tags.outputs.primary }} | |
| run: | | |
| set -euo pipefail | |
| cosign sign --yes "${PRIMARY}@${DIGEST}" | |
| echo "β Signed ${PRIMARY}@${DIGEST}" | |
| # Phase-53 / p53-sbom β Software Bill of Materials. | |
| # syft generates a CycloneDX SBOM of the image contents (every | |
| # Go module, OS package, and JS dependency that landed in the | |
| # final layer). The SBOM is attested to the image via cosign so | |
| # consumers can `cosign verify-attestation --type cyclonedx` | |
| # without trusting any side-channel artifact server. | |
| - name: Install syft | |
| uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| - name: Generate SBOM (CycloneDX) | |
| env: | |
| DIGEST: ${{ steps.promote.outputs.digest }} | |
| PRIMARY: ${{ steps.tags.outputs.primary }} | |
| run: | | |
| set -euo pipefail | |
| syft "${PRIMARY}@${DIGEST}" \ | |
| -o cyclonedx-json="sbom-${{ matrix.image }}.cdx.json" \ | |
| -o spdx-json="sbom-${{ matrix.image }}.spdx.json" | |
| echo "β Generated SBOMs for ${PRIMARY}@${DIGEST}" | |
| ls -la sbom-${{ matrix.image }}.*.json | |
| - name: Attest SBOM with cosign (keyless) | |
| env: | |
| DIGEST: ${{ steps.promote.outputs.digest }} | |
| PRIMARY: ${{ steps.tags.outputs.primary }} | |
| run: | | |
| set -euo pipefail | |
| cosign attest --yes \ | |
| --predicate "sbom-${{ matrix.image }}.cdx.json" \ | |
| --type cyclonedx \ | |
| "${PRIMARY}@${DIGEST}" | |
| echo "β Attested CycloneDX SBOM to ${PRIMARY}@${DIGEST}" | |
| - name: Upload SBOM artifacts | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: sbom-${{ matrix.image }}-${{ needs.version.outputs.version }} | |
| path: | | |
| sbom-${{ matrix.image }}.cdx.json | |
| sbom-${{ matrix.image }}.spdx.json | |
| retention-days: 90 | |
| # OPS-08 / p53-provenance β SLSA build provenance. | |
| # | |
| # The cosign signature proves "this repository's workflow signed | |
| # this digest". Provenance proves *how* it was built: which | |
| # workflow file, which commit, which runner, which inputs. A | |
| # consumer verifies it with: | |
| # gh attestation verify oci://<image>@<digest> --repo <owner>/<repo> | |
| # | |
| # It is bound to the DIGEST, not the tag, so re-tagging cannot | |
| # transplant provenance onto different bytes. | |
| - name: Attest build provenance | |
| uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 | |
| with: | |
| subject-name: ghcr.io/${{ github.repository }}-${{ matrix.image }} | |
| subject-digest: ${{ steps.promote.outputs.digest }} | |
| push-to-registry: true | |
| helm: | |
| name: Publish Helm Chart | |
| needs: [version, docker] | |
| runs-on: ${{ inputs.runner || 'arc-runner' }} | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Install Helm | |
| uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 | |
| with: | |
| version: v3.16.0 | |
| - name: Log in to GHCR | |
| env: | |
| GHCR_ACTOR: ${{ github.actor }} | |
| GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: echo "$GHCR_TOKEN" | helm registry login ghcr.io -u "$GHCR_ACTOR" --password-stdin | |
| - name: Package and push chart | |
| env: | |
| VERSION: ${{ needs.version.outputs.version }} | |
| OWNER: ${{ github.repository_owner }} | |
| run: | | |
| set -euo pipefail | |
| helm package helm/teslasync \ | |
| --version "$VERSION" \ | |
| --app-version "$VERSION" | |
| helm push teslasync-*.tgz "oci://ghcr.io/${OWNER}/charts" | |
| # ββ Stage 4: TAG AND ANNOUNCE β the last publish side effect ββββββββ | |
| # | |
| # The git tag is created HERE, not in the version job. A tag is a | |
| # persistent, public artifact: pushing it before the vulnerability | |
| # verdict meant a blocked release still left a permanent tag behind | |
| # claiming a version that was never fit to ship. | |
| release-notes: | |
| name: Tag & create GitHub Release | |
| needs: [version, docker, helm, vulnerability-report] | |
| if: ${{ !cancelled() && needs.docker.result == 'success' && needs.helm.result == 'success' && needs.vulnerability-report.result == 'success' }} | |
| runs-on: ${{ inputs.runner || 'arc-runner' }} | |
| permissions: | |
| contents: write | |
| packages: read | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Create and push tag | |
| env: | |
| NEW_TAG: ${{ needs.version.outputs.new_tag }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git tag -a "$NEW_TAG" -m "Release $NEW_TAG" | |
| git push origin "$NEW_TAG" | |
| - name: Generate release notes | |
| env: | |
| # Job outputs go through the ENVIRONMENT, never into the script | |
| # text. `needs.vulnerability-report.outputs.summary` is a | |
| # Markdown table whose image names are wrapped in BACKTICKS. | |
| # Interpolated into the (expanding) heredoc it became a command | |
| # substitution: bash ran `api` as a command, printed | |
| # `api: command not found` to stderr, substituted an empty | |
| # string into the cell, and still exited 0 β so the release | |
| # published a vulnerability table with blank image names and | |
| # nothing failed. | |
| # | |
| # Expanding "$VULN_SUMMARY" instead is safe: bash does not | |
| # re-scan the RESULT of a parameter expansion for backticks or | |
| # command substitution. `set -euo pipefail` would not have | |
| # helped β the substitution "succeeded". | |
| TAG: ${{ needs.version.outputs.new_tag }} | |
| VERSION: ${{ needs.version.outputs.version }} | |
| IS_PRE: ${{ needs.version.outputs.is_prerelease }} | |
| REF_NAME: ${{ github.ref_name }} | |
| VULN_SUMMARY: ${{ needs.vulnerability-report.outputs.summary }} | |
| run: | | |
| set -euo pipefail | |
| PREV=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "^${TAG}$" | head -1 || true) | |
| if [ "$IS_PRE" = "true" ]; then | |
| LABEL="β οΈ Pre-release (branch: ${REF_NAME})" | |
| else | |
| LABEL="π TeslaSync ${TAG}" | |
| fi | |
| if [ -n "$PREV" ]; then | |
| CHANGELOG=$(git log "${PREV}..HEAD" --pretty=format:"- %s (@%an)" --no-merges) | |
| COMPARE="**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV}...${TAG}" | |
| else | |
| CHANGELOG=$(git log --pretty=format:"- %s (@%an)" --no-merges -20) | |
| COMPARE="" | |
| fi | |
| cat > release-notes.md << NOTES_EOF | |
| ## ${LABEL} | |
| ### π¦ Docker Images | |
| \`\`\`bash | |
| # Backend API | |
| docker pull ghcr.io/${{ github.repository }}-api:${VERSION} | |
| # Web Frontend | |
| docker pull ghcr.io/${{ github.repository }}-web:${VERSION} | |
| # Notification Worker | |
| docker pull ghcr.io/${{ github.repository }}-notification-worker:${VERSION} | |
| # Export Worker | |
| docker pull ghcr.io/${{ github.repository }}-export-worker:${VERSION} | |
| # Automation Worker | |
| docker pull ghcr.io/${{ github.repository }}-automation-worker:${VERSION} | |
| # Fleet Telemetry (event-time preserving) | |
| docker pull ghcr.io/${{ github.repository }}-fleet-telemetry:${VERSION} | |
| \`\`\` | |
| ### β Helm Chart | |
| \`\`\`bash | |
| helm install teslasync oci://ghcr.io/${{ github.repository_owner }}/charts/teslasync --version ${VERSION} | |
| \`\`\` | |
| ### π Image Verification | |
| All images are signed with [cosign](https://github.com/sigstore/cosign) (keyless, via GitHub OIDC), ship a CycloneDX SBOM attestation, and carry SLSA build provenance: | |
| \`\`\`bash | |
| # Verify image signature | |
| cosign verify ghcr.io/${{ github.repository }}-api:${VERSION} \\ | |
| --certificate-identity-regexp="github.com/${{ github.repository }}" \\ | |
| --certificate-oidc-issuer="https://token.actions.githubusercontent.com" | |
| # Verify + extract SBOM attestation | |
| cosign verify-attestation \\ | |
| --type cyclonedx \\ | |
| --certificate-identity-regexp="github.com/${{ github.repository }}" \\ | |
| --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \\ | |
| ghcr.io/${{ github.repository }}-api:${VERSION} \\ | |
| | jq -r '.payload' | base64 -d | jq '.predicate' > sbom.cdx.json | |
| # Verify SLSA build provenance (proves WHICH workflow built these bytes) | |
| gh attestation verify \\ | |
| oci://ghcr.io/${{ github.repository }}-api:${VERSION} \\ | |
| --repo ${{ github.repository }} | |
| \`\`\` | |
| See \`docs/operations/release-verification.md\` for the full verification procedure. | |
| ### π‘οΈ Vulnerability status | |
| Scanned with Trivy at build time (fixable findings only; policy in \`ops/release/supply-chain.yaml\`). A release is blocked on any fixable CRITICAL. | |
| ${VULN_SUMMARY} | |
| ### π Changes | |
| ${CHANGELOG} | |
| ${COMPARE} | |
| ### π Assets | |
| | Asset | Tag | | |
| |-------|-----| | |
| | Backend Image | \`ghcr.io/${{ github.repository }}-api:${VERSION}\` | | |
| | Web Image | \`ghcr.io/${{ github.repository }}-web:${VERSION}\` | | |
| | Notification Worker | \`ghcr.io/${{ github.repository }}-notification-worker:${VERSION}\` | | |
| | Export Worker | \`ghcr.io/${{ github.repository }}-export-worker:${VERSION}\` | | |
| | Automation Worker | \`ghcr.io/${{ github.repository }}-automation-worker:${VERSION}\` | | |
| | Fleet Telemetry | \`ghcr.io/${{ github.repository }}-fleet-telemetry:${VERSION}\` | | |
| | Helm Chart | \`oci://ghcr.io/${{ github.repository_owner }}/charts/teslasync:${VERSION}\` | | |
| NOTES_EOF | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 | |
| with: | |
| tag_name: ${{ needs.version.outputs.new_tag }} | |
| name: TeslaSync ${{ needs.version.outputs.new_tag }} | |
| body_path: release-notes.md | |
| prerelease: ${{ needs.version.outputs.is_prerelease == 'true' }} | |
| generate_release_notes: false | |
| # OPS-07 β Grafana release annotation. | |
| # | |
| # Puts the release boundary on the same time axis as every SLO | |
| # dashboard, tagged with the build SHA, version, and environment, | |
| # so an operator staring at a burn-rate alert can see "a release | |
| # happened here" without cross-referencing a CI log. | |
| # | |
| # No-ops (exit 0) when GRAFANA_URL is unset. An annotation must | |
| # never be able to fail a release. | |
| - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 | |
| with: | |
| go-version: '1.25' | |
| - name: Annotate the Grafana release timeline | |
| continue-on-error: true | |
| env: | |
| GRAFANA_URL: ${{ secrets.GRAFANA_URL }} | |
| GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }} | |
| VERSION: ${{ needs.version.outputs.version }} | |
| ENVIRONMENT: ${{ github.ref_name == 'main' && 'production' || 'prerelease' }} | |
| run: | | |
| set -euo pipefail | |
| go run ./cmd/release-annotate \ | |
| -action deploy \ | |
| -version "$VERSION" \ | |
| -commit "${{ github.sha }}" \ | |
| -environment "$ENVIRONMENT" \ | |
| -note "release workflow run ${{ github.run_id }}" |