Skip to content

test(outlook-addon): pin buildSignAttributes' combined flow #100

test(outlook-addon): pin buildSignAttributes' combined flow

test(outlook-addon): pin buildSignAttributes' combined flow #100

Workflow file for this run

name: Outlook addon
# Checks and release for apps/outlook-addon.
#
# Deliberately NOT path-filtered: the point of the monorepo is that an SDK change
# in packages/pg-js is tested against its consumers in the same PR.
#
# Image name is hardcoded, not derived from ${{ github.repository }}: that
# expression resolves to `postguard-js` here, while postguard-ops pins
# ghcr.io/encryption4all/postguard-outlook-addon, so deriving it would publish to
# a name nothing deploys.
#
# Release tags are app-scoped (`outlook-addin-v0.5.1`), never `v*`: the tag
# namespace is shared with @e4a/pg-js's changesets releases and still holds the
# pre-monorepo `v2.3.3`-style pg-js tags.
on:
pull_request:
branches: [main]
push:
branches: [main]
tags: ['outlook-addin-v*']
workflow_dispatch:
permissions:
contents: read
# This workflow is the only publisher of the image below. There was briefly a
# race — encryption4all/postguard-outlook-addon's own release.yml pushed `:edge`
# and `:latest` to the same package and the two could overwrite each other with
# different content — and that is over: the repo is archived, and an archived
# repo runs no workflows, so nothing else can push this name. Note that
# archiving the *source* repo of a GHCR package does not revoke the package
# itself or anyone's push access to it, so "the old repo is archived" is the
# reason the race ended, not "the package moved".
env:
IMAGE: ghcr.io/encryption4all/postguard-outlook-addon
# Baked into the bundle by webpack's DefinePlugin. `storage.postguard.eu`,
# not `fileshare.*`: the latter no longer resolves and shipping it broke file
# sending for every Outlook user (postguard-outlook-addon#132).
PROD_ADDIN_HOST: addin.postguard.eu
PROD_PKG_URL: https://postguard.eu/pkg
PROD_CRYPTIFY_URL: https://storage.postguard.eu
PROD_WEBSITE_URL: https://postguard.eu
EDGE_ADDIN_HOST: addin.staging.postguard.eu
EDGE_PKG_URL: https://staging.postguard.eu/pkg
EDGE_CRYPTIFY_URL: https://storage.staging.postguard.eu
EDGE_WEBSITE_URL: https://staging.postguard.eu
defaults:
run:
working-directory: apps/outlook-addon
jobs:
check:
name: Lint, typecheck & build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
working-directory: .
# These globs match what `.husky/pre-commit` already enforces via
# lint-staged: `*.{js,mjs,ts}` and `*.{json,md,yml,yaml,css,html}`,
# package-wide. A CI gate narrower than the hook it complements is
# strictly weaker than it looks, and the src-only version excluded
# exactly the build-critical files — webpack.config.js, which carries
# the manifest AppDomain transform, and scripts/.
#
# Reaching parity cost two reformats, which is the argument for closing
# the gap rather than against it: nine tracked files were unformatted,
# and the hook would have rewritten them under whoever next touched a
# taskpane template, turning a one-line change into a formatting diff.
# `scripts/**`, not `scripts/*`, so a subdirectory cannot slip out.
#
# The `{json,md,yml,yaml,css,html}` glob would also match webpack's
# output. What keeps it out is `dist/` in apps/outlook-addon/.gitignore,
# which prettier 3 honours as a default ignore path: with an unformatted
# file planted in dist/, the gate stays clean, and moving that .gitignore
# aside makes it fail. So that entry is load-bearing here, not just a
# build-artifact listing. (These steps run with the workflow's
# apps/outlook-addon working directory, which is the .gitignore prettier
# picks up.)
- name: Lint (no errors, no warnings)
run: pnpm exec eslint -c eslint.config.mjs --max-warnings=0 "src/**/*.{ts,js}" "test/**/*.ts" "scripts/**/*.mjs" webpack.config.js eslint.config.mjs
- name: Prettier (format check)
run: pnpm exec prettier --check "src/**/*.{ts,js}" "test/**/*.ts" "scripts/**/*.mjs" webpack.config.js eslint.config.mjs "**/*.{json,md,yml,yaml,css,html}"
- run: pnpm typecheck
# Outlook keys sideloaded and centrally-deployed updates on the
# manifest's <Version>, which changesets does not touch. `version-packages`
# syncs it; this refuses a commit where the two disagree.
- name: Manifest version matches package.json
run: pnpm check-version
# Both origin sets are built, not just edge. The manifest transform
# fails per target — it refuses to emit a manifest missing an
# <AppDomain> for an origin that build needs — and the *production*
# set is the one that ships. Building only edge left that failure to
# surface inside the tag-time image build, which is late for the one
# thing the transform exists to catch early. The extra webpack pass
# costs a few seconds.
#
# Each is validated against dist/manifest.xml rather than the source:
# the source still carries the localhost URLs, and the copy admins
# sideload — the one the release job attaches — is what webpack emits.
- name: Build (edge configuration)
run: pnpm build
env:
# ADDIN_PUBLIC_URL too: unset, webpack falls back to the
# production add-in origin, so this would build a manifest
# pairing the prod origin with staging backends — a combination
# that never ships.
ADDIN_PUBLIC_URL: https://${{ env.EDGE_ADDIN_HOST }}/
PKG_URL: ${{ env.EDGE_PKG_URL }}
CRYPTIFY_URL: ${{ env.EDGE_CRYPTIFY_URL }}
POSTGUARD_WEBSITE_URL: ${{ env.EDGE_WEBSITE_URL }}
- name: Validate the built manifest (edge)
run: pnpm validate:dist
- name: Build (production configuration)
run: pnpm build
env:
ADDIN_PUBLIC_URL: https://${{ env.PROD_ADDIN_HOST }}/
PKG_URL: ${{ env.PROD_PKG_URL }}
CRYPTIFY_URL: ${{ env.PROD_CRYPTIFY_URL }}
POSTGUARD_WEBSITE_URL: ${{ env.PROD_WEBSITE_URL }}
- name: Validate the built manifest (production)
run: pnpm validate:dist
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
working-directory: .
- run: pnpm test
# Every host this app can bake into its bundle must resolve, plus yivi.app,
# which the build now refuses to produce a manifest without. A dead Cryptify
# host reached production undetected (encryption4all/postguard-outlook-addon#132):
# the add-in loaded fine and only failed when a user sent a file.
#
# This is a DNS check, not a reachability check — it says a name resolves, not
# that anything answers on it.
#
# It reads the two files that actually carry the values as well as this
# workflow's env, because #132 was a wrong `ARG` default in the Dockerfile —
# exactly the copy a workflow-only check does not see.
urls:
name: Baked URLs resolve
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check every configured host
shell: bash
# From the repo root: this workflow defaults to apps/outlook-addon,
# which made the file paths below resolve one level deep and grep
# exit 2 (file not found) rather than finding anything.
working-directory: .
run: |
set -uo pipefail
# `set -e` is deliberately absent — a correctly-pathed grep
# that legitimately matches nothing exits 1 — which also means
# grep's exit 2 on a missing file would not abort. Without this
# guard a rename silently degrades the job to an env-only check
# that still passes, and with no visible change: the two files
# currently duplicate the env values exactly, so even the host
# count stays the same.
for f in apps/outlook-addon/Dockerfile apps/outlook-addon/webpack.config.js; do
[ -f "$f" ] || { echo "::error::$f is missing; this check would silently stop covering it"; exit 1; }
done
# This workflow's env, plus every postguard.eu host hardcoded in
# the two files that feed the build. Collected together so the
# Dockerfile ARG defaults and webpack's envDefaults — the copy
# that broke in #132 — are covered too.
{
printf '%s\n' "$PROD_PKG_URL" "$PROD_CRYPTIFY_URL" "$PROD_WEBSITE_URL" \
"https://$PROD_ADDIN_HOST" \
"$EDGE_PKG_URL" "$EDGE_CRYPTIFY_URL" "$EDGE_WEBSITE_URL" \
"https://$EDGE_ADDIN_HOST"
# Every https host in the two files, not just *.postguard.eu
# ones. The narrower pattern required a subdomain label, so it
# matched neither file's bare-apex defaults
# (`ARG POSTGUARD_WEBSITE_URL=https://postguard.eu`,
# `ARG PKG_URL=https://postguard.eu/pkg`) — postguard.eu was
# checked only because PROD_WEBSITE_URL happens to hold the
# same string, the coincidence this job exists to stop
# depending on. It also could not see a default that drifted
# out of the family entirely, and missed yivi.app, which
# webpack now refuses to build a manifest without. Deriving
# the list from the files covers all three and leaves no
# second list to keep in step.
grep -ohE 'https://[A-Za-z0-9.:-]+' \
apps/outlook-addon/Dockerfile \
apps/outlook-addon/webpack.config.js
} | sed -E 's#^https://##; s#[:/].*$##' | sort -u \
| grep -v '^localhost$' > /tmp/hosts
# `grep -v` exits 1 when it filters everything, and without
# `set -e` that would leave an empty host list and a green job.
if [ ! -s /tmp/hosts ]; then
echo "::error::collected no hosts to check; this would pass vacuously"
exit 1
fi
if ! command -v getent >/dev/null && ! command -v host >/dev/null \
&& ! command -v nslookup >/dev/null; then
echo "::error::no DNS lookup tool available; this check would pass vacuously"
exit 1
fi
echo "checking $(wc -l < /tmp/hosts) distinct hosts:"
fail=0
while read -r host; do
[ -z "$host" ] && continue
# Fallback chain rather than getent alone: it is glibc-only
# and absent on some images, so a single tool would report
# every host dead and make this check useless-but-green-looking
# the moment the runner image changes.
if getent hosts "$host" >/dev/null 2>&1 \
|| host "$host" >/dev/null 2>&1 \
|| nslookup "$host" >/dev/null 2>&1; then
echo " ok $host"
else
echo "::error::$host does not resolve, but the build can bake it in"
fail=1
fi
done < /tmp/hosts
exit "$fail"
nginx:
# Qualified with the app, and it has to stay that way: website.yml has a
# job that validates ITS nginx config, and a required status check is
# matched by name across the whole repo. While both were called
# `nginx config test`, requiring that name covered both copies — and
# deleting either job would have left the name still reporting from the
# other, so the gate would look alive while covering half of it.
name: nginx config test (outlook-addon)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# Mirrors the Dockerfile, which serves nginx/default.conf from
# /etc/nginx/conf.d/default.conf on nginx:1.27-alpine, so the map
# blocks are validated in the real http{} context.
- name: Validate nginx config (nginx -t)
run: |
docker run --rm \
-v "${{ github.workspace }}/apps/outlook-addon/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro" \
nginx:1.27-alpine nginx -t
# The `image` job below only runs on a push, so without this the Dockerfile is
# never built before merge — and this workflow's most invasive change is the
# Dockerfile: repo-root context, new COPY paths, and ARGs deliberately placed
# below `pnpm install`. Building without pushing gates that, needs no package
# write grant, and makes the ARG-placement cache win observable in CI rather
# than only on a laptop. `website.yml` does the same thing for the same reason.
docker:
name: Image builds (PR, no push)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
defaults:
run:
working-directory: .
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Build (validate only, no push)
uses: docker/build-push-action@v7
with:
context: .
file: apps/outlook-addon/Dockerfile
platforms: linux/amd64
push: false
# Production values: the build refuses staging fallbacks in
# production mode, and the manifest transform fails when an
# <AppDomain> the target needs is absent, so these are the values
# worth exercising.
build-args: |
ADDIN_PUBLIC_URL=https://${{ env.PROD_ADDIN_HOST }}/
PKG_URL=${{ env.PROD_PKG_URL }}
CRYPTIFY_URL=${{ env.PROD_CRYPTIFY_URL }}
POSTGUARD_WEBSITE_URL=${{ env.PROD_WEBSITE_URL }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Edge image on every push to main; the versioned image and the sideloadable
# manifest on an `outlook-addin-v*` tag. Context is the repo root: the app
# depends on @e4a/pg-js as workspace:*, so the image build has to install the
# workspace and build the SDK first (see apps/outlook-addon/Dockerfile).
image:
name: Image (${{ github.ref_type == 'tag' && 'release' || 'edge' }})
# Gated on every check. Without this the job publishes in parallel with
# them, so a red `urls` would not stop the same bundle shipping — which is
# precisely the failure this workflow exists to prevent.
needs: [check, test, urls, nginx]
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
defaults:
run:
working-directory: .
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve the target
id: target
shell: bash
run: |
if [ "$GITHUB_REF_TYPE" = tag ]; then
version="${GITHUB_REF_NAME#outlook-addin-v}"
declared="$(jq -r .version apps/outlook-addon/package.json)"
if [ "$version" != "$declared" ]; then
echo "::error::tag says $version, apps/outlook-addon says $declared" >&2
exit 1
fi
{
echo "tags=${IMAGE}:${version},${IMAGE}:latest"
echo "addin_host=${PROD_ADDIN_HOST}"
echo "pkg=${PROD_PKG_URL}"
echo "cryptify=${PROD_CRYPTIFY_URL}"
echo "website=${PROD_WEBSITE_URL}"
echo "version=${version}"
} >> "$GITHUB_OUTPUT"
else
{
echo "tags=${IMAGE}:edge"
echo "addin_host=${EDGE_ADDIN_HOST}"
echo "pkg=${EDGE_PKG_URL}"
echo "cryptify=${EDGE_CRYPTIFY_URL}"
echo "website=${EDGE_WEBSITE_URL}"
} >> "$GITHUB_OUTPUT"
fi
# The production manifest is what admins sideload from, so it is
# attached to the release with a stable versioned URL rather than
# taken from whatever the addin host currently serves.
#
# Built and validated BEFORE the image is pushed. These four steps need
# nothing from the pushed image, and validation can genuinely fail:
# `office-addin-manifest validate` is a network call to Microsoft's
# acceptance-test service, so their outage is enough. With the push
# first, that left `:<version>` and `:latest` live with no release and
# no manifest attached — admins sideloading the old manifest against a
# new bundle, while sync-addons.mjs found nothing matching
# `outlook-addin-v*` and took its keep-serving path, so the website went
# on mirroring the pre-migration artifact with nothing recording the
# mismatch. Ordering costs no wall clock and makes the gate real.
- uses: pnpm/action-setup@v4
if: github.ref_type == 'tag'
- uses: actions/setup-node@v6
if: github.ref_type == 'tag'
with:
node-version: 24
cache: pnpm
- name: Build the production manifest
if: github.ref_type == 'tag'
run: pnpm install --frozen-lockfile && pnpm --filter postguard-outlook-addin build
env:
ADDIN_PUBLIC_URL: https://${{ steps.target.outputs.addin_host }}/
PKG_URL: ${{ steps.target.outputs.pkg }}
CRYPTIFY_URL: ${{ steps.target.outputs.cryptify }}
POSTGUARD_WEBSITE_URL: ${{ steps.target.outputs.website }}
# The `check` job validates the edge- and production-configured
# manifests it builds itself, not this one. This is the actual release
# asset, and it differs from the edge copy in exactly what validation
# inspects: the SourceLocation and icon origins, and the scoped
# AppDomains. Validating it here is what makes "the file admins
# sideload is validated" true of the released artifact.
- name: Validate the manifest being released
if: github.ref_type == 'tag'
run: pnpm --filter postguard-outlook-addin validate:dist
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
file: apps/outlook-addon/Dockerfile
# amd64 only, matching what the standalone repo actually
# published. arm64 would be new here and nothing exercises it
# before a tag: there is no setup-qemu-action, and an emulated
# `webpack --mode production` over a WASM-embedding bundle is
# slow and OOM-prone. Worth adding deliberately, with a build
# that runs on PRs, rather than discovering it at release time.
platforms: linux/amd64
push: true
tags: ${{ steps.target.outputs.tags }}
build-args: |
ADDIN_PUBLIC_URL=https://${{ steps.target.outputs.addin_host }}/
PKG_URL=${{ steps.target.outputs.pkg }}
CRYPTIFY_URL=${{ steps.target.outputs.cryptify }}
POSTGUARD_WEBSITE_URL=${{ steps.target.outputs.website }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create the release with the manifest attached
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "$GITHUB_REF_NAME" \
--title "Outlook add-in ${{ steps.target.outputs.version }}" \
--notes "Sideload the attached manifest.xml, or pull ${IMAGE}:${{ steps.target.outputs.version }}." \
apps/outlook-addon/dist/manifest.xml